wayleave 0.1.6 → 0.2.1

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
@@ -52,19 +52,21 @@ app.use(gate.express());
52
52
  - 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
53
  - Sub-millisecond per request. Your latency budget won't notice.
54
54
  - The metering hook can throw, crash, or hang your billing backend — serving continues. Your uptime never depends on ours.
55
- - Key directories still load from config rather than a live JWKS fetch, so key rotation is not handled. That is the next real gap.
56
- - **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, no IP verification, and no challenge here; that work belongs at your edge or CDN, and pretending otherwise would be the dishonest version of this list. `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.
55
+ - 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
+ - **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.
57
57
  - Wayleave prices **disclosure**; it does not detect **concealment**. It is a tollbooth, not a wall — it works on operators who want to be identifiable, which today is most of the ones worth billing.
58
58
  - This is a **screening and pricing layer, not a guarantee**. Every decision returns its evidence and is loggable.
59
59
 
60
60
  ## Status
61
61
 
62
- v0.1.6 — verification, lanes, policy, pricing, and the metering hook, all under test (55 scenarios, including forgery, tampering, replay, guessed payment proofs, spoofed rate-limit identities, spoofed browser headers, 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.
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.
63
63
 
64
64
  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
65
 
66
66
  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
67
 
68
+ 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
+
68
70
  ## How payment actually works
69
71
 
70
72
  Pay-at-the-door, not IOUs. On a route you price:
@@ -130,9 +132,62 @@ nothing but a confirmed settlement is ever written to the ledger as billed.
130
132
  agent hitting two paths in the same second produces byte-identical
131
133
  signatures. Rejecting those would break real traffic. Without a signer
132
134
  nonce, replay is bounded by the expiry window and nothing tighter.
133
- - **State is per-process.** Rate limits and replay memory do not survive a
134
- restart and do not span instances — two instances mean two independent
135
- limiters. Shared state is the next real gap, alongside key rotation.
135
+ - **State is per-process by default.** Rate limits and replay memory do not
136
+ survive a restart and do not span instances. Pass a `store` implementing
137
+ `hit` / `hasNonce` / `rememberNonce` to share them. Keep it synchronous —
138
+ it runs on every request — so back a distributed store with a local view
139
+ that replicates asynchronously. Note the two uses differ in how much they
140
+ mind lag: fixed-window rate limiting tolerates approximate counts, replay
141
+ does not, because a nonce that has not replicated yet can be spent twice.
142
+ - **Key rotation is possible but not built in.** `directories` accepts a
143
+ resolver function, so point it at a cache you refresh from a JWKS endpoint
144
+ on a timer. There is no fetcher in the box, and the resolver must not block.
145
+
146
+ ## Extending it
147
+
148
+ Three seams, each defaulting to the behaviour above. Nothing here is required.
149
+
150
+ ```js
151
+ new Wayleave({
152
+ store: myRedisBackedStore, // shared rate limits + replay across instances
153
+ sink: myBufferedSink, // emit() with batching, retry, dedup
154
+ directories: url => keyCache[url],// rotatable keys, refreshed out of band
155
+ verifyAgentIP: (ip, ua) => isPublishedRange(ip, ua),
156
+ });
157
+ ```
158
+
159
+ Every metering event carries `v` (schema version) and `idempotencyKey`, so a
160
+ sink that retries can be deduplicated at the far end rather than double-billing.
161
+
162
+ A buffering, retrying sink ships in the box:
163
+
164
+ ```js
165
+ import Wayleave from 'wayleave';
166
+ import { MeterSink } from 'wayleave/meter';
167
+
168
+ const sink = new MeterSink({
169
+ endpoint: 'https://meter.wayleave.dev',
170
+ apiKey: process.env.WAYLEAVE_METER_KEY,
171
+ onError: (err, info) => log.warn({ err, info }, 'meter'),
172
+ });
173
+
174
+ const gate = new Wayleave({ pricedPaths: { '/api/premium': 0.05 }, sink });
175
+ process.on('SIGTERM', () => sink.close()); // drain, or lose what is buffered
176
+ ```
177
+
178
+ `emit` is synchronous, non-throwing, and does no I/O — a billing backend
179
+ having a bad day must not become your customers having one. Retries are safe
180
+ because of the idempotency key: sending a batch twice costs a round trip, not
181
+ a double-billed customer. The buffer is bounded, and anything shed is counted
182
+ and reported rather than dropped quietly.
183
+
184
+ `verifyAgentIP` is the one that changes a lane. A `user-agent` naming a known
185
+ operator is a claim; when that operator publishes its ranges the claim is
186
+ checkable. Returning `false` moves the request to `suspected_bot` — a false
187
+ claim of identity is a stronger signal than no claim, which is the same
188
+ reasoning that puts an invalid signature there. Return `null` for operators you
189
+ cannot check; that leaves the request `declared_agent`, because unknown is not
190
+ guilty. Throwing is treated as `null` for the same reason.
136
191
 
137
192
  ## License
138
193
 
package/index.d.ts CHANGED
@@ -144,6 +144,17 @@ export interface Decision {
144
144
 
145
145
  /** What the metering hook receives. It may throw; serving continues. */
146
146
  export interface MeterEvent {
147
+ /**
148
+ * Event schema version. Present so a meter that outlives one release can
149
+ * tell which shape it is reading. Currently always 1.
150
+ */
151
+ v: number;
152
+ /**
153
+ * Unique per crossing and stable across retries of the same event, so a
154
+ * sink that resends after a failure can be deduplicated at the far end.
155
+ * Namespaced per process — two instances never collide.
156
+ */
157
+ idempotencyKey: string;
147
158
  /** Unix seconds. */
148
159
  t: number;
149
160
  path: string;
@@ -161,8 +172,90 @@ export interface MeterEvent {
161
172
  /** `[pathPrefix, allow]` — first matching prefix wins. */
162
173
  export type PolicyRule = [prefix: string, allow: boolean];
163
174
 
175
+ /**
176
+ * Rate-limit counters and consumed nonces. Implement this against Redis or
177
+ * Postgres and pass it as `store` to share state across instances.
178
+ *
179
+ * Must be synchronous — it is called on every request, and an awaited round
180
+ * trip per crossing costs more than the crossing earns. Put a distributed
181
+ * backend behind a local view that replicates asynchronously.
182
+ *
183
+ * Note the two uses differ in how much they mind approximation: fixed-window
184
+ * rate limiting tolerates it, replay does not. A nonce that has not yet
185
+ * replicated is a nonce that can be spent twice.
186
+ */
187
+ export interface Store {
188
+ /** Requests by this identity in the current window. Returns the new count. */
189
+ hit(identity: string, windowSeconds: number, now: number): number;
190
+ hasNonce(nonce: string): boolean;
191
+ rememberNonce(nonce: string, expiresAt: number): void;
192
+ }
193
+
194
+ /** The default `Store`: bounded, in this process's memory, lost on restart. */
195
+ export class MemoryStore implements Store {
196
+ constructor(opts?: { maxTracked?: number });
197
+ maxTracked: number;
198
+ hit(identity: string, windowSeconds: number, now: number): number;
199
+ hasNonce(nonce: string): boolean;
200
+ rememberNonce(nonce: string, expiresAt: number): void;
201
+ }
202
+
203
+ /**
204
+ * Where metering events go. Implement `emit` to buffer, batch, retry, and
205
+ * dedup on `idempotencyKey`. It must never throw into the request path.
206
+ */
207
+ export interface Sink {
208
+ emit(event: MeterEvent): void;
209
+ }
210
+
211
+ /** The default `Sink`: calls `onEvent` and swallows whatever it throws. */
212
+ export class DirectSink implements Sink {
213
+ constructor(onEvent?: (event: MeterEvent) => void);
214
+ emit(event: MeterEvent): void;
215
+ }
216
+
217
+ /**
218
+ * Key lookup. The function form is what makes rotation possible: point it at
219
+ * a cache your app refreshes from a JWKS endpoint on a timer. Synchronous by
220
+ * design — do not fetch here.
221
+ */
222
+ export type KeyResolver = (directoryUrl: string) =>
223
+ Record<string, PublicKeyLike> | undefined;
224
+
225
+ /**
226
+ * Checks whether a request claiming to be a known operator actually came from
227
+ * that operator's published addresses.
228
+ *
229
+ * Return `true` to confirm, `false` to accuse — a false claim is a stronger
230
+ * fraud signal than silence, so it lands in `suspected_bot` alongside an
231
+ * invalid signature. Return `null`/`undefined` for "cannot check", which
232
+ * leaves the request merely `declared_agent`: unknown is not guilty. A throw
233
+ * is treated as "cannot check" too, so a DNS blip never demotes real agents.
234
+ */
235
+ export type AgentIPVerifier = (
236
+ ip: string | undefined,
237
+ userAgent: string
238
+ ) => boolean | null | undefined;
239
+
164
240
  export interface WayleaveOptions {
165
- directories?: KeyDirectories;
241
+ /** Config object, or a resolver function for rotatable keys. */
242
+ directories?: KeyDirectories | KeyResolver;
243
+ /**
244
+ * Verify that a self-declared operator is on its published addresses.
245
+ * Without it, `declared_agent` is a claim taken at face value.
246
+ */
247
+ verifyAgentIP?: AgentIPVerifier;
248
+ /**
249
+ * Rate-limit and replay state. Defaults to a bounded `MemoryStore`, which
250
+ * is per-process: two instances mean two independent limiters. Supply a
251
+ * shared implementation to fix that without touching anything else.
252
+ */
253
+ store?: Store;
254
+ /**
255
+ * Where metering events go. Defaults to a `DirectSink` wrapping `onEvent`.
256
+ * Replace it with a buffering, retrying sink when losing events costs money.
257
+ */
258
+ sink?: Sink;
166
259
  /** Per-lane allow/deny rules, evaluated in order. */
167
260
  rules?: Partial<Record<Lane, PolicyRule[]>>;
168
261
  /** Max requests per identity per window, per lane. */
@@ -249,7 +342,7 @@ export function signRequest(
249
342
  export function verifySignature(
250
343
  headers: Record<string, string | string[] | undefined>,
251
344
  authority: string,
252
- directories: KeyDirectories,
345
+ directories: KeyDirectories | KeyResolver,
253
346
  now?: number
254
347
  ): VerifyResult;
255
348
 
@@ -259,8 +352,14 @@ export function verifySignature(
259
352
  */
260
353
  export function classify(
261
354
  req: WayleaveRequest,
262
- directories: KeyDirectories,
263
- now?: number
355
+ directories: KeyDirectories | KeyResolver,
356
+ now?: number,
357
+ opts?: {
358
+ /** Confirms a declared operator is on its published addresses. */
359
+ verifyAgentIP?: AgentIPVerifier;
360
+ /** Source address handed to `verifyAgentIP`. */
361
+ ip?: string;
362
+ }
264
363
  ): Classification;
265
364
 
266
365
  export class Wayleave {
package/index.js CHANGED
@@ -18,7 +18,7 @@
18
18
  */
19
19
 
20
20
  import { createPublicKey, verify as edVerify, sign as edSign,
21
- createHash } from 'node:crypto';
21
+ createHash, randomUUID } from 'node:crypto';
22
22
 
23
23
  export const LANES = Object.freeze({
24
24
  VERIFIED: 'verified_agent',
@@ -108,8 +108,28 @@ export function signRequest(headers, authority, privateKey, keyid, directoryUrl,
108
108
  return headers;
109
109
  }
110
110
 
111
+ /**
112
+ * Key lookup, as a seam rather than an object read.
113
+ *
114
+ * `directories` may be the config object it has always been, or a function
115
+ * `(directoryUrl) => { keyid: key }`. The function form is what makes rotation
116
+ * possible without touching this file: point it at a cache your app refreshes
117
+ * from a JWKS endpoint on a timer.
118
+ *
119
+ * It MUST be synchronous. This runs on every signed request, and awaiting a
120
+ * network fetch per crossing would cost far more than the crossing earns —
121
+ * the sub-millisecond claim in the README is a real constraint, not a boast.
122
+ * Fetch on a schedule, resolve from memory.
123
+ */
124
+ function asResolver(directories) {
125
+ if (typeof directories === 'function') return directories;
126
+ const d = directories || {};
127
+ return (directoryUrl) => d[directoryUrl];
128
+ }
129
+
111
130
  /**
112
131
  * directories: { [directoryUrl]: { [keyid]: publicKey (KeyObject|PEM|raw b64) } }
132
+ * or (directoryUrl) => { [keyid]: publicKey }
113
133
  * Returns { ok, agentId, reason, nonce, expires }
114
134
  *
115
135
  * `nonce` is set only when the signer supplied RFC 9421's `nonce` parameter,
@@ -157,7 +177,7 @@ export function verifySignature(h, authority, directories,
157
177
  if (expires - created > 3600)
158
178
  return { ok: false, agentId: null, reason: 'validity window too long (replay risk)' };
159
179
 
160
- const dir = directories[directory];
180
+ const dir = asResolver(directories)(directory);
161
181
  if (!dir) return { ok: false, agentId: null, reason: `unknown key directory ${directory}` };
162
182
  let key = dir[keyid];
163
183
  if (!key) return { ok: false, agentId: null, reason: `keyid "${keyid}" not in directory` };
@@ -209,7 +229,20 @@ const AUTOMATION_TELLS = [
209
229
  ['wget', 'wget UA'],
210
230
  ];
211
231
 
212
- export function classify(req, directories, now) {
232
+ /**
233
+ * A verifier that throws must not decide the lane. Treat the failure as
234
+ * "could not check" rather than "is a forger" — a DNS timeout is not evidence
235
+ * of fraud, and demoting real agents to the fraud lane because a lookup blipped
236
+ * would be a self-inflicted outage on the traffic we most want to bill.
237
+ */
238
+ function tryVerifyIP(verify, ip, ua) {
239
+ try {
240
+ const r = verify(ip, ua);
241
+ return r === true ? true : r === false ? false : null;
242
+ } catch { return null; }
243
+ }
244
+
245
+ export function classify(req, directories, now, opts = {}) {
213
246
  const h = req.headers, ua = (h['user-agent'] || '').toLowerCase();
214
247
  const v = verifySignature(h, req.authority, directories, now);
215
248
  if (v.ok) return { lane: LANES.VERIFIED, agentId: v.agentId,
@@ -218,10 +251,25 @@ export function classify(req, directories, now) {
218
251
  if (h['signature'] || h['signature-input'])
219
252
  return { lane: LANES.SUSPECT, agentId: null,
220
253
  evidence: [`crypto: ${v.reason}`, 'presented invalid signature'] };
221
- if (AGENT_UA.some(p => p.test(ua)))
254
+ if (AGENT_UA.some(p => p.test(ua))) {
255
+ // A UA naming a known operator is a claim, and claims are checkable when
256
+ // the operator publishes its addresses. Claiming to be GPTBot from an
257
+ // address OpenAI does not own is a stronger fraud signal than saying
258
+ // nothing at all — so it lands in the fraud lane, exactly like a
259
+ // signature that fails to verify. An unrecognised operator returns
260
+ // null/undefined and stays merely declared: unknown is not guilty.
261
+ const ipOk = opts.verifyAgentIP
262
+ ? tryVerifyIP(opts.verifyAgentIP, opts.ip, ua)
263
+ : null;
264
+ if (ipOk === false)
265
+ return { lane: LANES.SUSPECT, agentId: null,
266
+ evidence: [`UA claims "${ua.slice(0, 40)}"`,
267
+ `but ${opts.ip || 'the client'} is not a published address for it`] };
222
268
  return { lane: LANES.DECLARED, agentId: null,
223
269
  evidence: [`self-identified in UA: "${ua.slice(0, 40)}"`,
224
- 'no signature presented'] };
270
+ ipOk === true ? 'source address matches published range'
271
+ : 'no signature presented'] };
272
+ }
225
273
  for (const [tell, why] of AUTOMATION_TELLS)
226
274
  if (ua.includes(tell))
227
275
  return { lane: LANES.SUSPECT, agentId: null, evidence: [why] };
@@ -232,6 +280,77 @@ export function classify(req, directories, now) {
232
280
  return { lane: LANES.HUMAN, agentId: null, evidence: ['browser-shaped request'] };
233
281
  }
234
282
 
283
+ // ── state ───────────────────────────────────────────────────────────────
284
+
285
+ /**
286
+ * Rate-limit counters and consumed nonces, in this process's memory.
287
+ *
288
+ * This is the seam that a shared backend replaces. Implement the same four
289
+ * methods against Redis or Postgres and pass it as `store` — the gate does not
290
+ * know the difference. Two properties any replacement must preserve:
291
+ *
292
+ * Synchronous. Called on every request. An awaited round trip per crossing
293
+ * is a worse tax than the crossing is worth; a distributed store belongs
294
+ * behind a local view that replicates asynchronously.
295
+ *
296
+ * Bounded. `maxTracked` exists because an attacker who can mint identities
297
+ * can otherwise mint memory. Evicting the oldest keeps recent arrivals
298
+ * counted, which is the useful half.
299
+ *
300
+ * A distributed store trades exactness for reach, and the two uses differ in
301
+ * how much they mind. Fixed-window rate limiting tolerates approximate counts.
302
+ * Replay does NOT: a nonce that has not replicated yet is a nonce that can be
303
+ * spent twice. Say which you chose in your deployment notes.
304
+ */
305
+ export class MemoryStore {
306
+ constructor({ maxTracked = 10000 } = {}) {
307
+ this.maxTracked = maxTracked;
308
+ this._hits = new Map();
309
+ this._window = -1;
310
+ this._seen = new Map();
311
+ }
312
+
313
+ /** Count this identity's requests in the current fixed window. */
314
+ hit(identity, windowSeconds, now) {
315
+ const w = Math.floor(now / windowSeconds);
316
+ if (w !== this._window) {
317
+ this._window = w;
318
+ this._hits.clear();
319
+ for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
320
+ }
321
+ if (this._hits.size >= this.maxTracked && !this._hits.has(identity))
322
+ this._hits.delete(this._hits.keys().next().value);
323
+ const n = (this._hits.get(identity) || 0) + 1;
324
+ this._hits.set(identity, n);
325
+ return n;
326
+ }
327
+
328
+ hasNonce(nonce) { return this._seen.has(nonce); }
329
+
330
+ rememberNonce(nonce, expiresAt) {
331
+ if (this._seen.size >= this.maxTracked)
332
+ this._seen.delete(this._seen.keys().next().value);
333
+ this._seen.set(nonce, expiresAt);
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Where metering events go. The default hands each one straight to `onEvent`
339
+ * and swallows anything it throws, because serving must not depend on billing.
340
+ *
341
+ * That swallow is correct for uptime and lossy for revenue, which is the
342
+ * trade a durable sink is meant to change: buffer locally, flush in batches,
343
+ * retry on failure, and dedup on `idempotencyKey` at the far end. Implement
344
+ * `emit(event)` and pass it as `sink`. Whatever you build, it must not throw
345
+ * into the request path — this class is the last line that guarantees that.
346
+ */
347
+ export class DirectSink {
348
+ constructor(onEvent) { this.onEvent = onEvent || (() => {}); }
349
+ emit(event) {
350
+ try { this.onEvent(event); } catch { /* metering must never break serving */ }
351
+ }
352
+ }
353
+
235
354
  // ── the gate ────────────────────────────────────────────────────────────
236
355
 
237
356
  export class Wayleave {
@@ -281,23 +400,40 @@ export class Wayleave {
281
400
  this.replayProtection = opts.replayProtection !== false;
282
401
  this.maxTracked = opts.maxTracked || 10000;
283
402
 
284
- this._hits = new Map(); // identity -> count, cleared each window
285
- this._window = -1;
286
- this._seen = new Map(); // signature nonce -> expiry, pruned on rollover
403
+ // Verifies that a UA claiming to be a known operator comes from that
404
+ // operator's published addresses. Absent, a declared agent is taken at
405
+ // its word — which is all "declared" ever meant.
406
+ this.verifyAgentIP = opts.verifyAgentIP || null;
407
+
408
+ // State and metering are interfaces, not implementations. Swap either for
409
+ // a shared or durable one without the gate noticing. See MemoryStore.
410
+ this.store = opts.store || new MemoryStore({ maxTracked: this.maxTracked });
411
+ this.sink = opts.sink || new DirectSink(this.onEvent);
412
+
413
+ // Identifies events from this process so a retrying sink can dedup. A
414
+ // counter alone would collide across instances; a timestamp alone would
415
+ // collide within a millisecond.
416
+ this._instance = randomUUID();
417
+ this._seq = 0;
287
418
  }
288
419
 
289
420
  /** req: { method, path, authority, headers, ip } → decision */
290
421
  handle(req, now = Math.floor(Date.now() / 1000), paymentProof = '') {
291
- const c = classify(req, this.directories, now);
422
+ const c = classify(req, this.directories, now,
423
+ { verifyAgentIP: this.verifyAgentIP, ip: this._client(req) });
292
424
  const { lane, agentId, evidence } = c;
293
425
  const ident = agentId || `${lane}:${this._client(req)}`;
294
426
  const proof = paymentProof || headerValue(req.headers, 'x-payment-proof');
295
427
  const d = this._decide(req, lane, ident, now, proof, c);
296
- const entry = { t: now, path: req.path, lane, identity: ident,
428
+ // v is the event schema version. A meter that outlives one release has to
429
+ // know which shape it is reading; adding this after receipts exist in the
430
+ // wild would mean reconciling two formats forever.
431
+ const entry = { v: 1, idempotencyKey: `${this._instance}:${this._seq++}`,
432
+ t: now, path: req.path, lane, identity: ident,
297
433
  status: d.status, why: d.why, evidence,
298
434
  billedUsd: d.billed || 0 };
299
435
  if (d.paymentRef) entry.paymentRef = d.paymentRef;
300
- try { this.onEvent(entry); } catch { /* metering must never break serving */ }
436
+ this.sink.emit(entry);
301
437
  return { lane, identity: ident, ...d, evidence };
302
438
  }
303
439
 
@@ -329,26 +465,9 @@ export class Wayleave {
329
465
  catch { return false; }
330
466
  }
331
467
 
332
- /**
333
- * Fixed-window counting. On rollover the whole table is dropped — last
334
- * window's counts are meaningless anyway, and it keeps memory bounded to
335
- * one window's worth of identities instead of growing forever.
336
- */
468
+ /** Fixed-window counting, delegated to whichever store is configured. */
337
469
  _bump(ident, now) {
338
- const w = Math.floor(now / this.rateWindow);
339
- if (w !== this._window) {
340
- this._window = w;
341
- this._hits.clear();
342
- for (const [n, exp] of this._seen) if (exp <= now) this._seen.delete(n);
343
- }
344
- // Hard ceiling so a flood of distinct identities inside a single window
345
- // cannot exhaust memory. Evicting the oldest keeps the newest arrivals
346
- // counted; the durable fix is shared state, not a bigger Map.
347
- if (this._hits.size >= this.maxTracked && !this._hits.has(ident))
348
- this._hits.delete(this._hits.keys().next().value);
349
- const n = (this._hits.get(ident) || 0) + 1;
350
- this._hits.set(ident, n);
351
- return n;
470
+ return this.store.hit(ident, this.rateWindow, now);
352
471
  }
353
472
 
354
473
  _decide(req, lane, ident, now, paymentProof, c) {
@@ -363,11 +482,9 @@ export class Wayleave {
363
482
  // Only signers that send a nonce can be held to single use; see
364
483
  // verifySignature for why a signature hash is not a safe substitute.
365
484
  if (this.replayProtection && c && c.nonce) {
366
- if (this._seen.has(c.nonce))
485
+ if (this.store.hasNonce(c.nonce))
367
486
  return { status: 403, why: 'signature already used (replay)' };
368
- if (this._seen.size >= this.maxTracked)
369
- this._seen.delete(this._seen.keys().next().value);
370
- this._seen.set(c.nonce, c.sigExpires || now + this.rateWindow);
487
+ this.store.rememberNonce(c.nonce, c.sigExpires || now + this.rateWindow);
371
488
  }
372
489
  const limit = this.rateLimits[lane];
373
490
  if (limit) {
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.1.6",
3
+ "version": "0.2.1",
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,22 @@
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"
12
16
  }
13
17
  },
14
18
  "files": [
15
- "index.js",
16
- "index.d.ts",
19
+ "LICENSE",
17
20
  "README.md",
18
- "LICENSE"
21
+ "index.d.ts",
22
+ "index.js",
23
+ "meter.d.ts",
24
+ "meter.js"
19
25
  ],
20
26
  "scripts": {
21
- "test": "node --test"
27
+ "test": "node --test *.test.js"
22
28
  },
23
29
  "engines": {
24
30
  "node": ">=18"