@reclaimprotocol/agent 0.1.35 → 0.1.36

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.
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -13,7 +13,20 @@ type Recent = {
13
13
  httpStatus?: number;
14
14
  at: string;
15
15
  };
16
+ /** A session whose oldest pending event the app has not accepted. */
17
+ type Blocked = {
18
+ sessionId: string;
19
+ event: string;
20
+ eventId: string;
21
+ httpStatus?: number;
22
+ attempts: number;
23
+ lastError?: string;
24
+ /** When the first attempt failed, as an ISO timestamp. */
25
+ since: string;
26
+ };
16
27
  export interface ProxyDeps extends CallbackDeps {
28
+ /** The clock, in milliseconds. Defaults to `Date.now`. */
29
+ now?: () => number;
17
30
  /** Delivers an event to the app. Defaults to global `fetch`. */
18
31
  forwardFetch?: typeof globalThis.fetch;
19
32
  /** Waits between polls. Resolves early, without error, on abort. */
@@ -23,9 +36,13 @@ export interface ProxyDeps extends CallbackDeps {
23
36
  /**
24
37
  * Polls the org's pending pull events and POSTs each envelope to a local
25
38
  * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
26
- * anything else (redirects included) stays pending and is retried with
27
- * exponential backoff. After a failure the batch stops, so events reach the
28
- * app in order.
39
+ * anything else (redirects included) stays pending.
40
+ *
41
+ * Order holds per session. When an event fails, its session is held: its later
42
+ * events are not sent or acknowledged, and the failed event is retried with
43
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
44
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
45
+ * the app accepts the event.
29
46
  */
30
47
  declare class Forwarder {
31
48
  #private;
@@ -34,6 +51,7 @@ declare class Forwarder {
34
51
  constructor(client: ReclaimClient, listener: ListenerInfo, target: Target, deps: ProxyDeps);
35
52
  status(): {
36
53
  recent: Recent[];
54
+ blocked: Blocked[];
37
55
  lastError?: string | undefined;
38
56
  forwarding: boolean;
39
57
  target: string;
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _Forwarder_instances, _Forwarder_client, _Forwarder_deps, _Forwarder_abort, _Forwarder_forwarded, _Forwarder_failed, _Forwarder_lastError, _Forwarder_recent, _Forwarder_unacked, _Forwarder_host, _Forwarder_running, _Forwarder_loop, _Forwarder_tick, _Forwarder_forward, _ForwarderRegistry_forwarders;
12
+ var _Forwarder_instances, _Forwarder_client, _Forwarder_deps, _Forwarder_abort, _Forwarder_forwarded, _Forwarder_failed, _Forwarder_lastError, _Forwarder_recent, _Forwarder_unacked, _Forwarder_held, _Forwarder_host, _Forwarder_running, _Forwarder_loop, _Forwarder_tick, _Forwarder_hold, _Forwarder_forward, _ForwarderRegistry_forwarders;
13
13
  import { badRequest } from '@hapi/boom';
14
14
  import assert from 'node:assert';
15
15
  import { setTimeout as delay } from 'node:timers/promises';
@@ -21,15 +21,25 @@ const POLL_MS = 2000;
21
21
  const MAX_BACKOFF_MS = 30000;
22
22
  const FORWARD_TIMEOUT_MS = 10000;
23
23
  const BATCH = 50;
24
+ /**
25
+ * Pages read per poll. A page is `BATCH` events, so a poll can look past up to
26
+ * 500 pending events. That is enough to reach other sessions behind several
27
+ * held ones, and still bounds the work of one poll.
28
+ */
29
+ const MAX_PAGES = 10;
24
30
  const RECENT = 10;
25
31
  /** Loopback addresses, in the order tried. Dev servers often bind only one. */
26
32
  const HOSTS = ['127.0.0.1', '[::1]'];
27
33
  /**
28
34
  * Polls the org's pending pull events and POSTs each envelope to a local
29
35
  * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
30
- * anything else (redirects included) stays pending and is retried with
31
- * exponential backoff. After a failure the batch stops, so events reach the
32
- * app in order.
36
+ * anything else (redirects included) stays pending.
37
+ *
38
+ * Order holds per session. When an event fails, its session is held: its later
39
+ * events are not sent or acknowledged, and the failed event is retried with
40
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
41
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
42
+ * the app accepts the event.
33
43
  */
34
44
  class Forwarder {
35
45
  constructor(client, listener, target, deps) {
@@ -44,7 +54,11 @@ class Forwarder {
44
54
  /** Delivered to the app but not yet acknowledged; never POSTed twice. */
45
55
  );
46
56
  /** Delivered to the app but not yet acknowledged; never POSTed twice. */
47
- _Forwarder_unacked.set(this, new Set());
57
+ _Forwarder_unacked.set(this, new Set()
58
+ /** Held sessions by session id, with the time of their next attempt. */
59
+ );
60
+ /** Held sessions by session id, with the time of their next attempt. */
61
+ _Forwarder_held.set(this, new Map());
48
62
  _Forwarder_host.set(this, HOSTS[0]);
49
63
  _Forwarder_running.set(this, void 0);
50
64
  __classPrivateFieldSet(this, _Forwarder_client, client, "f");
@@ -62,6 +76,7 @@ class Forwarder {
62
76
  failed: __classPrivateFieldGet(this, _Forwarder_failed, "f"),
63
77
  ...(__classPrivateFieldGet(this, _Forwarder_lastError, "f") ? { lastError: __classPrivateFieldGet(this, _Forwarder_lastError, "f") } : {}),
64
78
  recent: [...__classPrivateFieldGet(this, _Forwarder_recent, "f")],
79
+ blocked: [...__classPrivateFieldGet(this, _Forwarder_held, "f").values()].map((h) => h.blocked),
65
80
  };
66
81
  }
67
82
  async stop() {
@@ -69,16 +84,16 @@ class Forwarder {
69
84
  await __classPrivateFieldGet(this, _Forwarder_running, "f");
70
85
  }
71
86
  }
72
- _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_abort = new WeakMap(), _Forwarder_forwarded = new WeakMap(), _Forwarder_failed = new WeakMap(), _Forwarder_lastError = new WeakMap(), _Forwarder_recent = new WeakMap(), _Forwarder_unacked = new WeakMap(), _Forwarder_host = new WeakMap(), _Forwarder_running = new WeakMap(), _Forwarder_instances = new WeakSet(), _Forwarder_loop = async function _Forwarder_loop() {
87
+ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_abort = new WeakMap(), _Forwarder_forwarded = new WeakMap(), _Forwarder_failed = new WeakMap(), _Forwarder_lastError = new WeakMap(), _Forwarder_recent = new WeakMap(), _Forwarder_unacked = new WeakMap(), _Forwarder_held = new WeakMap(), _Forwarder_host = new WeakMap(), _Forwarder_running = new WeakMap(), _Forwarder_instances = new WeakSet(), _Forwarder_loop = async function _Forwarder_loop() {
73
88
  const sleep = __classPrivateFieldGet(this, _Forwarder_deps, "f").sleep || sleepFor;
74
89
  const signal = __classPrivateFieldGet(this, _Forwarder_abort, "f").signal;
75
90
  let backoff = 0;
76
91
  while (!signal.aborted) {
77
92
  let wait = POLL_MS;
78
93
  try {
79
- const { ok, count } = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_tick).call(this);
80
- backoff = ok ? 0 : nextBackoff(backoff);
81
- wait = ok ? (count >= BATCH ? 0 : POLL_MS) : backoff;
94
+ const more = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_tick).call(this);
95
+ backoff = 0;
96
+ wait = more ? 0 : POLL_MS;
82
97
  }
83
98
  catch (err) {
84
99
  __classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
@@ -90,37 +105,96 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
90
105
  await sleep(wait, signal);
91
106
  }
92
107
  }
93
- }, _Forwarder_tick = async function _Forwarder_tick() {
94
- const { data } = await __classPrivateFieldGet(this, _Forwarder_client, "f").call('ListCallbackEvents', {
95
- query: { pending: true, limit: BATCH },
96
- });
97
- const ack = [];
98
- let ok = true;
99
- for (const item of data.items) {
100
- if (__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted) {
101
- break;
102
- }
103
- if (__classPrivateFieldGet(this, _Forwarder_unacked, "f").has(item.id) || await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_forward).call(this, item)) {
104
- ack.push(item.id);
105
- }
106
- else {
107
- // Stop the batch so later events don't overtake the failed one.
108
- ok = false;
109
- break;
108
+ }, _Forwarder_tick =
109
+ /**
110
+ * Forwards one poll's worth of pending events. Returns whether to poll again
111
+ * at once: the page limit was hit with events still unread, and progress was
112
+ * made (so held sessions alone never cause a busy loop).
113
+ */
114
+ async function _Forwarder_tick() {
115
+ const now = (__classPrivateFieldGet(this, _Forwarder_deps, "f").now || Date.now)();
116
+ // Sessions whose later events are skipped in this poll.
117
+ const skip = new Set();
118
+ const seen = new Set();
119
+ let after;
120
+ let page = 0;
121
+ let full = false;
122
+ let progress = false;
123
+ do {
124
+ const { data } = await __classPrivateFieldGet(this, _Forwarder_client, "f").call('ListCallbackEvents', {
125
+ query: { pending: true, limit: BATCH, ...(after ? { after } : {}) },
126
+ });
127
+ const ack = [];
128
+ for (const item of data.items) {
129
+ if (__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted) {
130
+ break;
131
+ }
132
+ seen.add(item.sessionId);
133
+ const held = __classPrivateFieldGet(this, _Forwarder_held, "f").get(item.sessionId);
134
+ if (skip.has(item.sessionId)
135
+ || (held && held.retryAt > now && !__classPrivateFieldGet(this, _Forwarder_unacked, "f").has(item.id))) {
136
+ skip.add(item.sessionId);
137
+ continue;
138
+ }
139
+ if (__classPrivateFieldGet(this, _Forwarder_unacked, "f").has(item.id)) {
140
+ ack.push(item.id);
141
+ continue;
142
+ }
143
+ const outcome = await __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_forward).call(this, item);
144
+ if (outcome.ok) {
145
+ __classPrivateFieldGet(this, _Forwarder_held, "f").delete(item.sessionId);
146
+ ack.push(item.id);
147
+ }
148
+ else {
149
+ // Hold the session so its later events don't overtake this one.
150
+ __classPrivateFieldGet(this, _Forwarder_instances, "m", _Forwarder_hold).call(this, item, outcome, now);
151
+ skip.add(item.sessionId);
152
+ }
110
153
  }
111
- }
112
- if (ack.length) {
113
- for (const id of ack) {
114
- __classPrivateFieldGet(this, _Forwarder_unacked, "f").add(id);
154
+ if (ack.length) {
155
+ progress = true;
156
+ for (const id of ack) {
157
+ __classPrivateFieldGet(this, _Forwarder_unacked, "f").add(id);
158
+ }
159
+ await __classPrivateFieldGet(this, _Forwarder_client, "f").call('AcknowledgeCallbackEvents', {
160
+ body: { ids: ack },
161
+ });
162
+ for (const id of ack) {
163
+ __classPrivateFieldGet(this, _Forwarder_unacked, "f").delete(id);
164
+ }
115
165
  }
116
- await __classPrivateFieldGet(this, _Forwarder_client, "f").call('AcknowledgeCallbackEvents', {
117
- body: { ids: ack },
118
- });
119
- for (const id of ack) {
120
- __classPrivateFieldGet(this, _Forwarder_unacked, "f").delete(id);
166
+ // `next` skips what this page returned, including held events.
167
+ after = data.next;
168
+ full = data.items.length >= BATCH;
169
+ page += 1;
170
+ } while (full && page < MAX_PAGES && !__classPrivateFieldGet(this, _Forwarder_abort, "f").signal.aborted);
171
+ if (!full) {
172
+ // The listing was read to its end: forget sessions with nothing pending.
173
+ for (const sessionId of __classPrivateFieldGet(this, _Forwarder_held, "f").keys()) {
174
+ if (!seen.has(sessionId)) {
175
+ __classPrivateFieldGet(this, _Forwarder_held, "f").delete(sessionId);
176
+ }
121
177
  }
122
178
  }
123
- return { ok, count: data.items.length };
179
+ return full && progress;
180
+ }, _Forwarder_hold = function _Forwarder_hold(item, outcome, now) {
181
+ const prev = __classPrivateFieldGet(this, _Forwarder_held, "f").get(item.sessionId);
182
+ const delay = nextBackoff(prev?.delay || 0);
183
+ __classPrivateFieldGet(this, _Forwarder_held, "f").set(item.sessionId, {
184
+ blocked: {
185
+ sessionId: item.sessionId,
186
+ event: item.event,
187
+ eventId: item.id,
188
+ ...(outcome.httpStatus === undefined
189
+ ? {}
190
+ : { httpStatus: outcome.httpStatus }),
191
+ attempts: (prev?.blocked.attempts || 0) + 1,
192
+ lastError: outcome.error,
193
+ since: prev?.blocked.since || new Date(now).toISOString(),
194
+ },
195
+ retryAt: now + delay,
196
+ delay,
197
+ });
124
198
  }, _Forwarder_forward = async function _Forwarder_forward(item) {
125
199
  const doFetch = __classPrivateFieldGet(this, _Forwarder_deps, "f").forwardFetch || globalThis.fetch;
126
200
  const record = (status, httpStatus) => {
@@ -150,7 +224,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
150
224
  if (res.ok) {
151
225
  __classPrivateFieldSet(this, _Forwarder_forwarded, __classPrivateFieldGet(this, _Forwarder_forwarded, "f") + 1, "f");
152
226
  record('delivered', res.status);
153
- return true;
227
+ return { ok: true };
154
228
  }
155
229
  __classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
156
230
  const url = targetUrl(this.target, host);
@@ -159,7 +233,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
159
233
  + 'followed; call again with the redirect target\'s `path`.'
160
234
  : `${url} returned HTTP ${res.status}`, "f");
161
235
  record('failed', res.status);
162
- return false;
236
+ return { ok: false, httpStatus: res.status, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") };
163
237
  }
164
238
  catch (err) {
165
239
  __classPrivateFieldSet(this, _Forwarder_lastError, err instanceof Error ? err.message : String(err), "f");
@@ -168,7 +242,7 @@ _Forwarder_client = new WeakMap(), _Forwarder_deps = new WeakMap(), _Forwarder_a
168
242
  // No loopback address accepted the connection.
169
243
  __classPrivateFieldSet(this, _Forwarder_failed, __classPrivateFieldGet(this, _Forwarder_failed, "f") + 1, "f");
170
244
  record('failed');
171
- return false;
245
+ return { ok: false, error: __classPrivateFieldGet(this, _Forwarder_lastError, "f") || 'Connection failed' };
172
246
  };
173
247
  /** The forwarders of one MCP process, one per org key. */
174
248
  export class ForwarderRegistry {
@@ -207,17 +281,26 @@ export function proxyCallbackEventsTool(deps = {}) {
207
281
  + 'to a local route, like `stripe listen --forward-to`. Call it '
208
282
  + 'when the app\'s callback route runs locally. It POSTs each '
209
283
  + 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
210
- + 'only; also tries `[::1]`) in order, acknowledges 2xx '
211
- + 'responses, and retries anything else, redirects included, '
212
- + 'with backoff. Creates the listener on first use, so it is not '
284
+ + 'only; also tries `[::1]`) and acknowledges 2xx responses. '
285
+ + 'Events of one session arrive in order. When the app rejects '
286
+ + 'an event, including with a redirect, that session is held and '
287
+ + 'retried with backoff (2 s up to 30 s) while other sessions '
288
+ + 'keep flowing. Creates the listener on first use, so it is not '
213
289
  + 'read-only. One forwarder runs per org: call again to read '
214
290
  + 'status, or with another `port` or `path` to retarget. '
215
291
  + '`stop: true` stops forwarding only. Forwarding lasts only '
216
292
  + 'while this MCP server runs; production needs '
217
293
  + '`create_org_callback_subscription`. Returns `{ forwarding, '
218
- + 'target, listener, forwarded, failed, lastError?, recent }`; '
219
- + '`listener.expiresAt` is the lease end, and `recent` lists the '
220
- + 'last events with `status` and `httpStatus`.',
294
+ + 'target, listener, forwarded, failed, lastError?, recent, '
295
+ + 'blocked }`. `listener.expiresAt` is the lease end, and '
296
+ + '`recent` lists the last events with `status` and '
297
+ + '`httpStatus`. `blocked` lists each held session as '
298
+ + '`{ sessionId, event, eventId, httpStatus?, attempts, '
299
+ + 'lastError?, since }`, and is `[]` when none is held. When '
300
+ + '`blocked` is not empty, check the app\'s response and logs '
301
+ + 'for that session. The app must return 2xx for a result that '
302
+ + 'failed verification, and 5xx only for a temporary error. '
303
+ + 'Fix the app, and the session resumes on the next retry.',
221
304
  inputSchema: {
222
305
  type: 'object',
223
306
  properties: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.35",
4
+ "version": "0.1.36",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -83,7 +83,7 @@
83
83
  "@modelcontextprotocol/sdk": "^1.29.0",
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@reclaimprotocol/attestor-core": "^5.1.5",
86
- "@reclaimprotocol/client": "0.1.35",
86
+ "@reclaimprotocol/client": "0.1.36",
87
87
  "@reclaimprotocol/tls": "^0.1.3",
88
88
  "chrome-remote-interface": "^0.33.0",
89
89
  "domhandler": "^6.0.0",
@@ -75,9 +75,17 @@ TEE binding. Edit the app with your file tools. Use the HTTP API
75
75
  `@reclaimprotocol/client`. The only exception is `results.receive` on the
76
76
  callback, or when the developer asks for the client package.
77
77
 
78
+ The app's frontend renders every state and every failure, never only a
79
+ spinner. Follow "UI states" in `how_it_works({ topic: "integration" })`. The
80
+ callback route responds `error.retryable ? 503 : 200` for a thrown
81
+ `ResultVerificationError`; a result that failed verification gets 2xx.
82
+
78
83
  For local development, call `proxy_callback_events({ port, path })` to forward
79
84
  events to the app's callback route on localhost. The local listener works only
80
- while this MCP server runs. Production needs a webhook:
85
+ while this MCP server runs. It keeps events in order per session and retries
86
+ a session the app rejects with backoff, so other sessions keep flowing. When
87
+ its status lists `blocked` sessions, read that session's callback route
88
+ response and logs, and fix the app. Production needs a webhook:
81
89
  `create_org_callback_subscription` with the deployed app's public URL. Without
82
90
  one, session creation returns 409.
83
91
 
@@ -32,8 +32,9 @@ Without subagents, do step 2 first.
32
32
  - [ ] Detect the app's framework and package manager from the repository, and use them. Read `how_it_works({ topic: "integration" })`.
33
33
  - [ ] Call `issue_org_token` with the organization, this project's absolute path, and the server env file. It writes the key to that file and never returns it. Never print or commit the key. Confirm the env file is in `.gitignore`.
34
34
  - [ ] Add a backend route that creates a verification session (`POST /verifications/sessions`) and returns `verificationUrl`. Read the provider ID from server config (an env variable such as `RECLAIM_PROVIDER_ID`), so the route works before the provider exists.
35
- - [ ] Add a callback route that verifies each result with `results.receive` from `@reclaimprotocol/client`, then stores only the requested data points.
36
- - [ ] Add a frontend Verify button that opens `verificationUrl`, and shows the stored result when it arrives.
35
+ - [ ] Add a callback route that verifies each result with `results.receive` from `@reclaimprotocol/client`, then stores only the requested data points. Store every failure on the verification session record and log it server-side: a failure event (`verification_rejected`, `verification_error`, `verification_cancelled`, `session_expired`), or a thrown `ResultVerificationError` (`error.reason` plus each failing proof's `reason`). Respond `error.retryable ? 503 : 200`. A result that failed verification gets 2xx, because retrying can't fix it. Read the exact pattern in `how_it_works({ topic: "integration" })`.
36
+ - [ ] Add a status route the frontend polls. It returns the stored status, data points, and failure reason. While the app has no result, it also reads `GET /verifications/sessions/{sessionId}`, so a session that ended in Builder never stays pending.
37
+ - [ ] Add a frontend Verify button that opens `verificationUrl`, and render every state in "UI states" in `how_it_works({ topic: "integration" })`: idle, waiting in the other tab, verifying, verified (with the data points), failed (plain-language reason and **Try again**), and expired or cancelled (**Try again**). Never leave a spinner as the only feedback.
37
38
  - [ ] Start the app. Call `proxy_callback_events({ port, path })` with the callback route's port and path. It forwards the org's verification events to that route on localhost, with no public URL.
38
39
  <!-- ifnot:providerIds -->
39
40
  - [ ] Call `check_attestor` once. If it fails, tell the developer before they spend time on the provider build: this machine cannot reach the attestor.
@@ -67,8 +68,8 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
67
68
  ## 4. Wire the provider and test end to end
68
69
 
69
70
  - [ ] Set the provider ID in the app's server config.
70
- - [ ] Confirm the callback route is forwarding: `proxy_callback_events` with no `stop` returns its status.
71
- - [ ] Tell the developer exactly how to test: open the app, click Verify, sign in, and see the stored result.
72
- - [ ] After they click Verify, read the delivered events from `proxy_callback_events` status (`recent`) or `read_callback_events`. If the result did not arrive, call `list_verification_deliveries` and fix the cause. Repeat until the app shows the stored result.
71
+ - [ ] Confirm the callback route is forwarding: `proxy_callback_events` with no `stop` returns its status. If `blocked` is not empty, the app rejected that session's event: read the callback route's response and logs, fix the app, and check again.
72
+ - [ ] Tell the developer exactly how to test: open the app, click Verify, sign in, and see the stored result. Also test one failure, such as cancelling in the verification tab, and confirm the app shows the failed state with **Try again**.
73
+ - [ ] After they click Verify, read the delivered events from `proxy_callback_events` status (`recent`) or `read_callback_events`. If the result did not arrive, or the app shows an unexpected failure, call `list_verification_deliveries` and fix the cause. Repeat until the app shows the stored result.
73
74
  - [ ] Ask the developer to set up the production callback. The local listener works only while this coding agent's MCP server runs; once it stops, the deployed app receives no results. Call `create_org_callback_subscription` with the deployed app's public callback route URL for all five session-ending events: `verification_success`, `verification_rejected`, `verification_error`, `verification_cancelled`, and `session_expired`.
74
75
  - [ ] If they have no deployed URL yet, tell them: "When you deploy, ask me to run `create_org_callback_subscription` with your public callback URL, or results will not reach the app."
@@ -47,12 +47,22 @@ server), or migrate to the session flow below. When migrating, remove
47
47
  is not proof of success. Use `@reclaimprotocol/client`
48
48
  (`ReclaimVerification.create({ orgSecret })`, `sessions.create`) only when
49
49
  the developer asks for it.
50
- 5. Add a backend callback endpoint. Persist each delivery idempotently and load
50
+ 5. Add a backend callback route. Persist each delivery idempotently and load
51
51
  the expected session ID and org ID from app storage. To verify the result on
52
- your callback, install `@reclaimprotocol/client` in the backend and call
52
+ your callback route, install `@reclaimprotocol/client` in the backend and call
53
53
  `reclaim.results.receive(body, { reclaimSessionId, expectedAud,
54
54
  orgEthPrivateKey })`. Use the verified proof data to update app state.
55
- Handle callback retries and terminal failure outcomes.
55
+ Store every failure on the verification session record: failure events, and
56
+ a thrown `ResultVerificationError` (`error.reason` and each
57
+ `error.outcome.proofs[].reason`). Respond `error.retryable ? 503 : 200`.
58
+ A result that failed verification gets 2xx: retrying can't fix it, and an
59
+ unacknowledged delivery holds back that session's later events. Respond 5xx
60
+ for any other temporary problem too, such as a database outage.
61
+ Add a status route that returns the stored status, data points, and failure
62
+ reason. While the app has no result, it also reads
63
+ `GET /verifications/sessions/{sessionId}`, so a session Builder has ended
64
+ never stays pending in the app. Render the frontend per
65
+ [UI states](#ui-states).
56
66
  6. Exercise one session through the selected registered Verification Client,
57
67
  inspect its Builder events, and test the app's success and failure paths.
58
68
  Omit `verificationClientUrl` to use the Portal, or select another client
@@ -125,16 +135,37 @@ Receive and verify the result on the callback route. This is the one place the
125
135
  client package is needed:
126
136
 
127
137
  ```js
128
- import { ReclaimVerification } from '@reclaimprotocol/client'
138
+ import { ReclaimVerification, ResultVerificationError } from '@reclaimprotocol/client'
129
139
 
130
140
  const reclaim = ReclaimVerification.create({
131
141
  orgSecret: process.env.RECLAIM_ORG_SECRET,
132
142
  })
133
143
 
134
144
  app.post('/reclaim/callback', async(req, res) => {
135
- const outcome = await reclaim.results.receive(req.body, {
136
- reclaimSessionId: sessionId, // the id you stored
137
- })
145
+ let outcome
146
+ try {
147
+ outcome = await reclaim.results.receive(req.body, {
148
+ reclaimSessionId: sessionId, // the id you stored
149
+ })
150
+ } catch(error) {
151
+ if(!(error instanceof ResultVerificationError)) {
152
+ throw error
153
+ }
154
+
155
+ if(error.retryable) {
156
+ return res.sendStatus(503) // temporary: Builder retries the delivery
157
+ }
158
+
159
+ // Retrying can't fix a result that failed verification. Store the failure
160
+ // where the status route returns it, then acknowledge the delivery.
161
+ const proofReasons = (error.outcome.proofs ?? [])
162
+ .filter((proof) => !proof.proofValid)
163
+ .map((proof) => proof.reason)
164
+ console.warn('result failed verification', error.reason, proofReasons)
165
+ await markSessionFailed(sessionId, error.reason) // your app's storage
166
+ return res.sendStatus(200)
167
+ }
168
+
138
169
  if(outcome.kind === 'result') {
139
170
  console.log(outcome.result.payload) // verified
140
171
  }
@@ -143,8 +174,34 @@ app.post('/reclaim/callback', async(req, res) => {
143
174
  })
144
175
  ```
145
176
 
146
- Check or investigate a session, from the backend (optional, e.g. a status
147
- endpoint the frontend polls) or by hand with `curl`. All take the org key:
177
+ ### UI states
178
+
179
+ Apply these rules when you write the frontend. Test each state, including one
180
+ failure.
181
+
182
+ | State | Render |
183
+ | --- | --- |
184
+ | Idle | The **Verify** button. |
185
+ | Waiting in the other tab | The current step ("Finish verifying in the new tab") and a way to reopen `verificationUrl`. After about 30 seconds, say what is happening and offer **Cancel** or **Try again**. |
186
+ | Verifying | "Checking your result". |
187
+ | Verified | The requested data points. |
188
+ | Failed | A plain-language reason and what to do next, near the **Verify** button, with **Try again**. |
189
+ | Expired or cancelled | Say which, with **Try again**. |
190
+
191
+ - Show the current step, never an indefinite spinner (Nielsen heuristic 1,
192
+ visibility of system status).
193
+ - Write errors in plain language that says what happened and what to do next,
194
+ visible next to the action, with a recovery action (Nielsen Norman Group
195
+ error-message guidelines).
196
+ - Send reason codes (`error.reason`, proof `reason`) to logs or an expandable
197
+ **Details** section, not the headline.
198
+ - Announce changes: `role="status"` (or `aria-live="polite"`) for progress,
199
+ `role="alert"` for failures (WCAG 2.2 SC 4.1.3).
200
+ - Pair color with text or an icon (WCAG 2.2 SC 1.4.1).
201
+ - Stop the waiting state when the status route reports a terminal status.
202
+
203
+ Check or investigate a session from the status route or by hand with `curl`.
204
+ All routes take the org key:
148
205
 
149
206
  | Route | Use |
150
207
  | --- | --- |
@@ -1,5 +1,5 @@
1
1
  // AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
2
- // from packages/app/openapi.yaml (spec v0.1.0, source sha256:de7373741e35).
2
+ // from packages/app/openapi.yaml (spec v0.1.0, source sha256:96339e8dff91).
3
3
  // The hash catches drift the version misses (a spec can change without a
4
4
  // version bump) — do not remove it as "redundant" with the version.
5
5
  // Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
@@ -17,6 +17,12 @@ const POLL_MS = 2_000
17
17
  const MAX_BACKOFF_MS = 30_000
18
18
  const FORWARD_TIMEOUT_MS = 10_000
19
19
  const BATCH = 50
20
+ /**
21
+ * Pages read per poll. A page is `BATCH` events, so a poll can look past up to
22
+ * 500 pending events. That is enough to reach other sessions behind several
23
+ * held ones, and still bounds the work of one poll.
24
+ */
25
+ const MAX_PAGES = 10
20
26
  const RECENT = 10
21
27
  /** Loopback addresses, in the order tried. Dev servers often bind only one. */
22
28
  const HOSTS = ['127.0.0.1', '[::1]']
@@ -42,7 +48,25 @@ type Recent = {
42
48
  at: string
43
49
  }
44
50
 
51
+ /** A session whose oldest pending event the app has not accepted. */
52
+ type Blocked = {
53
+ sessionId: string
54
+ event: string
55
+ eventId: string
56
+ httpStatus?: number
57
+ attempts: number
58
+ lastError?: string
59
+ /** When the first attempt failed, as an ISO timestamp. */
60
+ since: string
61
+ }
62
+
63
+ type Held = { blocked: Blocked, retryAt: number, delay: number }
64
+
65
+ type Outcome = { ok: true } | { ok: false, httpStatus?: number, error: string }
66
+
45
67
  export interface ProxyDeps extends CallbackDeps {
68
+ /** The clock, in milliseconds. Defaults to `Date.now`. */
69
+ now?: () => number
46
70
  /** Delivers an event to the app. Defaults to global `fetch`. */
47
71
  forwardFetch?: typeof globalThis.fetch
48
72
  /** Waits between polls. Resolves early, without error, on abort. */
@@ -53,9 +77,13 @@ export interface ProxyDeps extends CallbackDeps {
53
77
  /**
54
78
  * Polls the org's pending pull events and POSTs each envelope to a local
55
79
  * route, like `stripe listen --forward-to`. 2xx responses are acknowledged;
56
- * anything else (redirects included) stays pending and is retried with
57
- * exponential backoff. After a failure the batch stops, so events reach the
58
- * app in order.
80
+ * anything else (redirects included) stays pending.
81
+ *
82
+ * Order holds per session. When an event fails, its session is held: its later
83
+ * events are not sent or acknowledged, and the failed event is retried with
84
+ * exponential backoff (2 s, 4 s, ... 30 s). Other sessions keep flowing at the
85
+ * normal poll rate. Held sessions appear in `status().blocked`, and clear when
86
+ * the app accepts the event.
59
87
  */
60
88
  class Forwarder {
61
89
  target: Target
@@ -69,6 +97,8 @@ class Forwarder {
69
97
  #recent: Recent[] = []
70
98
  /** Delivered to the app but not yet acknowledged; never POSTed twice. */
71
99
  #unacked = new Set<string>()
100
+ /** Held sessions by session id, with the time of their next attempt. */
101
+ #held = new Map<string, Held>()
72
102
  #host = HOSTS[0]
73
103
  #running: Promise<void>
74
104
 
@@ -94,6 +124,7 @@ class Forwarder {
94
124
  failed: this.#failed,
95
125
  ...(this.#lastError ? { lastError: this.#lastError } : {}),
96
126
  recent: [...this.#recent],
127
+ blocked: [...this.#held.values()].map((h) => h.blocked),
97
128
  }
98
129
  }
99
130
 
@@ -109,9 +140,9 @@ class Forwarder {
109
140
  while(!signal.aborted) {
110
141
  let wait = POLL_MS
111
142
  try {
112
- const { ok, count } = await this.#tick()
113
- backoff = ok ? 0 : nextBackoff(backoff)
114
- wait = ok ? (count >= BATCH ? 0 : POLL_MS) : backoff
143
+ const more = await this.#tick()
144
+ backoff = 0
145
+ wait = more ? 0 : POLL_MS
115
146
  } catch(err) {
116
147
  this.#lastError = err instanceof Error ? err.message : String(err)
117
148
  LOGGER.warn({ err }, 'callback proxy poll failed')
@@ -125,43 +156,113 @@ class Forwarder {
125
156
  }
126
157
  }
127
158
 
159
+ /**
160
+ * Forwards one poll's worth of pending events. Returns whether to poll again
161
+ * at once: the page limit was hit with events still unread, and progress was
162
+ * made (so held sessions alone never cause a busy loop).
163
+ */
128
164
  async #tick() {
129
- const { data } = await this.#client.call('ListCallbackEvents', {
130
- query: { pending: true, limit: BATCH },
131
- })
132
- const ack: string[] = []
133
- let ok = true
134
- for(const item of data.items) {
135
- if(this.#abort.signal.aborted) {
136
- break
137
- }
165
+ const now = (this.#deps.now || Date.now)()
166
+ // Sessions whose later events are skipped in this poll.
167
+ const skip = new Set<string>()
168
+ const seen = new Set<string>()
169
+ let after: string | undefined
170
+ let page = 0
171
+ let full = false
172
+ let progress = false
173
+ do {
174
+ const { data } = await this.#client.call('ListCallbackEvents', {
175
+ query: { pending: true, limit: BATCH, ...(after ? { after } : {}) },
176
+ })
177
+ const ack: string[] = []
178
+ for(const item of data.items) {
179
+ if(this.#abort.signal.aborted) {
180
+ break
181
+ }
182
+
183
+ seen.add(item.sessionId)
184
+ const held = this.#held.get(item.sessionId)
185
+ if(
186
+ skip.has(item.sessionId)
187
+ || (held && held.retryAt > now && !this.#unacked.has(item.id))
188
+ ) {
189
+ skip.add(item.sessionId)
190
+ continue
191
+ }
192
+
193
+ if(this.#unacked.has(item.id)) {
194
+ ack.push(item.id)
195
+ continue
196
+ }
138
197
 
139
- if(this.#unacked.has(item.id) || await this.#forward(item)) {
140
- ack.push(item.id)
141
- } else {
142
- // Stop the batch so later events don't overtake the failed one.
143
- ok = false
144
- break
198
+ const outcome = await this.#forward(item)
199
+ if(outcome.ok) {
200
+ this.#held.delete(item.sessionId)
201
+ ack.push(item.id)
202
+ } else {
203
+ // Hold the session so its later events don't overtake this one.
204
+ this.#hold(item, outcome, now)
205
+ skip.add(item.sessionId)
206
+ }
145
207
  }
146
- }
147
208
 
148
- if(ack.length) {
149
- for(const id of ack) {
150
- this.#unacked.add(id)
209
+ if(ack.length) {
210
+ progress = true
211
+ for(const id of ack) {
212
+ this.#unacked.add(id)
213
+ }
214
+
215
+ await this.#client.call('AcknowledgeCallbackEvents', {
216
+ body: { ids: ack },
217
+ })
218
+ for(const id of ack) {
219
+ this.#unacked.delete(id)
220
+ }
151
221
  }
152
222
 
153
- await this.#client.call('AcknowledgeCallbackEvents', {
154
- body: { ids: ack },
155
- })
156
- for(const id of ack) {
157
- this.#unacked.delete(id)
223
+ // `next` skips what this page returned, including held events.
224
+ after = data.next
225
+ full = data.items.length >= BATCH
226
+ page += 1
227
+ } while(full && page < MAX_PAGES && !this.#abort.signal.aborted)
228
+
229
+ if(!full) {
230
+ // The listing was read to its end: forget sessions with nothing pending.
231
+ for(const sessionId of this.#held.keys()) {
232
+ if(!seen.has(sessionId)) {
233
+ this.#held.delete(sessionId)
234
+ }
158
235
  }
159
236
  }
160
237
 
161
- return { ok, count: data.items.length }
238
+ return full && progress
239
+ }
240
+
241
+ #hold(
242
+ item: CallbackEvent,
243
+ outcome: Extract<Outcome, { ok: false }>,
244
+ now: number,
245
+ ) {
246
+ const prev = this.#held.get(item.sessionId)
247
+ const delay = nextBackoff(prev?.delay || 0)
248
+ this.#held.set(item.sessionId, {
249
+ blocked: {
250
+ sessionId: item.sessionId,
251
+ event: item.event,
252
+ eventId: item.id,
253
+ ...(outcome.httpStatus === undefined
254
+ ? {}
255
+ : { httpStatus: outcome.httpStatus }),
256
+ attempts: (prev?.blocked.attempts || 0) + 1,
257
+ lastError: outcome.error,
258
+ since: prev?.blocked.since || new Date(now).toISOString(),
259
+ },
260
+ retryAt: now + delay,
261
+ delay,
262
+ })
162
263
  }
163
264
 
164
- async #forward(item: CallbackEvent) {
265
+ async #forward(item: CallbackEvent): Promise<Outcome> {
165
266
  const doFetch = this.#deps.forwardFetch || globalThis.fetch
166
267
  const record = (status: Recent['status'], httpStatus?: number) => {
167
268
  this.#recent.push({
@@ -191,7 +292,7 @@ class Forwarder {
191
292
  if(res.ok) {
192
293
  this.#forwarded += 1
193
294
  record('delivered', res.status)
194
- return true
295
+ return { ok: true }
195
296
  }
196
297
 
197
298
  this.#failed += 1
@@ -201,7 +302,7 @@ class Forwarder {
201
302
  + 'followed; call again with the redirect target\'s `path`.'
202
303
  : `${url} returned HTTP ${res.status}`
203
304
  record('failed', res.status)
204
- return false
305
+ return { ok: false, httpStatus: res.status, error: this.#lastError }
205
306
  } catch(err) {
206
307
  this.#lastError = err instanceof Error ? err.message : String(err)
207
308
  }
@@ -210,7 +311,7 @@ class Forwarder {
210
311
  // No loopback address accepted the connection.
211
312
  this.#failed += 1
212
313
  record('failed')
213
- return false
314
+ return { ok: false, error: this.#lastError || 'Connection failed' }
214
315
  }
215
316
  }
216
317
 
@@ -257,17 +358,26 @@ export function proxyCallbackEventsTool(deps: ProxyDeps = {}): RegisteredTool {
257
358
  + 'to a local route, like `stripe listen --forward-to`. Call it '
258
359
  + 'when the app\'s callback route runs locally. It POSTs each '
259
360
  + 'envelope as JSON to `http://127.0.0.1:<port><path>` (loopback '
260
- + 'only; also tries `[::1]`) in order, acknowledges 2xx '
261
- + 'responses, and retries anything else, redirects included, '
262
- + 'with backoff. Creates the listener on first use, so it is not '
361
+ + 'only; also tries `[::1]`) and acknowledges 2xx responses. '
362
+ + 'Events of one session arrive in order. When the app rejects '
363
+ + 'an event, including with a redirect, that session is held and '
364
+ + 'retried with backoff (2 s up to 30 s) while other sessions '
365
+ + 'keep flowing. Creates the listener on first use, so it is not '
263
366
  + 'read-only. One forwarder runs per org: call again to read '
264
367
  + 'status, or with another `port` or `path` to retarget. '
265
368
  + '`stop: true` stops forwarding only. Forwarding lasts only '
266
369
  + 'while this MCP server runs; production needs '
267
370
  + '`create_org_callback_subscription`. Returns `{ forwarding, '
268
- + 'target, listener, forwarded, failed, lastError?, recent }`; '
269
- + '`listener.expiresAt` is the lease end, and `recent` lists the '
270
- + 'last events with `status` and `httpStatus`.',
371
+ + 'target, listener, forwarded, failed, lastError?, recent, '
372
+ + 'blocked }`. `listener.expiresAt` is the lease end, and '
373
+ + '`recent` lists the last events with `status` and '
374
+ + '`httpStatus`. `blocked` lists each held session as '
375
+ + '`{ sessionId, event, eventId, httpStatus?, attempts, '
376
+ + 'lastError?, since }`, and is `[]` when none is held. When '
377
+ + '`blocked` is not empty, check the app\'s response and logs '
378
+ + 'for that session. The app must return 2xx for a result that '
379
+ + 'failed verification, and 5xx only for a temporary error. '
380
+ + 'Fix the app, and the session resumes on the next retry.',
271
381
  inputSchema: {
272
382
  type: 'object',
273
383
  properties: {