agent402-tollbooth 0.1.3 → 0.1.4

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.
Files changed (3) hide show
  1. package/edge.js +13 -6
  2. package/package.json +1 -1
  3. package/worker.js +8 -2
package/edge.js CHANGED
@@ -37,22 +37,28 @@ function constEq(a, b) {
37
37
  /**
38
38
  * Web Crypto proof-of-work. `secret` is required (edge runtimes can't keep a
39
39
  * random per-process secret across invocations). `store` is an optional
40
- * single-use replay backend ({ has(token), add(token, expMs) }, both async) —
41
- * e.g. a Cloudflare KV wrapper; defaults to in-isolate memory.
40
+ * single-use replay backend: { claim(token, expMs) => bool|Promise<bool> } that
41
+ * atomically returns true only the first time a token is seen — e.g. a Cloudflare
42
+ * KV wrapper; defaults to in-isolate memory.
42
43
  */
43
44
  export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000, store } = {}) {
44
45
  if (!secret) throw new Error("createEdgePow requires a stable `secret` string");
45
46
  const mem = new Map();
46
47
  const MAX = 50_000; // bound memory: edge runtimes have no timer to sweep, so prune on write
47
48
  const used = store || {
48
- has: async (k) => { const e = mem.get(k); if (e && e < Date.now()) { mem.delete(k); return false; } return mem.has(k); },
49
- add: async (k, exp) => {
49
+ // Atomic single-use claim. The body is synchronous (no await), so two
50
+ // concurrent claims of the same token cannot both win within an isolate —
51
+ // closing the TOCTOU that a separate has()+add() would open.
52
+ claim: (k, exp) => {
53
+ const e = mem.get(k);
54
+ if (e && e >= Date.now()) return false; // a still-valid entry => already used
50
55
  if (mem.size >= MAX) {
51
56
  const now = Date.now();
52
57
  for (const [kk, ee] of mem) if (ee < now) mem.delete(kk); // drop expired first
53
58
  if (mem.size >= MAX) mem.delete(mem.keys().next().value); // hard cap: evict oldest
54
59
  }
55
60
  mem.set(k, exp);
61
+ return true;
56
62
  },
57
63
  };
58
64
 
@@ -88,10 +94,11 @@ export function createEdgePow({ secret, difficulty = 18, ttlMs = 5 * 60 * 1000,
88
94
  if (!constEq(sig, expected)) return { ok: false, reason: "bad signature" };
89
95
  if (res !== resource) return { ok: false, reason: "wrong resource" };
90
96
  if (Date.now() > Number(expStr)) return { ok: false, reason: "expired" };
91
- if (await used.has(token)) return { ok: false, reason: "already used" };
92
97
  const h = await sha256(`${chal}:${nonce}`);
93
98
  if (leadingZeroBits(h) < Number(diffStr)) return { ok: false, reason: "insufficient work" };
94
- await used.add(token, Number(expStr));
99
+ // Atomically claim single-use only AFTER the work is validated, so an invalid
100
+ // attempt never consumes the token and concurrent dupes can't both pass.
101
+ if (!(await used.claim(token, Number(expStr)))) return { ok: false, reason: "already used" };
95
102
  return { ok: true };
96
103
  }
97
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402, or free proof-of-work) while humans browse free. Drop-in Express middleware or reverse proxy — no Cloudflare, no Stripe, no signup.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/worker.js CHANGED
@@ -13,8 +13,14 @@
13
13
  import { createEdgeTollbooth } from "./edge.js";
14
14
 
15
15
  const kvStore = (kv) => ({
16
- has: async (k) => (await kv.get(k)) != null,
17
- add: async (k, expMs) => kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) }),
16
+ // Best-effort atomic claim. KV has no native compare-and-set, so this is
17
+ // get-then-put (eventually consistent); for strict single-use, back it with a
18
+ // Durable Object. Returns true only the first time a token is seen.
19
+ claim: async (k, expMs) => {
20
+ if ((await kv.get(k)) != null) return false;
21
+ await kv.put(k, "1", { expiration: Math.ceil(expMs / 1000) });
22
+ return true;
23
+ },
18
24
  });
19
25
 
20
26
  export default {