@reclaimprotocol/agent 0.1.12 → 0.1.13
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/lib/mcp/tools/agent/shared.js +30 -1
- package/lib/mcp/tools/authenticate/decrypt-key.js +1 -1
- package/lib/old/tools/js-sdk-integration-guide.js +7 -121
- package/lib/old/tools/link-app.js +4 -3
- package/lib/old/tools/publish.js +16 -21
- package/lib/old/tools/scaffold-demo-server.js +31 -179
- package/lib/old/tools/verify-sdk-setup.js +7 -3
- package/lib/proof/attestor.js +2 -2
- package/lib/provider/draft.js +51 -1
- package/package.json +3 -2
- package/samples/demo-server/.env.example +12 -0
- package/samples/demo-server/README.md +24 -0
- package/samples/demo-server/package.json +13 -0
- package/samples/demo-server/server.js +156 -0
- package/skill/js-sdk-integration.md +119 -0
- package/src/cdp/launcher.ts +33 -33
- package/src/mcp/api/tools.ts +3 -4
- package/src/mcp/remap_errors.ts +1 -1
- package/src/mcp/tools/agent/shared.ts +35 -1
- package/src/mcp/tools/authenticate/authenticate.ts +1 -3
- package/src/mcp/tools/authenticate/decrypt-key.ts +1 -4
- package/src/old/tools/issue-app-credentials.ts +3 -1
- package/src/old/tools/js-sdk-integration-guide.ts +12 -121
- package/src/old/tools/link-app.ts +9 -4
- package/src/old/tools/publish.ts +16 -26
- package/src/old/tools/scaffold-demo-server.ts +40 -185
- package/src/old/tools/verify-sdk-setup.ts +7 -3
- package/src/proof/attestor.ts +7 -3
- package/src/provider/draft.ts +59 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 =
|
|
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 {
|
|
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
|
|
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 =
|
|
65
|
+
const signature = signEip191(message, appSecret);
|
|
65
66
|
await client.linkAppToAccount(appId, signature, nonce);
|
|
66
67
|
return {
|
|
67
68
|
linked: true,
|
package/lib/old/tools/publish.js
CHANGED
|
@@ -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
|
|
108
|
-
'
|
|
109
|
-
'together with `provider` ONLY when adding a version
|
|
110
|
-
'EXISTING provider (`providerId` set) —
|
|
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
|
-
//
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if (
|
|
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
|
-
/**
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* `
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
197
|
-
'
|
|
198
|
-
'
|
|
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'),
|
|
232
|
-
writeFileSync(join(dir, '.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
|
|
104
|
-
'
|
|
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
|
|
117
|
+
(linked
|
|
118
|
+
? ''
|
|
119
|
+
: ' — run link_app_to_account to lift the sandbox limit.'),
|
|
116
120
|
});
|
|
117
121
|
}
|
|
118
122
|
catch (err) {
|
package/lib/proof/attestor.js
CHANGED
|
@@ -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
|
|
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
|
|
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. */
|
package/lib/provider/draft.js
CHANGED
|
@@ -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())
|
|
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;
|