agent402-tollbooth 0.6.1 → 0.6.2

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 (2) hide show
  1. package/package.json +1 -1
  2. package/replay.js +23 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent402-tollbooth",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "Open-source, self-hostable x402 pay-per-crawl tollbooth: charge AI bots per request (USDC via x402 — or USDG on Robinhood Chain — 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/replay.js CHANGED
@@ -115,12 +115,29 @@ export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
115
115
  // Detection above is a heuristic; this is the observation. Two claims of a
116
116
  // throwaway key must produce a win and then a refusal. Anything else means
117
117
  // single-use is not working, and the store refuses rather than pretending.
118
+ // Tri-state on PURPOSE: null = not yet observed, true = proven, false = proven
119
+ // BROKEN and permanently refused.
120
+ //
121
+ // A verdict of false must keep throwing on EVERY later call, not just the one
122
+ // that discovered it. Memoising false and returning it as a resolved value
123
+ // would make the store refuse exactly once and then grant every replay
124
+ // forever - the precise failure this self-test exists to prevent, reached one
125
+ // request later. A caller that solves one challenge could then replay that
126
+ // token for its whole TTL.
127
+ //
128
+ // A probe that THROWS (Redis unreachable) leaves the verdict at null so the
129
+ // next call re-probes: a transient outage must not be remembered as a verdict.
118
130
  let nxProven = null;
131
+ const NX_BROKEN =
132
+ "redisReplayStore: SET NX is not honoured by this client, so proof-of-work single-use cannot be enforced. " +
133
+ "The gate refuses rather than granting replays. Pass a client whose SET supports NX, or supply your own { claim(token, expiresAtMs) } store.";
119
134
  const proveNx = async () => {
120
- if (nxProven !== null) return nxProven;
135
+ if (nxProven === true) return true;
136
+ if (nxProven === false) throw new Error(NX_BROKEN);
121
137
  const probe = `${prefix}__nxselftest__${Math.random().toString(36).slice(2)}${Date.now()}`;
122
138
  const px = 5000;
123
139
  const set = (k) => (isIoredis ? client.set(k, "1", "PX", px, "NX") : client.set(k, "1", { NX: true, PX: px }));
140
+ // Deliberately NOT wrapped: a throw here leaves nxProven null (re-probe).
124
141
  const first = await set(probe);
125
142
  const second = await set(probe);
126
143
  const firstWon = first === "OK" || first === true;
@@ -128,15 +145,16 @@ export function redisReplayStore(client, { prefix = "tollbooth:pow:" } = {}) {
128
145
  nxProven = firstWon && secondRefused;
129
146
  if (!nxProven) {
130
147
  throw new Error(
131
- "redisReplayStore: SET NX did not behave as required (first claim won: " + firstWon + ", second refused: " + secondRefused + "). " +
132
- "Proof-of-work single-use cannot be enforced against this client, so the gate refuses rather than granting replays."
148
+ `${NX_BROKEN} (first claim won: ${firstWon}, second refused: ${secondRefused})`
133
149
  );
134
150
  }
135
- return nxProven;
151
+ return true;
136
152
  };
137
153
  return {
138
154
  claim: async (token, expiresAtMs) => {
139
- await proveNx();
155
+ // Respect the verdict: proveNx throws when unproven, but assert anyway so
156
+ // no future refactor can turn "unproven" into a silently ignored value.
157
+ if ((await proveNx()) !== true) throw new Error(NX_BROKEN);
140
158
  const key = prefix + token;
141
159
  // Floor of 1ms: Redis rejects a non-positive PX. pow.js has already
142
160
  // refused anything actually expired, so a tiny window here just means the