@reclaimprotocol/agent 0.1.37 → 0.1.39

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,211 @@
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, lstatSync, readFileSync } from 'node:fs';
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises';
15
+ import { isAbsolute, join, relative, sep } from 'node:path';
16
+ import { defineTool } from "../../server.js";
17
+ const REPO = 'reclaimprotocol/builder-example';
18
+ /** GitHub tree paths are always `/`-separated, on every OS. */
19
+ const BACKEND_PACKAGE = 'packages/backend/package.json';
20
+ const CLIENT_PACKAGE = '@reclaimprotocol/client';
21
+ /** The example is ~50 small files; anything far past that isn't it. */
22
+ const MAX_FILES = 500;
23
+ const MAX_BYTES = 10000000;
24
+ export function exampleAppTool(fetchFn = fetch) {
25
+ return defineTool({
26
+ name: 'create_example_app',
27
+ description: 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
28
+ + 'a Fastify backend and a Vite frontend that create sessions, '
29
+ + 'verify callbacks, and render every verification state) into '
30
+ + '`projectDir`, and sets its backend to the latest '
31
+ + '`@reclaimprotocol/client`. Use it when the workspace has no app '
32
+ + 'yet, with the workspace root you were started in as '
33
+ + '`projectDir`; don\'t clone the repository yourself. Refuses '
34
+ + 'when a file it would write already exists with different '
35
+ + 'content. Returns the commit, file count, client version, and '
36
+ + 'next steps.',
37
+ inputSchema: {
38
+ type: 'object',
39
+ required: ['projectDir'],
40
+ properties: {
41
+ projectDir: {
42
+ type: 'string',
43
+ description: 'Absolute path of the workspace root you were started in.',
44
+ },
45
+ },
46
+ },
47
+ annotations: { openWorldHint: true, destructiveHint: false },
48
+ }, async (args) => {
49
+ assert(isAbsolute(args.projectDir), badRequest('`projectDir` must be an absolute path.'));
50
+ const projectDir = await realpath(args.projectDir);
51
+ const commit = await latestCommit();
52
+ const files = await listFiles(commit);
53
+ const clientVersion = await latestClientVersion();
54
+ const staged = await Promise.all(files.map(async (file) => {
55
+ const bytes = await download(commit, file);
56
+ return {
57
+ path: file.path,
58
+ bytes: file.path === BACKEND_PACKAGE
59
+ ? withClientVersion(bytes, clientVersion)
60
+ : bytes,
61
+ };
62
+ }));
63
+ for (const { path } of staged) {
64
+ assertNoSymlinks(projectDir, path);
65
+ }
66
+ const conflicts = staged
67
+ .filter(({ path, bytes }) => {
68
+ const dest = join(projectDir, path);
69
+ return existsSync(dest) && !readFileSync(dest).equals(bytes);
70
+ })
71
+ .map(({ path }) => path);
72
+ assert(!conflicts.length, conflict('These files already exist with different content: '
73
+ + `${conflicts.join(', ')}. Ask the developer whether to use `
74
+ + 'another directory or move them.'));
75
+ for (const { path, bytes } of staged) {
76
+ await installFile(projectDir, path, bytes);
77
+ }
78
+ return {
79
+ projectDir,
80
+ repository: `https://github.com/${REPO}`,
81
+ commit,
82
+ files: staged.length,
83
+ clientVersion,
84
+ next: [
85
+ 'Call issue_org_token with this projectDir and envFile '
86
+ + '"packages/backend/.env".',
87
+ 'Run `npm install`, then `npm run dev`, from projectDir. The '
88
+ + 'backend listens on port 3000; the app opens at '
89
+ + 'http://localhost:5173.',
90
+ 'Call proxy_callback_events({ port: 3000, path: '
91
+ + '"/reclaim/callback" }).',
92
+ 'Put the provider in packages/backend/config.json and restart '
93
+ + 'the backend.',
94
+ ],
95
+ };
96
+ });
97
+ async function latestCommit() {
98
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/commits/main`, { headers: { Accept: 'application/vnd.github.sha' } });
99
+ assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`));
100
+ const text = await res.text();
101
+ const commit = text.trim();
102
+ assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'));
103
+ return commit;
104
+ }
105
+ async function listFiles(commit) {
106
+ const res = await fetchFn(`https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`, { headers: { Accept: 'application/vnd.github+json' } });
107
+ assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`));
108
+ const tree = await res.json();
109
+ assert(typeof tree === 'object' && tree !== null && 'tree' in tree
110
+ && Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated), badGateway(`unexpected ${REPO} file listing`));
111
+ const files = tree.tree
112
+ .filter((e) => {
113
+ return typeof e === 'object' && e !== null && 'type' in e
114
+ && e.type === 'blob';
115
+ })
116
+ .map((entry) => {
117
+ assert(typeof entry === 'object' && entry !== null
118
+ && 'path' in entry && typeof entry.path === 'string'
119
+ && entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
120
+ && s !== '.' && s !== '..' && s !== '.git')
121
+ && 'sha' in entry && typeof entry.sha === 'string'
122
+ && 'size' in entry && typeof entry.size === 'number', badGateway(`unexpected entry in the ${REPO} file listing`));
123
+ return { path: entry.path, sha: entry.sha, size: entry.size };
124
+ });
125
+ assert(files.length > 0 && files.length <= MAX_FILES
126
+ && files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES, badGateway(`unexpected ${REPO} size`));
127
+ return files;
128
+ }
129
+ async function download(commit, file) {
130
+ const res = await fetchFn(`https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`);
131
+ assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`));
132
+ const bytes = Buffer.from(await res.arrayBuffer());
133
+ assert(gitBlobSha(bytes) === file.sha, badGateway(`${file.path} failed its integrity check`));
134
+ return bytes;
135
+ }
136
+ async function latestClientVersion() {
137
+ const res = await fetchFn(`https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`);
138
+ assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`));
139
+ const body = await res.json();
140
+ assert(typeof body === 'object' && body !== null && 'version' in body
141
+ && typeof body.version === 'string'
142
+ && /^\d+\.\d+\.\d+$/.test(body.version), badGateway(`npm ${CLIENT_PACKAGE}: no version`));
143
+ return body.version;
144
+ }
145
+ }
146
+ /**
147
+ * Write one file under `root` without following a symlink out of it or
148
+ * replacing anything: each parent is created one level at a time and must be
149
+ * a real directory, the file is created exclusively (`wx` fails on any path
150
+ * that appeared since the conflict scan, symlinks included), and its real
151
+ * path is checked to still be inside `root` afterwards. A file already there
152
+ * with the same bytes (a re-run) is left as is.
153
+ */
154
+ async function installFile(root, path, bytes) {
155
+ const parts = path.split('/');
156
+ let dir = root;
157
+ for (const part of parts.slice(0, -1)) {
158
+ dir = join(dir, part);
159
+ await mkdir(dir).catch((err) => {
160
+ if (!isEexist(err)) {
161
+ throw err;
162
+ }
163
+ });
164
+ assertNoSymlinks(root, relative(root, dir));
165
+ }
166
+ const dest = join(dir, parts.at(-1));
167
+ try {
168
+ await writeFile(dest, bytes, { flag: 'wx' });
169
+ }
170
+ catch (err) {
171
+ if (!isEexist(err)) {
172
+ throw err;
173
+ }
174
+ assertNoSymlinks(root, path);
175
+ assert(readFileSync(dest).equals(bytes), conflict(`${path} appeared with different content; nothing replaced`));
176
+ return;
177
+ }
178
+ const real = await realpath(dest);
179
+ if (!real.startsWith(root + sep)) {
180
+ await rm(dest, { force: true });
181
+ throw badRequest(`${path} resolved outside the workspace; removed it`);
182
+ }
183
+ }
184
+ function isEexist(err) {
185
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST';
186
+ }
187
+ /** Throws when `path`, or any of its existing parents under `root`, is a
188
+ * symlink: writing through one would land outside the workspace. */
189
+ function assertNoSymlinks(root, path) {
190
+ let current = root;
191
+ for (const part of path.split(/[\\/]/)) {
192
+ current += sep + part;
193
+ const stat = lstatSync(current, { throwIfNoEntry: false });
194
+ assert(!stat?.isSymbolicLink(), badRequest(`${current} is a symlink; the example app is only written to `
195
+ + 'real directories inside the workspace'));
196
+ }
197
+ }
198
+ /** The backend's package.json with the client range set to `^version`. */
199
+ function withClientVersion(bytes, version) {
200
+ const text = bytes.toString('utf8');
201
+ const next = text.replace(/("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/, (_, open, close) => `${open}^${version}${close}`);
202
+ assert(next !== text || text.includes(`"^${version}"`), badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`));
203
+ return Buffer.from(next);
204
+ }
205
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
206
+ function gitBlobSha(bytes) {
207
+ return createHash('sha1')
208
+ .update(`blob ${bytes.length}\0`)
209
+ .update(bytes)
210
+ .digest('hex');
211
+ }
@@ -80,10 +80,11 @@ export function assertPortableMatches(matches, redactions, targets) {
80
80
  // proving an email's domain substring. Reject it regardless of the declared
81
81
  // targets (the local part is usually not itself a target, so the value scan
82
82
  // below wouldn't catch it). Strip params + named groups + regex escapes
83
- // first, then look for `localpart@` as a surviving literal.
83
+ // first, then look for `localpart@` as a surviving literal. A placeholder
84
+ // never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
84
85
  const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/;
85
86
  const deregex = (s) => s
86
- .replace(/\{\{[^}]*\}\}/g, '')
87
+ .replace(/\{\{[^{}]*\}\}/g, '')
87
88
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
88
89
  .replace(/\\(.)/g, '$1');
89
90
  const scanEmail = (text, where) => {
@@ -110,7 +111,7 @@ export function assertPortableMatches(matches, redactions, targets) {
110
111
  if (!guarded.length) {
111
112
  return;
112
113
  }
113
- const stripParams = (s) => s.replace(/\{\{[^}]*\}\}/g, '');
114
+ const stripParams = (s) => s.replace(/\{\{[^{}]*\}\}/g, '');
114
115
  const scan = (text, where, own) => {
115
116
  const bare = stripParams(text);
116
117
  for (const v of guarded) {
@@ -572,7 +572,7 @@ function stableOpeningTail(openTag) {
572
572
  * content (the JSON body the attestor exposes when a jsonPath is paired with
573
573
  * the script's xPath); otherwise undefined. */
574
574
  function scriptJsonContent(markup) {
575
- const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup);
575
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup);
576
576
  if (!match) {
577
577
  return undefined;
578
578
  }
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.39",
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.39",
88
88
  "@reclaimprotocol/tls": "^0.1.3",
89
89
  "chrome-remote-interface": "^0.33.0",
90
90
  "domhandler": "^6.0.0",
@@ -8,6 +8,7 @@
8
8
  "dependencies": {
9
9
  "@reclaimprotocol/js-sdk": "^5.8.1",
10
10
  "dotenv": "^16.4.5",
11
- "express": "^4.21.2"
11
+ "express": "^4.21.2",
12
+ "express-rate-limit": "^8.7.1"
12
13
  }
13
14
  }
@@ -1,4 +1,5 @@
1
1
  import express from "express";
2
+ import { rateLimit } from "express-rate-limit";
2
3
  import "dotenv/config";
3
4
  import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
4
5
  import { randomUUID } from "node:crypto";
@@ -9,8 +10,12 @@ const providerId = __PROVIDER_ID__;
9
10
  // Left unpinned by default — set this if you need to pin a specific version.
10
11
  const providerVersion = __PROVIDER_VERSION__;
11
12
 
12
- app.set("trust proxy", true);
13
+ // One proxy hop (ngrok or a load balancer), so the rate limit below keys on
14
+ // the client's address rather than the proxy's.
15
+ app.set("trust proxy", 1);
13
16
  app.use(express.json({ limit: "2mb" }));
17
+ // Every route verifies or creates something; cap each client's request rate.
18
+ app.use(rateLimit({ windowMs: 60_000, limit: 60 }));
14
19
 
15
20
  app.get("/health", (_req, res) => {
16
21
  res.json({ ok: true });
@@ -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,279 @@
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, lstatSync, readFileSync } from 'node:fs'
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
15
+ import { isAbsolute, join, relative, sep } from 'node:path'
16
+ import { defineTool, type RegisteredTool } from '../../server.ts'
17
+
18
+ const REPO = 'reclaimprotocol/builder-example'
19
+ /** GitHub tree paths are always `/`-separated, on every OS. */
20
+ const BACKEND_PACKAGE = 'packages/backend/package.json'
21
+ const CLIENT_PACKAGE = '@reclaimprotocol/client'
22
+ /** The example is ~50 small files; anything far past that isn't it. */
23
+ const MAX_FILES = 500
24
+ const MAX_BYTES = 10_000_000
25
+
26
+ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
27
+ return defineTool<{ projectDir: string }>(
28
+ {
29
+ name: 'create_example_app',
30
+ description:
31
+ 'Writes the Reclaim example app (reclaimprotocol/builder-example: '
32
+ + 'a Fastify backend and a Vite frontend that create sessions, '
33
+ + 'verify callbacks, and render every verification state) into '
34
+ + '`projectDir`, and sets its backend to the latest '
35
+ + '`@reclaimprotocol/client`. Use it when the workspace has no app '
36
+ + 'yet, with the workspace root you were started in as '
37
+ + '`projectDir`; don\'t clone the repository yourself. Refuses '
38
+ + 'when a file it would write already exists with different '
39
+ + 'content. Returns the commit, file count, client version, and '
40
+ + 'next steps.',
41
+ inputSchema: {
42
+ type: 'object',
43
+ required: ['projectDir'],
44
+ properties: {
45
+ projectDir: {
46
+ type: 'string',
47
+ description:
48
+ 'Absolute path of the workspace root you were started in.',
49
+ },
50
+ },
51
+ },
52
+ annotations: { openWorldHint: true, destructiveHint: false },
53
+ },
54
+ async(args) => {
55
+ assert(
56
+ isAbsolute(args.projectDir),
57
+ badRequest('`projectDir` must be an absolute path.'),
58
+ )
59
+ const projectDir = await realpath(args.projectDir)
60
+ const commit = await latestCommit()
61
+ const files = await listFiles(commit)
62
+ const clientVersion = await latestClientVersion()
63
+ const staged = await Promise.all(files.map(async(file) => {
64
+ const bytes = await download(commit, file)
65
+ return {
66
+ path: file.path,
67
+ bytes: file.path === BACKEND_PACKAGE
68
+ ? withClientVersion(bytes, clientVersion)
69
+ : bytes,
70
+ }
71
+ }))
72
+ for(const { path } of staged) {
73
+ assertNoSymlinks(projectDir, path)
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
+ for(const { path, bytes } of staged) {
92
+ await installFile(projectDir, path, bytes)
93
+ }
94
+
95
+ return {
96
+ projectDir,
97
+ repository: `https://github.com/${REPO}`,
98
+ commit,
99
+ files: staged.length,
100
+ clientVersion,
101
+ next: [
102
+ 'Call issue_org_token with this projectDir and envFile '
103
+ + '"packages/backend/.env".',
104
+ 'Run `npm install`, then `npm run dev`, from projectDir. The '
105
+ + 'backend listens on port 3000; the app opens at '
106
+ + 'http://localhost:5173.',
107
+ 'Call proxy_callback_events({ port: 3000, path: '
108
+ + '"/reclaim/callback" }).',
109
+ 'Put the provider in packages/backend/config.json and restart '
110
+ + 'the backend.',
111
+ ],
112
+ }
113
+ },
114
+ )
115
+
116
+ async function latestCommit() {
117
+ const res = await fetchFn(
118
+ `https://api.github.com/repos/${REPO}/commits/main`,
119
+ { headers: { Accept: 'application/vnd.github.sha' } },
120
+ )
121
+ assert(res.ok, badGateway(`${REPO} commit lookup: HTTP ${res.status}`))
122
+ const text = await res.text()
123
+ const commit = text.trim()
124
+ assert(/^[0-9a-f]{40}$/.test(commit), badGateway('invalid commit SHA'))
125
+ return commit
126
+ }
127
+
128
+ async function listFiles(commit: string) {
129
+ const res = await fetchFn(
130
+ `https://api.github.com/repos/${REPO}/git/trees/${commit}?recursive=1`,
131
+ { headers: { Accept: 'application/vnd.github+json' } },
132
+ )
133
+ assert(res.ok, badGateway(`${REPO} file listing: HTTP ${res.status}`))
134
+ const tree: unknown = await res.json()
135
+ assert(
136
+ typeof tree === 'object' && tree !== null && 'tree' in tree
137
+ && Array.isArray(tree.tree) && !('truncated' in tree && tree.truncated),
138
+ badGateway(`unexpected ${REPO} file listing`),
139
+ )
140
+ const files = tree.tree
141
+ .filter((e: unknown) => {
142
+ return typeof e === 'object' && e !== null && 'type' in e
143
+ && e.type === 'blob'
144
+ })
145
+ .map((entry: unknown) => {
146
+ assert(
147
+ typeof entry === 'object' && entry !== null
148
+ && 'path' in entry && typeof entry.path === 'string'
149
+ && entry.path.split('/').every((s) => /^[\w@.-]+$/.test(s)
150
+ && s !== '.' && s !== '..' && s !== '.git')
151
+ && 'sha' in entry && typeof entry.sha === 'string'
152
+ && 'size' in entry && typeof entry.size === 'number',
153
+ badGateway(`unexpected entry in the ${REPO} file listing`),
154
+ )
155
+ return { path: entry.path, sha: entry.sha, size: entry.size }
156
+ })
157
+ assert(
158
+ files.length > 0 && files.length <= MAX_FILES
159
+ && files.reduce((n, f) => n + f.size, 0) <= MAX_BYTES,
160
+ badGateway(`unexpected ${REPO} size`),
161
+ )
162
+ return files
163
+ }
164
+
165
+ async function download(commit: string, file: { path: string, sha: string }) {
166
+ const res = await fetchFn(
167
+ `https://raw.githubusercontent.com/${REPO}/${commit}/${file.path}`,
168
+ )
169
+ assert(res.ok, badGateway(`${file.path}: HTTP ${res.status}`))
170
+ const bytes = Buffer.from(await res.arrayBuffer())
171
+ assert(
172
+ gitBlobSha(bytes) === file.sha,
173
+ badGateway(`${file.path} failed its integrity check`),
174
+ )
175
+ return bytes
176
+ }
177
+
178
+ async function latestClientVersion() {
179
+ const res = await fetchFn(
180
+ `https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
181
+ )
182
+ assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
183
+ const body: unknown = await res.json()
184
+ assert(
185
+ typeof body === 'object' && body !== null && 'version' in body
186
+ && typeof body.version === 'string'
187
+ && /^\d+\.\d+\.\d+$/.test(body.version),
188
+ badGateway(`npm ${CLIENT_PACKAGE}: no version`),
189
+ )
190
+ return body.version
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Write one file under `root` without following a symlink out of it or
196
+ * replacing anything: each parent is created one level at a time and must be
197
+ * a real directory, the file is created exclusively (`wx` fails on any path
198
+ * that appeared since the conflict scan, symlinks included), and its real
199
+ * path is checked to still be inside `root` afterwards. A file already there
200
+ * with the same bytes (a re-run) is left as is.
201
+ */
202
+ async function installFile(root: string, path: string, bytes: Buffer) {
203
+ const parts = path.split('/')
204
+ let dir = root
205
+ for(const part of parts.slice(0, -1)) {
206
+ dir = join(dir, part)
207
+ await mkdir(dir).catch((err: unknown) => {
208
+ if(!isEexist(err)) {
209
+ throw err
210
+ }
211
+ })
212
+ assertNoSymlinks(root, relative(root, dir))
213
+ }
214
+
215
+ const dest = join(dir, parts.at(-1)!)
216
+ try {
217
+ await writeFile(dest, bytes, { flag: 'wx' })
218
+ } catch(err) {
219
+ if(!isEexist(err)) {
220
+ throw err
221
+ }
222
+
223
+ assertNoSymlinks(root, path)
224
+ assert(
225
+ readFileSync(dest).equals(bytes),
226
+ conflict(`${path} appeared with different content; nothing replaced`),
227
+ )
228
+ return
229
+ }
230
+
231
+ const real = await realpath(dest)
232
+ if(!real.startsWith(root + sep)) {
233
+ await rm(dest, { force: true })
234
+ throw badRequest(`${path} resolved outside the workspace; removed it`)
235
+ }
236
+ }
237
+
238
+ function isEexist(err: unknown) {
239
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST'
240
+ }
241
+
242
+ /** Throws when `path`, or any of its existing parents under `root`, is a
243
+ * symlink: writing through one would land outside the workspace. */
244
+ function assertNoSymlinks(root: string, path: string) {
245
+ let current = root
246
+ for(const part of path.split(/[\\/]/)) {
247
+ current += sep + part
248
+ const stat = lstatSync(current, { throwIfNoEntry: false })
249
+ assert(
250
+ !stat?.isSymbolicLink(),
251
+ badRequest(
252
+ `${current} is a symlink; the example app is only written to `
253
+ + 'real directories inside the workspace',
254
+ ),
255
+ )
256
+ }
257
+ }
258
+
259
+ /** The backend's package.json with the client range set to `^version`. */
260
+ function withClientVersion(bytes: Buffer, version: string) {
261
+ const text = bytes.toString('utf8')
262
+ const next = text.replace(
263
+ /("@reclaimprotocol\/client"\s*:\s*")[^"]*(")/,
264
+ (_, open: string, close: string) => `${open}^${version}${close}`,
265
+ )
266
+ assert(
267
+ next !== text || text.includes(`"^${version}"`),
268
+ badGateway(`${BACKEND_PACKAGE} has no ${CLIENT_PACKAGE} dependency`),
269
+ )
270
+ return Buffer.from(next)
271
+ }
272
+
273
+ /** Git's blob object id: sha1 of `blob <size>\0<content>`. */
274
+ function gitBlobSha(bytes: Buffer) {
275
+ return createHash('sha1')
276
+ .update(`blob ${bytes.length}\0`)
277
+ .update(bytes)
278
+ .digest('hex')
279
+ }
@@ -132,10 +132,11 @@ export function assertPortableMatches(
132
132
  // proving an email's domain substring. Reject it regardless of the declared
133
133
  // targets (the local part is usually not itself a target, so the value scan
134
134
  // below wouldn't catch it). Strip params + named groups + regex escapes
135
- // first, then look for `localpart@` as a surviving literal.
135
+ // first, then look for `localpart@` as a surviving literal. A placeholder
136
+ // never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
136
137
  const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
137
138
  const deregex = (s: string): string => s
138
- .replace(/\{\{[^}]*\}\}/g, '')
139
+ .replace(/\{\{[^{}]*\}\}/g, '')
139
140
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
140
141
  .replace(/\\(.)/g, '$1')
141
142
  const scanEmail = (text: string, where: string) => {
@@ -168,7 +169,7 @@ export function assertPortableMatches(
168
169
  return
169
170
  }
170
171
 
171
- const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
172
+ const stripParams = (s: string): string => s.replace(/\{\{[^{}]*\}\}/g, '')
172
173
  const scan = (text: string, where: string, own: string | null) => {
173
174
  const bare = stripParams(text)
174
175
  for(const v of guarded) {
@@ -789,7 +789,7 @@ function stableOpeningTail(openTag: string): string {
789
789
  * content (the JSON body the attestor exposes when a jsonPath is paired with
790
790
  * the script's xPath); otherwise undefined. */
791
791
  function scriptJsonContent(markup: string): string | undefined {
792
- const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup)
792
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup)
793
793
  if(!match) {
794
794
  return undefined
795
795
  }