@reclaimprotocol/agent 0.1.12 → 0.1.14

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.
@@ -1,131 +1,22 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { fileURLToPath } from 'node:url'
1
3
  import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
2
4
 
3
5
  /**
4
6
  * Verified against `@reclaimprotocol/js-sdk` v5.8.1 source
5
7
  * (`reclaim-js-sdk/src/Reclaim.ts`) — NOT copied unchecked from the archived
6
8
  * `reclaim-mcp-server`'s guide, which predates several of these signatures.
7
- * Corrections that matter most, all called out inline below:
8
- * - `getRequestUrl()` and `ReclaimProofRequest.fromJsonString()` are ASYNC.
9
- * - `setContext` is current; `addContext` is deprecated (calls setContext).
10
- * - `verifyProof`'s second arg (`{ providerId, providerVersion? }`) is
11
- * REQUIRED, not optional.
12
- * - The verified result's flag is `isVerified` (camelCase), not
13
- * `is_verified`.
14
- * - A raw webhook body is TEXT — it must be JSON-parsed (and URL-decoded if
15
- * form-encoded) into a `Proof`/`Proof[]` before calling `verifyProof`.
9
+ * The prose lives in `skill/js-sdk-integration.md` so it can be edited as
10
+ * markdown without touching code — same pattern as `provider/injection-doc.ts`.
11
+ * The path resolves the same from `src/old/tools` (dev) and `lib/old/tools`
12
+ * (published): both are three levels below the package root.
16
13
  */
17
- const GUIDE = `# @reclaimprotocol/js-sdk — backend integration guide
18
-
19
- Keep \`appSecret\` on your BACKEND only. Everything below runs server-side;
20
- the browser only ever sees the \`requestUrl\`.
21
-
22
- ## Testing locally, without deploying anything
23
-
24
- - **providerId \`"example"\`**: a canonical built-in smoke-test provider —
25
- no need to author/publish your own provider just to confirm the SDK
26
- plumbing works end-to-end. Swap in your real providerId once this passes.
27
- - **ngrok for the callback URL**: \`setAppCallbackUrl\` needs a URL Reclaim's
28
- servers can reach over the public internet — \`localhost\` will never
29
- receive the callback. Run \`ngrok http 3000\` (or your port) alongside your
30
- local server and set \`PUBLIC_URL\` to the \`https://<id>.ngrok-free.app\`
31
- URL it prints. Without this, only the frontend-side flow (opening
32
- \`requestUrl\` and polling/reading the result client-side) will work — the
33
- async webhook never lands.
34
- - \`scaffold_reclaim_demo_server\` defaults \`providerId\` to \`"example"\` and
35
- its README calls out the ngrok step — use it to get a working local loop
36
- before wiring in a real provider.
37
-
38
- ## 1. Install
39
-
40
- \`\`\`bash
41
- npm install @reclaimprotocol/js-sdk
42
- \`\`\`
43
-
44
- ## 2. Create a proof request (backend)
45
-
46
- \`\`\`js
47
- import { ReclaimProofRequest } from "@reclaimprotocol/js-sdk";
48
-
49
- const reclaimProofRequest = await ReclaimProofRequest.init(
50
- process.env.RECLAIM_APP_ID,
51
- process.env.RECLAIM_APP_SECRET,
52
- providerId,
53
- {
54
- // acceptAiProviders: true, // allow AI-verified providers
55
- // useAppClip: true, // mobile app-clip flow instead of a browser popup
56
- // providerVersion: "1.0.0", // pin a specific provider version
57
- },
58
- );
59
-
60
- // Deliver the verified proof back to your server asynchronously instead of
61
- // (or in addition to) reading it from the frontend callback:
62
- reclaimProofRequest.setAppCallbackUrl(
63
- \`\${process.env.PUBLIC_URL}/webhooks/reclaim\`,
64
- true, // jsonProofResponse: send the callback body as JSON, not form-encoded
65
- );
66
-
67
- // Optional: attach your own correlation id / metadata, retrievable later.
68
- reclaimProofRequest.setContext(sessionId, JSON.stringify({ createdAt: Date.now() }));
69
-
70
- // IMPORTANT: getRequestUrl() is ASYNC — await it.
71
- const requestUrl = await reclaimProofRequest.getRequestUrl();
72
-
73
- // Hand the requestUrl to the frontend to open in a new tab/window/webview.
74
- // If you need to rehydrate this request object later (e.g. in the webhook
75
- // handler, in a different process), persist reclaimProofRequest.toJsonString()
76
- // (sync) and rehydrate with:
77
- // const req = await ReclaimProofRequest.fromJsonString(json); // ASYNC
78
- \`\`\`
79
-
80
- ## 3. Frontend: open the request URL
81
-
82
- Fetch \`requestUrl\` from your backend, then open it (popup/new tab/QR code) —
83
- never construct it in the browser, since that would require the appSecret
84
- client-side. Trigger the fetch+open from a real user click (not a page-load
85
- effect) or some browsers block the popup.
86
-
87
- ## 4. Verify the callback (backend webhook)
88
-
89
- \`\`\`js
90
- import { verifyProof } from "@reclaimprotocol/js-sdk";
91
-
92
- app.post("/webhooks/reclaim", express.json(), async (req, res) => {
93
- // req.body is already a parsed object here because jsonProofResponse was
94
- // true above AND express.json() parsed it. If you did NOT set
95
- // jsonProofResponse (or aren't using express.json()), the body arrives as
96
- // form-encoded/raw text and must be JSON.parse()'d (and decodeURIComponent'd
97
- // if form-encoded) into a Proof / Proof[] before calling verifyProof.
98
- const proof = req.body;
99
-
100
- // The second argument is REQUIRED — at minimum providerId.
101
- const result = await verifyProof(proof, {
102
- providerId,
103
- // providerVersion, // optional, pin if you pinned it in init()
104
- });
105
-
106
- if (!result.isVerified) { // NOTE: isVerified, not is_verified
107
- console.error("Proof verification failed:", result.error);
108
- return res.status(400).json({ verified: false, error: result.error });
109
- }
110
-
111
- console.log("Verified data:", result.data, result.publicData);
112
- res.status(200).json({ verified: true });
113
- });
114
- \`\`\`
115
-
116
- ## Gotchas
117
-
118
- - \`getRequestUrl()\` and \`ReclaimProofRequest.fromJsonString()\` are async —
119
- a missing \`await\` silently hands you a Promise instead of a string/instance.
120
- - \`verifyProof\`'s config arg is mandatory; omitting \`providerId\` throws.
121
- - Prefer \`setContext\` over the deprecated \`addContext\` (same signature).
122
- - \`ReclaimProofRequest.init\` needs network access to the Reclaim backend at
123
- call time — don't call it at module load / cold-start if your platform pools
124
- processes long-term; call it per-request instead.
125
- - Use \`check_app_status\` (this MCP server) to confirm your app is linked
126
- and not still sandbox-limited before debugging "verification never lands"
127
- issues.
128
- `
14
+ const GUIDE = readFileSync(
15
+ fileURLToPath(
16
+ new URL('../../../skill/js-sdk-integration.md', import.meta.url),
17
+ ),
18
+ 'utf8',
19
+ ).trim()
129
20
 
130
21
  export function getJsSdkIntegrationGuideTool(): RegisteredTool {
131
22
  return defineTool<Record<string, never>>(
@@ -1,10 +1,14 @@
1
- import { Wallet } from 'ethers'
1
+ import { signEip191 } from '@reclaimprotocol/client'
2
2
  import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
3
3
  import type { ReclaimOldClient } from '../client.ts'
4
4
 
5
5
  /** Byte-for-byte the same format as the backend's `buildLinkMessage` in
6
6
  * `application.controller.ts` — MUST stay in lockstep with it. */
7
- function buildLinkMessage(appId: string, userId: string, nonce: string): string {
7
+ function buildLinkMessage(
8
+ appId: string,
9
+ userId: string,
10
+ nonce: string,
11
+ ): string {
8
12
  return `Link ${appId} to account ${userId} | nonce: ${nonce}`
9
13
  }
10
14
 
@@ -15,7 +19,8 @@ async function resolveUserId(client: ReclaimOldClient): Promise<string> {
15
19
  const identity = client.getIdentity()
16
20
  if(!identity) {
17
21
  throw new Error(
18
- 'Not authenticated against old devtools. Call reclaim_authenticate first.',
22
+ 'Not authenticated against old devtools. Call reclaim_authenticate ' +
23
+ 'first.',
19
24
  )
20
25
  }
21
26
 
@@ -76,7 +81,7 @@ export function linkAppTool(client: ReclaimOldClient): RegisteredTool {
76
81
  const userId = await resolveUserId(client)
77
82
  const { nonce } = await client.getLinkNonce()
78
83
  const message = buildLinkMessage(appId, userId, nonce)
79
- const signature = await new Wallet(appSecret).signMessage(message)
84
+ const signature = signEip191(message, appSecret)
80
85
  await client.linkAppToAccount(appId, signature, nonce)
81
86
  return {
82
87
  linked: true,
@@ -147,10 +147,10 @@ export function createVersionFromCaptureTool(
147
147
  description:
148
148
  'Multiple proven drafts → one multi-request version (each ' +
149
149
  'becomes a parallel `requestData` entry, all verified ' +
150
- 'together). Each item has the same shape as `provider`. Pass ' +
151
- 'this OR `provider`, not both. May be omitted (or empty) ' +
152
- 'together with `provider` ONLY when adding a version to an ' +
153
- 'EXISTING provider (`providerId` set) — this publishes with ' +
150
+ 'together). Each item has the same shape as `provider` (if ' +
151
+ 'both are passed, they are combined). May be omitted (or ' +
152
+ 'empty) together with `provider` ONLY when adding a version ' +
153
+ 'to an EXISTING provider (`providerId` set) — publishes with ' +
154
154
  'zero `requestData` entries, for a pure customInjection/' +
155
155
  'HAWKEYE provider that relies entirely on ' +
156
156
  '`allowedInjectedRequestData` for validation instead of a ' +
@@ -306,26 +306,18 @@ export function createVersionFromCaptureTool(
306
306
  },
307
307
  },
308
308
  async(args) => {
309
- // Exactly one of `provider` / `providers` — normalize to an array so a
310
- // multi-request capture publishes every request, not just the first.
311
- // Omitting both is allowed too, but ONLY when adding a version to an
312
- // EXISTING provider (`providerId` set): that publishes with zero
313
- // `requestData` entries, which only makes sense for a pure
314
- // customInjection/HAWKEYE provider whose claims are validated entirely
315
- // via `allowedInjectedRequestData` instead of a static requestData stub.
316
- // Registering a brand-new provider (no `providerId`) still needs at
317
- // least one draft — its name comes from the first one.
318
- const hasMany = Array.isArray(args.providers) && args.providers.length > 0
319
- const hasOne = !!args.provider
320
- if(hasMany && hasOne) {
321
- throw new Boom(
322
- 'Pass at most one of `provider` (a single draft) or `providers` ' +
323
- '(an array of 1+ drafts) — not both.',
324
- { statusCode: 400 },
325
- )
326
- }
327
-
328
- if(!hasMany && !hasOne && !args.providerId) {
309
+ // `provider` / `providers` are just two spellings of the same input —
310
+ // fold them into one array. Zero drafts is allowed ONLY when adding a
311
+ // version to an EXISTING provider (`providerId` set): that publishes
312
+ // with zero `requestData` entries, for a pure customInjection/HAWKEYE
313
+ // provider whose claims are validated entirely via
314
+ // `allowedInjectedRequestData`. Registering a brand-new provider still
315
+ // needs at least one draft — its name comes from the first one.
316
+ const providers = [
317
+ ...(args.provider ? [args.provider] : []),
318
+ ...(args.providers ?? []),
319
+ ]
320
+ if(!providers.length && !args.providerId) {
329
321
  throw new Boom(
330
322
  'Pass at least one of `provider`/`providers` when registering a ' +
331
323
  'new provider (its name comes from the first draft). Omitting ' +
@@ -335,8 +327,6 @@ export function createVersionFromCaptureTool(
335
327
  )
336
328
  }
337
329
 
338
- const providers = hasMany ? args.providers! : (hasOne ? [args.provider!] : [])
339
-
340
330
  const opts: OldProviderOpts = {
341
331
  initialUrl: args.initialUrl,
342
332
  description: args.description,
@@ -1,190 +1,42 @@
1
- import { mkdirSync, writeFileSync } from 'node:fs'
1
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import { isAbsolute, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
3
4
  import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
4
5
  import { findProjectRoot } from '../../paths.ts'
5
6
 
6
- /** Corrected against `@reclaimprotocol/js-sdk` v5.8.1 (`Reclaim.ts`): awaits
7
- * `getRequestUrl()`, uses `setContext` (not deprecated `addContext`), JSON-
8
- * parses the raw webhook body before `verifyProof`, passes the required
9
- * `{ providerId, providerVersion }` config, and reads `result.isVerified`
10
- * (not `is_verified`). `providerVersion` is left unpinned (`undefined`) by
11
- * default — the built-in "example" providerId isn't versioned, so pinning
7
+ /** The demo's source files live in `samples/demo-server/` so they can be
8
+ * edited (and even run) as plain files without touching code — same pattern
9
+ * as `skill/*.md`. The path resolves the same from `src/old/tools` (dev) and
10
+ * `lib/old/tools` (published): both are three levels below the package root.
11
+ *
12
+ * `server.js` there is corrected against `@reclaimprotocol/js-sdk` v5.8.1
13
+ * (`Reclaim.ts`): awaits `getRequestUrl()`, uses `setContext` (not deprecated
14
+ * `addContext`), JSON-parses the raw webhook body before `verifyProof`,
15
+ * passes the required `{ providerId, providerVersion }` config, and reads
16
+ * `result.isVerified` (not `is_verified`). `providerVersion` is left unpinned
17
+ * by default — the built-in "example" providerId isn't versioned, so pinning
12
18
  * one is only meaningful once you swap in your own provider. */
13
- function serverJs(providerId: string, providerVersion: string | undefined): string {
14
- return `import express from "express";
15
- import "dotenv/config";
16
- import { ReclaimProofRequest, verifyProof } from "@reclaimprotocol/js-sdk";
17
- import { randomUUID } from "node:crypto";
19
+ const SAMPLE_DIR = fileURLToPath(
20
+ new URL('../../../samples/demo-server', import.meta.url),
21
+ )
18
22
 
19
- const app = express();
20
- const port = process.env.PORT || 3000;
21
- const providerId = ${JSON.stringify(providerId)};
22
- // Left unpinned by default — set this if you need to pin a specific version.
23
- const providerVersion = ${providerVersion ? JSON.stringify(providerVersion) : 'undefined'};
24
-
25
- app.set("trust proxy", true);
26
- app.use(express.json({ limit: "2mb" }));
27
-
28
- app.get("/health", (_req, res) => {
29
- res.json({ ok: true });
30
- });
31
-
32
- app.get("/verify", (_req, res) => {
33
- res.type("html").send(\`<!doctype html>
34
- <html lang="en">
35
- <head>
36
- <meta charset="utf-8" />
37
- <meta name="viewport" content="width=device-width, initial-scale=1" />
38
- <title>Reclaim demo verification</title>
39
- <style>
40
- body { font-family: system-ui, sans-serif; max-width: 680px; margin: 64px auto; padding: 0 20px; line-height: 1.5; }
41
- code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; }
42
- button { background: #111827; border: 0; border-radius: 8px; color: white; cursor: pointer; font: inherit; padding: 12px 18px; }
43
- button:disabled { cursor: wait; opacity: 0.7; }
44
- pre { background: #f3f4f6; border-radius: 8px; overflow: auto; padding: 12px; }
45
- </style>
46
- </head>
47
- <body>
48
- <h1>Reclaim demo verification</h1>
49
- <p>Uses provider <code>\${providerId}</code>.</p>
50
- <button id="start">Start verification</button>
51
- <pre id="status">Ready.</pre>
52
- <p>Webhook endpoint: <code>/webhooks/reclaim</code></p>
53
- <script>
54
- const button = document.getElementById("start");
55
- const status = document.getElementById("status");
56
- button.addEventListener("click", async () => {
57
- button.disabled = true;
58
- status.textContent = "Creating verification request...";
59
- try {
60
- const response = await fetch("/api/reclaim/request", { method: "POST" });
61
- if (!response.ok) throw new Error(await response.text());
62
- const { requestUrl } = await response.json();
63
- status.textContent = "Opening Reclaim verification...";
64
- window.open(requestUrl, "_blank", "noopener,noreferrer");
65
- status.textContent = "Verification opened. Complete it in the Reclaim window.";
66
- } catch (error) {
67
- status.textContent = error instanceof Error ? error.message : String(error);
68
- button.disabled = false;
69
- }
70
- });
71
- </script>
72
- </body>
73
- </html>\`);
74
- });
75
-
76
- app.post("/api/reclaim/request", async (req, res, next) => {
77
- try {
78
- const appId = process.env.RECLAIM_APP_ID;
79
- const appSecret = process.env.RECLAIM_APP_SECRET;
80
- if (!appId || !appSecret) {
81
- return res.status(500).json({ error: "Missing RECLAIM_APP_ID or RECLAIM_APP_SECRET" });
82
- }
83
-
84
- const origin = process.env.PUBLIC_URL || \`\${req.protocol}://\${req.get("host")}\`;
85
- const reclaimProofRequest = await ReclaimProofRequest.init(appId, appSecret, providerId, {
86
- providerVersion,
87
- });
88
- reclaimProofRequest.setAppCallbackUrl(\`\${origin}/webhooks/reclaim\`, true);
89
- reclaimProofRequest.setContext(
90
- randomUUID(),
91
- JSON.stringify({ createdAt: new Date().toISOString() }),
92
- );
93
-
94
- // getRequestUrl() is ASYNC in the current SDK — must be awaited.
95
- const requestUrl = await reclaimProofRequest.getRequestUrl();
96
-
97
- res.json({
98
- request: reclaimProofRequest.toJsonString(),
99
- requestUrl,
100
- });
101
- } catch (error) {
102
- next(error);
103
- }
104
- });
105
-
106
- app.post("/webhooks/reclaim", async (req, res, next) => {
107
- try {
108
- // With jsonProofResponse: true + express.json() above, req.body is
109
- // already a parsed Proof/Proof[]. Without either of those, the raw body
110
- // is text and must be JSON.parse()'d (and URL-decoded if form-encoded)
111
- // before calling verifyProof.
112
- const proof = req.body;
113
-
114
- // The second argument is REQUIRED — must include providerId.
115
- const result = await verifyProof(proof, { providerId, providerVersion });
116
-
117
- console.log("Received Reclaim verification:");
118
- console.log(JSON.stringify({ isVerified: result.isVerified, data: result.data, error: result.error }, null, 2));
119
-
120
- res.status(200).json({ received: true, verified: result.isVerified });
121
- } catch (error) {
122
- next(error);
123
- }
124
- });
125
-
126
- app.use((err, _req, res, _next) => {
127
- console.error(err);
128
- res.status(500).json({ error: err instanceof Error ? err.message : "Internal server error" });
129
- });
130
-
131
- app.listen(port, () => {
132
- console.log(\`Reclaim demo server listening on http://localhost:\${port}\`);
133
- console.log(\`Verify page: http://localhost:\${port}/verify\`);
134
- console.log(\`Webhook endpoint: http://localhost:\${port}/webhooks/reclaim\`);
135
- if (!process.env.PUBLIC_URL) {
136
- console.log("Set PUBLIC_URL (e.g. an ngrok URL) so the webhook is reachable from Reclaim's servers.");
137
- }
138
- });
139
- `
23
+ function readSample(name: string): string {
24
+ return readFileSync(join(SAMPLE_DIR, name), 'utf8')
140
25
  }
141
26
 
142
- const PACKAGE_JSON = (name: string) => JSON.stringify(
143
- {
144
- name,
145
- private: true,
146
- type: 'module',
147
- scripts: { start: 'node server.js' },
148
- dependencies: {
149
- '@reclaimprotocol/js-sdk': '^5.8.1',
150
- dotenv: '^16.4.5',
151
- express: '^4.21.2',
152
- },
153
- },
154
- null,
155
- 2,
156
- ) + '\n'
157
-
158
- const ENV_EXAMPLE = `RECLAIM_APP_ID=
159
- RECLAIM_APP_SECRET=
160
- PORT=3000
161
- # REQUIRED for the webhook callback to land — Reclaim's servers can't reach
162
- # localhost. Run: ngrok http 3000 (in a separate terminal) and paste the
163
- # https://<id>.ngrok-free.app URL it prints here. Without this, only the
164
- # frontend-side flow works (opening requestUrl) — the webhook never fires.
165
- PUBLIC_URL=
166
- `
167
-
168
- const README = `# Reclaim demo server
169
-
170
- 1. \`npm install\`
171
- 2. Fill in \`.env\` (copy from \`.env.example\`) — get \`RECLAIM_APP_ID\`/
172
- \`RECLAIM_APP_SECRET\` from the \`issue_app_credentials\` MCP tool, or your
173
- existing app.
174
- 3. In a separate terminal, run \`ngrok http 3000\` (install from
175
- https://ngrok.com if needed) and copy the \`https://<id>.ngrok-free.app\`
176
- URL it prints into \`PUBLIC_URL\` in \`.env\`. This is REQUIRED — Reclaim's
177
- servers must reach your webhook over the public internet, and
178
- \`localhost\` is not reachable from there.
179
- 4. \`npm start\`, then open http://localhost:3000/verify and click "Start
180
- verification".
181
- 5. Watch the server logs for the \`/webhooks/reclaim\` payload once you
182
- complete verification.
183
-
184
- This demo defaults to providerId \`"example"\` — a built-in smoke-test
185
- provider, so you can confirm the whole loop (request → open → callback)
186
- works before wiring in a real provider.
187
- `
27
+ /** `server.js` with its `__PROVIDER_ID__` / `__PROVIDER_VERSION__` tokens
28
+ * substituted with JS literals. */
29
+ function serverJs(
30
+ providerId: string,
31
+ providerVersion: string | undefined,
32
+ ): string {
33
+ return readSample('server.js')
34
+ .replace('__PROVIDER_ID__', JSON.stringify(providerId))
35
+ .replace(
36
+ '__PROVIDER_VERSION__',
37
+ providerVersion ? JSON.stringify(providerVersion) : 'undefined',
38
+ )
39
+ }
188
40
 
189
41
  /**
190
42
  * Scaffold a small, runnable Express demo that exercises the full
@@ -209,9 +61,12 @@ export function scaffoldDemoServerTool(): RegisteredTool {
209
61
  'verifies the async webhook callback, using the CURRENT ' +
210
62
  '@reclaimprotocol/js-sdk API. Defaults `providerId` to the built-in ' +
211
63
  '"example" smoke-test provider so you can confirm the whole loop ' +
212
- 'without authoring one first, and requires ngrok (or similar) to ' +
213
- 'expose the local server so the webhook callback can reach it — ' +
214
- 'the generated README walks through that step. Use ' +
64
+ 'without authoring one first. The webhook callback needs a URL ' +
65
+ 'Reclaim\'s servers can reach — localhost never works — via EITHER ' +
66
+ 'a tunnel (ngrok or similar) OR quickly deploying this demo to a ' +
67
+ 'dev/staging server the user already has; ASK THE USER which they\'d ' +
68
+ 'rather use before assuming ngrok, since either sets `PUBLIC_URL` in ' +
69
+ '.env (the generated README covers both). Use ' +
215
70
  'get_js_sdk_integration_guide for the annotated write-up of the ' +
216
71
  'same flow.',
217
72
  inputSchema: {
@@ -249,9 +104,9 @@ export function scaffoldDemoServerTool(): RegisteredTool {
249
104
 
250
105
  const id = providerId ?? DEFAULT_PROVIDER_ID
251
106
  writeFileSync(join(dir, 'server.js'), serverJs(id, providerVersion))
252
- writeFileSync(join(dir, 'package.json'), PACKAGE_JSON('reclaim-demo-server'))
253
- writeFileSync(join(dir, '.env.example'), ENV_EXAMPLE)
254
- writeFileSync(join(dir, 'README.md'), README)
107
+ writeFileSync(join(dir, 'package.json'), readSample('package.json'))
108
+ writeFileSync(join(dir, '.env.example'), readSample('.env.example'))
109
+ writeFileSync(join(dir, 'README.md'), readSample('README.md'))
255
110
 
256
111
  return {
257
112
  dir,
@@ -121,8 +121,10 @@ export function verifySdkSetupTool(client: ReclaimOldClient): RegisteredTool {
121
121
  'base for setAppCallbackUrl.'
122
122
  : 'PUBLIC_URL not set — fine if you only read the proof from the ' +
123
123
  'frontend flow, but the async webhook callback needs a ' +
124
- 'publicly-reachable URL (e.g. from `ngrok http <port>`) or it ' +
125
- 'will never fire.',
124
+ 'publicly-reachable URL or it will never fire. Get one via a ' +
125
+ 'tunnel (e.g. `ngrok http <port>`) or by deploying to a ' +
126
+ 'dev/staging server you already have — ask the user which they ' +
127
+ 'prefer.',
126
128
  })
127
129
 
128
130
  if(appId) {
@@ -139,7 +141,9 @@ export function verifySdkSetupTool(client: ReclaimOldClient): RegisteredTool {
139
141
  detail:
140
142
  `isLinked=${linked}, sandboxMode=${status?.sandboxMode}, ` +
141
143
  `available=${status?.available}` +
142
- (linked ? '' : ' — run link_app_to_account to lift the sandbox limit.'),
144
+ (linked
145
+ ? ''
146
+ : ' — run link_app_to_account to lift the sandbox limit.'),
143
147
  })
144
148
  } catch(err) {
145
149
  checks.push({
@@ -8,7 +8,8 @@
8
8
 
9
9
  import { bytesToHex } from '@noble/hashes/utils.js'
10
10
  import {
11
- createClaimOnAttestor, type Logger,
11
+ createClaimOnAttestor,
12
+ type Logger
12
13
  } from '@reclaimprotocol/attestor-core'
13
14
  // Side-effect: make external domhandler nodes DOM-like so attestor-core's
14
15
  // bundled `xpath` accepts them (see patch-domhandler.ts). Must load before any
@@ -19,7 +20,10 @@ import { resolveEthKey } from '../mcp/tools/authenticate/eth-key.ts'
19
20
  import type { ReclaimProvider } from '../provider/schema.ts'
20
21
  import { installResponseDump } from './debug-response-dump.ts'
21
22
  import {
22
- buildHttpParams, ensureAttestorCrypto, parseClaim, resolveAttestorUrl,
23
+ buildHttpParams,
24
+ ensureAttestorCrypto,
25
+ parseClaim,
26
+ resolveAttestorUrl
23
27
  } from './proof-core.ts'
24
28
  import { ensureZkFiles } from './zk-files.ts'
25
29
 
@@ -129,7 +133,7 @@ const LOG_TAIL_LINES = 80
129
133
  function makeCaptureLogger(): {
130
134
  logger: unknown
131
135
  entries: AttestorLogEntry[]
132
- } {
136
+ } {
133
137
  const entries: AttestorLogEntry[] = []
134
138
  const make = (baseCtx: Record<string, unknown>): unknown => {
135
139
  const log =
@@ -93,6 +93,16 @@ const STRIP_REQUEST_HEADERS = new Set([
93
93
  'host',
94
94
  'content-length',
95
95
  'transfer-encoding',
96
+ // Per-session anti-CSRF tokens. Not caught by splitSecrets (no `-token`
97
+ // suffix), so without this they baked in as PUBLIC headers and went stale at
98
+ // verify time — the attestor replayed one user's captured CSRF for everyone,
99
+ // which the origin rejects. A CSRF token is never portable; drop it.
100
+ 'csrf',
101
+ 'x-csrf',
102
+ 'xsrf',
103
+ 'x-xsrf',
104
+ 'x-csrf-token',
105
+ 'xsrf-token',
96
106
  ])
97
107
 
98
108
  /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
@@ -101,6 +111,19 @@ function isPseudoHeader(name: string): boolean {
101
111
  return name.startsWith(':')
102
112
  }
103
113
 
114
+ /** A header VALUE that is a per-session/request token — a UUID or a long opaque
115
+ * hex/base64 blob. Such a value is captured-user-specific: baked into the
116
+ * provider it goes stale and the origin rejects the replay for everyone else.
117
+ * Stable identifiers (a tenant/space id like `1237`, a timezone, client hints)
118
+ * are short/structured and don't match, so they survive. */
119
+ function isVolatileHeaderValue(value: string): boolean {
120
+ const v = value.trim()
121
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
122
+ .test(v)
123
+ || /^[0-9a-f]{16,}$/i.test(v)
124
+ || /^[A-Za-z0-9_-]{32,}$/.test(v)
125
+ }
126
+
104
127
  /** Drop the headers the attestor manages or that would 304 at proof time
105
128
  * (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
106
129
  * attestor's replay looks like the browser's request. */
@@ -109,7 +132,11 @@ function sanitizeRequestHeaders(
109
132
  ): Record<string, string> {
110
133
  const out: Record<string, string> = {}
111
134
  for(const [k, v] of Object.entries(headers)) {
112
- if(STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
135
+ if(
136
+ STRIP_REQUEST_HEADERS.has(k.toLowerCase())
137
+ || isPseudoHeader(k)
138
+ || isVolatileHeaderValue(v)
139
+ ) {
113
140
  continue
114
141
  }
115
142
 
@@ -134,6 +161,37 @@ export function assertPortableMatches(
134
161
  redactions: ResponseRedaction[],
135
162
  targets: DraftTarget[],
136
163
  ): void {
164
+ // A baked email LOCAL-PART ("jane.doe@…") pins the provider to one user
165
+ // even when only the domain is captured as a param — the classic result of
166
+ // proving an email's domain substring. Reject it regardless of the declared
167
+ // targets (the local part is usually not itself a target, so the value scan
168
+ // below wouldn't catch it). Strip params + named groups + regex escapes
169
+ // first, then look for `localpart@` as a surviving literal.
170
+ const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
171
+ const deregex = (s: string): string => s
172
+ .replace(/\{\{[^}]*\}\}/g, '')
173
+ .replace(/\(\?<[^>]*>[^)]*\)/g, '')
174
+ .replace(/\\(.)/g, '$1')
175
+ const scanEmail = (text: string, where: string): void => {
176
+ if(EMAIL_LOCAL_BAKED.test(deregex(text))) {
177
+ throw new Error(
178
+ `drafted ${where} bakes a per-user email local-part as a literal — `
179
+ + 'the provider would be pinned to this user. Capture the WHOLE email '
180
+ + 'as a {{param}} (optionally OPRF-hashed), not just its domain.',
181
+ )
182
+ }
183
+ }
184
+
185
+ for(const [i, m] of matches.entries()) {
186
+ scanEmail(m.value, `responseMatch for "${targets[i]?.name ?? '?'}"`)
187
+ }
188
+
189
+ for(const r of redactions) {
190
+ if(r.regex) {
191
+ scanEmail(r.regex, 'responseRedaction regex')
192
+ }
193
+ }
194
+
137
195
  // Guard only values long enough to be plausibly user-specific; shorter runs
138
196
  // (single digits, 2-3 char tokens) collide with ordinary structural bytes.
139
197
  const MIN_ALNUM = 4