@insumermodel/mppx-condition-gate 3.0.1 → 3.1.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/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Method, Receipt } from 'mppx'
2
+ import { Errors } from 'mppx'
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Types
@@ -153,8 +154,51 @@ export type ConditionGateOptions = {
153
154
  * Override for self-hosted deployments or testing.
154
155
  */
155
156
  apiBaseUrl?: string
156
- /** Request JWT format alongside the raw attestation. Defaults to false. */
157
+ /**
158
+ * @deprecated No effect. The gate consumes the attestation internally and
159
+ * returns only an mppx receipt, so a requested JWT never reached the caller.
160
+ * Accepted for type compatibility; not sent to the API. Call POST /v1/attest
161
+ * with `format: "jwt"` directly if you need the `jwt` and `pqJwt` tokens.
162
+ */
157
163
  jwt?: boolean
164
+ /**
165
+ * Where the gate records credentials it has accepted, so each one gets
166
+ * through at most once. Defaults to a bounded in-process store.
167
+ *
168
+ * Pass a shared store when the route runs on more than one instance, or a
169
+ * credential used on one instance can be presented again on another. The
170
+ * store must claim atomically and remove expired entries: `Store.redis`,
171
+ * `Store.upstash` or `Store.cloudflare` from `mppx/server` built from a client
172
+ * that exposes an atomic `update`, or any store with an atomic `tryClaim`.
173
+ * mppx's `Store.memory()` never removes expired entries; use it only in tests.
174
+ */
175
+ replayStore?: ReplayStore
176
+ /**
177
+ * Longest remaining validity, in seconds, a credential may have and still be
178
+ * given free access. Defaults to 600. The gate has to remember a credential
179
+ * until it expires, and x402 clients choose their own expiry, so longer-lived
180
+ * credentials take the paid path instead.
181
+ */
182
+ maxCredentialLifetimeSeconds?: number
183
+ }
184
+
185
+ /**
186
+ * An atomic claim store. Structurally compatible with mppx's `AtomicStore`:
187
+ * `tryClaim` is used when present, otherwise `update`. `get`, when present,
188
+ * lets the gate refuse a repeat before spending an attestation on it, and
189
+ * `delete` (or `update`) lets it release a credential whose payment failed.
190
+ */
191
+ export type ReplayStore = {
192
+ get?: (key: string) => unknown | Promise<unknown>
193
+ delete?: (key: string) => void | Promise<void>
194
+ tryClaim?: (key: string, expires: number) => boolean | Promise<boolean>
195
+ update?: <result>(
196
+ key: string,
197
+ fn: (current: any) =>
198
+ | { op: 'noop'; result: result }
199
+ | { op: 'set'; value: any; result: result }
200
+ | { op: 'delete'; result: result },
201
+ ) => Promise<result>
158
202
  }
159
203
 
160
204
  export type InsumerAttestation = {
@@ -183,7 +227,12 @@ export type InsumerAttestation = {
183
227
  }
184
228
  sig: string
185
229
  kid: string
230
+ /** ML-DSA-65 post-quantum companion signature (since 2026-09-01, additive). */
231
+ pqSig?: string
232
+ /** Post-quantum key id, insumer-attest-pq1 (resolved in the JWKS as an RFC 9964 AKP entry). */
233
+ pqKid?: string
186
234
  jwt?: string
235
+ pqJwt?: string
187
236
  }
188
237
  meta: {
189
238
  version: string
@@ -222,9 +271,199 @@ function cacheKey(address: string, conditions: Condition[]): string {
222
271
  return `${address.toLowerCase()}:${JSON.stringify(sorted)}`
223
272
  }
224
273
 
225
- /** Clear the in-memory ownership cache. Useful in tests. */
274
+ /** Clear the in-memory ownership cache and the default replay store. Useful in tests. */
226
275
  export function clearConditionGateCache(): void {
227
276
  cache.clear()
277
+ defaultMarkers.clear()
278
+ freeCountByOwner.clear()
279
+ freeCount = 0
280
+ paidCount = 0
281
+ nextSweepAt = 0
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Single-use credentials
286
+ // ---------------------------------------------------------------------------
287
+
288
+ // mppx makes a credential single-use when it settles: the on-chain nonce is
289
+ // spent, or the method claims the hash or proof in its store. `validate` is
290
+ // non-mutating by design, so a credential that is granted free access and
291
+ // never settled would otherwise stay usable by anyone who holds a copy.
292
+
293
+ /** Kept this long past the credential's own expiry, to absorb clock skew. */
294
+ const REPLAY_SKEW_MS = 60 * 1000
295
+ const DEFAULT_MAX_CREDENTIAL_LIFETIME_SECONDS = 600
296
+ /** Default store: live free grants across the process, and per wallet. */
297
+ const DEFAULT_STORE_MAX_FREE = 100_000
298
+ const DEFAULT_STORE_MAX_FREE_PER_WALLET = 5_000
299
+ /** Default store: payments it remembers. Past this it stops recording payments. */
300
+ const DEFAULT_STORE_MAX_PAID = 100_000
301
+ const DEFAULT_STORE_SWEEP_MS = 60_000
302
+
303
+ type RecordKind = 'free' | 'paid'
304
+ type DefaultMarker = { expires: number; owner: string; kind: RecordKind }
305
+
306
+ const defaultMarkers = new Map<string, DefaultMarker>()
307
+ const freeCountByOwner = new Map<string, number>()
308
+ let freeCount = 0
309
+ let paidCount = 0
310
+ let nextSweepAt = 0
311
+
312
+ function forgetDefault(key: string): void {
313
+ const marker = defaultMarkers.get(key)
314
+ if (!marker) return
315
+ defaultMarkers.delete(key)
316
+ if (marker.kind === 'paid') {
317
+ paidCount--
318
+ return
319
+ }
320
+ freeCount--
321
+ const remaining = (freeCountByOwner.get(marker.owner) ?? 1) - 1
322
+ if (remaining <= 0) freeCountByOwner.delete(marker.owner)
323
+ else freeCountByOwner.set(marker.owner, remaining)
324
+ }
325
+
326
+ /** What the gate needs from a store. The default store and a caller's store both sit behind it. */
327
+ type Recorder = {
328
+ isTaken(key: string): Promise<boolean>
329
+ /** true when claimed, false when already taken; throws when it cannot record. */
330
+ claim(key: string, expires: number, owner: string, kind: RecordKind): Promise<boolean>
331
+ release(key: string): Promise<void>
332
+ }
333
+
334
+ const defaultRecorder: Recorder = {
335
+ async isTaken(key) {
336
+ const marker = defaultMarkers.get(key)
337
+ return marker !== undefined && marker.expires > Date.now()
338
+ },
339
+ async claim(key, expires, owner, kind) {
340
+ const now = Date.now()
341
+ if (now >= nextSweepAt) {
342
+ for (const [k, marker] of defaultMarkers) if (marker.expires <= now) forgetDefault(k)
343
+ nextSweepAt = now + DEFAULT_STORE_SWEEP_MS
344
+ }
345
+ const current = defaultMarkers.get(key)
346
+ if (current !== undefined) {
347
+ if (current.expires > now) return false
348
+ forgetDefault(key)
349
+ }
350
+ if (kind === 'free') {
351
+ const held = freeCountByOwner.get(owner) ?? 0
352
+ if (freeCount >= DEFAULT_STORE_MAX_FREE || held >= DEFAULT_STORE_MAX_FREE_PER_WALLET) {
353
+ throw new Error('mppx-condition-gate: default replay store has no room for this wallet')
354
+ }
355
+ freeCount++
356
+ freeCountByOwner.set(owner, held + 1)
357
+ } else {
358
+ if (paidCount >= DEFAULT_STORE_MAX_PAID) {
359
+ throw new Error('mppx-condition-gate: default replay store is full')
360
+ }
361
+ paidCount++
362
+ }
363
+ defaultMarkers.set(key, { expires, owner, kind })
364
+ return true
365
+ },
366
+ async release(key) {
367
+ forgetDefault(key)
368
+ },
369
+ }
370
+
371
+ function isReplayMarker(value: unknown): value is { expires: number; type: 'mppx:replay' } {
372
+ return (
373
+ typeof value === 'object' &&
374
+ value !== null &&
375
+ typeof (value as { expires?: unknown }).expires === 'number' &&
376
+ (value as { type?: unknown }).type === 'mppx:replay'
377
+ )
378
+ }
379
+
380
+ async function tryClaim(store: ReplayStore, key: string, expires: number): Promise<boolean> {
381
+ if (typeof store.tryClaim === 'function') return store.tryClaim(key, expires)
382
+ // Same marker as mppx's Store.tryClaim. A live marker, or any other stored
383
+ // value, counts as taken; an empty slot or an expired marker does not.
384
+ return store.update!(key, (current) => {
385
+ const empty = current === null || current === undefined || current === false
386
+ const taken = !empty && (!isReplayMarker(current) || current.expires > Date.now())
387
+ return taken
388
+ ? { op: 'noop', result: false }
389
+ : { op: 'set', value: { expires, type: 'mppx:replay' }, result: true }
390
+ })
391
+ }
392
+
393
+ function storeRecorder(store: ReplayStore): Recorder {
394
+ return {
395
+ async isTaken(key) {
396
+ // Advisory only: the atomic claim decides. Anything but a live marker reads as free.
397
+ if (typeof store.get !== 'function') return false
398
+ const current = await store.get(key)
399
+ return isReplayMarker(current) && current.expires > Date.now()
400
+ },
401
+ async claim(key, expires) {
402
+ return tryClaim(store, key, expires)
403
+ },
404
+ async release(key) {
405
+ if (typeof store.delete === 'function') {
406
+ await store.delete(key)
407
+ return
408
+ }
409
+ if (typeof store.update === 'function') {
410
+ await store.update(key, (current) =>
411
+ isReplayMarker(current)
412
+ ? { op: 'delete', result: undefined }
413
+ : { op: 'noop', result: undefined },
414
+ )
415
+ }
416
+ },
417
+ }
418
+ }
419
+
420
+ function lowerHex(value: unknown): string | null {
421
+ return typeof value === 'string' && /^0x[0-9a-fA-F]+$/.test(value) ? value.toLowerCase() : null
422
+ }
423
+
424
+ /**
425
+ * One key that identifies the credential, or null when the gate cannot
426
+ * identify it safely, in which case it gets no free access.
427
+ *
428
+ * - EIP-3009 authorization (mppx EVM charge, native and x402): signer + nonce.
429
+ * Neither changes when the signature is re-encoded or the challenge is
430
+ * rebuilt, and two users never share one.
431
+ * - Tempo proof: challenge id + proven payer. The proof signs the challenge
432
+ * id, so it cannot be moved to another challenge.
433
+ *
434
+ * Other credential types are payments in their own right (a Tempo hash is a
435
+ * transfer already on chain, a Tempo transaction is one waiting to be sent),
436
+ * so they take the paid path, where the method's own replay protection applies.
437
+ */
438
+ function credentialKey(methodName: string, credential: any, payerAddress: string): string | null {
439
+ const payload = credential?.payload
440
+ if (payload === null || typeof payload !== 'object') return null
441
+ let id: string | null = null
442
+ if (payload.type === 'authorization') {
443
+ const from = lowerHex(payload.from)
444
+ const nonce = lowerHex(payload.nonce)
445
+ if (from && nonce) id = `authorization:${from}:${nonce}`
446
+ } else if (payload.type === 'proof') {
447
+ const challengeId = credential?.challenge?.id
448
+ if (typeof challengeId === 'string' && challengeId.length > 0) {
449
+ id = `proof:${challengeId}:${payerAddress.toLowerCase()}`
450
+ }
451
+ }
452
+ return id === null ? null : `mppx-condition-gate:${methodName}:${id}`
453
+ }
454
+
455
+ /** The latest moment the credential could still be accepted, or null if unknown. */
456
+ function credentialExpiry(credential: any): number | null {
457
+ const payload = credential?.payload
458
+ if (payload?.type === 'authorization') {
459
+ // An authorization is bounded by its own validBefore. For x402 the
460
+ // challenge is rebuilt on every request, so its expiry says nothing about
461
+ // the credential; a native credential's validBefore is its challenge's expiry.
462
+ const validBefore = Number(payload.validBefore)
463
+ return Number.isFinite(validBefore) && validBefore > 0 ? validBefore * 1000 : null
464
+ }
465
+ const challengeExpires = Date.parse(credential?.challenge?.expires)
466
+ return Number.isFinite(challengeExpires) ? challengeExpires : null
228
467
  }
229
468
 
230
469
  // ---------------------------------------------------------------------------
@@ -338,7 +577,7 @@ async function callAttest(
338
577
  wallet: string,
339
578
  walletType: 'evm' | 'solana' | 'xrpl' | 'bitcoin',
340
579
  conditions: Condition[],
341
- options: Pick<ConditionGateOptions, 'apiKey' | 'apiBaseUrl' | 'jwt'>,
580
+ options: Pick<ConditionGateOptions, 'apiKey' | 'apiBaseUrl'>,
342
581
  ): Promise<InsumerAttestation> {
343
582
  const apiKey = options.apiKey || process.env.INSUMER_API_KEY
344
583
  if (!apiKey) {
@@ -359,8 +598,6 @@ async function callAttest(
359
598
  else if (walletType === 'bitcoin') body.bitcoinWallet = wallet
360
599
  else body.wallet = wallet
361
600
 
362
- if (options.jwt) body.format = 'jwt'
363
-
364
601
  const response = await fetch(`${baseUrl}/v1/attest`, {
365
602
  method: 'POST',
366
603
  headers: {
@@ -430,6 +667,16 @@ export function normalizeProvenPayer(
430
667
  * credential that fails validation, conditions not met, or an attestation
431
668
  * error. There is no path from an unproven wallet to a free receipt.
432
669
  *
670
+ * Each credential gets free access at most once. The gate records a
671
+ * credential when it grants free access, and records an authorization just
672
+ * before the paid path settles it; a recorded credential is refused with a
673
+ * verification error rather than passed on to payment. A payment that fails is
674
+ * released, so it can be retried. See the README for the limits of this. Free access is given only to credentials the gate can identify
675
+ * (EIP-3009 authorizations and Tempo proofs) whose remaining validity is within
676
+ * `maxCredentialLifetimeSeconds`. The default store is bounded and shared by
677
+ * every gated route in the process; pass a shared `replayStore` for production
678
+ * traffic or when the route runs on more than one instance.
679
+ *
433
680
  * Conditions are evaluated across the 35 chains this adapter reaches (32 EVM +
434
681
  * Solana + XRPL + Bitcoin; the engine itself covers 38). Six condition types
435
682
  * are typed here: token_balance, nft_ownership, eas_attestation, farcaster_id,
@@ -444,8 +691,9 @@ export function normalizeProvenPayer(
444
691
  * ```ts
445
692
  * import { conditionGate } from '@insumermodel/mppx-condition-gate'
446
693
  *
447
- * const gated = conditionGate(tempoCharge, {
694
+ * const gated = conditionGate(evmCharge, {
448
695
  * // Return only a payer this method has proven for THIS request.
696
+ * // mppx's EVM charge reports the recovered signer as `payer`.
449
697
  * provenPayer: (details) => (details as { payer?: string }).payer ?? null,
450
698
  * conditions: [
451
699
  * { type: 'eas_attestation', template: 'coinbase_verified_account', chainId: 8453 },
@@ -462,6 +710,21 @@ export function conditionGate(
462
710
  options: ConditionGateOptions,
463
711
  ): Method.AnyServer {
464
712
  const { conditions, matchMode = 'any', cacheTtlSeconds = 300, provenPayer } = options
713
+ const lifetimeSeconds = options.maxCredentialLifetimeSeconds ?? DEFAULT_MAX_CREDENTIAL_LIFETIME_SECONDS
714
+ if (typeof lifetimeSeconds !== 'number' || !Number.isFinite(lifetimeSeconds) || lifetimeSeconds < 0) {
715
+ throw new Error('mppx-condition-gate: maxCredentialLifetimeSeconds must be a finite number of seconds, 0 or more.')
716
+ }
717
+ const maxLifetimeMs = lifetimeSeconds * 1000
718
+
719
+ const { replayStore } = options
720
+ if (
721
+ replayStore !== undefined &&
722
+ typeof replayStore.tryClaim !== 'function' &&
723
+ typeof replayStore.update !== 'function'
724
+ ) {
725
+ throw new Error('mppx-condition-gate: replayStore must provide an atomic tryClaim or update (an mppx AtomicStore).')
726
+ }
727
+ const recorder = replayStore ? storeRecorder(replayStore) : defaultRecorder
465
728
 
466
729
  const anyServer = server as unknown as {
467
730
  verify: (params: any) => Promise<any>
@@ -480,9 +743,37 @@ export function conditionGate(
480
743
  const canGate =
481
744
  typeof provenPayer === 'function' && typeof originalValidate === 'function'
482
745
 
483
- /** Returns a free receipt, or null to take the paid path. */
484
- async function tryFree(params: any): Promise<any | null> {
485
- if (!canGate) return null
746
+ const refuse = () =>
747
+ new Errors.VerificationFailedError({ reason: 'this credential has already been presented' })
748
+
749
+ /** Attestation result for a payer, from the cache or a fresh call. Throws on API error. */
750
+ async function evaluate(payer: { address: string; type: WalletType }) {
751
+ const key = cacheKey(payer.address, conditions)
752
+ const cached = cache.get(key)
753
+ if (cached && cached.expiresAt > Date.now()) return cached
754
+
755
+ const result = await callAttest(payer.address, payer.type, conditions, options)
756
+ const attestation = result.data.attestation
757
+ const pass =
758
+ matchMode === 'all'
759
+ ? attestation.pass
760
+ : attestation.results.some((r) => r.met)
761
+ const entry = {
762
+ pass,
763
+ attestationId: attestation.id,
764
+ expiresAt: Date.now() + cacheTtlSeconds * 1000,
765
+ }
766
+ cache.set(key, entry)
767
+ return entry
768
+ }
769
+
770
+ type Attempt =
771
+ | { free: true; receipt: any }
772
+ | { free: false; record?: { key: string; expires: number; owner: string; payment: boolean } }
773
+
774
+ /** Decides between a free receipt and the paid path. Throws to refuse a repeat. */
775
+ async function tryFree(params: any): Promise<Attempt> {
776
+ if (!canGate) return { free: false }
486
777
 
487
778
  let details: unknown
488
779
  try {
@@ -490,68 +781,124 @@ export function conditionGate(
490
781
  details = (validation as { details?: unknown } | undefined)?.details
491
782
  } catch {
492
783
  // Credential is not currently acceptable → let the payment method decide.
493
- return null
784
+ return { free: false }
494
785
  }
495
786
 
496
787
  let resolved: ProvenPayer
497
788
  try {
498
789
  resolved = await provenPayer!(details)
499
790
  } catch {
500
- return null
791
+ return { free: false }
501
792
  }
502
793
 
503
794
  const payer = normalizeProvenPayer(resolved)
504
- if (!payer) return null
795
+ if (!payer) return { free: false }
796
+
797
+ const credential = params?.credential
798
+ const key = credentialKey(server.name, credential, payer.address)
799
+ const expiry = credentialExpiry(credential)
800
+ if (key === null || expiry === null) return { free: false }
801
+
802
+ const record = {
803
+ key,
804
+ owner: payer.address.toLowerCase(),
805
+ expires: Math.min(expiry + REPLAY_SKEW_MS, Number.MAX_SAFE_INTEGER),
806
+ // Only an authorization moves value; a Tempo proof is accepted on zero-amount routes.
807
+ payment: credential?.payload?.type === 'authorization',
808
+ }
505
809
 
506
- const key = cacheKey(payer.address, conditions)
507
- const cached = cache.get(key)
508
- if (cached && cached.expiresAt > Date.now()) {
509
- if (!cached.pass) return null
510
- return {
810
+ // A recorded credential is refused before any attestation is spent on it.
811
+ let taken: boolean
812
+ try {
813
+ taken = await recorder.isTaken(key)
814
+ } catch {
815
+ return { free: false, record }
816
+ }
817
+ if (taken) {
818
+ // A repeated Tempo proof moves no value, so the method decides; a payment is refused.
819
+ if (!record.payment) return { free: false }
820
+ throw refuse()
821
+ }
822
+
823
+ if (expiry - Date.now() > maxLifetimeMs) return { free: false, record }
824
+
825
+ let evaluation: CacheEntry
826
+ try {
827
+ evaluation = await evaluate(payer)
828
+ } catch {
829
+ // Attestation error → paid path.
830
+ return { free: false, record }
831
+ }
832
+ if (!evaluation.pass) return { free: false, record }
833
+
834
+ let claimed: boolean
835
+ try {
836
+ claimed = await recorder.claim(key, record.expires, record.owner, 'free')
837
+ } catch {
838
+ // The store cannot record the credential, so it gets no free access.
839
+ return { free: false, record }
840
+ }
841
+ if (!claimed) {
842
+ if (!record.payment) return { free: false }
843
+ throw refuse()
844
+ }
845
+
846
+ return {
847
+ free: true,
848
+ receipt: {
511
849
  method: server.name,
512
- reference: `condition-gate:free:${cached.attestationId}`,
850
+ reference: `condition-gate:free:${evaluation.attestationId}`,
513
851
  status: 'success' as const,
514
852
  timestamp: new Date().toISOString(),
853
+ },
854
+ }
855
+ }
856
+
857
+ /** Runs the gate, then the original method on the paid path. */
858
+ async function gated(original: (params: any) => Promise<any>, params: any) {
859
+ const attempt = await tryFree(params)
860
+ if (attempt.free) return attempt.receipt
861
+
862
+ const record = attempt.record
863
+ let claimed = false
864
+ // Only a payment is recorded on this path. A Tempo proof moves no value, so
865
+ // the method's own rules decide whether it may be presented again.
866
+ if (record && record.payment) {
867
+ // Claimed before settlement, so a copy presented while this payment is
868
+ // in flight is refused too. Kept until the credential itself expires.
869
+ let result: boolean | null
870
+ try {
871
+ result = await recorder.claim(record.key, record.expires, record.owner, 'paid')
872
+ } catch {
873
+ // Cannot record: pay as bare mppx would; the method's own replay protection applies.
874
+ result = null
515
875
  }
876
+ if (result === false) throw refuse()
877
+ claimed = result === true
516
878
  }
517
879
 
518
880
  try {
519
- const result = await callAttest(payer.address, payer.type, conditions, options)
520
- const attestation = result.data.attestation
521
-
522
- const pass =
523
- matchMode === 'all'
524
- ? attestation.pass
525
- : attestation.results.some((r) => r.met)
526
-
527
- cache.set(key, {
528
- pass,
529
- attestationId: attestation.id,
530
- expiresAt: Date.now() + cacheTtlSeconds * 1000,
531
- })
532
-
533
- if (!pass) return null
534
- return {
535
- method: server.name,
536
- reference: `condition-gate:free:${attestation.id}`,
537
- status: 'success' as const,
538
- timestamp: new Date().toISOString(),
881
+ return await original(params)
882
+ } catch (error) {
883
+ // A payment that fails is released, so the holder can retry with the same credential.
884
+ if (claimed) {
885
+ try {
886
+ await recorder.release(record!.key)
887
+ } catch {
888
+ // Stays recorded until it expires.
889
+ }
539
890
  }
540
- } catch {
541
- // Attestation error → paid path.
542
- return null
891
+ throw error
543
892
  }
544
893
  }
545
894
 
546
895
  const wrapped = { ...server } as unknown as Record<string, unknown>
547
896
 
548
897
  if (typeof originalBroadcast === 'function') {
549
- wrapped.broadcast = async (params: any) =>
550
- (await tryFree(params)) ?? originalBroadcast(params)
898
+ wrapped.broadcast = async (params: any) => gated(originalBroadcast, params)
551
899
  }
552
900
 
553
- wrapped.verify = async (params: any) =>
554
- (await tryFree(params)) ?? originalVerify(params)
901
+ wrapped.verify = async (params: any) => gated(originalVerify, params)
555
902
 
556
903
  return wrapped as unknown as Method.AnyServer
557
904
  }