@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.
- package/README.md +8 -8
- package/lib/authoring/backend.d.ts +12 -1
- package/lib/authoring/backend.js +3 -0
- package/lib/authoring/tools.js +33 -5
- package/lib/authoring/util.d.ts +43 -0
- package/lib/authoring/util.js +54 -0
- package/lib/cdp/chrome-for-testing.d.ts +14 -0
- package/lib/cdp/chrome-for-testing.js +43 -0
- package/lib/cdp/geo.d.ts +3 -0
- package/lib/cdp/geo.js +27 -15
- package/lib/cdp/launcher.d.ts +79 -1
- package/lib/cdp/launcher.js +244 -24
- package/lib/logger.js +3 -1
- package/lib/mcp/api/operations.gen.js +1 -1
- package/lib/mcp/instructions.js +3 -3
- package/lib/mcp/start.js +17 -2
- package/lib/mcp/tools/agent/attach.d.ts +6 -0
- package/lib/mcp/tools/agent/attach.js +58 -118
- package/lib/mcp/tools/agent/build.js +6 -0
- package/lib/mcp/tools/agent/capture.js +33 -10
- package/lib/mcp/tools/agent/check-attestor.js +15 -7
- package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
- package/lib/mcp/tools/agent/draft-tools.js +170 -0
- package/lib/mcp/tools/agent/pod-proof.js +5 -1
- package/lib/mcp/tools/agent/proof.d.ts +28 -0
- package/lib/mcp/tools/agent/proof.js +72 -0
- package/lib/mcp/tools/agent/shared.d.ts +8 -1
- package/lib/mcp/tools/agent/shared.js +98 -19
- package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
- package/lib/mcp/tools/agent/start-integration.js +10 -6
- package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
- package/lib/mcp/tools/authenticate/authenticate.js +212 -116
- package/lib/mcp/tools/authenticate/build.d.ts +2 -2
- package/lib/mcp/tools/authenticate/build.js +6 -3
- package/lib/mcp/tools/authenticate/builder-access.js +5 -2
- package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
- package/lib/mcp/tools/authenticate/eth-key.js +29 -5
- package/lib/mcp/tools/authenticate/index.d.ts +1 -1
- package/lib/mcp/tools/authenticate/index.js +1 -1
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
- package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
- package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
- package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
- package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
- package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
- package/lib/mcp/tools/authenticate/session-cache.js +95 -4
- package/lib/mcp/tools/mode/mode.js +6 -6
- package/lib/mcp/tools/verification/proxy-callback-events.d.ts +21 -3
- package/lib/mcp/tools/verification/proxy-callback-events.js +128 -45
- package/lib/old/login-cdp.js +2 -3
- package/lib/proof/attestor.js +2 -3
- package/lib/proof/check-attestor.d.ts +12 -2
- package/lib/proof/check-attestor.js +40 -10
- package/lib/proof/check-matchers.d.ts +36 -0
- package/lib/proof/check-matchers.js +161 -0
- package/lib/proof/zk-files.d.ts +50 -11
- package/lib/proof/zk-files.js +259 -63
- package/lib/provider/draft.d.ts +24 -1
- package/lib/provider/draft.js +135 -0
- package/lib/provider/matcher.js +171 -4
- package/package.json +6 -4
- package/skill/SKILL.builder.md +7 -2
- package/skill/SKILL.common.md +90 -70
- package/skill/SKILL.old.md +3 -3
- package/skill/builder-mode.md +44 -6
- package/skill/old-mode.md +18 -13
- package/skill/start-integration.md +51 -35
- package/skill/verification-client-integration.md +87 -15
- package/src/authoring/backend.ts +12 -1
- package/src/authoring/tools.ts +48 -7
- package/src/authoring/util.ts +64 -0
- package/src/cdp/chrome-for-testing.ts +66 -0
- package/src/cdp/geo.ts +36 -21
- package/src/cdp/launcher.ts +328 -24
- package/src/logger.ts +3 -1
- package/src/mcp/api/operations.gen.ts +1 -1
- package/src/mcp/instructions.ts +3 -3
- package/src/mcp/start.ts +20 -1
- package/src/mcp/tools/agent/attach.ts +65 -128
- package/src/mcp/tools/agent/build.ts +7 -0
- package/src/mcp/tools/agent/capture.ts +54 -11
- package/src/mcp/tools/agent/check-attestor.ts +15 -7
- package/src/mcp/tools/agent/draft-tools.ts +234 -0
- package/src/mcp/tools/agent/pod-proof.ts +5 -1
- package/src/mcp/tools/agent/proof.ts +110 -1
- package/src/mcp/tools/agent/shared.ts +130 -19
- package/src/mcp/tools/agent/start-integration.ts +10 -6
- package/src/mcp/tools/authenticate/authenticate.ts +276 -131
- package/src/mcp/tools/authenticate/build.ts +6 -3
- package/src/mcp/tools/authenticate/builder-access.ts +5 -2
- package/src/mcp/tools/authenticate/eth-key.ts +41 -7
- package/src/mcp/tools/authenticate/index.ts +1 -1
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
- package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
- package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
- package/src/mcp/tools/authenticate/session-cache.ts +125 -5
- package/src/mcp/tools/mode/mode.ts +6 -6
- package/src/mcp/tools/verification/proxy-callback-events.ts +151 -41
- package/src/old/login-cdp.ts +2 -3
- package/src/proof/attestor.ts +2 -3
- package/src/proof/check-attestor.ts +55 -12
- package/src/proof/check-matchers.ts +227 -0
- package/src/proof/zk-files.ts +357 -65
- package/src/provider/draft.ts +215 -7
- 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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
|
113
|
-
backoff =
|
|
114
|
-
wait =
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
let
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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]`)
|
|
261
|
-
+ '
|
|
262
|
-
+ '
|
|
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
|
|
270
|
-
+ 'last events with `status` and
|
|
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: {
|
package/src/old/login-cdp.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { connectCdp } from '../cdp/client.ts'
|
|
2
2
|
import {
|
|
3
3
|
defaultProfileDir,
|
|
4
|
-
|
|
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
|
-
|
|
164
|
-
await waitForCdpReady(port, 10_000)
|
|
163
|
+
await launchLocalBrowser({ port, profileDir: defaultProfileDir() })
|
|
165
164
|
launchedByUs = true
|
|
166
165
|
}
|
|
167
166
|
|
package/src/proof/attestor.ts
CHANGED
|
@@ -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,
|
|
222
|
-
'
|
|
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
|
-
/**
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|