@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
@@ -1,84 +1,376 @@
1
- // Lazy download of the @reclaimprotocol/zk-symmetric-crypto circuit resources
2
- // the proof engines need (the downloader fetches every engine — stwo, snarkjs,
3
- // gnark — as one bundle). These are ~280MB and are NOT fetched in a
4
- // `postinstall` (that blocked the MCP server's first launch past the connect
5
- // timeout — see the install notes). Instead we fetch them on the first proof,
6
- // once, so the server starts immediately and only `run_proof` pays the cost.
7
-
8
- import { spawn } from 'node:child_process'
9
- import { existsSync } from 'node:fs'
1
+ // Download of the @reclaimprotocol/zk-symmetric-crypto circuit resources the
2
+ // proof engines read from disk. Never a `postinstall`: that blocked the MCP
3
+ // server's first launch past the connect timeout. Instead the MCP server starts
4
+ // the download in the background at launch (`startZkFilesDownload`), and every
5
+ // proof awaits it (`ensureZkFiles`) as the safety net.
6
+ //
7
+ // Proofs default to the stwo engine. Its resources are ~1.5MB, so we fetch only
8
+ // `resources/stwo` file by file from the package's pinned commit and check each
9
+ // file's git blob hash against the listing for that commit. When that fails
10
+ // (GitHub API rate limit, a changed layout), we fall back to the package's own
11
+ // downloader, which fetches the whole repository archive (~350MB).
12
+ // (In zk-symmetric-crypto 5.1.x the stwo prover loads its WASM from the npm
13
+ // package itself; `resources/stwo` is kept as the readiness signal.)
14
+
15
+ import { badGateway, internal } from '@hapi/boom'
16
+ import assert from 'node:assert'
17
+ import { spawn as nodeSpawn, type StdioOptions } from 'node:child_process'
18
+ import { createHash } from 'node:crypto'
19
+ import { existsSync, statSync } from 'node:fs'
20
+ import {
21
+ mkdir,
22
+ mkdtemp,
23
+ rename,
24
+ rm,
25
+ writeFile
26
+ } from 'node:fs/promises'
10
27
  import { createRequire } from 'node:module'
11
28
  import { dirname, join } from 'node:path'
29
+ import { pathToFileURL } from 'node:url'
12
30
 
13
- const require = createRequire(import.meta.url)
31
+ const REPO = 'reclaimprotocol/zk-symmetric-crypto'
32
+ /** The stwo resource dir; its presence means the circuits are ready. */
33
+ const STWO_DIR = join('resources', 'stwo')
34
+ /** Where the package's downloader writes the archive, relative to its cwd. */
35
+ const ARCHIVE_PATH = join('zk-symmetric-crypto-download', 'repo.zip')
14
36
 
15
- let inFlight: Promise<void> | undefined
37
+ export type ZkFilesState =
38
+ | { status: 'idle' }
39
+ | {
40
+ status: 'downloading'
41
+ /** `stwo-files`: the ~1.5MB stwo files. `full-archive`: the ~350MB
42
+ * repository archive (fallback). */
43
+ method: 'stwo-files' | 'full-archive'
44
+ elapsedMs: number
45
+ bytesDownloaded?: number
46
+ totalBytes?: number
47
+ }
48
+ | { status: 'ready' }
49
+ | { status: 'failed', reason: string }
50
+
51
+ /** Minimal child process surface `download-files.js` is driven through. */
52
+ export interface ChildLike {
53
+ once(event: 'exit', listener: (code: number | null) => void): unknown
54
+ once(event: 'error', listener: (err: Error) => void): unknown
55
+ }
56
+
57
+ interface Progress {
58
+ method: 'stwo-files' | 'full-archive'
59
+ startedAt: number
60
+ bytesDownloaded?: number
61
+ totalBytes?: number
62
+ /** The archive file whose size is the progress (full-archive only). */
63
+ archivePath?: string
64
+ }
65
+
66
+ export interface ZkFilesDeps {
67
+ /** The zk-symmetric-crypto package root, or undefined when not installed. */
68
+ resolvePackageDir?: () => string | undefined
69
+ /** The pinned repository commit the package's resources come from. */
70
+ readCommit?: (pkgDir: string) => Promise<string>
71
+ fetch?: typeof fetch
72
+ spawn?: (
73
+ command: string,
74
+ args: string[],
75
+ options: { cwd: string, stdio: StdioOptions },
76
+ ) => ChildLike
77
+ /** Progress lines. Defaults to stderr: stdout carries MCP JSON-RPC. */
78
+ log?: (message: string) => void
79
+ }
16
80
 
17
81
  /**
18
- * Ensure the zk circuit resources are on disk before a proof runs. Idempotent
19
- * and concurrency-safe: the download runs at most once per process, and a
20
- * failed attempt clears the latch so a later proof can retry.
21
- *
22
- * Resolves silently when the resources are already present (the warm npx cache,
23
- * or after a manual `npm run download:zk-files`) and when
24
- * `@reclaimprotocol/zk-symmetric-crypto` isn't installed at all (SDK-only
25
- * consumers have nothing to prove).
26
- *
27
- * All output goes to stderr (fd 2): the MCP server speaks JSON-RPC over stdout,
28
- * so a stray write to fd 1 mid-session would corrupt the protocol.
82
+ * Build a downloader with its own state. The module exports one default
83
+ * instance; tests build their own with injected `spawn` and `fetch`.
29
84
  */
30
- export function ensureZkFiles() {
31
- inFlight ??= download().catch((err) => {
32
- inFlight = undefined
33
- throw err
85
+ export function makeZkFiles(deps: ZkFilesDeps = {}) {
86
+ const resolvePackageDir = deps.resolvePackageDir ?? resolveZkPackageDir
87
+ const readCommit = deps.readCommit ?? readPinnedCommit
88
+ const fetchFn = deps.fetch ?? fetch
89
+ const spawn = deps.spawn ?? nodeSpawn
90
+ const log = deps.log ?? ((message: string) => {
91
+ process.stderr.write(`[reclaim-agent] ${message}\n`)
34
92
  })
35
- return inFlight
36
- }
37
93
 
38
- async function download() {
39
- let mainPath: string
40
- try {
41
- mainPath = require.resolve('@reclaimprotocol/zk-symmetric-crypto')
42
- } catch{
43
- return
94
+ let inFlight: Promise<void> | undefined
95
+ let failure: string | undefined
96
+ let ready = false
97
+ let progress: Progress | undefined
98
+
99
+ return { ensure, start, state }
100
+
101
+ /**
102
+ * Ensure the circuit resources are on disk. Idempotent and
103
+ * concurrency-safe: one download runs at a time per process, and a failed
104
+ * attempt clears the latch so a later call retries.
105
+ */
106
+ function ensure() {
107
+ inFlight ??= download()
108
+ .then(() => {
109
+ ready = true
110
+ failure = undefined
111
+ }, (err: unknown) => {
112
+ inFlight = undefined
113
+ failure = messageOf(err)
114
+ throw err
115
+ })
116
+ .finally(() => {
117
+ progress = undefined
118
+ })
119
+ return inFlight
44
120
  }
45
121
 
46
- // mainPath is <pkg>/lib/index.js → resources live at <pkg>/resources, the
47
- // downloader at <pkg>/lib/scripts/download-files.js. Proofs default to the
48
- // stwo (WASM) engine, so its resource dir being present is the signal that
49
- // the circuits are ready (the downloader fetches all engines together).
50
- const libDir = dirname(mainPath)
51
- const pkgDir = dirname(libDir)
52
- if(existsSync(join(pkgDir, 'resources', 'stwo'))) {
53
- return
122
+ /** Start `ensure` in the background; `state` reports the outcome. */
123
+ function start() {
124
+ ensure().catch(() => {})
54
125
  }
55
126
 
56
- const downloadScript = join(libDir, 'scripts', 'download-files.js')
57
- if(!existsSync(downloadScript)) {
58
- throw new Error(
59
- 'zk circuit downloader not found in '
60
- + '@reclaimprotocol/zk-symmetric-crypto; '
61
- + 'run `npm run download:zk-files` and retry.',
127
+ function state(): ZkFilesState {
128
+ if(ready) {
129
+ return { status: 'ready' }
130
+ }
131
+
132
+ if(progress) {
133
+ const bytesDownloaded = progress.archivePath
134
+ ? fileSize(progress.archivePath)
135
+ : progress.bytesDownloaded
136
+ return {
137
+ status: 'downloading',
138
+ method: progress.method,
139
+ elapsedMs: Date.now() - progress.startedAt,
140
+ ...(bytesDownloaded !== undefined ? { bytesDownloaded } : {}),
141
+ ...(progress.totalBytes !== undefined
142
+ ? { totalBytes: progress.totalBytes }
143
+ : {}),
144
+ }
145
+ }
146
+
147
+ if(failure !== undefined) {
148
+ return { status: 'failed', reason: failure }
149
+ }
150
+
151
+ return { status: 'idle' }
152
+ }
153
+
154
+ async function download() {
155
+ const pkgDir = resolvePackageDir()
156
+ // SDK-only consumers don't install the package: nothing to prove.
157
+ if(!pkgDir || existsSync(join(pkgDir, STWO_DIR))) {
158
+ return
159
+ }
160
+
161
+ try {
162
+ progress = { method: 'stwo-files', startedAt: Date.now() }
163
+ await fetchStwoFiles(pkgDir, progress)
164
+ } catch(err) {
165
+ const stwoError = messageOf(err)
166
+ log(
167
+ `fetching the stwo proof files failed (${stwoError}); `
168
+ + 'downloading the full circuit archive (~350MB, one-time)…',
169
+ )
170
+ progress = {
171
+ method: 'full-archive',
172
+ startedAt: Date.now(),
173
+ archivePath: join(pkgDir, ARCHIVE_PATH),
174
+ }
175
+ try {
176
+ await runArchiveDownloader(pkgDir)
177
+ } catch(archiveErr) {
178
+ throw badGateway(
179
+ `stwo files: ${stwoError}; full archive: ${messageOf(archiveErr)}`,
180
+ )
181
+ }
182
+ }
183
+
184
+ assert(
185
+ existsSync(join(pkgDir, STWO_DIR)),
186
+ internal(
187
+ `zk circuit download finished but ${join(pkgDir, STWO_DIR)} `
188
+ + 'is missing; run `npm run download:zk-files` and retry.',
189
+ ),
62
190
  )
63
191
  }
64
192
 
65
- process.stderr.write(
66
- '[reclaim-agent] downloading zkTLS proof circuits '
67
- + '(~280MB, one-time)…\n',
68
- )
69
- // stdio fd 1→2: send the downloader's stdout to our stderr so its progress
70
- // never lands on the MCP server's JSON-RPC stdout.
71
- await new Promise<void>((resolve, reject) => {
72
- const child = spawn(process.execPath, [downloadScript], {
73
- stdio: ['ignore', 2, 2],
74
- })
75
- child.on('error', reject)
76
- child.on('exit', (code) => {
77
- if(code === 0) {
78
- resolve()
79
- } else {
80
- reject(new Error(`zk download exited ${code}`))
193
+ /**
194
+ * Fetch `resources/stwo` at the pinned commit, file by file, into a temp
195
+ * dir inside the package, then rename it into place (same filesystem).
196
+ */
197
+ async function fetchStwoFiles(pkgDir: string, stats: Progress) {
198
+ const commit = await readCommit(pkgDir)
199
+ assert(/^[0-9a-f]{40}$/.test(commit), internal('invalid pinned commit'))
200
+ const files = await listStwoFiles(commit)
201
+ stats.bytesDownloaded = 0
202
+ stats.totalBytes = files.reduce((sum, f) => sum + f.size, 0)
203
+ log(
204
+ `downloading zkTLS stwo proof files (${stats.totalBytes} bytes, `
205
+ + 'one-time)…',
206
+ )
207
+
208
+ const tmp = await mkdtemp(join(pkgDir, '.stwo-download-'))
209
+ try {
210
+ for(const file of files) {
211
+ const res = await fetchFn(
212
+ `https://raw.githubusercontent.com/${REPO}/${commit}/`
213
+ + `resources/stwo/${file.name}`,
214
+ )
215
+ assert(
216
+ res.ok,
217
+ badGateway(`stwo file ${file.name}: HTTP ${res.status}`),
218
+ )
219
+ const bytes = new Uint8Array(await res.arrayBuffer())
220
+ assert(
221
+ gitBlobSha(bytes) === file.sha,
222
+ badGateway(`stwo file ${file.name} failed its integrity check`),
223
+ )
224
+ await writeFile(join(tmp, file.name), bytes)
225
+ stats.bytesDownloaded = (stats.bytesDownloaded ?? 0) + bytes.length
81
226
  }
227
+
228
+ const dest = join(pkgDir, STWO_DIR)
229
+ await mkdir(dirname(dest), { recursive: true })
230
+ // Another process may have finished first; its copy is as good.
231
+ await rename(tmp, dest).catch((err: unknown) => {
232
+ if(!existsSync(dest)) {
233
+ throw err
234
+ }
235
+ })
236
+ } finally {
237
+ await rm(tmp, { recursive: true, force: true })
238
+ }
239
+ }
240
+
241
+ /** The file listing of `resources/stwo` at `commit`, with blob hashes. */
242
+ async function listStwoFiles(commit: string) {
243
+ const res = await fetchFn(
244
+ `https://api.github.com/repos/${REPO}/contents/resources/stwo`
245
+ + `?ref=${commit}`,
246
+ { headers: { Accept: 'application/vnd.github+json' } },
247
+ )
248
+ assert(res.ok, badGateway(`stwo file listing: HTTP ${res.status}`))
249
+ const listing: unknown = await res.json()
250
+ assert(
251
+ Array.isArray(listing) && listing.length > 0,
252
+ badGateway('stwo file listing is empty'),
253
+ )
254
+ return listing.map((entry: unknown) => {
255
+ assert(
256
+ typeof entry === 'object' && entry !== null
257
+ && 'type' in entry && entry.type === 'file'
258
+ && 'name' in entry && typeof entry.name === 'string'
259
+ && /^[\w.-]+$/.test(entry.name) && !entry.name.startsWith('.')
260
+ && 'size' in entry && typeof entry.size === 'number'
261
+ && 'sha' in entry && typeof entry.sha === 'string',
262
+ badGateway('unexpected entry in the stwo file listing'),
263
+ )
264
+ return { name: entry.name, size: entry.size, sha: entry.sha }
82
265
  })
83
- })
266
+ }
267
+
268
+ /**
269
+ * Run the package's `download-files.js`. Its temp paths are relative, so
270
+ * cwd is the package dir: the archive and extracted tree then sit on the
271
+ * same filesystem as the final `rename()` (no EXDEV) and never land in the
272
+ * developer's project.
273
+ */
274
+ async function runArchiveDownloader(pkgDir: string) {
275
+ const script = join(pkgDir, 'lib', 'scripts', 'download-files.js')
276
+ assert(
277
+ existsSync(script),
278
+ internal(
279
+ 'zk circuit downloader not found in '
280
+ + '@reclaimprotocol/zk-symmetric-crypto; '
281
+ + 'run `npm run download:zk-files` and retry.',
282
+ ),
283
+ )
284
+
285
+ await new Promise<void>((resolve, reject) => {
286
+ // stdio fd 1→2: the downloader's progress must never reach the MCP
287
+ // server's JSON-RPC stdout.
288
+ const child = spawn(process.execPath, [script], {
289
+ cwd: pkgDir,
290
+ stdio: ['ignore', 2, 2],
291
+ })
292
+ child.once('error', reject)
293
+ child.once('exit', (code) => {
294
+ if(code === 0) {
295
+ resolve()
296
+ } else {
297
+ reject(internal(`zk circuit download exited with code ${code}`))
298
+ }
299
+ })
300
+ })
301
+ }
302
+ }
303
+
304
+ const DEFAULT = makeZkFiles()
305
+
306
+ /**
307
+ * Ensure the zk circuit resources are on disk before a proof runs. Resolves
308
+ * at once when they're present or the package isn't installed.
309
+ */
310
+ export function ensureZkFiles() {
311
+ return DEFAULT.ensure()
312
+ }
313
+
314
+ /** Start the download in the background (MCP server launch). */
315
+ export function startZkFilesDownload() {
316
+ DEFAULT.start()
317
+ }
318
+
319
+ /** Where the download stands: idle, downloading, ready, or failed. */
320
+ export function zkFilesState() {
321
+ return DEFAULT.state()
322
+ }
323
+
324
+ /**
325
+ * The zk-symmetric-crypto copy attestor-core loads (it may be nested under
326
+ * attestor-core), else the hoisted one.
327
+ */
328
+ function resolveZkPackageDir() {
329
+ const require = createRequire(import.meta.url)
330
+ for(const from of [
331
+ () => createRequire(require.resolve('@reclaimprotocol/attestor-core')),
332
+ () => require,
333
+ ]) {
334
+ try {
335
+ // <pkg>/lib/index.js → <pkg>
336
+ return dirname(dirname(
337
+ from().resolve('@reclaimprotocol/zk-symmetric-crypto'),
338
+ ))
339
+ } catch{
340
+ // try the next resolution root
341
+ }
342
+ }
343
+
344
+ return undefined
345
+ }
346
+
347
+ async function readPinnedCommit(pkgDir: string) {
348
+ const mod: { GIT_COMMIT_HASH?: unknown } = await import(
349
+ pathToFileURL(join(pkgDir, 'lib', 'index.js')).href
350
+ )
351
+ assert(
352
+ typeof mod.GIT_COMMIT_HASH === 'string',
353
+ internal('zk-symmetric-crypto exports no GIT_COMMIT_HASH'),
354
+ )
355
+ return mod.GIT_COMMIT_HASH
356
+ }
357
+
358
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
359
+ function gitBlobSha(bytes: Uint8Array) {
360
+ return createHash('sha1')
361
+ .update(`blob ${bytes.length}\0`)
362
+ .update(bytes)
363
+ .digest('hex')
364
+ }
365
+
366
+ function fileSize(path: string) {
367
+ try {
368
+ return statSync(path).size
369
+ } catch{
370
+ return undefined
371
+ }
372
+ }
373
+
374
+ function messageOf(err: unknown) {
375
+ return err instanceof Error ? err.message : String(err)
84
376
  }
@@ -1,12 +1,15 @@
1
+ import { badRequest } from '@hapi/boom'
2
+ import assert from 'node:assert'
1
3
  import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
2
4
  import { normalizeCandidates } from './normalize.ts'
3
- import type {
4
- CapturedRequest,
5
- OprfMode,
6
- ReclaimProvider,
7
- ResponseMatch,
8
- ResponseRedaction,
9
- SecretParamRef,
5
+ import {
6
+ type CapturedRequest,
7
+ DYNAMIC_GEO,
8
+ type OprfMode,
9
+ type ReclaimProvider,
10
+ type ResponseMatch,
11
+ type ResponseRedaction,
12
+ type SecretParamRef,
10
13
  } from './schema.ts'
11
14
  import {
12
15
  secretUrlCharBudget,
@@ -433,3 +436,208 @@ export function draftProvider(
433
436
 
434
437
  return { provider, secretRefs, extractions }
435
438
  }
439
+
440
+ /** Fields `update_draft` may replace on a drafted provider. */
441
+ export interface DraftEdit {
442
+ responseMatches?: ResponseMatch[]
443
+ responseRedactions?: ResponseRedaction[]
444
+ paramValues?: Record<string, string>
445
+ /** ISO country the request goes out from, or `{{DYNAMIC_GEO}}` /
446
+ * `{{DYNAMIC_GEO_SECRET}}` for the verifying user's own country. */
447
+ geoLocation?: string
448
+ }
449
+
450
+ const GEO_TEMPLATES = new Set([DYNAMIC_GEO, '{{DYNAMIC_GEO_SECRET}}'])
451
+
452
+ const MATCH_TYPES = new Set(['contains', 'regex'])
453
+ const OPRF_MODES = new Set(['oprf', 'oprf-raw', 'oprf-mpc'])
454
+
455
+ /**
456
+ * Apply an agent's edit to a drafted provider: validate the new matchers the
457
+ * way the attestor reads them (regexes compile with its `sgi` flags, every
458
+ * redaction has a selector, a hashed redaction has exactly one named group),
459
+ * then run the same portability gate as {@link draftProvider} so an edit can't
460
+ * bake a captured value in as a literal. Returns the updated provider, its
461
+ * extractions (re-paired with the redaction that names each param), and
462
+ * advisory warnings (for example, a `{{param}}` nothing resolves).
463
+ */
464
+ export function editDraftProvider(
465
+ provider: ReclaimProvider,
466
+ extractions: Extraction[],
467
+ edit: DraftEdit,
468
+ ): {
469
+ provider: ReclaimProvider
470
+ extractions: Extraction[]
471
+ warnings: string[]
472
+ } {
473
+ const matches = edit.responseMatches ?? provider.responseMatches
474
+ const redactions = edit.responseRedactions
475
+ ?? provider.responseRedactions ?? []
476
+ const paramValues = edit.paramValues ?? provider.paramValues ?? {}
477
+ const geoLocation = GEO_TEMPLATES.has(edit.geoLocation ?? '')
478
+ ? edit.geoLocation
479
+ : edit.geoLocation?.toUpperCase()
480
+ assert(
481
+ geoLocation === undefined || GEO_TEMPLATES.has(geoLocation)
482
+ || /^[A-Z]{2}$/.test(geoLocation),
483
+ badRequest(
484
+ 'geoLocation must be a 2-letter ISO country code, {{DYNAMIC_GEO}}, '
485
+ + 'or {{DYNAMIC_GEO_SECRET}}',
486
+ ),
487
+ )
488
+ assert(
489
+ matches.length > 0,
490
+ badRequest('responseMatches must have at least one entry'),
491
+ )
492
+ for(const [i, m] of matches.entries()) {
493
+ assert(
494
+ MATCH_TYPES.has(m.type) && typeof m.value === 'string' && m.value,
495
+ badRequest(
496
+ `responseMatches[${i}] needs type "contains" or "regex" and a `
497
+ + 'non-empty value',
498
+ ),
499
+ )
500
+ if(m.type === 'regex') {
501
+ assertRegex(m.value, `responseMatches[${i}].value`)
502
+ }
503
+ }
504
+
505
+ for(const [i, r] of redactions.entries()) {
506
+ assert(
507
+ r.xPath || r.jsonPath || r.regex,
508
+ badRequest(`responseRedactions[${i}] needs an xPath, jsonPath, or regex`),
509
+ )
510
+ if(r.regex) {
511
+ assertRegex(r.regex, `responseRedactions[${i}].regex`)
512
+ }
513
+
514
+ assert(
515
+ !r.hash || (OPRF_MODES.has(r.hash) && groupNames(r.regex).length === 1),
516
+ badRequest(
517
+ `responseRedactions[${i}] is hashed (${r.hash}), so it needs a hash `
518
+ + 'of oprf, oprf-raw, or oprf-mpc and a regex with exactly one named '
519
+ + 'capture group',
520
+ ),
521
+ )
522
+ }
523
+
524
+ // An edit can't lower privacy: a draft that redacts keeps redacting, and
525
+ // every group it hashed stays hashed with the same mode.
526
+ const original = provider.responseRedactions ?? []
527
+ assert(
528
+ !original.length || redactions.length,
529
+ badRequest(
530
+ 'responseRedactions can\'t be emptied: the attestor would see the '
531
+ + 'whole response',
532
+ ),
533
+ )
534
+ for(const r of original.filter((o) => o.hash)) {
535
+ for(const name of groupNames(r.regex)) {
536
+ const kept = redactions.find((e) => groupNames(e.regex).includes(name))
537
+ assert(
538
+ kept?.hash === r.hash,
539
+ badRequest(
540
+ `capture group ${name} is ${r.hash}-hashed in the draft; keep a `
541
+ + 'redaction for it with the same hash',
542
+ ),
543
+ )
544
+ }
545
+ }
546
+
547
+ const groups = matcherGroups(matches, redactions)
548
+ // A group the draft never extracted has a value nothing below checks, and
549
+ // no extraction would track it: new data points need a fresh draft.
550
+ const known = new Set([
551
+ ...extractions.map((e) => e.name),
552
+ ...matcherGroups(
553
+ provider.responseMatches,
554
+ provider.responseRedactions ?? [],
555
+ ),
556
+ ])
557
+ const unknown = [...groups].filter((name) => !known.has(name))
558
+ assert(
559
+ !unknown.length,
560
+ badRequest(
561
+ `capture group(s) ${unknown.join(', ')} are not drafted extractions; `
562
+ + 'propose a new draft to add a data point',
563
+ ),
564
+ )
565
+
566
+ // Always the captured values, so editing a paramValues entry can't hide
567
+ // one from the portability gate; edited values are checked as well.
568
+ const targets: DraftTarget[] = [
569
+ ...extractions.map(({ name, value }) => ({ name, value })),
570
+ ...Object.entries(paramValues)
571
+ .filter(([name, value]) => !extractions
572
+ .some((e) => e.name === name && e.value === value))
573
+ .map(([name, value]) => ({ name, value })),
574
+ ]
575
+ assertPortableMatches(matches, redactions, targets)
576
+
577
+ // Same OPRF gate as draftProvider, against the URL as the attestor signs it.
578
+ const hashed = redactions.flatMap(({ regex, hash }) => hash
579
+ ? groupNames(regex).flatMap((name) => targets
580
+ .filter((t) => t.name === name)
581
+ .map((t) => ({ ...t, hash })))
582
+ : [])
583
+ const url = provider.url
584
+ .replace(/\{\{([^{}]+)\}\}/g, (t, name: string) => paramValues[name] ?? t)
585
+ assertNoOprfUrlConflict(url, hashed, hashed.map((t) => t.hash))
586
+
587
+ const { responseRedactions: _r, paramValues: _p, ...rest } = provider
588
+ void _r
589
+ void _p
590
+ const next: ReclaimProvider = {
591
+ ...rest,
592
+ responseMatches: matches,
593
+ ...(redactions.length ? { responseRedactions: redactions } : {}),
594
+ ...(Object.keys(paramValues).length ? { paramValues } : {}),
595
+ ...(geoLocation ? { geoLocation } : {}),
596
+ }
597
+ const warnings = matches
598
+ .flatMap((m) => [...m.value.matchAll(/\{\{([^{}]+)\}\}/g)].map((t) => t[1]))
599
+ .filter((name) => !(name in paramValues) && !groups.has(name))
600
+ .map((name) => `{{${name}}} has no paramValues entry or named capture `
601
+ + 'group, so the attestor would look for it literally.')
602
+ const nextExtractions = extractions.map((e) => {
603
+ const redaction = redactions
604
+ .find((r) => groupNames(r.regex).includes(e.name))
605
+ // The captured value, never an edited one, so a later edit's gate still
606
+ // sees it.
607
+ return {
608
+ name: e.name,
609
+ value: e.value,
610
+ ...(redaction ? { redaction } : {}),
611
+ }
612
+ })
613
+ return { provider: next, extractions: nextExtractions, warnings }
614
+ }
615
+
616
+ /** Named capture groups across regex matches and redactions. */
617
+ function matcherGroups(
618
+ matches: ResponseMatch[],
619
+ redactions: ResponseRedaction[],
620
+ ) {
621
+ return new Set([
622
+ ...matches.flatMap((m) => m.type === 'regex' ? groupNames(m.value) : []),
623
+ ...redactions.flatMap((r) => groupNames(r.regex)),
624
+ ])
625
+ }
626
+
627
+ /** Named capture groups in `regex` (none when absent). */
628
+ function groupNames(regex: string | undefined): string[] {
629
+ return [...(regex ?? '').matchAll(/\(\?<([A-Za-z_][A-Za-z0-9_]*)>/g)]
630
+ .map((m) => m[1])
631
+ }
632
+
633
+ /** Throw a 400 unless `regex` compiles the way the attestor compiles it. */
634
+ function assertRegex(regex: string, where: string) {
635
+ let error: string | undefined
636
+ try {
637
+ new RegExp(regex, 'sgi')
638
+ } catch(err) {
639
+ error = err instanceof Error ? err.message : String(err)
640
+ }
641
+
642
+ assert(!error, badRequest(`${where} is not a valid regex: ${error}`))
643
+ }