wayleave 0.2.0 → 0.3.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/README.md CHANGED
@@ -27,6 +27,7 @@ Only the first lane is cryptographic. The other three are read off what the clie
27
27
 
28
28
  ```js
29
29
  import Wayleave from 'wayleave';
30
+ import { coinbaseFacilitator } from 'wayleave/x402';
30
31
 
31
32
  const gate = new Wayleave({
32
33
  directories: {
@@ -38,14 +39,22 @@ const gate = new Wayleave({
38
39
  },
39
40
  rateLimits: { declared_agent: 10 },
40
41
  pricedPaths: { '/api/premium': 0.05 }, // agents pay 5¢/call, humans free
41
- verifyPayment: (proof, { price, resource }) =>
42
- facilitator.confirmSettled(proof, price, resource), // REQUIRED to sell
42
+ verifyPayment: coinbaseFacilitator({ // REQUIRED to sell
43
+ apiKeyId: process.env.CDP_API_KEY_ID,
44
+ apiKeySecret: process.env.CDP_API_KEY_SECRET,
45
+ receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
46
+ }),
43
47
  onEvent: e => queueForBilling(e), // metering hook; never blocks serving
44
48
  });
45
49
 
46
- app.use(gate.express());
50
+ app.use(gate.express()); // async: it awaits your verifier
47
51
  ```
48
52
 
53
+ **If you sell anything, use `gate.express()` or `gate.handleAsync()`.**
54
+ Confirming settlement is a network call, so a real `verifyPayment` returns a
55
+ Promise. The synchronous `handle()` cannot await one and will tell you so
56
+ rather than quietly denying every payment.
57
+
49
58
  ## Guarantees, honestly stated
50
59
 
51
60
  - Signature verification is real Ed25519 over an RFC 9421 signature base — forged keys, tampered requests, expired signatures, and replay-farming windows are all rejected. Tested adversarially, and against other implementations' wire formats rather than only its own.
@@ -59,13 +68,13 @@ app.use(gate.express());
59
68
 
60
69
  ## Status
61
70
 
62
- v0.2.0 — verification, lanes, policy, pricing, and the metering hook, all under test (70 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
71
+ v0.3.0 — verification, lanes, policy, pricing, and the metering hook, all under test (101 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, spoofed operator identity, and cross-implementation wire formats). Payment settlement network integration is in progress; the 402 challenge is built in and settlement confirmation is a function you supply.
63
72
 
64
73
  0.1.5 closes a hole worth naming: before it, a priced route accepted a payment proof that any agent could derive from the 402 challenge it had just been sent — free passage, recorded as revenue. Settlement confirmation is now yours to supply and denies by default. If you shipped 0.1.4 or earlier on a priced route, upgrade.
65
74
 
66
75
  0.1.6 addresses the other half of the same question. Payment could be faked; so could being human. `strictPricedPaths` lets a priced route demand positive evidence instead of accepting the absence of a bot signal as one.
67
76
 
68
- 0.2.0 turns three hardcoded strategies into seams — state, metering, and key lookup — so shared storage, durable billing, and key rotation become configuration rather than a rewrite. Defaults are unchanged; see [Extending it](#extending-it).
77
+ 0.2.1 ships `wayleave/meter`, a buffering and retrying sink for the hosted meter. 0.2.0 turns three hardcoded strategies into seams — state, metering, and key lookup — so shared storage, durable billing, and key rotation become configuration rather than a rewrite. Defaults are unchanged; see [Extending it](#extending-it).
69
78
 
70
79
  ## How payment actually works
71
80
 
@@ -159,6 +168,28 @@ new Wayleave({
159
168
  Every metering event carries `v` (schema version) and `idempotencyKey`, so a
160
169
  sink that retries can be deduplicated at the far end rather than double-billing.
161
170
 
171
+ A buffering, retrying sink ships in the box:
172
+
173
+ ```js
174
+ import Wayleave from 'wayleave';
175
+ import { MeterSink } from 'wayleave/meter';
176
+
177
+ const sink = new MeterSink({
178
+ endpoint: 'https://meter.wayleave.dev',
179
+ apiKey: process.env.WAYLEAVE_METER_KEY,
180
+ onError: (err, info) => log.warn({ err, info }, 'meter'),
181
+ });
182
+
183
+ const gate = new Wayleave({ pricedPaths: { '/api/premium': 0.05 }, sink });
184
+ process.on('SIGTERM', () => sink.close()); // drain, or lose what is buffered
185
+ ```
186
+
187
+ `emit` is synchronous, non-throwing, and does no I/O — a billing backend
188
+ having a bad day must not become your customers having one. Retries are safe
189
+ because of the idempotency key: sending a batch twice costs a round trip, not
190
+ a double-billed customer. The buffer is bounded, and anything shed is counted
191
+ and reported rather than dropped quietly.
192
+
162
193
  `verifyAgentIP` is the one that changes a lane. A `user-agent` naming a known
163
194
  operator is a claim; when that operator publishes its ranges the claim is
164
195
  checkable. Returning `false` moves the request to `suspected_bot` — a false
package/index.d.ts CHANGED
@@ -116,7 +116,8 @@ export interface PaymentContext {
116
116
  export type PaymentVerifier = (
117
117
  proof: string,
118
118
  ctx: PaymentContext
119
- ) => boolean | { ok: boolean; ref?: string; reason?: string };
119
+ ) => boolean | { ok: boolean; ref?: string; reason?: string }
120
+ | Promise<boolean | { ok: boolean; ref?: string; reason?: string }>;
120
121
 
121
122
  /** x402-shaped payment challenge returned with a 402. */
122
123
  export interface PaymentChallenge {
@@ -315,6 +316,12 @@ export interface WayleaveOptions {
315
316
  * billing must never break serving.
316
317
  */
317
318
  onEvent?: (event: MeterEvent) => void;
319
+ /**
320
+ * Shorthand for wiring a `MeterSink` yourself: `meter: { apiKey }` builds
321
+ * one pointed at meter.wayleave.dev. Pass `sink` directly for full control;
322
+ * an explicit `sink` always wins.
323
+ */
324
+ meter?: { apiKey: string; endpoint?: string; [k: string]: unknown };
318
325
  }
319
326
 
320
327
  /** Build the `@signature-params` value for the Web Bot Auth profile. */
@@ -364,6 +371,12 @@ export function classify(
364
371
 
365
372
  export class Wayleave {
366
373
  constructor(opts?: WayleaveOptions);
374
+ /**
375
+ * Async twin of `handle`. Required if `verifyPayment` returns a Promise —
376
+ * which any real facilitator does, since confirming settlement is a network
377
+ * call. `express()` uses this.
378
+ */
379
+ handleAsync(req: WayleaveRequest, now?: number, paymentProof?: string): Promise<Decision>;
367
380
  /** Classify, apply policy, price, and emit the metering event. */
368
381
  handle(
369
382
  req: WayleaveRequest,
package/index.js CHANGED
@@ -19,6 +19,9 @@
19
19
 
20
20
  import { createPublicKey, verify as edVerify, sign as edSign,
21
21
  createHash, randomUUID } from 'node:crypto';
22
+ // Same package, zero dependencies, ~9KB. Importing it unconditionally costs
23
+ // nothing measurable and makes `meter: { apiKey }` work without ceremony.
24
+ import { MeterSink } from './meter.js';
22
25
 
23
26
  export const LANES = Object.freeze({
24
27
  VERIFIED: 'verified_agent',
@@ -351,6 +354,24 @@ export class DirectSink {
351
354
  }
352
355
  }
353
356
 
357
+ /**
358
+ * Turn whatever a verifier returned into a verdict.
359
+ *
360
+ * A Promise reaching here means an async verifier was used on the synchronous
361
+ * path. Reading `.ok` off a Promise yields undefined and denies silently —
362
+ * which is precisely how the documented facilitator example failed from 0.1.5
363
+ * to 0.2.1. It now says so instead.
364
+ */
365
+ function normalizeVerdict(r) {
366
+ if (r && typeof r.then === 'function')
367
+ return { ok: false, reason:
368
+ 'verifyPayment returned a Promise — use gate.handleAsync() or gate.express(), ' +
369
+ 'which await it. The synchronous handle() cannot.' };
370
+ if (r === true) return { ok: true, ref: null };
371
+ if (r && r.ok === true) return { ok: true, ref: r.ref || null };
372
+ return { ok: false, reason: (r && r.reason) || 'not settled' };
373
+ }
374
+
354
375
  // ── the gate ────────────────────────────────────────────────────────────
355
376
 
356
377
  export class Wayleave {
@@ -408,7 +429,19 @@ export class Wayleave {
408
429
  // State and metering are interfaces, not implementations. Swap either for
409
430
  // a shared or durable one without the gate noticing. See MemoryStore.
410
431
  this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
411
- this.sink = opts.sink || new DirectSink(this.onEvent);
432
+
433
+ // `meter: { apiKey }` is shorthand for wiring a MeterSink yourself. It
434
+ // exists because the two-line version of this was the most common thing
435
+ // people got wrong: forgetting to drain on shutdown, or handing the sink
436
+ // a callback that throws. Pass `sink` directly if you want control.
437
+ this.sink = opts.sink
438
+ || (opts.meter?.apiKey
439
+ ? new MeterSink({
440
+ endpoint: 'https://meter.wayleave.dev',
441
+ ...opts.meter,
442
+ })
443
+ : null)
444
+ || new DirectSink(this.onEvent);
412
445
 
413
446
  // Identifies events from this process so a retrying sink can dedup. A
414
447
  // counter alone would collide across instances; a timestamp alone would
@@ -437,6 +470,28 @@ export class Wayleave {
437
470
  return { lane, identity: ident, ...d, evidence };
438
471
  }
439
472
 
473
+ /**
474
+ * Async twin of handle(), for verifiers that talk to a network.
475
+ *
476
+ * Identical in every respect except that it can await your verifyPayment.
477
+ * Use this whenever you actually sell something; the Express adapter does.
478
+ */
479
+ async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
480
+ const c = classify(req, this.directories, now,
481
+ { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
482
+ const { lane, agentId, evidence } = c;
483
+ const ident = agentId || `${lane}:${this._client(req)}`;
484
+ const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
485
+ const d = await this._decideAsync(req, lane, ident, now, proof, c);
486
+ const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
487
+ t: now, path: req.path, lane, identity: ident,
488
+ status: d.status, why: d.why, evidence,
489
+ billedUsd: d.billed || 0 };
490
+ if (d.paymentRef) entry.paymentRef = d.paymentRef;
491
+ this.sink.emit(entry);
492
+ return { lane, identity: ident, ...d, evidence };
493
+ }
494
+
440
495
  /**
441
496
  * Who to rate-limit. A cryptographic identity is authoritative; a network
442
497
  * address is at least expensive to rotate. A header is neither.
@@ -449,6 +504,7 @@ export class Wayleave {
449
504
  return req.ip || 'unknown';
450
505
  }
451
506
 
507
+
452
508
  /**
453
509
  * Positive evidence that a person is present, which only your application
454
510
  * holds — a signed-in session, a cookie you set, a challenge you passed.
@@ -470,7 +526,17 @@ export class Wayleave {
470
526
  return this.store.hit(ident, this.rateWindow, now);
471
527
  }
472
528
 
473
- _decide(req, lane, ident, now, paymentProof, c) {
529
+ /**
530
+ * Everything up to the payment question.
531
+ *
532
+ * Split out so the sync and async paths share one copy of the policy,
533
+ * replay, and rate-limit logic. Two copies of this would drift, and the
534
+ * half that drifted would be the one nobody tested.
535
+ *
536
+ * Returns either a final decision, or a marker saying a priced route was
537
+ * matched and a payment verdict is needed.
538
+ */
539
+ _preDecide(req, lane, ident, now, paymentProof, c) {
474
540
  for (const [prefix, allow] of this.rules[lane] || []) {
475
541
  if (req.path.startsWith(prefix)) {
476
542
  if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
@@ -508,12 +574,10 @@ export class Wayleave {
508
574
  : 'payment required for agent access';
509
575
  return { status: 402, why, challenge };
510
576
  }
511
- const v = this._checkPayment(paymentProof, {
512
- price, resource: prefix, identity: ident, path: req.path, now });
513
- if (v.ok)
514
- return { status: 200, billed: price, paymentRef: v.ref,
515
- why: `settled $${price}` };
516
- return { status: 402, why: `payment proof rejected: ${v.reason}`, challenge };
577
+ // Hand back to the caller, which knows whether it can await.
578
+ return { needsPayment: true, challenge, price,
579
+ ctx: { price, resource: prefix, identity: ident,
580
+ path: req.path, now } };
517
581
  }
518
582
  }
519
583
  }
@@ -522,6 +586,28 @@ export class Wayleave {
522
586
  return { status: 200, why: `${lane} allowed` };
523
587
  }
524
588
 
589
+ /** Turn a payment verdict into the final decision. Shared by both paths. */
590
+ _settleDecision(v, pre) {
591
+ if (v.ok)
592
+ return { status: 200, billed: pre.price, paymentRef: v.ref,
593
+ why: `settled $${pre.price}` };
594
+ return { status: 402, why: `payment proof rejected: ${v.reason}`,
595
+ challenge: pre.challenge };
596
+ }
597
+
598
+ _decide(req, lane, ident, now, paymentProof, c) {
599
+ const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
600
+ if (!pre.needsPayment) return pre;
601
+ return this._settleDecision(this._checkPayment(paymentProof, pre.ctx), pre);
602
+ }
603
+
604
+ async _decideAsync(req, lane, ident, now, paymentProof, c) {
605
+ const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
606
+ if (!pre.needsPayment) return pre;
607
+ return this._settleDecision(
608
+ await this._checkPaymentAsync(paymentProof, pre.ctx), pre);
609
+ }
610
+
525
611
  /**
526
612
  * Settlement is somebody else's job — a facilitator that actually moved the
527
613
  * money. This asks yours and believes nothing else. A verifier that throws
@@ -533,15 +619,32 @@ export class Wayleave {
533
619
  let r;
534
620
  try { r = this.verifyPayment(proof, ctx); }
535
621
  catch (e) { return { ok: false, reason: `verifier threw: ${e.message}` }; }
536
- if (r === true) return { ok: true, ref: null };
537
- if (r && r.ok === true) return { ok: true, ref: r.ref || null };
538
- return { ok: false, reason: (r && r.reason) || 'not settled' };
622
+ return normalizeVerdict(r);
623
+ }
624
+
625
+ /**
626
+ * The async twin of _checkPayment.
627
+ *
628
+ * Every real facilitator is a network call, so a useful verifier returns a
629
+ * Promise. The synchronous path cannot await one and refuses it loudly
630
+ * rather than reading `.ok` off a Promise and silently denying — which is
631
+ * what shipped from 0.1.5 to 0.2.1, and meant the documented way to sell
632
+ * anything never worked.
633
+ */
634
+ async _checkPaymentAsync(proof, ctx) {
635
+ if (!this.verifyPayment)
636
+ return { ok: false, reason: 'no verifyPayment configured' };
637
+ try {
638
+ return normalizeVerdict(await this.verifyPayment(proof, ctx));
639
+ } catch (e) {
640
+ return { ok: false, reason: `verifier threw: ${e.message}` };
641
+ }
539
642
  }
540
643
 
541
644
  /** Express/Connect adapter: app.use(wayleave.express()) */
542
645
  express() {
543
- return (req, res, next) => {
544
- const r = this.handle({
646
+ return async (req, res, next) => {
647
+ const r = await this.handleAsync({
545
648
  method: req.method, path: req.path || req.url,
546
649
  authority: req.headers.host || '',
547
650
  headers: req.headers,
package/meter.d.ts ADDED
@@ -0,0 +1,74 @@
1
+ import type { MeterEvent, Sink } from './index.js';
2
+
3
+ export interface MeterSinkOptions {
4
+ /** Base URL of your meter, e.g. "https://meter.wayleave.dev". */
5
+ endpoint: string;
6
+ /** API key issued by the meter. Sent as a bearer token. */
7
+ apiKey: string;
8
+ /** Drain interval in ms. Default 5000. */
9
+ flushMs?: number;
10
+ /** Events per request. Default 500; the meter caps batches at 1000. */
11
+ maxBatch?: number;
12
+ /**
13
+ * Hard ceiling on buffered events. Default 10000. Past this the oldest are
14
+ * shed — counted in `stats.dropped` and reported through `onError`, never
15
+ * dropped silently.
16
+ */
17
+ maxBuffer?: number;
18
+ /** Retries per batch, exponential backoff with jitter. Default 5. */
19
+ maxRetries?: number;
20
+ /** Per-request timeout in ms. Default 10000. */
21
+ timeoutMs?: number;
22
+ /**
23
+ * Observability hook for drops, permanent rejections, and unreachable
24
+ * meters. Anything it throws is swallowed — a broken error handler must not
25
+ * reach the request path either.
26
+ */
27
+ onError?: (err: Error, info: Record<string, unknown>) => void;
28
+ /** Injectable for tests. Defaults to global fetch. */
29
+ fetch?: typeof fetch;
30
+ }
31
+
32
+ export interface MeterSinkStats {
33
+ queued: number;
34
+ sent: number;
35
+ /** Shed from a full buffer, or permanently rejected by the meter. */
36
+ dropped: number;
37
+ failedBatches: number;
38
+ /** Reported by the meter as already-seen. Retries are supposed to produce these. */
39
+ duplicates: number;
40
+ }
41
+
42
+ /**
43
+ * A `Sink` that buffers, batches, and retries against a wayleave meter.
44
+ *
45
+ * Retrying is safe because every event carries an `idempotencyKey` and the
46
+ * meter deduplicates on it: sending a batch twice costs a round trip, not a
47
+ * double-billed customer.
48
+ *
49
+ * ```js
50
+ * import Wayleave from 'wayleave';
51
+ * import { MeterSink } from 'wayleave/meter';
52
+ *
53
+ * const sink = new MeterSink({
54
+ * endpoint: 'https://meter.wayleave.dev',
55
+ * apiKey: process.env.WAYLEAVE_METER_KEY,
56
+ * onError: (err, info) => log.warn({ err, info }, 'meter'),
57
+ * });
58
+ *
59
+ * const gate = new Wayleave({ pricedPaths: { '/api/premium': 0.05 }, sink });
60
+ * process.on('SIGTERM', () => sink.close());
61
+ * ```
62
+ */
63
+ export class MeterSink implements Sink {
64
+ constructor(opts: MeterSinkOptions);
65
+ readonly stats: MeterSinkStats;
66
+ /** Synchronous, non-throwing, no I/O. Safe on the request path. */
67
+ emit(event: MeterEvent): void;
68
+ /** Drain the buffer. Never rejects; concurrent calls collapse into one. */
69
+ flush(): Promise<void>;
70
+ /** Stop accepting events and make a final drain. Call on SIGTERM. */
71
+ close(): Promise<MeterSinkStats>;
72
+ }
73
+
74
+ export default MeterSink;
package/meter.js ADDED
@@ -0,0 +1,230 @@
1
+ /**
2
+ * MeterSink — ships metering events to a wayleave meter.
3
+ *
4
+ * This is the concrete `sink` that 0.2.0's interface was shaped for. The
5
+ * default DirectSink hands each event straight to `onEvent` and swallows what
6
+ * it throws: correct for uptime, lossy for revenue. This one buffers, batches,
7
+ * and retries, so a meter that is briefly unreachable costs you nothing.
8
+ *
9
+ * Three rules govern every line in this file, in this order:
10
+ *
11
+ * 1. NEVER throw into the request path. A billing backend having a bad day
12
+ * must not become your customers having a bad day. `emit` cannot throw,
13
+ * cannot await, and cannot block.
14
+ *
15
+ * 2. NEVER lose an event silently. Dropping is sometimes unavoidable — a
16
+ * buffer that grows forever is an outage, not a feature — but a drop is
17
+ * always counted and always reported.
18
+ *
19
+ * 3. Retry freely. Every event carries an `idempotencyKey`, and the meter
20
+ * deduplicates on it. Sending the same batch twice costs a round trip,
21
+ * not a double-billed customer. This is the entire reason that field was
22
+ * added before there was anything to receive it.
23
+ *
24
+ * Zero dependencies: `fetch` is global from Node 18.
25
+ */
26
+
27
+ const DEFAULTS = {
28
+ flushMs: 5000,
29
+ maxBatch: 500,
30
+ maxBuffer: 10000,
31
+ maxRetries: 5,
32
+ timeoutMs: 10000,
33
+ };
34
+
35
+ export class MeterSink {
36
+ /**
37
+ * opts = {
38
+ * endpoint: 'https://meter.wayleave.dev', // required
39
+ * apiKey: 'wm_live_...', // required
40
+ * flushMs: 5000, // how often to drain the buffer
41
+ * maxBatch: 500, // events per request; the meter caps at 1000
42
+ * maxBuffer: 10000, // hard ceiling before shedding
43
+ * maxRetries: 5, // per batch, exponential backoff
44
+ * timeoutMs: 10000,
45
+ * onError: (err, info) => {} // observability hook; never called with
46
+ * // anything that should crash your process
47
+ * }
48
+ */
49
+ constructor(opts = {}) {
50
+ if (!opts.endpoint) throw new Error('MeterSink requires an endpoint');
51
+ if (!opts.apiKey) throw new Error('MeterSink requires an apiKey');
52
+
53
+ this.endpoint = String(opts.endpoint).replace(/\/$/, '');
54
+ this.apiKey = opts.apiKey;
55
+ this.flushMs = opts.flushMs ?? DEFAULTS.flushMs;
56
+ this.maxBatch = opts.maxBatch ?? DEFAULTS.maxBatch;
57
+ this.maxBuffer = opts.maxBuffer ?? DEFAULTS.maxBuffer;
58
+ this.maxRetries = opts.maxRetries ?? DEFAULTS.maxRetries;
59
+ this.timeoutMs = opts.timeoutMs ?? DEFAULTS.timeoutMs;
60
+ this.onError = typeof opts.onError === 'function' ? opts.onError : () => {};
61
+ this._fetch = opts.fetch || globalThis.fetch;
62
+
63
+ this._buffer = [];
64
+ this._inFlight = false;
65
+ this._closed = false;
66
+
67
+ /** Counters worth exposing: a silent drop is a billing hole. */
68
+ this.stats = { queued: 0, sent: 0, dropped: 0, failedBatches: 0, duplicates: 0 };
69
+
70
+ this._timer = setInterval(() => { this.flush(); }, this.flushMs);
71
+ // Do not hold the process open just to run a billing timer.
72
+ if (typeof this._timer.unref === 'function') this._timer.unref();
73
+ }
74
+
75
+ /**
76
+ * Called by the gate, once per request, on the hot path.
77
+ *
78
+ * Synchronous, non-throwing, and does no I/O. The only work here is a push
79
+ * and possibly scheduling a flush on a later tick.
80
+ */
81
+ emit(event) {
82
+ if (this._closed) return;
83
+ try {
84
+ if (this._buffer.length >= this.maxBuffer) {
85
+ // Shed the oldest. They have been failing longest and are the most
86
+ // likely to be stale; the newest are the most likely to succeed on
87
+ // the next attempt. Either choice loses billing data, which is why
88
+ // this is counted and surfaced rather than quietly absorbed.
89
+ const shed = this._buffer.shift();
90
+ this.stats.dropped++;
91
+ this.onError(new Error('meter buffer full, dropped oldest event'), {
92
+ dropped: this.stats.dropped, idempotencyKey: shed?.idempotencyKey,
93
+ });
94
+ }
95
+ this._buffer.push(event);
96
+ this.stats.queued++;
97
+
98
+ // Schedule rather than await: emit must return to the request handler
99
+ // immediately.
100
+ if (this._buffer.length >= this.maxBatch && !this._inFlight)
101
+ queueMicrotask(() => this.flush());
102
+ } catch (err) {
103
+ // Even the buffering path must not throw into serving.
104
+ try { this.onError(err, { phase: 'emit' }); } catch { /* nothing left to do */ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Drain the buffer. Safe to call at any time; concurrent calls collapse
110
+ * into the one already running.
111
+ *
112
+ * Never rejects. Failures are reported through `onError` and the events go
113
+ * back on the buffer for the next attempt.
114
+ */
115
+ async flush() {
116
+ if (this._inFlight || this._buffer.length === 0) return;
117
+ this._inFlight = true;
118
+ try {
119
+ while (this._buffer.length > 0) {
120
+ const batch = this._buffer.splice(0, this.maxBatch);
121
+ const ok = await this._sendWithRetry(batch);
122
+ if (!ok) {
123
+ // Put them back at the FRONT so ordering is preserved, and stop —
124
+ // hammering a meter that is down helps nobody.
125
+ this._buffer.unshift(...batch.slice(0, Math.max(0, this.maxBuffer - this._buffer.length)));
126
+ break;
127
+ }
128
+ }
129
+ } catch (err) {
130
+ try { this.onError(err, { phase: 'flush' }); } catch { /* ignore */ }
131
+ } finally {
132
+ this._inFlight = false;
133
+ }
134
+ }
135
+
136
+ async _sendWithRetry(batch) {
137
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
138
+ const r = await this._send(batch);
139
+
140
+ if (r.ok) {
141
+ this.stats.sent += r.accepted ?? batch.length;
142
+ this.stats.duplicates += r.duplicates ?? 0;
143
+ if (r.rejected?.length) {
144
+ // The meter refused specific events permanently — a schema mismatch,
145
+ // usually. Retrying cannot fix them and would wedge everything
146
+ // queued behind. Report and move on.
147
+ this.onError(new Error(`meter rejected ${r.rejected.length} event(s)`), {
148
+ phase: 'send', rejected: r.rejected,
149
+ });
150
+ }
151
+ return true;
152
+ }
153
+
154
+ // 4xx other than 429 is our bug, not a blip. Retrying a malformed or
155
+ // unauthorised request forever is how a buffer becomes a memory leak.
156
+ if (r.status && r.status >= 400 && r.status < 500 && r.status !== 429) {
157
+ this.stats.failedBatches++;
158
+ this.stats.dropped += batch.length;
159
+ this.onError(new Error(`meter rejected batch: ${r.status} ${r.error ?? ''}`.trim()), {
160
+ phase: 'send', status: r.status, dropped: batch.length, permanent: true,
161
+ });
162
+ return true; // consumed: do not requeue something that can never succeed
163
+ }
164
+
165
+ if (attempt < this.maxRetries) {
166
+ // Exponential backoff with jitter, so a fleet of instances coming back
167
+ // after an outage does not stampede the meter in lockstep.
168
+ const base = Math.min(30000, 500 * 2 ** attempt);
169
+ await sleep(base / 2 + Math.random() * base / 2);
170
+ }
171
+ }
172
+
173
+ this.stats.failedBatches++;
174
+ this.onError(new Error('meter unreachable after retries; events requeued'), {
175
+ phase: 'send', batch: batch.length,
176
+ });
177
+ return false;
178
+ }
179
+
180
+ async _send(batch) {
181
+ const ctrl = new AbortController();
182
+ const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
183
+ try {
184
+ const res = await this._fetch(`${this.endpoint}/v1/events`, {
185
+ method: 'POST',
186
+ headers: {
187
+ 'content-type': 'application/json',
188
+ authorization: `Bearer ${this.apiKey}`,
189
+ },
190
+ body: JSON.stringify({ events: batch }),
191
+ signal: ctrl.signal,
192
+ });
193
+ let body = null;
194
+ try { body = await res.json(); } catch { /* non-JSON body stays null */ }
195
+ if (!res.ok) return { ok: false, status: res.status, error: body?.error };
196
+ return { ok: true, ...body };
197
+ } catch (err) {
198
+ return { ok: false, status: 0, error: err.message };
199
+ } finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * Stop accepting events and make a final attempt to drain.
206
+ *
207
+ * Call this on SIGTERM. Without it, whatever is buffered at shutdown is
208
+ * revenue you never billed for. With it, a rolling deploy costs nothing.
209
+ */
210
+ async close() {
211
+ this._closed = true;
212
+ clearInterval(this._timer);
213
+ await this.flush();
214
+ return this.stats;
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Deliberately NOT unref'd, unlike the flush interval.
220
+ *
221
+ * The interval is a heartbeat and must not hold a process open. A backoff is
222
+ * an operation already in flight: unref'ing it lets the event loop drain
223
+ * mid-retry, which abandons events that were about to be delivered — during
224
+ * exactly the shutdown where `close()` is trying to save them.
225
+ */
226
+ function sleep(ms) {
227
+ return new Promise(r => setTimeout(r, ms));
228
+ }
229
+
230
+ export default MeterSink;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Charge AI agents for passage across your app. Verify agent signatures (Web Bot Auth / Ed25519), classify every request, and return 402 with a price on routes you choose. Humans browse free.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,16 +9,28 @@
9
9
  ".": {
10
10
  "types": "./index.d.ts",
11
11
  "default": "./index.js"
12
+ },
13
+ "./meter": {
14
+ "types": "./meter.d.ts",
15
+ "default": "./meter.js"
16
+ },
17
+ "./x402": {
18
+ "types": "./x402.d.ts",
19
+ "default": "./x402.js"
12
20
  }
13
21
  },
14
22
  "files": [
15
- "index.js",
16
- "index.d.ts",
23
+ "LICENSE",
17
24
  "README.md",
18
- "LICENSE"
25
+ "index.d.ts",
26
+ "index.js",
27
+ "meter.d.ts",
28
+ "meter.js",
29
+ "x402.d.ts",
30
+ "x402.js"
19
31
  ],
20
32
  "scripts": {
21
- "test": "node --test"
33
+ "test": "node --test *.test.js"
22
34
  },
23
35
  "engines": {
24
36
  "node": ">=18"
package/x402.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { PaymentVerifier } from './index.js';
2
+
3
+ export interface CoinbaseFacilitatorOptions {
4
+ /** CDP API key id. Yours. */
5
+ apiKeyId: string;
6
+ /** CDP API key secret. Yours. */
7
+ apiKeySecret: string;
8
+ /**
9
+ * The address YOU get paid at. Money moves agent → facilitator → here.
10
+ * Wayleave is never a hop and holds nothing.
11
+ */
12
+ receivingAddress: string;
13
+ /** Default 'base'. */
14
+ network?: string;
15
+ /** Token contract address; omit for the network default (USDC). */
16
+ asset?: string;
17
+ /** Asset decimals. 6 for USDC. */
18
+ decimals?: number;
19
+ /**
20
+ * true (default) submits the payment on-chain. false only verifies it,
21
+ * which is useful for a dry run but pays nobody.
22
+ */
23
+ settle?: boolean;
24
+ baseUrl?: string;
25
+ onError?: (err: Error, info: Record<string, unknown>) => void;
26
+ fetch?: typeof fetch;
27
+ }
28
+
29
+ /**
30
+ * A ready-made `verifyPayment` for the Coinbase x402 rail.
31
+ *
32
+ * Returns a Promise, so use it with `gate.express()` or `gate.handleAsync()`.
33
+ * The synchronous `handle()` cannot await it and will say so rather than
34
+ * silently deny.
35
+ */
36
+ export function coinbaseFacilitator(opts: CoinbaseFacilitatorOptions): PaymentVerifier;
37
+
38
+ /** Dollars → atomic units as a decimal string. Throws on excess precision. */
39
+ export function toAtomicUnits(usd: number | string, decimals?: number): string;
40
+
41
+ export default coinbaseFacilitator;
package/x402.js ADDED
@@ -0,0 +1,181 @@
1
+ /**
2
+ * wayleave/x402 — a ready-made `verifyPayment` for the Coinbase x402 rail.
3
+ *
4
+ * Before this existed, selling anything meant writing your own integration
5
+ * against the facilitator API: two endpoints, a request body shaped
6
+ * { x402Version, paymentPayload, paymentRequirements }, atomic-unit
7
+ * conversion, and a response that returns HTTP 200 even when the payment is
8
+ * bad. That is a lot of surface to get subtly wrong in the one place where
9
+ * being wrong means giving away data for free or billing for money that never
10
+ * moved.
11
+ *
12
+ * So it lives here instead, tested, and you configure it:
13
+ *
14
+ * import Wayleave from 'wayleave';
15
+ * import { coinbaseFacilitator } from 'wayleave/x402';
16
+ *
17
+ * const gate = new Wayleave({
18
+ * pricedPaths: { '/api/premium': 0.05 },
19
+ * strictPricedPaths: true,
20
+ * confirmHuman: req => Boolean(req.session?.userId),
21
+ * verifyPayment: coinbaseFacilitator({
22
+ * apiKeyId: process.env.CDP_API_KEY_ID,
23
+ * apiKeySecret: process.env.CDP_API_KEY_SECRET,
24
+ * receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
25
+ * }),
26
+ * });
27
+ *
28
+ * YOUR credentials, YOUR receiving address. Money moves agent -> facilitator
29
+ * -> you. Wayleave is not a hop, does not hold funds, and this module has no
30
+ * method that moves value — there is a test asserting that, because the day it
31
+ * gains one this stops being metering and becomes money transmission.
32
+ *
33
+ * Zero dependencies: fetch is global from Node 18.
34
+ */
35
+
36
+ const DEFAULT_BASE = 'https://api.cdp.coinbase.com/platform';
37
+ const X402_VERSION = 1;
38
+ const DEFAULT_DECIMALS = 6; // USDC and most stablecoins
39
+ const TIMEOUT_MS = 10000;
40
+
41
+ /**
42
+ * Dollars -> atomic units, as a decimal string.
43
+ *
44
+ * String arithmetic, not `usd * 10 ** decimals`, because floating point
45
+ * mangles money: 0.07 * 1e6 is 70000.00000000001 in JavaScript, and rounding
46
+ * that is how a ledger acquires a cent of drift per transaction.
47
+ */
48
+ export function toAtomicUnits(usd, decimals = DEFAULT_DECIMALS) {
49
+ const s = String(usd);
50
+ if (!/^\d+(\.\d+)?$/.test(s))
51
+ throw new Error(`amount must be a non-negative decimal, got "${usd}"`);
52
+ const [whole, frac = ''] = s.split('.');
53
+ if (frac.length > decimals)
54
+ throw new Error(`amount ${usd} is finer than the asset's ${decimals} decimals`);
55
+ return (whole + frac.padEnd(decimals, '0')).replace(/^0+(?=\d)/, '');
56
+ }
57
+
58
+ /**
59
+ * Build a `verifyPayment(proof, ctx)` for the Coinbase x402 facilitator.
60
+ *
61
+ * @param {object} opts
62
+ * @param {string} opts.apiKeyId CDP API key id
63
+ * @param {string} opts.apiKeySecret CDP API key secret
64
+ * @param {string} opts.receivingAddress YOUR address. Never ours.
65
+ * @param {string} [opts.network] 'base' (default) | 'base-sepolia' | ...
66
+ * @param {string} [opts.asset] token contract; omit for the network default
67
+ * @param {number} [opts.decimals] asset decimals, 6 for USDC
68
+ * @param {boolean} [opts.settle] true (default) submits on-chain.
69
+ * false only verifies — useful for a
70
+ * dry run, but nobody gets paid.
71
+ * @param {(err:Error, info:object)=>void} [opts.onError]
72
+ * @returns {(proof:string, ctx:object)=> Promise<{ok:boolean, ref?:string, reason?:string}>}
73
+ */
74
+ export function coinbaseFacilitator({
75
+ apiKeyId, apiKeySecret, receivingAddress,
76
+ network = 'base', asset = null, decimals = DEFAULT_DECIMALS,
77
+ settle = true, baseUrl = DEFAULT_BASE, onError = () => {}, fetch: f = fetch,
78
+ } = {}) {
79
+ if (!apiKeyId || !apiKeySecret)
80
+ throw new Error('coinbaseFacilitator requires apiKeyId and apiKeySecret');
81
+ if (!receivingAddress)
82
+ throw new Error('coinbaseFacilitator requires receivingAddress — the address YOU get paid at');
83
+
84
+ const base = baseUrl.replace(/\/$/, '');
85
+
86
+ return async function verifyPayment(proof, ctx) {
87
+ // The middleware hands us the proof as a string; agents send the signed
88
+ // payload as JSON. Accept either so a customer does not have to care.
89
+ let paymentPayload = proof;
90
+ if (typeof proof === 'string') {
91
+ try { paymentPayload = JSON.parse(proof); }
92
+ catch { return deny('payment proof is not a valid x402 payload'); }
93
+ }
94
+ if (!paymentPayload || typeof paymentPayload !== 'object')
95
+ return deny('no payment payload supplied');
96
+
97
+ let requirements;
98
+ try {
99
+ requirements = {
100
+ scheme: 'exact',
101
+ network,
102
+ // We assert what was required; the facilitator checks the payment
103
+ // matches. Under the exact scheme the transfer MUST equal this and
104
+ // MUST land at payTo, so a wrong amount or wrong address cannot come
105
+ // back valid. That is stronger than trusting a number the payer sent.
106
+ maxAmountRequired: toAtomicUnits(ctx.price, decimals),
107
+ resource: ctx.resource ?? ctx.path ?? '',
108
+ description: '',
109
+ mimeType: 'application/json',
110
+ payTo: receivingAddress,
111
+ maxTimeoutSeconds: 60,
112
+ ...(asset ? { asset } : {}),
113
+ };
114
+ } catch (err) {
115
+ return deny(`cannot build payment requirements: ${err.message}`);
116
+ }
117
+
118
+ const body = { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirements };
119
+ const path = settle ? '/v2/x402/settle' : '/v2/x402/verify';
120
+
121
+ let res;
122
+ try {
123
+ res = await post(f, `${base}${path}`, body, apiKeyId, apiKeySecret);
124
+ } catch (err) {
125
+ // Unreachable facilitator denies. An outage must degrade into unpaid,
126
+ // never into free passage recorded as revenue.
127
+ onError(err, { phase: 'facilitator', path });
128
+ return deny(`facilitator unreachable: ${err.message}`);
129
+ }
130
+
131
+ if (!res.ok) {
132
+ onError(new Error(`facilitator ${res.status}`), { phase: 'facilitator', path });
133
+ return deny(`facilitator returned ${res.status}`);
134
+ }
135
+
136
+ const d = res.data ?? {};
137
+
138
+ // CRITICAL: the facilitator answers HTTP 200 even when the payment is
139
+ // bad. Reading the status code alone books every rejected payment as
140
+ // revenue — which is exactly the bug 0.1.5 fixed, one layer down.
141
+ if (settle) {
142
+ if (d.success !== true)
143
+ return deny(`settlement failed: ${d.errorReason ?? 'no reason given'}`);
144
+ if (!d.transaction)
145
+ return deny('settlement reported success with nothing to reference');
146
+ return { ok: true, ref: String(d.transaction) };
147
+ }
148
+
149
+ if (d.isValid !== true)
150
+ return deny(`payment not valid: ${d.invalidReason ?? 'no reason given'}`);
151
+ return { ok: true, ref: d.payer ? `payer:${d.payer}` : null };
152
+ };
153
+ }
154
+
155
+ async function post(f, url, body, keyId, keySecret) {
156
+ const ctrl = new AbortController();
157
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
158
+ try {
159
+ const r = await f(url, {
160
+ method: 'POST',
161
+ headers: {
162
+ 'content-type': 'application/json',
163
+ 'cb-access-key': keyId,
164
+ 'cb-access-secret': keySecret,
165
+ },
166
+ body: JSON.stringify(body),
167
+ signal: ctrl.signal,
168
+ });
169
+ let data = null;
170
+ try { data = await r.json(); } catch { /* non-JSON body stays null */ }
171
+ return { ok: r.ok, status: r.status, data };
172
+ } finally {
173
+ clearTimeout(timer);
174
+ }
175
+ }
176
+
177
+ function deny(reason) {
178
+ return { ok: false, reason };
179
+ }
180
+
181
+ export default coinbaseFacilitator;