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