@reclaimprotocol/agent 0.1.10 → 0.1.12
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/authoring/tools.js +67 -0
- package/lib/mcp/tools/agent/find.js +41 -16
- package/lib/mcp/tools/agent/propose.js +4 -4
- package/lib/old/client.d.ts +31 -5
- package/lib/old/client.js +33 -10
- package/lib/old/to-register.d.ts +2 -0
- package/lib/old/to-register.js +9 -5
- package/lib/old/tools/build.js +12 -2
- package/lib/old/tools/check-app-status.d.ts +8 -0
- package/lib/old/tools/check-app-status.js +27 -0
- package/lib/old/tools/get-provider-info.d.ts +10 -7
- package/lib/old/tools/get-provider-info.js +21 -15
- package/lib/old/tools/issue-app-credentials.d.ts +11 -0
- package/lib/old/tools/issue-app-credentials.js +35 -0
- package/lib/old/tools/js-sdk-integration-guide.d.ts +2 -0
- package/lib/old/tools/js-sdk-integration-guide.js +141 -0
- package/lib/old/tools/link-app.d.ts +10 -0
- package/lib/old/tools/link-app.js +72 -0
- package/lib/old/tools/publish.js +89 -126
- package/lib/old/tools/scaffold-demo-server.d.ts +2 -0
- package/lib/old/tools/scaffold-demo-server.js +242 -0
- package/lib/old/tools/verify-sdk-setup.d.ts +9 -0
- package/lib/old/tools/verify-sdk-setup.js +128 -0
- package/lib/provider/dom-xpath.js +178 -25
- package/lib/provider/draft.d.ts +6 -0
- package/lib/provider/draft.js +54 -5
- package/lib/provider/matcher.d.ts +4 -0
- package/lib/provider/matcher.js +6 -0
- package/lib/provider/to-version.d.ts +13 -0
- package/package.json +2 -1
- package/src/authoring/tools.ts +76 -0
- package/src/mcp/tools/agent/find.ts +44 -16
- package/src/mcp/tools/agent/propose.ts +4 -4
- package/src/old/client.ts +60 -18
- package/src/old/to-register.ts +11 -5
- package/src/old/tools/build.ts +12 -2
- package/src/old/tools/check-app-status.ts +33 -0
- package/src/old/tools/get-provider-info.ts +21 -15
- package/src/old/tools/issue-app-credentials.ts +42 -0
- package/src/old/tools/js-sdk-integration-guide.ts +147 -0
- package/src/old/tools/link-app.ts +88 -0
- package/src/old/tools/publish.ts +98 -158
- package/src/old/tools/scaffold-demo-server.ts +265 -0
- package/src/old/tools/verify-sdk-setup.ts +158 -0
- package/src/provider/dom-xpath.ts +227 -27
- package/src/provider/draft.ts +59 -5
- package/src/provider/matcher.ts +7 -0
- package/src/provider/to-version.ts +13 -0
- package/src/old/tools/get-version-config.ts +0 -56
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join } from 'node:path';
|
|
3
|
+
import { defineTool } from "../../mcp/server.js";
|
|
4
|
+
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
|
|
11
|
+
* one is only meaningful once you swap in your own provider. */
|
|
12
|
+
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
|
+
`;
|
|
139
|
+
}
|
|
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
|
+
/**
|
|
181
|
+
* Scaffold a small, runnable Express demo that exercises the full
|
|
182
|
+
* @reclaimprotocol/js-sdk flow end-to-end: request a proof (backend), open it
|
|
183
|
+
* (frontend), and verify the async webhook callback (backend). Corrected
|
|
184
|
+
* against the CURRENT SDK API (see js-sdk-integration-guide) rather than
|
|
185
|
+
* older examples that predate several signature changes.
|
|
186
|
+
*/
|
|
187
|
+
const DEFAULT_PROVIDER_ID = 'example';
|
|
188
|
+
export function scaffoldDemoServerTool() {
|
|
189
|
+
return defineTool({
|
|
190
|
+
name: 'scaffold_reclaim_demo_server',
|
|
191
|
+
description: 'Write a small runnable Express demo (server.js, package.json, ' +
|
|
192
|
+
'.env.example, README.md) that requests a Reclaim proof and ' +
|
|
193
|
+
'verifies the async webhook callback, using the CURRENT ' +
|
|
194
|
+
'@reclaimprotocol/js-sdk API. Defaults `providerId` to the built-in ' +
|
|
195
|
+
'"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 ' +
|
|
199
|
+
'get_js_sdk_integration_guide for the annotated write-up of the ' +
|
|
200
|
+
'same flow.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
type: 'object',
|
|
203
|
+
properties: {
|
|
204
|
+
targetDir: {
|
|
205
|
+
type: 'string',
|
|
206
|
+
description: 'Directory to write the demo into (created if missing). ' +
|
|
207
|
+
'Relative paths resolve against the project root.',
|
|
208
|
+
},
|
|
209
|
+
providerId: {
|
|
210
|
+
type: 'string',
|
|
211
|
+
description: 'Provider id the demo requests a proof for (default ' +
|
|
212
|
+
'"example", a built-in smoke-test provider — good for ' +
|
|
213
|
+
'confirming the flow works before wiring in a real one).',
|
|
214
|
+
},
|
|
215
|
+
providerVersion: {
|
|
216
|
+
type: 'string',
|
|
217
|
+
description: 'Provider version to pin (default: unpinned — "example" ' +
|
|
218
|
+
'isn\'t versioned, so only set this once you swap in your ' +
|
|
219
|
+
'own provider).',
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
required: ['targetDir'],
|
|
223
|
+
},
|
|
224
|
+
}, async ({ targetDir, providerId, providerVersion }) => {
|
|
225
|
+
const dir = isAbsolute(targetDir)
|
|
226
|
+
? targetDir
|
|
227
|
+
: join(findProjectRoot(process.cwd()), targetDir);
|
|
228
|
+
mkdirSync(dir, { recursive: true });
|
|
229
|
+
const id = providerId ?? DEFAULT_PROVIDER_ID;
|
|
230
|
+
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);
|
|
234
|
+
return {
|
|
235
|
+
dir,
|
|
236
|
+
providerId: id,
|
|
237
|
+
files: ['server.js', 'package.json', '.env.example', 'README.md'],
|
|
238
|
+
next: 'cd into the directory, npm install, copy .env.example to .env ' +
|
|
239
|
+
'and fill it in (including PUBLIC_URL from ngrok), then npm start.',
|
|
240
|
+
};
|
|
241
|
+
});
|
|
242
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type RegisteredTool } from '../../mcp/server.ts';
|
|
2
|
+
import type { ReclaimOldClient } from '../client.ts';
|
|
3
|
+
/**
|
|
4
|
+
* Health-check a project's @reclaimprotocol/js-sdk setup: SDK installed,
|
|
5
|
+
* RECLAIM_APP_ID/RECLAIM_APP_SECRET present (env or .env), and (if an appId
|
|
6
|
+
* is found) whether that app is actually linked/has quota, via the real
|
|
7
|
+
* check_app_status endpoint — not just static file checks.
|
|
8
|
+
*/
|
|
9
|
+
export declare function verifySdkSetupTool(client: ReclaimOldClient): RegisteredTool;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { defineTool } from "../../mcp/server.js";
|
|
4
|
+
import { findProjectRoot } from "../../paths.js";
|
|
5
|
+
function readEnvFile(dir) {
|
|
6
|
+
const path = join(dir, '.env');
|
|
7
|
+
if (!existsSync(path)) {
|
|
8
|
+
return {};
|
|
9
|
+
}
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
12
|
+
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/);
|
|
13
|
+
if (m) {
|
|
14
|
+
out[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Health-check a project's @reclaimprotocol/js-sdk setup: SDK installed,
|
|
21
|
+
* RECLAIM_APP_ID/RECLAIM_APP_SECRET present (env or .env), and (if an appId
|
|
22
|
+
* is found) whether that app is actually linked/has quota, via the real
|
|
23
|
+
* check_app_status endpoint — not just static file checks.
|
|
24
|
+
*/
|
|
25
|
+
export function verifySdkSetupTool(client) {
|
|
26
|
+
return defineTool({
|
|
27
|
+
name: 'verify_reclaim_sdk_setup',
|
|
28
|
+
description: 'Health-check a project\'s @reclaimprotocol/js-sdk integration: SDK ' +
|
|
29
|
+
'installed, RECLAIM_APP_ID/RECLAIM_APP_SECRET present, and (if an ' +
|
|
30
|
+
'appId is found) whether that app is actually linked and has quota ' +
|
|
31
|
+
'left, by calling the real status endpoint.',
|
|
32
|
+
inputSchema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
properties: {
|
|
35
|
+
projectDir: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Project directory (default: detected project root).',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}, async ({ projectDir }) => {
|
|
42
|
+
const dir = projectDir ?? findProjectRoot(process.cwd());
|
|
43
|
+
const checks = [];
|
|
44
|
+
const pkgPath = join(dir, 'package.json');
|
|
45
|
+
let sdkInPkg = false;
|
|
46
|
+
if (existsSync(pkgPath)) {
|
|
47
|
+
try {
|
|
48
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
49
|
+
sdkInPkg = Boolean(pkg.dependencies?.['@reclaimprotocol/js-sdk'] ||
|
|
50
|
+
pkg.devDependencies?.['@reclaimprotocol/js-sdk']);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// malformed package.json — fall through, reported below
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sdkInstalled = existsSync(join(dir, 'node_modules', '@reclaimprotocol', 'js-sdk'));
|
|
57
|
+
checks.push({
|
|
58
|
+
name: 'sdk_dependency',
|
|
59
|
+
ok: sdkInPkg,
|
|
60
|
+
detail: sdkInPkg
|
|
61
|
+
? '@reclaimprotocol/js-sdk is listed in package.json'
|
|
62
|
+
: '@reclaimprotocol/js-sdk is NOT in package.json — run ' +
|
|
63
|
+
'`npm install @reclaimprotocol/js-sdk`.',
|
|
64
|
+
});
|
|
65
|
+
checks.push({
|
|
66
|
+
name: 'sdk_installed',
|
|
67
|
+
ok: sdkInstalled,
|
|
68
|
+
detail: sdkInstalled
|
|
69
|
+
? '@reclaimprotocol/js-sdk found in node_modules'
|
|
70
|
+
: 'Not found in node_modules — run your package manager\'s ' +
|
|
71
|
+
'install command.',
|
|
72
|
+
});
|
|
73
|
+
const env = { ...readEnvFile(dir), ...process.env };
|
|
74
|
+
const appId = env.RECLAIM_APP_ID;
|
|
75
|
+
const appSecret = env.RECLAIM_APP_SECRET;
|
|
76
|
+
checks.push({
|
|
77
|
+
name: 'app_id',
|
|
78
|
+
ok: Boolean(appId),
|
|
79
|
+
detail: appId
|
|
80
|
+
? `RECLAIM_APP_ID set (${appId})`
|
|
81
|
+
: 'RECLAIM_APP_ID missing (env or .env) — run ' +
|
|
82
|
+
'issue_app_credentials or link_app_to_account.',
|
|
83
|
+
});
|
|
84
|
+
checks.push({
|
|
85
|
+
name: 'app_secret',
|
|
86
|
+
ok: Boolean(appSecret),
|
|
87
|
+
detail: appSecret
|
|
88
|
+
? 'RECLAIM_APP_SECRET set'
|
|
89
|
+
: 'RECLAIM_APP_SECRET missing (env or .env).',
|
|
90
|
+
});
|
|
91
|
+
// Informational only — PUBLIC_URL is only needed for the async
|
|
92
|
+
// webhook callback, not the frontend-only flow, so a missing value
|
|
93
|
+
// doesn't fail the overall check.
|
|
94
|
+
const publicUrl = env.PUBLIC_URL;
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'public_url',
|
|
97
|
+
ok: true,
|
|
98
|
+
detail: publicUrl
|
|
99
|
+
? `PUBLIC_URL set (${publicUrl}) — used as the webhook callback ` +
|
|
100
|
+
'base for setAppCallbackUrl.'
|
|
101
|
+
: 'PUBLIC_URL not set — fine if you only read the proof from the ' +
|
|
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.',
|
|
105
|
+
});
|
|
106
|
+
if (appId) {
|
|
107
|
+
try {
|
|
108
|
+
const status = await client.getApplicationStatus(appId);
|
|
109
|
+
const linked = status?.isLinked === true;
|
|
110
|
+
checks.push({
|
|
111
|
+
name: 'app_status',
|
|
112
|
+
ok: status?.available !== false,
|
|
113
|
+
detail: `isLinked=${linked}, sandboxMode=${status?.sandboxMode}, ` +
|
|
114
|
+
`available=${status?.available}` +
|
|
115
|
+
(linked ? '' : ' — run link_app_to_account to lift the sandbox limit.'),
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
checks.push({
|
|
120
|
+
name: 'app_status',
|
|
121
|
+
ok: false,
|
|
122
|
+
detail: `Could not reach status endpoint: ${err instanceof Error ? err.message : String(err)}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { projectDir: dir, checks, ok: checks.every((c) => c.ok) };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -15,6 +15,76 @@ import "../proof/patch-domhandler.js";
|
|
|
15
15
|
/** Identifying attributes stable enough to anchor an xPath on (value-bearing
|
|
16
16
|
* attrs like `content`/`href`/`value` are excluded). */
|
|
17
17
|
const IDENT_ATTRS = ['name', 'property', 'itemprop', 'data-testid', 'rel', 'for'];
|
|
18
|
+
/** Base weight by anchor kind: purpose-built test hooks and ids beat classes,
|
|
19
|
+
* which beat a positional path. Modulated by {@link tokenQuality}. */
|
|
20
|
+
const TYPE_WEIGHT = {
|
|
21
|
+
'ident-attr': 0.95,
|
|
22
|
+
id: 0.9,
|
|
23
|
+
class: 0.6,
|
|
24
|
+
positional: 0.3,
|
|
25
|
+
};
|
|
26
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
27
|
+
/**
|
|
28
|
+
* How likely an identifier token is to survive a future render, by its SHAPE —
|
|
29
|
+
* framework-agnostic, so it catches generated ids/classes from any tool (this
|
|
30
|
+
* year's or next) without naming them. Returns `[0.02, 1]`.
|
|
31
|
+
*
|
|
32
|
+
* Volatile shapes (machine-generated): a `:` (React `useId` `:r1:`, Radix), a
|
|
33
|
+
* UUID, digit-heavy or trailing-digit tokens (`ember123`, `tab-2`), hex/hash
|
|
34
|
+
* runs (`css-1a2b3c`), or opaque wordless strings. Durable shapes (human
|
|
35
|
+
* authored): real word segments, kebab/snake of words (`settings-header`,
|
|
36
|
+
* `primary_email_select`).
|
|
37
|
+
*/
|
|
38
|
+
function tokenQuality(token) {
|
|
39
|
+
const t = token.trim();
|
|
40
|
+
if (!t) {
|
|
41
|
+
return 0.3;
|
|
42
|
+
}
|
|
43
|
+
// Kill signals — these tokens are (almost) always per-render generated.
|
|
44
|
+
if (t.includes(':') || UUID_RE.test(t)) {
|
|
45
|
+
return 0.05;
|
|
46
|
+
}
|
|
47
|
+
let q = 1;
|
|
48
|
+
const len = t.length;
|
|
49
|
+
const digits = (t.match(/\d/g) ?? []).length;
|
|
50
|
+
const digitRatio = digits / len;
|
|
51
|
+
// A trailing digit run after letters is the classic generated-id tail.
|
|
52
|
+
if (/[A-Za-z][-_]?\d+$/.test(t)) {
|
|
53
|
+
q *= 0.3;
|
|
54
|
+
}
|
|
55
|
+
if (digitRatio > 0.3) {
|
|
56
|
+
q *= 0.4;
|
|
57
|
+
}
|
|
58
|
+
else if (digitRatio > 0.15) {
|
|
59
|
+
q *= 0.7;
|
|
60
|
+
}
|
|
61
|
+
// A pure hex/short-hash run (`1a2b3c`, `deadbeef`) — no semantic content.
|
|
62
|
+
if (/^[0-9a-f]{6,}$/i.test(t)) {
|
|
63
|
+
q *= 0.15;
|
|
64
|
+
}
|
|
65
|
+
// A real word — an alpha run ≥3 that ITSELF contains a vowel — is the
|
|
66
|
+
// strongest durability signal; its absence means the token is opaque /
|
|
67
|
+
// hash-like (`css-1a2b3c`, `sc-bdfBwQ`). Checking per-segment matters: a
|
|
68
|
+
// stray vowel elsewhere in a hash must not count as "has a word".
|
|
69
|
+
const hasWord = (t.match(/[A-Za-z]+/g) ?? [])
|
|
70
|
+
.some((run) => run.length >= 3 && /[aeiou]/i.test(run));
|
|
71
|
+
if (!hasWord) {
|
|
72
|
+
q *= 0.3;
|
|
73
|
+
}
|
|
74
|
+
if (len > 40) {
|
|
75
|
+
q *= 0.6;
|
|
76
|
+
}
|
|
77
|
+
return Math.max(0.02, Math.min(1, q));
|
|
78
|
+
}
|
|
79
|
+
/** Stability score for a candidate: base weight for its kind × the shape
|
|
80
|
+
* quality of its identifier, minus a small depth nudge so a nearer/shorter
|
|
81
|
+
* anchor wins an otherwise-tie. Positional paths score on kind alone. */
|
|
82
|
+
function anchorStability(c) {
|
|
83
|
+
const base = TYPE_WEIGHT[c.kind];
|
|
84
|
+
const quality = c.kind === 'positional' ? 1 : tokenQuality(c.token ?? '');
|
|
85
|
+
const depthNudge = 1 - Math.min(c.depth, 8) * 0.01;
|
|
86
|
+
return base * quality * depthNudge;
|
|
87
|
+
}
|
|
18
88
|
/**
|
|
19
89
|
* A verified xPath to the tightest element containing one of `targets`, or
|
|
20
90
|
* undefined if the value isn't present in the parsed tree. Prefers a short
|
|
@@ -45,14 +115,24 @@ export function elementAnchor(html, targets) {
|
|
|
45
115
|
if (startIndex === null || endIndex === null) {
|
|
46
116
|
return undefined;
|
|
47
117
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
118
|
+
// Score EVERY candidate that provably resolves (first-match, uniquely) to the
|
|
119
|
+
// target, and take the most durable — not merely the first that resolves.
|
|
120
|
+
// Because each survivor already matches uniquely NOW, the choice only trades
|
|
121
|
+
// on FUTURE stability; it can never pick a wrong/non-resolving node.
|
|
122
|
+
const verified = [...candidateXPaths(el)]
|
|
123
|
+
.map((c, i) => ({ c, i }))
|
|
124
|
+
.filter(({ c }) => matchesUniquely(doc, c.xPath, el));
|
|
125
|
+
if (!verified.length) {
|
|
126
|
+
return undefined;
|
|
54
127
|
}
|
|
55
|
-
|
|
128
|
+
verified.sort((a, b) => {
|
|
129
|
+
return anchorStability(b.c) - anchorStability(a.c) ||
|
|
130
|
+
a.c.depth - b.c.depth ||
|
|
131
|
+
a.i - b.i;
|
|
132
|
+
});
|
|
133
|
+
// `[startIndex, endIndex)` is exactly the slice the attestor reveals for this
|
|
134
|
+
// xPath (its getNodeRange uses these same parse5 source locations).
|
|
135
|
+
return { xPath: verified[0].c.xPath, start: startIndex, end: endIndex };
|
|
56
136
|
}
|
|
57
137
|
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
58
138
|
* occurrence — an attribute whose value IS the target, or a text child that IS
|
|
@@ -97,46 +177,119 @@ function smallestElement(els) {
|
|
|
97
177
|
.sort((a, b) => elementSpan(a) - elementSpan(b))
|
|
98
178
|
.at(0);
|
|
99
179
|
}
|
|
100
|
-
/**
|
|
180
|
+
/** Every candidate xPath for `el`, each tagged with what it anchors on. The
|
|
181
|
+
* caller verifies + scores them ({@link anchorStability}) rather than trusting
|
|
182
|
+
* emission order, so the DURABILITY winner is chosen, not the first that
|
|
183
|
+
* resolves. Order here is only the final tie-break. */
|
|
101
184
|
function* candidateXPaths(el) {
|
|
102
185
|
const tag = el.name;
|
|
103
186
|
const a = el.attribs ?? {};
|
|
104
187
|
if (a.id) {
|
|
105
|
-
yield `//${tag}[@id=${xpathLiteral(a.id)}]
|
|
188
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(a.id)}]`, kind: 'id',
|
|
189
|
+
token: a.id, depth: 0 };
|
|
106
190
|
}
|
|
107
191
|
for (const name of IDENT_ATTRS) {
|
|
108
192
|
if (a[name]) {
|
|
109
|
-
yield `//${tag}[@${name}=${xpathLiteral(a[name])}]
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
if (a.class) {
|
|
113
|
-
const distinctive = a.class
|
|
114
|
-
.split(/\s+/)
|
|
115
|
-
.filter(Boolean)
|
|
116
|
-
.sort((x, y) => y.length - x.length)[0];
|
|
117
|
-
if (distinctive) {
|
|
118
|
-
yield `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`;
|
|
193
|
+
yield { xPath: `//${tag}[@${name}=${xpathLiteral(a[name])}]`,
|
|
194
|
+
kind: 'ident-attr', token: a[name], depth: 0 };
|
|
119
195
|
}
|
|
120
196
|
}
|
|
121
197
|
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
122
|
-
// path (`//span[span[@id="labels"]]`). Self-verification
|
|
123
|
-
//
|
|
124
|
-
//
|
|
198
|
+
// path (`//span[span[@id="labels"]]`). Self-verification discards it when the
|
|
199
|
+
// child id/attr isn't unique — e.g. GitHub reuses `id="labels"` per email row
|
|
200
|
+
// — so it never silently matches the wrong node.
|
|
125
201
|
for (const child of el.children) {
|
|
126
202
|
if (!isTag(child)) {
|
|
127
203
|
continue;
|
|
128
204
|
}
|
|
129
205
|
const ca = child.attribs ?? {};
|
|
130
206
|
if (ca.id) {
|
|
131
|
-
yield `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]
|
|
207
|
+
yield { xPath: `//${tag}[${child.name}[@id=${xpathLiteral(ca.id)}]]`,
|
|
208
|
+
kind: 'id', token: ca.id, depth: 0 };
|
|
132
209
|
}
|
|
133
210
|
for (const name of IDENT_ATTRS) {
|
|
134
211
|
if (ca[name]) {
|
|
135
|
-
yield
|
|
212
|
+
yield {
|
|
213
|
+
xPath: `//${tag}[${child.name}[@${name}=${xpathLiteral(ca[name])}]]`,
|
|
214
|
+
kind: 'ident-attr', token: ca[name], depth: 0,
|
|
215
|
+
};
|
|
136
216
|
}
|
|
137
217
|
}
|
|
138
218
|
}
|
|
139
|
-
|
|
219
|
+
// The value element's own distinctive class.
|
|
220
|
+
const distinctive = distinctiveClass(a.class);
|
|
221
|
+
if (distinctive) {
|
|
222
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(distinctive)})]`,
|
|
223
|
+
kind: 'class', token: distinctive, depth: 0 };
|
|
224
|
+
}
|
|
225
|
+
// Ancestor-anchored (id/attr, then class), descending positionally to `el`.
|
|
226
|
+
yield* ancestorAnchoredCandidates(el);
|
|
227
|
+
yield { xPath: absolutePath(el), kind: 'positional', depth: 99 };
|
|
228
|
+
}
|
|
229
|
+
/** The longest whitespace-delimited token of a class attribute (the most
|
|
230
|
+
* distinctive one), or undefined when there's no class. */
|
|
231
|
+
function distinctiveClass(cls) {
|
|
232
|
+
return (cls ?? '')
|
|
233
|
+
.split(/\s+/)
|
|
234
|
+
.filter(Boolean)
|
|
235
|
+
.sort((x, y) => y.length - x.length)[0];
|
|
236
|
+
}
|
|
237
|
+
/** Positional path from `anc` (exclusive) down to `el` (inclusive), e.g.
|
|
238
|
+
* `/ul[1]/li[3]/a[1]`; empty string when `anc` is not an ancestor of `el`. */
|
|
239
|
+
function relativeSteps(anc, el) {
|
|
240
|
+
const parts = [];
|
|
241
|
+
let node = el;
|
|
242
|
+
while (node && isTag(node) && node !== anc) {
|
|
243
|
+
const tag = node.name;
|
|
244
|
+
let index = 1;
|
|
245
|
+
for (let sib = node.prev; sib; sib = sib.prev) {
|
|
246
|
+
if (isTag(sib) && sib.name === tag) {
|
|
247
|
+
index++;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
parts.unshift(`/${tag}[${index}]`);
|
|
251
|
+
node = node.parent;
|
|
252
|
+
}
|
|
253
|
+
return node === anc ? parts.join('') : '';
|
|
254
|
+
}
|
|
255
|
+
/** Ancestors of `el` (nearest first) with their depth, skipping
|
|
256
|
+
* `html`/`body`/`head` (≈ the absolute path) and non-ancestors. Capped depth
|
|
257
|
+
* keeps paths short. */
|
|
258
|
+
function* anchorAncestors(el) {
|
|
259
|
+
let depth = 1;
|
|
260
|
+
for (let anc = el.parent; anc && isTag(anc) && depth <= 10; anc = anc.parent, depth++) {
|
|
261
|
+
const tag = anc.name;
|
|
262
|
+
if (tag === 'html' || tag === 'body' || tag === 'head') {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const rel = relativeSteps(anc, el);
|
|
266
|
+
if (rel) {
|
|
267
|
+
yield { tag, attribs: anc.attribs ?? {}, rel, depth };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Ancestor-anchored candidates, descending positionally to `el`
|
|
272
|
+
* (`//div[@id="nav"]/ul[1]/li[1]/a[1]`): an id / identifying-attr or a
|
|
273
|
+
* distinctive-class anchor per ancestor, tagged with its kind + depth so the
|
|
274
|
+
* scorer weighs a stable ancestor id above the value's own utility class. */
|
|
275
|
+
function* ancestorAnchoredCandidates(el) {
|
|
276
|
+
for (const { tag, attribs, rel, depth } of anchorAncestors(el)) {
|
|
277
|
+
if (attribs.id) {
|
|
278
|
+
yield { xPath: `//${tag}[@id=${xpathLiteral(attribs.id)}]${rel}`,
|
|
279
|
+
kind: 'id', token: attribs.id, depth };
|
|
280
|
+
}
|
|
281
|
+
for (const name of IDENT_ATTRS) {
|
|
282
|
+
if (attribs[name]) {
|
|
283
|
+
const xp = `//${tag}[@${name}=${xpathLiteral(attribs[name])}]${rel}`;
|
|
284
|
+
yield { xPath: xp, kind: 'ident-attr', token: attribs[name], depth };
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const dc = distinctiveClass(attribs.class);
|
|
288
|
+
if (dc) {
|
|
289
|
+
yield { xPath: `//${tag}[contains(@class, ${xpathLiteral(dc)})]${rel}`,
|
|
290
|
+
kind: 'class', token: dc, depth };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
140
293
|
}
|
|
141
294
|
/** Chrome "full XPath"-style absolute path: `/html[1]/body[1]/…/tag[n]`, where
|
|
142
295
|
* n is the element's 1-based position among same-tag siblings. */
|
package/lib/provider/draft.d.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import type { CapturedRequest, OprfMode, ReclaimProvider, ResponseMatch, ResponseRedaction, SecretParamRef } from './schema.ts';
|
|
2
|
+
/** OPRF mode to apply when the caller passed none: the anti-leak floor —
|
|
3
|
+
* force-hash a value whose SHAPE is unambiguous PII, but ONLY when it's
|
|
4
|
+
* OPRF-hashable AND within the 62-byte MPC-circuit cap (UTF-8). Undefined =
|
|
5
|
+
* leave plaintext (a longer value can't be OPRF'd; contextual PII + the proven
|
|
6
|
+
* identity are the model's/plaintext call). */
|
|
7
|
+
export declare function defaultSensitiveHash(value: string): OprfMode | undefined;
|
|
2
8
|
/** One value to extract from the request: the captured `value`, a `name` (its
|
|
3
9
|
* param / capture-group), and an optional per-target OPRF `hash`. */
|
|
4
10
|
export interface DraftTarget {
|