@reclaimprotocol/agent 0.1.35 → 0.1.37

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 (106) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/api/operations.gen.js +1 -1
  15. package/lib/mcp/instructions.js +3 -3
  16. package/lib/mcp/start.js +17 -2
  17. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  18. package/lib/mcp/tools/agent/attach.js +58 -118
  19. package/lib/mcp/tools/agent/build.js +6 -0
  20. package/lib/mcp/tools/agent/capture.js +33 -10
  21. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  22. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  23. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  24. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  25. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  26. package/lib/mcp/tools/agent/proof.js +72 -0
  27. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  28. package/lib/mcp/tools/agent/shared.js +98 -19
  29. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  30. package/lib/mcp/tools/agent/start-integration.js +10 -6
  31. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  32. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  33. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  34. package/lib/mcp/tools/authenticate/build.js +6 -3
  35. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  36. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  37. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  38. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  39. package/lib/mcp/tools/authenticate/index.js +1 -1
  40. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  41. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  42. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  43. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  44. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  45. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  46. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  47. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  48. package/lib/mcp/tools/mode/mode.js +6 -6
  49. package/lib/mcp/tools/verification/proxy-callback-events.d.ts +21 -3
  50. package/lib/mcp/tools/verification/proxy-callback-events.js +128 -45
  51. package/lib/old/login-cdp.js +2 -3
  52. package/lib/proof/attestor.js +2 -3
  53. package/lib/proof/check-attestor.d.ts +12 -2
  54. package/lib/proof/check-attestor.js +40 -10
  55. package/lib/proof/check-matchers.d.ts +36 -0
  56. package/lib/proof/check-matchers.js +161 -0
  57. package/lib/proof/zk-files.d.ts +50 -11
  58. package/lib/proof/zk-files.js +259 -63
  59. package/lib/provider/draft.d.ts +24 -1
  60. package/lib/provider/draft.js +135 -0
  61. package/lib/provider/matcher.js +171 -4
  62. package/package.json +6 -4
  63. package/skill/SKILL.builder.md +7 -2
  64. package/skill/SKILL.common.md +90 -70
  65. package/skill/SKILL.old.md +3 -3
  66. package/skill/builder-mode.md +44 -6
  67. package/skill/old-mode.md +18 -13
  68. package/skill/start-integration.md +51 -35
  69. package/skill/verification-client-integration.md +87 -15
  70. package/src/authoring/backend.ts +12 -1
  71. package/src/authoring/tools.ts +48 -7
  72. package/src/authoring/util.ts +64 -0
  73. package/src/cdp/chrome-for-testing.ts +66 -0
  74. package/src/cdp/geo.ts +36 -21
  75. package/src/cdp/launcher.ts +328 -24
  76. package/src/logger.ts +3 -1
  77. package/src/mcp/api/operations.gen.ts +1 -1
  78. package/src/mcp/instructions.ts +3 -3
  79. package/src/mcp/start.ts +20 -1
  80. package/src/mcp/tools/agent/attach.ts +65 -128
  81. package/src/mcp/tools/agent/build.ts +7 -0
  82. package/src/mcp/tools/agent/capture.ts +54 -11
  83. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  84. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  85. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  86. package/src/mcp/tools/agent/proof.ts +110 -1
  87. package/src/mcp/tools/agent/shared.ts +130 -19
  88. package/src/mcp/tools/agent/start-integration.ts +10 -6
  89. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  90. package/src/mcp/tools/authenticate/build.ts +6 -3
  91. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  92. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  93. package/src/mcp/tools/authenticate/index.ts +1 -1
  94. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  95. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  96. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  97. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  98. package/src/mcp/tools/mode/mode.ts +6 -6
  99. package/src/mcp/tools/verification/proxy-callback-events.ts +151 -41
  100. package/src/old/login-cdp.ts +2 -3
  101. package/src/proof/attestor.ts +2 -3
  102. package/src/proof/check-attestor.ts +55 -12
  103. package/src/proof/check-matchers.ts +227 -0
  104. package/src/proof/zk-files.ts +357 -65
  105. package/src/provider/draft.ts +215 -7
  106. package/src/provider/matcher.ts +237 -4
@@ -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: {
@@ -1,7 +1,7 @@
1
1
  import { connectCdp } from '../cdp/client.ts'
2
2
  import {
3
3
  defaultProfileDir,
4
- launchChrome,
4
+ launchLocalBrowser,
5
5
  waitForCdpReady,
6
6
  } from '../cdp/launcher.ts'
7
7
  import { ConfigStore } from '../config/store.ts'
@@ -160,8 +160,7 @@ export async function captureFirebaseTokenViaCdp(opts: {
160
160
  )
161
161
  }
162
162
 
163
- launchChrome({ port, profileDir: defaultProfileDir() })
164
- await waitForCdpReady(port, 10_000)
163
+ await launchLocalBrowser({ port, profileDir: defaultProfileDir() })
165
164
  launchedByUs = true
166
165
  }
167
166
 
@@ -218,9 +218,8 @@ export async function runAttestorProof(
218
218
  verified: false,
219
219
  error:
220
220
  'No eth proof-owner key available (no RECLAIM_PRIVATE_KEY in env, ' +
221
- '.env, or RECLAIM_PRIVATE_KEY_FILE). Call resolve_owner_key, then ' +
222
- 'issue_credentials (generate) or import_credentials (provide one), ' +
223
- 'and retry run_proof.',
221
+ '.env, or RECLAIM_PRIVATE_KEY_FILE). Call resolve_owner_key, ' +
222
+ 'which creates a local test key, and retry run_proof.',
224
223
  errorKind: 'missing-owner-key',
225
224
  }
226
225
  }
@@ -12,7 +12,7 @@ import {
12
12
  parseClaim,
13
13
  resolveAttestorUrl,
14
14
  } from './proof-core.ts'
15
- import { ensureZkFiles } from './zk-files.ts'
15
+ import { ensureZkFiles, type ZkFilesState, zkFilesState } from './zk-files.ts'
16
16
 
17
17
  /**
18
18
  * Default time budget for one check. A live run takes about 8 s; a longer
@@ -63,11 +63,16 @@ const COIN_GECKO_SECRET_HEADERS = {
63
63
  }
64
64
 
65
65
  export type AttestorCheckErrorKind =
66
- /** Socket, connection, DNS, TLS, or timeout failure. */
66
+ /** Socket, connection, DNS, TLS, or timeout failure, with the proof files
67
+ * ready: the attestor itself didn't answer. */
67
68
  | 'attestor-unreachable'
68
69
  /** The attestor answered and rejected or failed the claim. */
69
70
  | 'attestor-error'
70
- /** Local setup (ZK files) failed before the attestor was contacted. */
71
+ /** The proof files are still downloading; the attestor wasn't contacted. */
72
+ | 'proof-files-downloading'
73
+ /** The proof file download failed; the attestor wasn't contacted. */
74
+ | 'proof-files-failed'
75
+ /** Other local setup failed before the attestor was contacted. */
71
76
  | 'setup-failed'
72
77
 
73
78
  export interface AttestorCheckResult {
@@ -77,6 +82,8 @@ export interface AttestorCheckResult {
77
82
  extractedParameters?: Record<string, string>
78
83
  error?: string
79
84
  errorKind?: AttestorCheckErrorKind
85
+ /** The proof file download state when the check finished. */
86
+ proofFiles?: ZkFilesState
80
87
  }
81
88
 
82
89
  /** Creates the claim on the attestor (`createClaimOnAttestor` by default). */
@@ -91,6 +98,8 @@ export interface CheckAttestorOptions {
91
98
  createClaim?: CreateClaim
92
99
  /** Replaces the ZK file download in tests. */
93
100
  prepare?: () => Promise<void>
101
+ /** Reports the ZK file download state (`zkFilesState` by default). */
102
+ proofFilesState?: () => ZkFilesState
94
103
  }
95
104
 
96
105
  /** Prefix of the error raised when the check exceeds its time budget. */
@@ -135,11 +144,17 @@ export async function checkAttestor(
135
144
  ensureAttestorCrypto()
136
145
  await ensureZkFiles()
137
146
  })
147
+ const proofFilesState = opts.proofFilesState ?? zkFilesState
138
148
  const startedAt = Date.now()
139
149
  const finish = (
140
150
  outcome: Omit<AttestorCheckResult, 'attestorUrl' | 'durationMs'>,
141
151
  ): AttestorCheckResult => {
142
- return { ...outcome, attestorUrl, durationMs: Date.now() - startedAt }
152
+ return {
153
+ ...outcome,
154
+ attestorUrl,
155
+ durationMs: Date.now() - startedAt,
156
+ proofFiles: proofFilesState(),
157
+ }
143
158
  }
144
159
 
145
160
  let timer: NodeJS.Timeout | undefined
@@ -153,14 +168,7 @@ export async function checkAttestor(
153
168
  try {
154
169
  await Promise.race([prepare(), timeout])
155
170
  } catch(err) {
156
- const error = messageOf(err)
157
- return finish({
158
- ok: false,
159
- error,
160
- errorKind: error.startsWith(CHECK_TIMEOUT_MESSAGE)
161
- ? 'attestor-unreachable'
162
- : 'setup-failed',
163
- })
171
+ return finish({ ok: false, ...setupFailure(messageOf(err)) })
164
172
  }
165
173
 
166
174
  const claim = await Promise.race([
@@ -202,6 +210,41 @@ export async function checkAttestor(
202
210
  } finally {
203
211
  clearTimeout(timer)
204
212
  }
213
+
214
+ /** Classify a failure before the attestor was contacted by the proof
215
+ * file download state, so a slow or failed download never reads as an
216
+ * unreachable attestor. */
217
+ function setupFailure(error: string) {
218
+ const files = proofFilesState()
219
+ if(files.status === 'downloading') {
220
+ const done = files.bytesDownloaded !== undefined
221
+ ? `, ${files.bytesDownloaded}`
222
+ + (files.totalBytes !== undefined ? `/${files.totalBytes}` : '')
223
+ + ' bytes'
224
+ : ''
225
+ return {
226
+ error:
227
+ `proof files are still downloading (${files.method}, `
228
+ + `${Math.round(files.elapsedMs / 1000)}s${done}); the attestor `
229
+ + 'was not contacted. Run check_attestor again shortly.',
230
+ errorKind: 'proof-files-downloading' as const,
231
+ }
232
+ }
233
+
234
+ if(files.status === 'failed') {
235
+ return {
236
+ error: `proof file download failed: ${files.reason}`,
237
+ errorKind: 'proof-files-failed' as const,
238
+ }
239
+ }
240
+
241
+ return {
242
+ error,
243
+ errorKind: error.startsWith(CHECK_TIMEOUT_MESSAGE)
244
+ ? 'attestor-unreachable' as const
245
+ : 'setup-failed' as const,
246
+ }
247
+ }
205
248
  }
206
249
 
207
250
  function messageOf(err: unknown): string {
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Offline matcher check: run a recipe's `responseRedactions` +
3
+ * `responseMatches` against a captured response with attestor-core's own HTTP
4
+ * provider, without a network call or a TLS session.
5
+ *
6
+ * It replays the two halves of a real proof on a synthetic transcript:
7
+ * 1. prover side: `providers.http.createRequest` builds the request and
8
+ * `getResponseRedactions` picks the response slices to reveal (xPath /
9
+ * jsonPath / regex, exactly as at proof time);
10
+ * 2. attestor side: `providers.http.assertValidProviderReceipt` checks the
11
+ * redacted transcript against `responseMatches` and extracts params.
12
+ *
13
+ * Hashed (OPRF) slices stay in plaintext here: OPRF needs the attestor, so the
14
+ * check treats the value as its own nullifier. Everything else is the same
15
+ * engine the attestor runs.
16
+ */
17
+ import {
18
+ CURRENT_ATTESTOR_VERSION,
19
+ type Logger,
20
+ PROVIDER_CTX,
21
+ providers,
22
+ type RedactedOrHashedArraySlice,
23
+ redactSlices,
24
+ } from '@reclaimprotocol/attestor-core'
25
+ // Side-effect: make external domhandler nodes DOM-like so attestor-core's
26
+ // bundled `xpath` accepts them (see patch-domhandler.ts).
27
+ import './patch-domhandler.ts'
28
+ import { LOGGER } from '../logger.ts'
29
+ import type { CapturedRequest, ReclaimProvider } from '../provider/schema.ts'
30
+ import { buildHttpParams } from './proof-core.ts'
31
+
32
+ /** Longest revealed slice returned; the full slice is still checked. */
33
+ const MAX_SLICE_CHARS = 400
34
+ /** Most revealed slices returned. */
35
+ const MAX_SLICES = 10
36
+ /** Longest error message returned (attestor errors can embed the base64 of
37
+ * a whole element). */
38
+ const MAX_REASON_CHARS = 600
39
+
40
+ export interface RevealedSlice {
41
+ /** Byte offsets in the response body. */
42
+ from: number
43
+ to: number
44
+ /** Revealed text, truncated to {@link MAX_SLICE_CHARS}. */
45
+ text: string
46
+ /** Set when the slice is an OPRF-hashed value (plaintext here). */
47
+ hashed?: true
48
+ }
49
+
50
+ export interface MatcherCheckResult {
51
+ /** True when the attestor would accept this response. */
52
+ ok: boolean
53
+ /** Where it failed: building the request, selecting what to reveal, or
54
+ * checking `responseMatches` on the revealed bytes. */
55
+ failedAt?: 'request' | 'redaction' | 'match'
56
+ /** Attestor-core's error, truncated. */
57
+ reason?: string
58
+ /** Params the attestor would extract (regex groups + `paramValues` the
59
+ * matches use). */
60
+ extractedParameters: Record<string, string>
61
+ /** The response body slices revealed to the attestor. */
62
+ revealed: RevealedSlice[]
63
+ revealedSliceCount: number
64
+ revealedBytes: number
65
+ bodyBytes: number
66
+ /** Set when an OPRF-hashed redaction was checked in plaintext. */
67
+ note?: string
68
+ }
69
+
70
+ /** Placeholder secret header: the request builder needs one secret, and the
71
+ * check never sends the request. */
72
+ const OFFLINE_SECRET = { 'X-Reclaim-Offline-Check': '1' }
73
+
74
+ /**
75
+ * Check `provider`'s matchers against `captured`'s response body, offline.
76
+ * Uses the captured status and content type, so a non-2xx capture fails the
77
+ * same way it would at proof time.
78
+ */
79
+ export function checkMatchersOffline(
80
+ provider: ReclaimProvider,
81
+ captured: Pick<CapturedRequest, 'status' | 'contentType' | 'responseBody'>,
82
+ ): MatcherCheckResult {
83
+ const http = providers.http
84
+ const params = buildHttpParams(provider)
85
+ const logger = silentLogger()
86
+ const body = Buffer.from(captured.responseBody ?? '', 'utf8')
87
+ const head = Buffer.from(
88
+ `HTTP/1.1 ${captured.status} ${captured.status < 300 ? 'OK' : 'ERR'}\r\n`
89
+ + `Content-Type: ${captured.contentType || 'text/plain'}\r\n`
90
+ + `Content-Length: ${body.length}\r\n\r\n`,
91
+ )
92
+ const response = new Uint8Array(Buffer.concat([head, body]))
93
+ const empty = {
94
+ extractedParameters: {},
95
+ revealed: [],
96
+ revealedSliceCount: 0,
97
+ revealedBytes: 0,
98
+ bodyBytes: body.length,
99
+ }
100
+
101
+ let request: Uint8Array
102
+ try {
103
+ const req = http.createRequest(
104
+ { headers: OFFLINE_SECRET }, structuredClone(params), logger,
105
+ )
106
+ const data = typeof req.data === 'string'
107
+ ? new Uint8Array(Buffer.from(req.data))
108
+ : req.data
109
+ request = redactSlices(data, req.redactions)
110
+ } catch(err) {
111
+ return { ...empty, ok: false, failedAt: 'request', reason: reasonOf(err) }
112
+ }
113
+
114
+ let slices: RedactedOrHashedArraySlice[]
115
+ try {
116
+ slices = http.getResponseRedactions?.({
117
+ response, params: structuredClone(params), logger, ctx: PROVIDER_CTX,
118
+ }) ?? []
119
+ } catch(err) {
120
+ return { ...empty, ok: false, failedAt: 'redaction', reason: reasonOf(err) }
121
+ }
122
+
123
+ const hidden = slices.filter((s) => !s.hash)
124
+ const hashed = slices.filter((s) => s.hash)
125
+ const view = revealedSlices(head.length, response.length, hidden, hashed)
126
+ const redacted = redactSlices(response, hidden)
127
+ const seen = {
128
+ revealed: view.slice(0, MAX_SLICES).map((s) => ({
129
+ from: s.from,
130
+ to: s.to,
131
+ text: truncate(
132
+ Buffer.from(response.subarray(head.length + s.from, head.length + s.to))
133
+ .toString('utf8'),
134
+ MAX_SLICE_CHARS,
135
+ ),
136
+ ...(s.hashed ? { hashed: true as const } : {}),
137
+ })),
138
+ revealedSliceCount: view.length,
139
+ revealedBytes: view.reduce((n, s) => n + s.to - s.from, 0),
140
+ bodyBytes: body.length,
141
+ ...(hashed.length
142
+ ? {
143
+ note: 'OPRF-hashed values were checked in plaintext; at proof time '
144
+ + 'the attestor sees their nullifier instead.',
145
+ }
146
+ : {}),
147
+ }
148
+
149
+ try {
150
+ const out = http.assertValidProviderReceipt({
151
+ clientVersion: CURRENT_ATTESTOR_VERSION,
152
+ receipt: [
153
+ { sender: 'client', message: request },
154
+ { sender: 'server', message: redacted },
155
+ ],
156
+ params: structuredClone(params),
157
+ logger,
158
+ ctx: PROVIDER_CTX,
159
+ })
160
+ // The http provider validates synchronously; a thenable here would mean
161
+ // a provider we don't run offline.
162
+ const extracted = out instanceof Promise ? undefined : out
163
+ return {
164
+ ok: true,
165
+ extractedParameters: extracted?.extractedParameters ?? {},
166
+ ...seen,
167
+ }
168
+ } catch(err) {
169
+ return {
170
+ ok: false,
171
+ failedAt: 'match',
172
+ reason: reasonOf(err),
173
+ extractedParameters: {},
174
+ ...seen,
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Body-relative revealed ranges: the complement of `hidden` within the body,
180
+ * merged with the hashed slices (revealed as their nullifier at proof time).
181
+ */
182
+ function revealedSlices(
183
+ bodyStart: number,
184
+ total: number,
185
+ hidden: RedactedOrHashedArraySlice[],
186
+ hashed: RedactedOrHashedArraySlice[],
187
+ ): { from: number, to: number, hashed?: true }[] {
188
+ const out: { from: number, to: number, hashed?: true }[] = []
189
+ let cursor = bodyStart
190
+ const sorted = [...hidden].sort((a, b) => a.fromIndex - b.fromIndex)
191
+ for(const s of sorted) {
192
+ if(s.fromIndex > cursor) {
193
+ out.push({ from: cursor - bodyStart, to: s.fromIndex - bodyStart })
194
+ }
195
+
196
+ cursor = Math.max(cursor, s.toIndex)
197
+ }
198
+
199
+ if(cursor < total) {
200
+ out.push({ from: cursor - bodyStart, to: total - bodyStart })
201
+ }
202
+
203
+ for(const h of hashed) {
204
+ out.push({
205
+ from: h.fromIndex - bodyStart, to: h.toIndex - bodyStart, hashed: true,
206
+ })
207
+ }
208
+
209
+ return out
210
+ .filter((s) => s.to > s.from && s.from >= 0)
211
+ .sort((a, b) => a.from - b.from)
212
+ }
213
+
214
+ function reasonOf(err: unknown): string {
215
+ return truncate(err instanceof Error ? err.message : String(err),
216
+ MAX_REASON_CHARS)
217
+ }
218
+
219
+ function truncate(s: string, max: number): string {
220
+ return s.length > max ? `${s.slice(0, max)}… (${s.length} chars)` : s
221
+ }
222
+
223
+ /** A logger for attestor-core calls that records nothing: the check reports
224
+ * its outcome in the result, and the provider logs whole responses. */
225
+ function silentLogger(): Logger {
226
+ return LOGGER.child({ scope: 'check-matchers' }, { level: 'silent' })
227
+ }