@reclaimprotocol/agent 0.1.37 → 0.1.38

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 CHANGED
@@ -134,7 +134,7 @@ The MCP server targets one of two backends. An explicit `USE_OLD_DEVTOOLS` setti
134
134
  - `get_me_providers` — list the providers you own (the only provider-listing tool in this mode).
135
135
  - `session_analytics_logs` — the milestone events for a verification session, which show how far it got.
136
136
  - `session_logs` — the log entries the in-app SDK emitted during that session, each with its event type, log level, and logger name. Filter by event type, level, or substring, page with `limit` and `offset`, or set `saveTo` to write every matching entry to an NDJSON file. The backend searches only the last 3 days unless you pass `startTime` and `endTime`, and it deletes entries after 30 days.
137
- - **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
137
+ - **Builder (default).** Registers the auto-generated HTTP-API tools, including `create_org`, `issue_org_token`, and `create_org_callback_subscription`, plus authentication/credential tools, `get_verification_result`, `read_callback_events` (read the org's pull-listener events), `proxy_callback_events` (forward them to a localhost callback route; the pull listener lives only while the MCP server runs), and `check_attestor`. `issue_credentials` creates a local proof-owner key; `issue_org_token` issues the organization's API secret; `create_example_app` writes the builder-example app into a workspace that has none; `issue_org_keypair` creates the organization's key pair for session binding and encrypted results, writing the private key to the app's env file.
138
138
 
139
139
  Relevant env vars: `RECLAIM_OLD_API_URL` (default `https://devapi.reclaimprotocol.org`), `RECLAIM_OLD_API_TOKEN` / `RECLAIM_OLD_ETH_UID` (headless identity), `RECLAIM_OLD_LOGIN_URL` (default `https://dev.reclaimprotocol.org`); builder mode uses `RECLAIM_API_URL` / `RECLAIM_API_TOKEN`.
140
140
 
@@ -17,6 +17,7 @@ import { captureTools, resetCaptures } from "./capture.js";
17
17
  import { checkAttestorTool } from "./check-attestor.js";
18
18
  import { draftTools } from "./draft-tools.js";
19
19
  import { listTabsTool } from "./eval.js";
20
+ import { exampleAppTool } from "./example-app.js";
20
21
  import { howItWorksTool } from "./how-it-works.js";
21
22
  import { liveViewTools } from "./live-view.js";
22
23
  import { podProofTools } from "./pod-proof.js";
@@ -81,6 +82,7 @@ export function buildAgentTools(client, builderClient = client, externalState) {
81
82
  }
82
83
  if (client) {
83
84
  tools.push(startIntegrationTool());
85
+ tools.push(exampleAppTool());
84
86
  tools.push(createVersionFromCaptureTool(client, state.attach, state.captures));
85
87
  }
86
88
  return tools;
@@ -0,0 +1,2 @@
1
+ import { type RegisteredTool } from '../../server.ts';
2
+ export declare function exampleAppTool(fetchFn?: typeof fetch): RegisteredTool;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * `create_example_app` — write reclaimprotocol/builder-example into the
3
+ * developer's workspace, so a project with no app starts from a ready-to-run
4
+ * backend and frontend instead of agent-written code. The MCP server fetches
5
+ * the files itself (each checked against its git blob hash) and writes them at
6
+ * the workspace root: the coding agent's later `npm install` and `npm run dev`
7
+ * then run in the workspace it started in, not in a clone. The backend's
8
+ * `@reclaimprotocol/client` is set to the latest published version.
9
+ */
10
+ import { badGateway, badRequest, conflict } from '@hapi/boom';
11
+ import assert from 'node:assert';
12
+ import { createHash } from 'node:crypto';
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
15
+ import { dirname, isAbsolute, join } from 'node:path';
16
+ import { defineTool } from "../../server.js";
17
+ const REPO = 'reclaimprotocol/builder-example';
18
+ const BACKEND_PACKAGE = join('packages', 'backend', 'package.json');
19
+ const CLIENT_PACKAGE = '@reclaimprotocol/client';
20
+ /** The example is ~50 small files; anything far past that isn't it. */
21
+ const MAX_FILES = 500;
22
+ const MAX_BYTES = 10000000;
23
+ export function exampleAppTool(fetchFn = fetch) {
24
+ return defineTool({
25
+ name: 'create_example_app',
26
+ description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
27
+ + 'a Fastify backend and a Vite frontend that create sessions, '
28
+ + 'verify callbacks, and render every verification state) into '
29
+ + '`projectDir`, and sets its backend to the latest '
30
+ + '`@reclaimprotocol/client`. Use it when the workspace has no app '
31
+ + 'yet, with the workspace root you were started in as '
32
+ + '`projectDir`; don\'t clone the repository yourself. Refuses '
33
+ + 'when a file it would write already exists with different '
34
+ + 'content. Returns the commit, file count, client version, and '
35
+ + 'next steps.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ required: ['projectDir'],
39
+ properties: {
40
+ projectDir: {
41
+ type: 'string',
42
+ description: 'Absolute path of the workspace root you were started in.',
43
+ },
44
+ },
45
+ },
46
+ annotations: { openWorldHint: true, destructiveHint: false },
47
+ }, async ({ projectDir }) => {
48
+ assert(isAbsolute(projectDir), badRequest('`projectDir` must be an absolute path.'));
49
+ const commit = await latestCommit();
50
+ const files = await listFiles(commit);
51
+ const clientVersion = await latestClientVersion();
52
+ const staged = await Promise.all(files.map(async (file) => {
53
+ const bytes = await download(commit, file);
54
+ return {
55
+ path: file.path,
56
+ bytes: file.path === BACKEND_PACKAGE
57
+ ? withClientVersion(bytes, clientVersion)
58
+ : bytes,
59
+ };
60
+ }));
61
+ const conflicts = staged
62
+ .filter(({ path, bytes }) => {
63
+ const dest = join(projectDir, path);
64
+ return existsSync(dest) && !readFileSync(dest).equals(bytes);
65
+ })
66
+ .map(({ path }) => path);
67
+ assert(!conflicts.length, conflict('These files already exist with different content: '
68
+ + `${conflicts.join(', ')}. Ask the developer whether to use `
69
+ + 'another directory or move them.'));
70
+ // Stage inside projectDir, so the moves below stay on one filesystem.
71
+ const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'));
72
+ try {
73
+ for (const { path, bytes } of staged) {
74
+ await mkdir(dirname(join(tmp, path)), { recursive: true });
75
+ await writeFile(join(tmp, path), bytes);
76
+ }
77
+ for (const { path } of staged) {
78
+ const dest = join(projectDir, path);
79
+ await mkdir(dirname(dest), { recursive: true });
80
+ await rename(join(tmp, path), dest);
81
+ }
82
+ }
83
+ finally {
84
+ await rm(tmp, { recursive: true, force: true });
85
+ }
86
+ return {
87
+ projectDir,
88
+ repository: `https://github.com/${REPO}`,
89
+ commit,
90
+ files: staged.length,
91
+ clientVersion,
92
+ next: [
93
+ 'Call issue_org_token with this projectDir and envFile '
94
+ + '"packages/backend/.env".',
95
+ 'Run `npm install`, then `npm run dev`, from projectDir. The '
96
+ + 'backend listens on port 3000; the app opens at '
97
+ + 'http://localhost:5173.',
98
+ 'Call proxy_callback_events({ port: 3000, path: '
99
+ + '"/reclaim/callback" }).',
100
+ 'Put the provider in packages/backend/config.json and restart '
101
+ + 'the backend.',
102
+ ],
103
+ };
104
+ });
105
+ async function latestCommit() {
106
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
107
+ assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
108
+ const text = await res.text();
109
+ const commit = text.trim();
110
+ assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'));
111
+ return commit;
112
+ }
113
+ async function listFiles(commit) {
114
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`, { headers: { Accept: 'application/vnd.github+json' } });
115
+ assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`));
116
+ const tree = await res.json();
117
+ assert(typeof tree === 'object' && tree !== null && 'tree' in tree
118
+ && Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated), badGateway(`unexpected ${REPO} file listing`));
119
+ const files = tree.tree
120
+ .filter((e) => {
121
+ return typeof e === 'object' && e !== null && 'type' in e
122
+ && e.type === 'blob';
123
+ })
124
+ .map((entry) => {
125
+ assert(typeof entry === 'object' && entry !== null
126
+ && 'path' in entry && typeof entry.path === 'string'
127
+ && entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
128
+ && s !== '.' && s !== '..' && s !== '.git')
129
+ && 'sha' in entry && typeof entry.sha === 'string'
130
+ && 'size' in entry && typeof entry.size === 'number', badGateway(`unexpected entry in the ${REPO} file listing`));
131
+ return { path: entry.path, sha: entry.sha, size: entry.size };
132
+ });
133
+ assert(files.length > 0 && files.length <= MAX_FILES
134
+ && files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES, badGateway(`unexpected ${REPO} size`));
135
+ return files;
136
+ }
137
+ async function download(commit, file) {
138
+ const res = await fetchFn(`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`);
139
+ assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`));
140
+ const bytes = Buffer.from(await res.arrayBuffer());
141
+ assert(gitBlobSha(bytes) === file.sha, badGateway(`${file.path} failed its integrity check`));
142
+ return bytes;
143
+ }
144
+ async function latestClientVersion() {
145
+ const res = await fetchFn(`https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`);
146
+ assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
147
+ const body = await res.json();
148
+ assert(typeof body === 'object' && body !== null && 'version' in body
149
+ && typeof body.version === 'string'
150
+ && /^\d+\.\d+\.\d+$/.test(body.version), badGateway(`npm ${CLIENT_PACKAGE}: no version`));
151
+ return body.version;
152
+ }
153
+ }
154
+ /** The backend's package.json with the client range set to `^version`. */
155
+ function withClientVersion(bytes, version) {
156
+ const text = bytes.toString('utf8');
157
+ const next = text.replace(/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/, (_, open, close) => `${open}^${version}${close}`);
158
+ assert(next !== text || text.includes(`"^${version}"`), badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`));
159
+ return Buffer.from(next);
160
+ }
161
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
162
+ function gitBlobSha(bytes) {
163
+ return createHash('sha1')
164
+ .update(`blob ${bytes.length}\0`)
165
+ .update(bytes)
166
+ .digest('hex');
167
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@reclaimprotocol/agent",
3
3
  "title": "Reclaim Agent",
4
- "version": "0.1.37",
4
+ "version": "0.1.38",
5
5
  "description": "Reclaim Protocol tools for AI coding agents. Reclaim lets your app verify data points about its users, such as a follower count, an account balance, or an order history. This package is the Reclaim MCP server: it gives your agent the tools to find, build, test, and fix data verification providers by capturing and replaying browser requests, drafting provider recipes, running zkTLS proofs, and publishing provider versions.",
6
6
  "type": "module",
7
7
  "main": "./lib/index.js",
@@ -84,7 +84,7 @@
84
84
  "@noble/hashes": "^2.2.0",
85
85
  "@puppeteer/browsers": "3.2.4",
86
86
  "@reclaimprotocol/attestor-core": "^5.1.6",
87
- "@reclaimprotocol/client": "0.1.37",
87
+ "@reclaimprotocol/client": "0.1.38",
88
88
  "@reclaimprotocol/tls": "^0.1.3",
89
89
  "chrome-remote-interface": "^0.33.0",
90
90
  "domhandler": "^6.0.0",
@@ -46,7 +46,7 @@ Build a new organization-private provider for exactly "{{goal}}". Do not search
46
46
 
47
47
  Setup does not need the provider. Start it after step 2 has a working proof, or while you wait on the developer during step 2 (for example, while they sign in to the site). Don't let it delay a step that needs the developer.
48
48
 
49
- **Delegation.** If you can run subagents, hand this step to one subagent as soon as you are signed in, and keep working on step 2: step 2 needs the developer at the browser, step 3 does not. Give the subagent a self-contained brief:
49
+ **Delegation.** When the workspace already has an app and you can run subagents, hand this step to one subagent as soon as you are signed in, and keep working on step 2: step 2 needs the developer at the browser, step 3 does not. Give the subagent a self-contained brief:
50
50
 
51
51
  ```
52
52
  Goal: let this app's users prove "{{goal}}" with Reclaim.
@@ -61,10 +61,12 @@ Without subagents, do step 2 first, and fill waits for the developer with this s
61
61
 
62
62
  **No app yet?** If the workspace has no app to add verification to (it is empty, or has no backend or frontend), don't write one. Bootstrap from [builder-example](https://github.com/reclaimprotocol/builder-example), a ready-to-run Fastify backend and Vite frontend that already create sessions, verify callbacks, poll status, and render every UI state:
63
63
 
64
- - [ ] Clone it: `git clone --depth 1 https://github.com/reclaimprotocol/builder-example <dir>` (use the workspace itself when it is empty), then delete the clone's `.git` directory so the code becomes the developer's own.
65
- - [ ] Call `issue_org_token` with the clone's absolute path as `projectDir` and `envFile: "packages/backend/.env"`.
64
+ Do this yourself, not in a subagent.
65
+
66
+ - [ ] Call `create_example_app` with the workspace root you were started in as `projectDir`. It writes the example there and sets its backend to the latest `@reclaimprotocol/client`. Don't clone the repository or create a subdirectory: commands you run inside a clone count as running external code, and permission checks may block them.
67
+ - [ ] Call `issue_org_token` with the same `projectDir` and `envFile: "packages/backend/.env"`.
66
68
  - [ ] Only when the developer asks for session binding or encrypted results: call `issue_org_keypair` with the same `projectDir` and `envFile` (add `encryptResults: true` for encrypted results). It writes `RECLAIM_ORG_ETH_PRIVATE_KEY` and registers the public key. Then pass `orgEthPrivateKey: process.env.RECLAIM_ORG_ETH_PRIVATE_KEY` to `reclaim.sessions.create` in `packages/backend/src/routes/verifications.ts` (binding) and to `reclaim.results.receive` in `packages/backend/src/routes/callback.ts` (decryption and TEE proof checks), and add an empty `RECLAIM_ORG_ETH_PRIVATE_KEY=` line to `packages/backend/example.env`.
67
- - [ ] From the clone's root, run `npm install`, then `npm install @reclaimprotocol/client@latest -w packages/backend` so the backend uses the latest client rather than the version in the example's lockfile. Then run `npm run dev`. The backend listens on port 3000; the app opens at <http://localhost:5173>.
69
+ - [ ] From the workspace root, run `npm install`, then `npm run dev`. The backend listens on port 3000; the app opens at <http://localhost:5173>. If a permission check blocks either command, don't work around it: end your turn, say which command was blocked and that it installs and runs the Reclaim example app, and ask the developer to approve it.
68
70
  - [ ] Call `proxy_callback_events({ port: 3000, path: "/reclaim/callback" })`.
69
71
  - [ ] Skip the rest of this step. In step 4, the provider goes in `packages/backend/config.json`: replace the example entry with `{ "id": "<provider ID>", "name": "<what it proves>" }`, then restart the backend, because it reads the file at startup.
70
72
 
@@ -14,10 +14,10 @@ proof works, or while you wait on the developer. When you need the developer
14
14
  to answer or act, end your turn with the question and every link they need;
15
15
  don't ask through a background or asynchronous question tool.
16
16
 
17
- When the workspace has no app yet, bootstrap from
18
- [builder-example](https://github.com/reclaimprotocol/builder-example) as the
19
- `start_integration` plan describes, instead of writing a new backend and
20
- frontend. Otherwise, inspect the app's frontend, backend, package manager,
17
+ When the workspace has no app yet, call `create_example_app` to write
18
+ [builder-example](https://github.com/reclaimprotocol/builder-example) into the
19
+ workspace root, as the `start_integration` plan describes, instead of writing a
20
+ new backend and frontend or cloning the repository. Otherwise, inspect the app's frontend, backend, package manager,
21
21
  configuration, and callback conventions. Complete the integration in its codebase and run its
22
22
  relevant checks. The MCP tools configure Builder resources; your file tools
23
23
  edit the app.
@@ -20,6 +20,7 @@ import { type CaptureStore, captureTools, resetCaptures } from './capture.ts'
20
20
  import { checkAttestorTool } from './check-attestor.ts'
21
21
  import { draftTools } from './draft-tools.ts'
22
22
  import { listTabsTool } from './eval.ts'
23
+ import { exampleAppTool } from './example-app.ts'
23
24
  import { howItWorksTool } from './how-it-works.ts'
24
25
  import { liveViewTools } from './live-view.ts'
25
26
  import { podProofTools } from './pod-proof.ts'
@@ -101,6 +102,7 @@ export function buildAgentTools(
101
102
 
102
103
  if(client) {
103
104
  tools.push(startIntegrationTool())
105
+ tools.push(exampleAppTool())
104
106
  tools.push(createVersionFromCaptureTool(
105
107
  client, state.attach, state.captures,
106
108
  ))
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `create_example_app` — write reclaimprotocol/builder-example into the
3
+ * developer's workspace, so a project with no app starts from a ready-to-run
4
+ * backend and frontend instead of agent-written code. The MCP server fetches
5
+ * the files itself (each checked against its git blob hash) and writes them at
6
+ * the workspace root: the coding agent's later `npm install` and `npm run dev`
7
+ * then run in the workspace it started in, not in a clone. The backend's
8
+ * `@reclaimprotocol/client` is set to the latest published version.
9
+ */
10
+ import { badGateway, badRequest, conflict } from '@hapi/boom'
11
+ import assert from 'node:assert'
12
+ import { createHash } from 'node:crypto'
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import {
15
+ mkdir,
16
+ mkdtemp,
17
+ rename,
18
+ rm,
19
+ writeFile
20
+ } from 'node:fs/promises'
21
+ import { dirname, isAbsolute, join } from 'node:path'
22
+ import { defineTool, type RegisteredTool } from '../../server.ts'
23
+
24
+ const REPO = 'reclaimprotocol/builder-example'
25
+ const BACKEND_PACKAGE = join('packages', 'backend', 'package.json')
26
+ const CLIENT_PACKAGE = '@reclaimprotocol/client'
27
+ /** The example is ~50 small files; anything far past that isn't it. */
28
+ const MAX_FILES = 500
29
+ const MAX_BYTES = 10_000_000
30
+
31
+ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
32
+ return defineTool<{ projectDir: string }>(
33
+ {
34
+ name: 'create_example_app',
35
+ description:
36
+ 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
37
+ + 'a Fastify backend and a Vite frontend that create sessions, '
38
+ + 'verify callbacks, and render every verification state) into '
39
+ + '`projectDir`, and sets its backend to the latest '
40
+ + '`@reclaimprotocol/client`. Use it when the workspace has no app '
41
+ + 'yet, with the workspace root you were started in as '
42
+ + '`projectDir`; don\'t clone the repository yourself. Refuses '
43
+ + 'when a file it would write already exists with different '
44
+ + 'content. Returns the commit, file count, client version, and '
45
+ + 'next steps.',
46
+ inputSchema: {
47
+ type: 'object',
48
+ required: ['projectDir'],
49
+ properties: {
50
+ projectDir: {
51
+ type: 'string',
52
+ description:
53
+ 'Absolute path of the workspace root you were started in.',
54
+ },
55
+ },
56
+ },
57
+ annotations: { openWorldHint: true, destructiveHint: false },
58
+ },
59
+ async({ projectDir }) => {
60
+ assert(
61
+ isAbsolute(projectDir),
62
+ badRequest('`projectDir` must be an absolute path.'),
63
+ )
64
+ const commit = await latestCommit()
65
+ const files = await listFiles(commit)
66
+ const clientVersion = await latestClientVersion()
67
+ const staged = await Promise.all(files.map(async(file) => {
68
+ const bytes = await download(commit, file)
69
+ return {
70
+ path: file.path,
71
+ bytes: file.path === BACKEND_PACKAGE
72
+ ? withClientVersion(bytes, clientVersion)
73
+ : bytes,
74
+ }
75
+ }))
76
+ const conflicts = staged
77
+ .filter(({ path, bytes }) => {
78
+ const dest = join(projectDir, path)
79
+ return existsSync(dest) && !readFileSync(dest).equals(bytes)
80
+ })
81
+ .map(({ path }) => path)
82
+ assert(
83
+ !conflicts.length,
84
+ conflict(
85
+ 'These files already exist with different content: '
86
+ + `${conflicts.join(', ')}. Ask the developer whether to use `
87
+ + 'another directory or move them.',
88
+ ),
89
+ )
90
+
91
+ // Stage inside projectDir, so the moves below stay on one filesystem.
92
+ const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'))
93
+ try {
94
+ for(const { path, bytes } of staged) {
95
+ await mkdir(dirname(join(tmp, path)), { recursive: true })
96
+ await writeFile(join(tmp, path), bytes)
97
+ }
98
+
99
+ for(const { path } of staged) {
100
+ const dest = join(projectDir, path)
101
+ await mkdir(dirname(dest), { recursive: true })
102
+ await rename(join(tmp, path), dest)
103
+ }
104
+ } finally {
105
+ await rm(tmp, { recursive: true, force: true })
106
+ }
107
+
108
+ return {
109
+ projectDir,
110
+ repository: `https://github.com/${REPO}`,
111
+ commit,
112
+ files: staged.length,
113
+ clientVersion,
114
+ next: [
115
+ 'Call issue_org_token with this projectDir and envFile '
116
+ + '"packages/backend/.env".',
117
+ 'Run `npm install`, then `npm run dev`, from projectDir. The '
118
+ + 'backend listens on port 3000; the app opens at '
119
+ + 'http://localhost:5173.',
120
+ 'Call proxy_callback_events({ port: 3000, path: '
121
+ + '"/reclaim/callback" }).',
122
+ 'Put the provider in packages/backend/config.json and restart '
123
+ + 'the backend.',
124
+ ],
125
+ }
126
+ },
127
+ )
128
+
129
+ async function latestCommit() {
130
+ const res = await fetchFn(
131
+ `https://api.github.com/repos/${REPO}/commits/main`,
132
+ { headers: { Accept: 'application/vnd.github.sha' } },
133
+ )
134
+ assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`))
135
+ const text = await res.text()
136
+ const commit = text.trim()
137
+ assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'))
138
+ return commit
139
+ }
140
+
141
+ async function listFiles(commit: string) {
142
+ const res = await fetchFn(
143
+ `https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`,
144
+ { headers: { Accept: 'application/vnd.github+json' } },
145
+ )
146
+ assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`))
147
+ const tree: unknown = await res.json()
148
+ assert(
149
+ typeof tree === 'object' && tree !== null && 'tree' in tree
150
+ && Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated),
151
+ badGateway(`unexpected ${REPO} file listing`),
152
+ )
153
+ const files = tree.tree
154
+ .filter((e: unknown) => {
155
+ return typeof e === 'object' && e !== null && 'type' in e
156
+ && e.type === 'blob'
157
+ })
158
+ .map((entry: unknown) => {
159
+ assert(
160
+ typeof entry === 'object' && entry !== null
161
+ && 'path' in entry && typeof entry.path === 'string'
162
+ && entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
163
+ && s !== '.' && s !== '..' && s !== '.git')
164
+ && 'sha' in entry && typeof entry.sha === 'string'
165
+ && 'size' in entry && typeof entry.size === 'number',
166
+ badGateway(`unexpected entry in the ${REPO} file listing`),
167
+ )
168
+ return { path: entry.path, sha: entry.sha, size: entry.size }
169
+ })
170
+ assert(
171
+ files.length > 0 && files.length <= MAX_FILES
172
+ && files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES,
173
+ badGateway(`unexpected ${REPO} size`),
174
+ )
175
+ return files
176
+ }
177
+
178
+ async function download(commit: string, file: { path: string, sha: string }) {
179
+ const res = await fetchFn(
180
+ `https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`,
181
+ )
182
+ assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`))
183
+ const bytes = Buffer.from(await res.arrayBuffer())
184
+ assert(
185
+ gitBlobSha(bytes) === file.sha,
186
+ badGateway(`${file.path} failed its integrity check`),
187
+ )
188
+ return bytes
189
+ }
190
+
191
+ async function latestClientVersion() {
192
+ const res = await fetchFn(
193
+ `https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`,
194
+ )
195
+ assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
196
+ const body: unknown = await res.json()
197
+ assert(
198
+ typeof body === 'object' && body !== null && 'version' in body
199
+ && typeof body.version === 'string'
200
+ && /^\d+\.\d+\.\d+$/.test(body.version),
201
+ badGateway(`npm ${CLIENT_PACKAGE}: no version`),
202
+ )
203
+ return body.version
204
+ }
205
+ }
206
+
207
+ /** The backend's package.json with the client range set to `^version`. */
208
+ function withClientVersion(bytes: Buffer, version: string) {
209
+ const text = bytes.toString('utf8')
210
+ const next = text.replace(
211
+ /("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/,
212
+ (_, open: string, close: string) => `${open}^${version}${close}`,
213
+ )
214
+ assert(
215
+ next !== text || text.includes(`"^${version}"`),
216
+ badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`),
217
+ )
218
+ return Buffer.from(next)
219
+ }
220
+
221
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
222
+ function gitBlobSha(bytes: Buffer) {
223
+ return createHash('sha1')
224
+ .update(`blob ${bytes.length}\0`)
225
+ .update(bytes)
226
+ .digest('hex')
227
+ }