wayleave 0.2.1 → 0.4.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
@@ -4,9 +4,9 @@
4
4
  [![npm](https://img.shields.io/npm/v/wayleave.svg)](https://www.npmjs.com/package/wayleave)
5
5
  [![install size](https://img.shields.io/badge/dependencies-0-brightgreen)](https://www.npmjs.com/package/wayleave)
6
6
 
7
- **Charge AI agents for passage across your app.**
7
+ **Observe agent traffic. Control access. Charge payment-ready agents.**
8
8
 
9
- A *wayleave* is the fee paid for the right of passage across private land — utility companies have paid them for 150 years to run cables through property. AI agents are about to cross your app millions of times for free. Charge them a wayleave.
9
+ Wayleave is open-source middleware for request classification, signed-agent verification, route-level policies and pricing. The [hosted Meter](https://meter.wayleave.dev) records traffic and settlement activity.
10
10
 
11
11
  Zero dependencies. Node's native crypto only. TypeScript declarations included.
12
12
 
@@ -14,7 +14,7 @@ Zero dependencies. Node's native crypto only. TypeScript declarations included.
14
14
 
15
15
  Every request gets classified into a lane:
16
16
 
17
- - **verified_agent** — valid Ed25519 signature (Web Bot Auth profile, RFC 9421) against a key directory you trust
17
+ - **verified_agent** — valid Ed25519 HTTP Message Signature (RFC 9421, compatible with emerging Web Bot Auth) against a key directory you trust
18
18
  - **declared_agent** — identifies as a bot, no valid signature
19
19
  - **suspected_bot** — automation fingerprints without disclosure (an *invalid* signature lands here too — faking verification is the strongest fraud signal there is)
20
20
  - **human** — browser-shaped traffic, which means *nothing gave it away*, not that a person is there
@@ -23,11 +23,45 @@ Then policy runs per lane: allow, deny, rate-limit — and on routes you price,
23
23
 
24
24
  Only the first lane is cryptographic. The other three are read off what the client says about itself, so a bot willing to lie reaches the human lane — see [Guarantees](#guarantees-honestly-stated), and set `strictPricedPaths` on anything you charge for.
25
25
 
26
- ## Quickstart
26
+ ## Quickstart: observe first
27
+
28
+ ```sh
29
+ npm install wayleave
30
+ ```
31
+
32
+ [Create a Meter account](https://meter.wayleave.dev/account.html), issue an API key,
33
+ and set `WAYLEAVE_METER_KEY` in your server environment. Signing in does not
34
+ start billing. The optional hosted Meter trial requires a card and becomes
35
+ $49/month after 3 days unless canceled; see your account for current terms.
36
+
37
+ In your existing Express app:
27
38
 
28
39
  ```js
29
40
  import Wayleave from 'wayleave';
30
41
 
42
+ if (!process.env.WAYLEAVE_METER_KEY) {
43
+ throw new Error('Set WAYLEAVE_METER_KEY before starting the app');
44
+ }
45
+ const gate = new Wayleave({
46
+ meter: { apiKey: process.env.WAYLEAVE_METER_KEY },
47
+ });
48
+ app.use(gate.express()); // before the routes you want to observe
49
+ ```
50
+
51
+ Send a request to your app, allow the buffer to flush, then open the
52
+ [Meter dashboard](https://meter.wayleave.dev/app.html). No pricing, blocking,
53
+ or rate limits are configured in this example. Drain `gate.sink.close()`
54
+ during your application's graceful shutdown. Keep the key server-side.
55
+
56
+ The middleware also works without an account: omit `meter` and use `onEvent`
57
+ to send events to your own backend. See [integration docs](https://meter.wayleave.dev/docs.html).
58
+
59
+ ## Add access rules and payments
60
+
61
+ ```js
62
+ import Wayleave from 'wayleave';
63
+ import { coinbaseFacilitator } from 'wayleave/x402';
64
+
31
65
  const gate = new Wayleave({
32
66
  directories: {
33
67
  'https://agents.anthropic.example/keys': { 'claude-1': publicKeyOrRawB64 },
@@ -38,19 +72,28 @@ const gate = new Wayleave({
38
72
  },
39
73
  rateLimits: { declared_agent: 10 },
40
74
  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
75
+ verifyPayment: coinbaseFacilitator({ // REQUIRED to sell
76
+ apiKeyId: process.env.CDP_API_KEY_ID,
77
+ apiKeySecret: process.env.CDP_API_KEY_SECRET,
78
+ receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
79
+ }),
43
80
  onEvent: e => queueForBilling(e), // metering hook; never blocks serving
44
81
  });
45
82
 
46
- app.use(gate.express());
83
+ app.use(gate.express()); // async: it awaits your verifier
47
84
  ```
48
85
 
86
+ **If you sell anything, use `gate.express()` or `gate.handleAsync()`.**
87
+ Confirming settlement is a network call, so a real `verifyPayment` returns a
88
+ Promise. The synchronous `handle()` cannot await one and will tell you so
89
+ rather than quietly denying every payment.
90
+
49
91
  ## Guarantees, honestly stated
50
92
 
51
93
  - 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.
94
+ - A signature is single-use only when the signer sends a nonce. `signRequest(..., nonce)` will issue one (`true` for random, or supply your own) and the gate then refuses a repeat. Without a nonce there is nothing to remember a request by, and a captured signature stays usable until it expires — on any path of the host, since the Web Bot Auth profile covers only `@authority`. Opt in when you sign per attempt; leave it off if your client retries by resending the same headers.
52
95
  - Signature parameters are parsed as an RFC 9421 dictionary: order-independent, `alg` enforced as Ed25519, any signature label, and the signature base is built from whatever components the signer declared. Requests signed in Cloudflare's documented format verify.
53
- - Sub-millisecond per request. Your latency budget won't notice.
96
+ - Local verification is benchmarked by the tests; latency depends on your hardware and configuration. Payment-provider network calls add latency.
54
97
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
55
98
  - Key rotation has a seam but no implementation. `directories` accepts a resolver function you can point at a cache, but there is no JWKS fetcher in the box and the resolver must not block. Wiring that cache is still your job.
56
99
  - **A bot that sends a browser `user-agent` and an `accept-language` header is classified `human` and crosses priced routes free.** Those two headers are the entire bypass. The `human` lane is a fall-through — it is reached by tripping none of the automation tells, which is absence of evidence, not evidence of a person. There is no TLS fingerprinting and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. (`verifyAgentIP` checks a *declared* operator against its published ranges — it does nothing about a request claiming to be a browser, which is this bypass.) `strictPricedPaths: true` inverts the burden on priced routes so that only a verified signature or your own `confirmHuman(req)` crosses. Recommended wherever there is a price.
@@ -59,7 +102,7 @@ app.use(gate.express());
59
102
 
60
103
  ## Status
61
104
 
62
- v0.2.1 — verification, lanes, policy, pricing, and the metering hook, all under test (87 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.
105
+ 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
106
 
64
107
  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
108
 
@@ -76,8 +119,8 @@ Pay-at-the-door, not IOUs. On a route you price:
76
119
  2. A wallet-carrying agent signs payment and retries with proof attached.
77
120
  3. The proof is verified and settlement executes on a licensed rail
78
121
  (x402 facilitator) — money moves to your account.
79
- 4. Only then: `200`. No settled payment, no passage. Data never moves
80
- on a promise.
122
+ 4. Access can be released after payment verification. Your verifier must
123
+ check the payment with the configured provider before returning success.
81
124
 
82
125
  Agents without wallets (most crawlers today) are simply turned away on
83
126
  priced routes — you aren't paid by them, but you also never serve them
@@ -85,9 +128,9 @@ free. The ledger shows exactly how much turned-away demand is standing
85
128
  at your gate.
86
129
 
87
130
  Wayleave never holds funds. Agent money flows agent → facilitator → you.
88
- The hosted meter (not built yet — see the waitlist) is what wires this
89
- end-to-end; today the 402 challenge is built in and step 3 is a function
90
- you supply.
131
+ The [hosted Meter](https://meter.wayleave.dev) records crossings and confirmed
132
+ settlement activity. The 402 challenge is built in; payment verification is
133
+ a function you configure. A Meter account does not configure a payment rail.
91
134
 
92
135
  **Step 3 is yours, and it defaults to no.** `verifyPayment(proof, ctx)` is
93
136
  the only thing that can turn a 402 into a 200. Configure nothing and every
package/cdp-auth.js ADDED
@@ -0,0 +1,35 @@
1
+ import { createPrivateKey, randomBytes, sign } from 'node:crypto';
2
+
3
+ /** Short-lived, request-bound CDP bearer token. The secret never leaves this process.
4
+ * https://docs.cdp.coinbase.com/api-reference/v2/authentication
5
+ */
6
+ export function cdpBearerToken({ apiKeyId, apiKeySecret, url, now = Math.floor(Date.now() / 1000) }) {
7
+ if (!apiKeyId || !apiKeySecret) throw new Error('CDP credentials are required');
8
+ const target = new URL(url);
9
+ if (target.protocol !== 'https:' || target.username || target.password || target.hash)
10
+ throw new Error('CDP endpoint must be an HTTPS URL without credentials or fragment');
11
+ let key;
12
+ try {
13
+ if (apiKeySecret.includes('-----BEGIN')) {
14
+ key = createPrivateKey(apiKeySecret.replaceAll('\\n', '\n'));
15
+ } else {
16
+ const raw = Buffer.from(apiKeySecret, 'base64');
17
+ if (raw.length !== 64) throw new Error('Invalid length');
18
+ key = createPrivateKey({ key: Buffer.concat([
19
+ Buffer.from('302e020100300506032b657004220420', 'hex'), raw.subarray(0, 32),
20
+ ]), format: 'der', type: 'pkcs8' });
21
+ }
22
+ } catch {
23
+ throw new Error('Invalid CDP signing key; use the Secret API Key, not a client or wallet key');
24
+ }
25
+ const ed = key.asymmetricKeyType === 'ed25519';
26
+ if (!ed && !(key.asymmetricKeyType === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1'))
27
+ throw new Error('CDP signing key must be Ed25519 or P-256');
28
+ const encode = value => Buffer.from(JSON.stringify(value)).toString('base64url');
29
+ const header = encode({ alg: ed ? 'EdDSA' : 'ES256', typ: 'JWT', kid: apiKeyId, nonce: randomBytes(16).toString('hex') });
30
+ const claims = encode({ sub: apiKeyId, iss: 'cdp', aud: ['cdp_service'], nbf: now, exp: now + 120,
31
+ uri: `POST ${target.host}${target.pathname}` });
32
+ const input = `${header}.${claims}`;
33
+ const signature = sign(ed ? null : 'sha256', Buffer.from(input), ed ? key : { key, dsaEncoding: 'ieee-p1363' });
34
+ return `${input}.${signature.toString('base64url')}`;
35
+ }
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,13 +316,21 @@ 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. */
321
328
  export function buildParams(
322
329
  keyid: string,
323
330
  created: number,
324
- expires: number
331
+ expires: number,
332
+ /** Appended as `;nonce="..."` when present. */
333
+ nonce?: string | null
325
334
  ): string;
326
335
 
327
336
  /**
@@ -335,7 +344,13 @@ export function signRequest(
335
344
  keyid: string,
336
345
  directoryUrl: string,
337
346
  created?: number,
338
- expires?: number
347
+ expires?: number,
348
+ /**
349
+ * Opt in to a single-use signature. `true` generates 16 random bytes; a
350
+ * string is used verbatim. Omit it if your client retries by resending the
351
+ * same headers — a nonce turns that retry into a rejected replay.
352
+ */
353
+ nonce?: string | boolean | null
339
354
  ): Record<string, string>;
340
355
 
341
356
  /** Verify a presented signature against your configured directories. */
@@ -364,6 +379,12 @@ export function classify(
364
379
 
365
380
  export class Wayleave {
366
381
  constructor(opts?: WayleaveOptions);
382
+ /**
383
+ * Async twin of `handle`. Required if `verifyPayment` returns a Promise —
384
+ * which any real facilitator does, since confirming settlement is a network
385
+ * call. `express()` uses this.
386
+ */
387
+ handleAsync(req: WayleaveRequest, now?: number, paymentProof?: string): Promise<Decision>;
367
388
  /** Classify, apply policy, price, and emit the metering event. */
368
389
  handle(
369
390
  req: WayleaveRequest,
package/index.js CHANGED
@@ -18,7 +18,10 @@
18
18
  */
19
19
 
20
20
  import { createPublicKey, verify as edVerify, sign as edSign,
21
- createHash, randomUUID } from 'node:crypto';
21
+ createHash, randomUUID, randomBytes } 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',
@@ -88,18 +91,38 @@ function sigBase(components, ctx, paramsRaw) {
88
91
  return Buffer.from(lines.join('\n'));
89
92
  }
90
93
 
91
- export function buildParams(keyid, created, expires) {
94
+ export function buildParams(keyid, created, expires, nonce = null) {
92
95
  return `("@authority" "signature-agent");created=${created};` +
93
- `keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"`;
96
+ `keyid="${keyid}";alg="ed25519";expires=${expires};tag="web-bot-auth"` +
97
+ (nonce ? `;nonce="${nonce}"` : '');
94
98
  }
95
99
 
96
- /** What a legitimate agent operator's SDK does before sending. */
100
+ /**
101
+ * What a legitimate agent operator's SDK does before sending.
102
+ *
103
+ * `nonce` is opt-in, and opting in is what makes a signature single-use.
104
+ * Without one, `verifySignature` has nothing to remember a request by, and
105
+ * `replayProtection` on the verifying side is inert however it is configured
106
+ * — the Web Bot Auth profile covers only `@authority`, so the same signature
107
+ * is valid for every path on the host until it expires.
108
+ *
109
+ * It is not the default because it changes what a retry means. An agent that
110
+ * signs once and resends those exact headers after a socket error is doing
111
+ * something reasonable, and a nonce turns that retry into a rejected replay.
112
+ * Callers that re-sign per attempt should pass `true`; callers that retry with
113
+ * held headers should not.
114
+ *
115
+ * Pass a string to supply your own, or `true` for 16 random bytes.
116
+ */
97
117
  export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
98
118
  created = Math.floor(Date.now() / 1000),
99
- expires = created + 300) {
119
+ expires = created + 300,
120
+ nonce = null) {
100
121
  // Structured-field string: the value is sent quoted.
101
122
  headers['signature-agent'] = `"${directoryUrl}"`;
102
- const params = buildParams(keyid, created, expires);
123
+ const n = nonce === true ? randomBytes(16).toString('base64url')
124
+ : (typeof nonce === 'string' && nonce ? nonce : null);
125
+ const params = buildParams(keyid, created, expires, n);
103
126
  const base = sigBase(['@authority', 'signature-agent'],
104
127
  { authority, headers }, params);
105
128
  const sig = edSign(null, base, privateKey);
@@ -351,6 +374,24 @@ export class DirectSink {
351
374
  }
352
375
  }
353
376
 
377
+ /**
378
+ * Turn whatever a verifier returned into a verdict.
379
+ *
380
+ * A Promise reaching here means an async verifier was used on the synchronous
381
+ * path. Reading `.ok` off a Promise yields undefined and denies silently —
382
+ * which is precisely how the documented facilitator example failed from 0.1.5
383
+ * to 0.2.1. It now says so instead.
384
+ */
385
+ function normalizeVerdict(r) {
386
+ if (r && typeof r.then === 'function')
387
+ return { ok: false, reason:
388
+ 'verifyPayment returned a Promise — use gate.handleAsync() or gate.express(), ' +
389
+ 'which await it. The synchronous handle() cannot.' };
390
+ if (r === true) return { ok: true, ref: null };
391
+ if (r && r.ok === true) return { ok: true, ref: r.ref || null };
392
+ return { ok: false, reason: (r && r.reason) || 'not settled' };
393
+ }
394
+
354
395
  // ── the gate ────────────────────────────────────────────────────────────
355
396
 
356
397
  export class Wayleave {
@@ -408,7 +449,19 @@ export class Wayleave {
408
449
  // State and metering are interfaces, not implementations. Swap either for
409
450
  // a shared or durable one without the gate noticing. See MemoryStore.
410
451
  this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
411
- this.sink = opts.sink || new DirectSink(this.onEvent);
452
+
453
+ // `meter: { apiKey }` is shorthand for wiring a MeterSink yourself. It
454
+ // exists because the two-line version of this was the most common thing
455
+ // people got wrong: forgetting to drain on shutdown, or handing the sink
456
+ // a callback that throws. Pass `sink` directly if you want control.
457
+ this.sink = opts.sink
458
+ || (opts.meter?.apiKey
459
+ ? new MeterSink({
460
+ endpoint: 'https://meter.wayleave.dev',
461
+ ...opts.meter,
462
+ })
463
+ : null)
464
+ || new DirectSink(this.onEvent);
412
465
 
413
466
  // Identifies events from this process so a retrying sink can dedup. A
414
467
  // counter alone would collide across instances; a timestamp alone would
@@ -437,6 +490,28 @@ export class Wayleave {
437
490
  return { lane, identity: ident, ...d, evidence };
438
491
  }
439
492
 
493
+ /**
494
+ * Async twin of handle(), for verifiers that talk to a network.
495
+ *
496
+ * Identical in every respect except that it can await your verifyPayment.
497
+ * Use this whenever you actually sell something; the Express adapter does.
498
+ */
499
+ async handleAsync(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
500
+ const c = classify(req, this.directories, now,
501
+ { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
502
+ const { lane, agentId, evidence } = c;
503
+ const ident = agentId || `${lane}:${this._client(req)}`;
504
+ const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
505
+ const d = await this._decideAsync(req, lane, ident, now, proof, c);
506
+ const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
507
+ t: now, path: req.path, lane, identity: ident,
508
+ status: d.status, why: d.why, evidence,
509
+ billedUsd: d.billed || 0 };
510
+ if (d.paymentRef) entry.paymentRef = d.paymentRef;
511
+ this.sink.emit(entry);
512
+ return { lane, identity: ident, ...d, evidence };
513
+ }
514
+
440
515
  /**
441
516
  * Who to rate-limit. A cryptographic identity is authoritative; a network
442
517
  * address is at least expensive to rotate. A header is neither.
@@ -449,6 +524,7 @@ export class Wayleave {
449
524
  return req.ip || 'unknown';
450
525
  }
451
526
 
527
+
452
528
  /**
453
529
  * Positive evidence that a person is present, which only your application
454
530
  * holds — a signed-in session, a cookie you set, a challenge you passed.
@@ -470,7 +546,17 @@ export class Wayleave {
470
546
  return this.store.hit(ident, this.rateWindow, now);
471
547
  }
472
548
 
473
- _decide(req, lane, ident, now, paymentProof, c) {
549
+ /**
550
+ * Everything up to the payment question.
551
+ *
552
+ * Split out so the sync and async paths share one copy of the policy,
553
+ * replay, and rate-limit logic. Two copies of this would drift, and the
554
+ * half that drifted would be the one nobody tested.
555
+ *
556
+ * Returns either a final decision, or a marker saying a priced route was
557
+ * matched and a payment verdict is needed.
558
+ */
559
+ _preDecide(req, lane, ident, now, paymentProof, c) {
474
560
  for (const [prefix, allow] of this.rules[lane] || []) {
475
561
  if (req.path.startsWith(prefix)) {
476
562
  if (!allow) return { status: 403, why: `${lane} denied on ${prefix}` };
@@ -508,12 +594,10 @@ export class Wayleave {
508
594
  : 'payment required for agent access';
509
595
  return { status: 402, why, challenge };
510
596
  }
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 };
597
+ // Hand back to the caller, which knows whether it can await.
598
+ return { needsPayment: true, challenge, price,
599
+ ctx: { price, resource: prefix, identity: ident,
600
+ path: req.path, now } };
517
601
  }
518
602
  }
519
603
  }
@@ -522,6 +606,28 @@ export class Wayleave {
522
606
  return { status: 200, why: `${lane} allowed` };
523
607
  }
524
608
 
609
+ /** Turn a payment verdict into the final decision. Shared by both paths. */
610
+ _settleDecision(v, pre) {
611
+ if (v.ok)
612
+ return { status: 200, billed: pre.price, paymentRef: v.ref,
613
+ why: `settled $${pre.price}` };
614
+ return { status: 402, why: `payment proof rejected: ${v.reason}`,
615
+ challenge: pre.challenge };
616
+ }
617
+
618
+ _decide(req, lane, ident, now, paymentProof, c) {
619
+ const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
620
+ if (!pre.needsPayment) return pre;
621
+ return this._settleDecision(this._checkPayment(paymentProof, pre.ctx), pre);
622
+ }
623
+
624
+ async _decideAsync(req, lane, ident, now, paymentProof, c) {
625
+ const pre = this._preDecide(req, lane, ident, now, paymentProof, c);
626
+ if (!pre.needsPayment) return pre;
627
+ return this._settleDecision(
628
+ await this._checkPaymentAsync(paymentProof, pre.ctx), pre);
629
+ }
630
+
525
631
  /**
526
632
  * Settlement is somebody else's job — a facilitator that actually moved the
527
633
  * money. This asks yours and believes nothing else. A verifier that throws
@@ -533,15 +639,32 @@ export class Wayleave {
533
639
  let r;
534
640
  try { r = this.verifyPayment(proof, ctx); }
535
641
  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' };
642
+ return normalizeVerdict(r);
643
+ }
644
+
645
+ /**
646
+ * The async twin of _checkPayment.
647
+ *
648
+ * Every real facilitator is a network call, so a useful verifier returns a
649
+ * Promise. The synchronous path cannot await one and refuses it loudly
650
+ * rather than reading `.ok` off a Promise and silently denying — which is
651
+ * what shipped from 0.1.5 to 0.2.1, and meant the documented way to sell
652
+ * anything never worked.
653
+ */
654
+ async _checkPaymentAsync(proof, ctx) {
655
+ if (!this.verifyPayment)
656
+ return { ok: false, reason: 'no verifyPayment configured' };
657
+ try {
658
+ return normalizeVerdict(await this.verifyPayment(proof, ctx));
659
+ } catch (e) {
660
+ return { ok: false, reason: `verifier threw: ${e.message}` };
661
+ }
539
662
  }
540
663
 
541
664
  /** Express/Connect adapter: app.use(wayleave.express()) */
542
665
  express() {
543
- return (req, res, next) => {
544
- const r = this.handle({
666
+ return async (req, res, next) => {
667
+ const r = await this.handleAsync({
545
668
  method: req.method, path: req.path || req.url,
546
669
  authority: req.headers.host || '',
547
670
  headers: req.headers,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wayleave",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",
@@ -13,6 +13,10 @@
13
13
  "./meter": {
14
14
  "types": "./meter.d.ts",
15
15
  "default": "./meter.js"
16
+ },
17
+ "./x402": {
18
+ "types": "./x402.d.ts",
19
+ "default": "./x402.js"
16
20
  }
17
21
  },
18
22
  "files": [
@@ -21,7 +25,10 @@
21
25
  "index.d.ts",
22
26
  "index.js",
23
27
  "meter.d.ts",
24
- "meter.js"
28
+ "meter.js",
29
+ "x402.d.ts",
30
+ "x402.js",
31
+ "cdp-auth.js"
25
32
  ],
26
33
  "scripts": {
27
34
  "test": "node --test *.test.js"
@@ -43,6 +50,7 @@
43
50
  "ed25519"
44
51
  ],
45
52
  "license": "MIT",
53
+ "author": "Gibran Corbin (https://github.com/gibrancorbin11-hub)",
46
54
  "repository": {
47
55
  "type": "git",
48
56
  "url": "git+https://github.com/gibrancorbin11-hub/wayleave.git"
@@ -50,5 +58,5 @@
50
58
  "bugs": {
51
59
  "url": "https://github.com/gibrancorbin11-hub/wayleave/issues"
52
60
  },
53
- "homepage": "https://github.com/gibrancorbin11-hub/wayleave#readme"
61
+ "homepage": "https://wayleave.dev"
54
62
  }
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 and never grants access.
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,183 @@
1
+ import { cdpBearerToken } from './cdp-auth.js';
2
+ /**
3
+ * wayleave/x402 — a ready-made `verifyPayment` for the Coinbase x402 rail.
4
+ *
5
+ * Before this existed, selling anything meant writing your own integration
6
+ * against the facilitator API: two endpoints, a request body shaped
7
+ * { x402Version, paymentPayload, paymentRequirements }, atomic-unit
8
+ * conversion, and a response that returns HTTP 200 even when the payment is
9
+ * bad. That is a lot of surface to get subtly wrong in the one place where
10
+ * being wrong means giving away data for free or billing for money that never
11
+ * moved.
12
+ *
13
+ * So it lives here instead, tested, and you configure it:
14
+ *
15
+ * import Wayleave from 'wayleave';
16
+ * import { coinbaseFacilitator } from 'wayleave/x402';
17
+ *
18
+ * const gate = new Wayleave({
19
+ * pricedPaths: { '/api/premium': 0.05 },
20
+ * strictPricedPaths: true,
21
+ * confirmHuman: req => Boolean(req.session?.userId),
22
+ * verifyPayment: coinbaseFacilitator({
23
+ * apiKeyId: process.env.CDP_API_KEY_ID,
24
+ * apiKeySecret: process.env.CDP_API_KEY_SECRET,
25
+ * receivingAddress: process.env.WAYLEAVE_RECEIVING_ADDRESS,
26
+ * }),
27
+ * });
28
+ *
29
+ * YOUR credentials, YOUR receiving address. Money moves agent -> facilitator
30
+ * -> you. Wayleave is not a hop, does not hold funds, and this module has no
31
+ * method that moves value — there is a test asserting that, because the day it
32
+ * gains one this stops being metering and becomes money transmission.
33
+ *
34
+ * Zero dependencies: fetch is global from Node 18.
35
+ */
36
+
37
+ const DEFAULT_BASE = 'https://api.cdp.coinbase.com/platform';
38
+ const X402_VERSION = 1;
39
+ const DEFAULT_DECIMALS = 6; // USDC and most stablecoins
40
+ const TIMEOUT_MS = 10000;
41
+
42
+ /**
43
+ * Dollars -> atomic units, as a decimal string.
44
+ *
45
+ * String arithmetic, not `usd * 10 ** decimals`, because floating point
46
+ * mangles money: 0.07 * 1e6 is 70000.00000000001 in JavaScript, and rounding
47
+ * that is how a ledger acquires a cent of drift per transaction.
48
+ */
49
+ export function toAtomicUnits(usd, decimals = DEFAULT_DECIMALS) {
50
+ const s = String(usd);
51
+ if (!/^\d+(\.\d+)?$/.test(s))
52
+ throw new Error(`amount must be a non-negative decimal, got "${usd}"`);
53
+ const [whole, frac = ''] = s.split('.');
54
+ if (frac.length > decimals)
55
+ throw new Error(`amount ${usd} is finer than the asset's ${decimals} decimals`);
56
+ return (whole + frac.padEnd(decimals, '0')).replace(/^0+(?=\d)/, '');
57
+ }
58
+
59
+ /**
60
+ * Build a `verifyPayment(proof, ctx)` for the Coinbase x402 facilitator.
61
+ *
62
+ * @param {object} opts
63
+ * @param {string} opts.apiKeyId CDP API key id
64
+ * @param {string} opts.apiKeySecret CDP API key secret
65
+ * @param {string} opts.receivingAddress YOUR address. Never ours.
66
+ * @param {string} [opts.network] 'base' (default) | 'base-sepolia' | ...
67
+ * @param {string} [opts.asset] token contract; omit for the network default
68
+ * @param {number} [opts.decimals] asset decimals, 6 for USDC
69
+ * @param {boolean} [opts.settle] true (default) submits on-chain.
70
+ * false only verifies — useful for a
71
+ * dry run, but nobody gets paid.
72
+ * @param {(err:Error, info:object)=>void} [opts.onError]
73
+ * @returns {(proof:string, ctx:object)=> Promise<{ok:boolean, ref?:string, reason?:string}>}
74
+ */
75
+ export function coinbaseFacilitator({
76
+ apiKeyId, apiKeySecret, receivingAddress,
77
+ network = 'base', asset = null, decimals = DEFAULT_DECIMALS,
78
+ settle = true, baseUrl = DEFAULT_BASE, onError = () => {}, fetch: f = fetch,
79
+ } = {}) {
80
+ if (!apiKeyId || !apiKeySecret)
81
+ throw new Error('coinbaseFacilitator requires apiKeyId and apiKeySecret');
82
+ if (!receivingAddress)
83
+ throw new Error('coinbaseFacilitator requires receivingAddress — the address YOU get paid at');
84
+
85
+ const base = baseUrl.replace(/\/$/, '');
86
+
87
+ return async function verifyPayment(proof, ctx) {
88
+ // The middleware hands us the proof as a string; agents send the signed
89
+ // payload as JSON. Accept either so a customer does not have to care.
90
+ let paymentPayload = proof;
91
+ if (typeof proof === 'string') {
92
+ try { paymentPayload = JSON.parse(proof); }
93
+ catch { return deny('payment proof is not a valid x402 payload'); }
94
+ }
95
+ if (!paymentPayload || typeof paymentPayload !== 'object')
96
+ return deny('no payment payload supplied');
97
+
98
+ let requirements;
99
+ try {
100
+ requirements = {
101
+ scheme: 'exact',
102
+ network,
103
+ // We assert what was required; the facilitator checks the payment
104
+ // matches. Under the exact scheme the transfer MUST equal this and
105
+ // MUST land at payTo, so a wrong amount or wrong address cannot come
106
+ // back valid. That is stronger than trusting a number the payer sent.
107
+ maxAmountRequired: toAtomicUnits(ctx.price, decimals),
108
+ resource: ctx.resource ?? ctx.path ?? '',
109
+ description: '',
110
+ mimeType: 'application/json',
111
+ payTo: receivingAddress,
112
+ maxTimeoutSeconds: 60,
113
+ ...(asset ? { asset } : {}),
114
+ };
115
+ } catch (err) {
116
+ return deny(`cannot build payment requirements: ${err.message}`);
117
+ }
118
+
119
+ const body = { x402Version: X402_VERSION, paymentPayload, paymentRequirements: requirements };
120
+ const path = settle ? '/v2/x402/settle' : '/v2/x402/verify';
121
+
122
+ let res;
123
+ try {
124
+ res = await post(f, `${base}${path}`, body, apiKeyId, apiKeySecret);
125
+ } catch (err) {
126
+ // Unreachable facilitator denies. An outage must degrade into unpaid,
127
+ // never into free passage recorded as revenue.
128
+ onError(err, { phase: 'facilitator', path });
129
+ return deny(`facilitator unreachable: ${err.message}`);
130
+ }
131
+
132
+ if (!res.ok) {
133
+ onError(new Error(`facilitator ${res.status}`), { phase: 'facilitator', path });
134
+ return deny(`facilitator returned ${res.status}`);
135
+ }
136
+
137
+ const d = res.data ?? {};
138
+
139
+ // CRITICAL: the facilitator answers HTTP 200 even when the payment is
140
+ // bad. Reading the status code alone books every rejected payment as
141
+ // revenue — which is exactly the bug 0.1.5 fixed, one layer down.
142
+ if (settle) {
143
+ if (d.success !== true)
144
+ return deny(`settlement failed: ${d.errorReason ?? 'no reason given'}`);
145
+ if (!d.transaction)
146
+ return deny('settlement reported success with nothing to reference');
147
+ return { ok: true, ref: String(d.transaction) };
148
+ }
149
+
150
+ if (d.isValid !== true)
151
+ return deny(`payment not valid: ${d.invalidReason ?? 'no reason given'}`);
152
+ // A valid authorization is not settled money. Do not release a priced
153
+ // resource or record billed revenue from a verification-only dry run.
154
+ return { ok: false, reason: 'Payment verified only; settlement is disabled' };
155
+ };
156
+ }
157
+
158
+ async function post(f, url, body, keyId, keySecret) {
159
+ const ctrl = new AbortController();
160
+ const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
161
+ try {
162
+ const r = await f(url, {
163
+ method: 'POST',
164
+ headers: {
165
+ 'content-type': 'application/json',
166
+ authorization: `Bearer ${cdpBearerToken({ apiKeyId: keyId, apiKeySecret: keySecret, url })}`,
167
+ },
168
+ body: JSON.stringify(body),
169
+ signal: ctrl.signal,
170
+ });
171
+ let data = null;
172
+ try { data = await r.json(); } catch { /* non-JSON body stays null */ }
173
+ return { ok: r.ok, status: r.status, data };
174
+ } finally {
175
+ clearTimeout(timer);
176
+ }
177
+ }
178
+
179
+ function deny(reason) {
180
+ return { ok: false, reason };
181
+ }
182
+
183
+ export default coinbaseFacilitator;