@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
|
@@ -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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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 =
|
|
84
|
+
const signature = signEip191(message, appSecret)
|
|
80
85
|
await client.linkAppToAccount(appId, signature, nonce)
|
|
81
86
|
return {
|
|
82
87
|
linked: true,
|
package/src/old/tools/publish.ts
CHANGED
|
@@ -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
|
|
151
|
-
'
|
|
152
|
-
'together with `provider` ONLY when adding a version
|
|
153
|
-
'EXISTING provider (`providerId` set) —
|
|
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
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
if(
|
|
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
|
-
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
213
|
-
'
|
|
214
|
-
'
|
|
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'),
|
|
253
|
-
writeFileSync(join(dir, '.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
|
|
125
|
-
'
|
|
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
|
|
144
|
+
(linked
|
|
145
|
+
? ''
|
|
146
|
+
: ' — run link_app_to_account to lift the sandbox limit.'),
|
|
143
147
|
})
|
|
144
148
|
} catch(err) {
|
|
145
149
|
checks.push({
|
package/src/proof/attestor.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { bytesToHex } from '@noble/hashes/utils.js'
|
|
10
10
|
import {
|
|
11
|
-
createClaimOnAttestor,
|
|
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,
|
|
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 =
|
package/src/provider/draft.ts
CHANGED
|
@@ -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(
|
|
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
|