@reclaimprotocol/agent 0.1.22 → 0.1.24
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/README.md +55 -20
- package/lib/authoring/backend.d.ts +13 -10
- package/lib/authoring/tools.js +106 -9
- package/lib/cdp/browser-proxy.d.ts +78 -0
- package/lib/cdp/browser-proxy.js +186 -0
- package/lib/cdp/capture-engine.js +8 -7
- package/lib/cdp/client.d.ts +10 -0
- package/lib/cdp/geo.d.ts +10 -0
- package/lib/cdp/geo.js +36 -0
- package/lib/cdp/index.d.ts +1 -0
- package/lib/cdp/index.js +1 -0
- package/lib/live/cloudflared.d.ts +15 -0
- package/lib/live/cloudflared.js +268 -0
- package/lib/live/page.d.ts +20 -0
- package/lib/live/page.js +134 -0
- package/lib/live/tunnel.d.ts +23 -0
- package/lib/live/tunnel.js +120 -0
- package/lib/live/viewer.d.ts +28 -0
- package/lib/live/viewer.js +232 -0
- package/lib/mcp/api/operations.gen.js +34 -26
- package/lib/mcp/api/to-zod.d.ts +1 -1
- package/lib/mcp/api/to-zod.js +1 -1
- package/lib/mcp/api/tools.js +1 -1
- package/lib/mcp/instructions.d.ts +15 -2
- package/lib/mcp/instructions.js +101 -38
- package/lib/mcp/start.js +4 -3
- package/lib/mcp/tools/agent/analyze.js +5 -1
- package/lib/mcp/tools/agent/attach.d.ts +8 -1
- package/lib/mcp/tools/agent/attach.js +166 -22
- package/lib/mcp/tools/agent/build.d.ts +13 -8
- package/lib/mcp/tools/agent/build.js +22 -11
- package/lib/mcp/tools/agent/capture.js +5 -1
- package/lib/mcp/tools/agent/eval.js +3 -1
- package/lib/mcp/tools/agent/find.js +6 -5
- package/lib/mcp/tools/agent/how-it-works.d.ts +15 -7
- package/lib/mcp/tools/agent/how-it-works.js +57 -17
- package/lib/mcp/tools/agent/live-view.d.ts +21 -0
- package/lib/mcp/tools/agent/live-view.js +153 -0
- package/lib/mcp/tools/agent/pod-proof.d.ts +8 -0
- package/lib/mcp/tools/agent/pod-proof.js +64 -0
- package/lib/mcp/tools/agent/propose.js +29 -3
- package/lib/mcp/tools/agent/proxy.d.ts +3 -0
- package/lib/mcp/tools/agent/proxy.js +199 -0
- package/lib/mcp/tools/agent/publish.js +49 -77
- package/lib/mcp/tools/agent/session.d.ts +2 -1
- package/lib/mcp/tools/agent/session.js +26 -7
- package/lib/mcp/tools/agent/shared.js +17 -8
- package/lib/mcp/tools/authenticate/authenticate.d.ts +11 -0
- package/lib/mcp/tools/authenticate/authenticate.js +143 -121
- package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -3
- package/lib/mcp/tools/authenticate/builder-access.js +8 -3
- package/lib/mcp/tools/mode/mode.d.ts +1 -1
- package/lib/mcp/tools/mode/mode.js +26 -7
- package/lib/old/client.d.ts +9 -0
- package/lib/old/client.js +19 -1
- package/lib/old/instructions.d.ts +9 -1
- package/lib/old/instructions.js +4 -6
- package/lib/old/login-cdp.d.ts +2 -2
- package/lib/old/login-cdp.js +46 -6
- package/lib/old/to-register.d.ts +20 -8
- package/lib/old/to-register.js +7 -1
- package/lib/old/tools/authenticate.js +9 -1
- package/lib/old/tools/get-provider-info.js +6 -5
- package/lib/old/tools/issue-app-credentials.js +3 -2
- package/lib/old/tools/me-providers.js +3 -1
- package/lib/old/tools/publish.d.ts +1 -1
- package/lib/old/tools/publish.js +132 -129
- package/lib/old/tools/update-provider-metadata.d.ts +1 -1
- package/lib/old/tools/update-provider-metadata.js +1 -1
- package/lib/old/tools/verify-sdk-setup.js +1 -1
- package/lib/proof/attestor.d.ts +3 -3
- package/lib/proof/debug-response-dump.js +4 -4
- package/lib/proof/index.d.ts +1 -0
- package/lib/proof/index.js +1 -0
- package/lib/proof/page-replay.d.ts +36 -0
- package/lib/proof/page-replay.js +76 -0
- package/lib/proof/pod-prover.d.ts +47 -0
- package/lib/proof/pod-prover.js +155 -0
- package/lib/proof/replay.d.ts +28 -0
- package/lib/proof/replay.js +39 -18
- package/lib/provider/dom-xpath.js +11 -9
- package/lib/provider/draft.d.ts +13 -8
- package/lib/provider/draft.js +47 -13
- package/lib/provider/injection-doc.d.ts +23 -1
- package/lib/provider/injection-doc.js +34 -21
- package/lib/provider/match-redaction-warning.d.ts +2 -2
- package/lib/provider/match-redaction-warning.js +3 -3
- package/lib/provider/matcher.js +122 -12
- package/lib/provider/secrets.d.ts +3 -3
- package/lib/provider/secrets.js +3 -3
- package/lib/provider/to-version.d.ts +12 -9
- package/lib/provider/to-version.js +16 -4
- package/lib/tools/registry.d.ts +5 -5
- package/lib/tools/registry.js +5 -5
- package/package.json +4 -3
- package/samples/demo-server/README.md +7 -6
- package/skill/SKILL.builder.md +356 -189
- package/skill/SKILL.common.md +314 -79
- package/skill/SKILL.old.md +340 -155
- package/skill/builder-mode.md +58 -17
- package/skill/hash-validation.md +170 -0
- package/skill/interception.md +235 -0
- package/skill/js-sdk-integration.md +13 -7
- package/skill/old-mode.md +53 -12
- package/skill/user-script.md +61 -0
- package/src/authoring/backend.ts +13 -10
- package/src/authoring/tools.ts +128 -9
- package/src/cdp/capture-engine.ts +8 -7
- package/src/cdp/client.ts +8 -0
- package/src/cdp/geo.ts +52 -0
- package/src/cdp/index.ts +1 -0
- package/src/consts.ts +1 -0
- package/src/live/cloudflared.ts +348 -0
- package/src/live/page.ts +145 -0
- package/src/live/tunnel.ts +157 -0
- package/src/live/viewer.ts +293 -0
- package/src/mcp/api/operations.gen.ts +34 -26
- package/src/mcp/api/to-zod.ts +1 -1
- package/src/mcp/api/tools.ts +1 -1
- package/src/mcp/instructions.ts +121 -48
- package/src/mcp/start.ts +4 -3
- package/src/mcp/tools/agent/analyze.ts +5 -1
- package/src/mcp/tools/agent/attach.ts +194 -22
- package/src/mcp/tools/agent/build.ts +26 -15
- package/src/mcp/tools/agent/capture.ts +5 -1
- package/src/mcp/tools/agent/eval.ts +4 -1
- package/src/mcp/tools/agent/find.ts +6 -5
- package/src/mcp/tools/agent/how-it-works.ts +62 -21
- package/src/mcp/tools/agent/live-view.ts +198 -0
- package/src/mcp/tools/agent/pod-proof.ts +90 -0
- package/src/mcp/tools/agent/propose.ts +31 -3
- package/src/mcp/tools/agent/publish.ts +54 -78
- package/src/mcp/tools/agent/session.ts +26 -5
- package/src/mcp/tools/agent/shared.ts +19 -8
- package/src/mcp/tools/authenticate/authenticate.ts +167 -165
- package/src/mcp/tools/authenticate/builder-access.ts +8 -3
- package/src/mcp/tools/mode/mode.ts +28 -7
- package/src/old/client.ts +30 -1
- package/src/old/instructions.ts +4 -10
- package/src/old/login-cdp.ts +50 -6
- package/src/old/to-register.ts +28 -8
- package/src/old/tools/authenticate.ts +9 -1
- package/src/old/tools/get-provider-info.ts +6 -5
- package/src/old/tools/issue-app-credentials.ts +3 -2
- package/src/old/tools/me-providers.ts +3 -1
- package/src/old/tools/publish.ts +140 -130
- package/src/old/tools/update-provider-metadata.ts +1 -1
- package/src/old/tools/verify-sdk-setup.ts +1 -1
- package/src/proof/attestor.ts +3 -3
- package/src/proof/debug-response-dump.ts +4 -4
- package/src/proof/index.ts +8 -0
- package/src/proof/page-replay.ts +131 -0
- package/src/proof/pod-prover.ts +231 -0
- package/src/proof/replay.ts +60 -19
- package/src/provider/dom-xpath.ts +11 -9
- package/src/provider/draft.ts +56 -13
- package/src/provider/injection-doc.ts +37 -24
- package/src/provider/match-redaction-warning.ts +3 -3
- package/src/provider/matcher.ts +151 -10
- package/src/provider/secrets.ts +3 -3
- package/src/provider/to-version.ts +30 -13
- package/src/tools/registry.ts +5 -5
- package/skill/injection-api.md +0 -335
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// Debug helper (gated by RECLAIM_AGENT_DUMP_RESPONSE): capture the EXACT
|
|
2
2
|
// response bytes the attestor validates a claim against, plus a per-match
|
|
3
3
|
// presence check. A "Response does not contain X" failure means the attestor's
|
|
4
|
-
// server-side re-fetch differs from what the browser (replay) saw —
|
|
5
|
-
// embeds nav/userMenu data for browser requests but not for the
|
|
6
|
-
// fetch. Seeing the attestor's actual response is the only way
|
|
7
|
-
// really returned.
|
|
4
|
+
// server-side re-fetch differs from what the browser (replay) saw — for
|
|
5
|
+
// example, GitHub embeds nav/userMenu data for browser requests but not for the
|
|
6
|
+
// attestor's raw fetch. Seeing the attestor's actual response is the only way
|
|
7
|
+
// to tell what it really returned.
|
|
8
8
|
//
|
|
9
9
|
// On each getResponseRedactions call it writes the response to
|
|
10
10
|
// `attestor-last-response.html` in the process CWD and logs the byte count +,
|
package/lib/proof/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export type { AttestorClaim, AttestorLogEntry, ProofResult, RunAttestorProofOptions, } from './attestor.ts';
|
|
2
2
|
export { runAttestorProof, tailLogs, warnsAndErrors } from './attestor.ts';
|
|
3
3
|
export { buildHttpParams, ensureAttestorCrypto, type ParsedClaim, parseClaim, resolveAttestorUrl, } from './proof-core.ts';
|
|
4
|
+
export { type HttpParams, type HttpSecretParams, type PopcornProveOptions, popcornProve, popcornValidateExtraction, type ValidateExtractionResult, } from './pod-prover.ts';
|
|
4
5
|
export { ensureZkFiles } from './zk-files.ts';
|
|
5
6
|
export { createClaimOnAttestor } from '@reclaimprotocol/attestor-core';
|
package/lib/proof/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { runAttestorProof, tailLogs, warnsAndErrors } from "./attestor.js";
|
|
2
2
|
export { buildHttpParams, ensureAttestorCrypto, parseClaim, resolveAttestorUrl, } from "./proof-core.js";
|
|
3
|
+
export { popcornProve, popcornValidateExtraction, } from "./pod-prover.js";
|
|
3
4
|
export { ensureZkFiles } from "./zk-files.js";
|
|
4
5
|
// Re-exported attestor-core claim primitive so the agent is the single
|
|
5
6
|
// integration point with @reclaimprotocol/attestor-core across the builder.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a drafted provider's AUTHENTICATED request FROM INSIDE the live
|
|
3
|
+
* attached browser tab, over CDP `Runtime.evaluate` — so cookies/session are
|
|
4
|
+
* always the real browser's, for every browser source the MCP can attach to
|
|
5
|
+
* (local Chrome, remote Popcorn, or a caller-supplied custom CDP endpoint all
|
|
6
|
+
* funnel through one `AuthoringBackend.sendCdp`). Ports the pattern already
|
|
7
|
+
* proven in the cloud/Popcorn authoring backend
|
|
8
|
+
* (`packages/app/src/author/backend.ts` `pageFetch`/`authenticatedFetch`),
|
|
9
|
+
* adapted to the MCP's single-page `sendCdp(method, params)` (no separate
|
|
10
|
+
* `pageSession` argument). The ANONYMOUS auth-bound check does NOT go through
|
|
11
|
+
* the page — see `replayProviderInPage`'s doc comment below for why.
|
|
12
|
+
*/
|
|
13
|
+
import type { ReclaimProvider } from '../provider/schema.ts';
|
|
14
|
+
import { type ReplayResult } from './replay.ts';
|
|
15
|
+
/** Everything this module needs from an `AuthoringBackend` — just the raw CDP
|
|
16
|
+
* seam, so callers can pass a minimal object instead of a full backend. */
|
|
17
|
+
export interface PageReplayTarget {
|
|
18
|
+
sendCdp(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Replay `provider` and judge the response. Without `withoutSecrets`, replays
|
|
22
|
+
* as the logged-in session — run FROM INSIDE the live tab
|
|
23
|
+
* (`authenticatedPageFetch`) so cookies are the real browser's, which a
|
|
24
|
+
* detached call could never reproduce without manually re-deriving them.
|
|
25
|
+
* WITH `withoutSecrets` (the auth-bound check — "does this still work with
|
|
26
|
+
* no credentials, that is, is it actually public"), replay instead through the
|
|
27
|
+
* detached Node-side `replayProvider`: the whole point of that check is to
|
|
28
|
+
* prove NO secret rides along, and the live page's own global `fetch` can't
|
|
29
|
+
* give that guarantee — a site (or a script installed via test_user_script)
|
|
30
|
+
* can wrap `fetch` and re-attach a token from localStorage/a service worker
|
|
31
|
+
* even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
|
|
32
|
+
* such surface.
|
|
33
|
+
*/
|
|
34
|
+
export declare function replayProviderInPage(backend: PageReplayTarget, provider: ReclaimProvider, secrets: Record<string, string>, opts?: {
|
|
35
|
+
withoutSecrets?: boolean;
|
|
36
|
+
}): Promise<ReplayResult>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { buildReplayRequest, evaluateReplay, replayProvider } from "./replay.js";
|
|
2
|
+
/** Run `fetch(...)` for `provider`'s (already-templated) request inside the
|
|
3
|
+
* attached tab, via `Runtime.evaluate`. `credentials` selects the visitor:
|
|
4
|
+
* `'omit'` = anonymous (the auth-bound check), `'include'` = the logged-in
|
|
5
|
+
* user (the reproducibility gate). Same-origin only — the tab is already on
|
|
6
|
+
* the target site; a cross-origin/CORS failure comes back as `error`. */
|
|
7
|
+
async function pageFetch(backend, provider, credentials, extraSecrets) {
|
|
8
|
+
const req = buildReplayRequest(provider, extraSecrets);
|
|
9
|
+
const expression = `(async () => {
|
|
10
|
+
try {
|
|
11
|
+
const r = await fetch(${JSON.stringify(req.url)}, {
|
|
12
|
+
method: ${JSON.stringify(req.method)},
|
|
13
|
+
headers: ${JSON.stringify(req.headers)},
|
|
14
|
+
credentials: ${JSON.stringify(credentials)},${req.body !== undefined
|
|
15
|
+
? `\n\t\t\t\tbody: ${JSON.stringify(req.body)},`
|
|
16
|
+
: ''}
|
|
17
|
+
})
|
|
18
|
+
return { status: r.status, body: await r.text() }
|
|
19
|
+
} catch(e) { return { error: String((e && e.message) || e) } }
|
|
20
|
+
})()`;
|
|
21
|
+
try {
|
|
22
|
+
const res = (await backend.sendCdp('Runtime.evaluate', {
|
|
23
|
+
expression,
|
|
24
|
+
awaitPromise: true,
|
|
25
|
+
returnByValue: true,
|
|
26
|
+
}));
|
|
27
|
+
if (res.exceptionDetails) {
|
|
28
|
+
return {
|
|
29
|
+
error: res.exceptionDetails.exception?.description
|
|
30
|
+
?? res.exceptionDetails.text
|
|
31
|
+
?? 'page fetch evaluation error',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return res.result?.value ?? { error: 'page fetch returned no value' };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Authenticated re-issue for the reproducibility gate. Cookies ride along
|
|
41
|
+
* via `credentials: 'include'` (a `Cookie` header can't be set from
|
|
42
|
+
* `fetch()` — it's a forbidden header name), so only NON-cookie secrets
|
|
43
|
+
* (Authorization, X-* tokens) are injected explicitly. */
|
|
44
|
+
function authenticatedPageFetch(backend, provider, secrets) {
|
|
45
|
+
const extra = {};
|
|
46
|
+
for (const [k, v] of Object.entries(secrets)) {
|
|
47
|
+
if (k.toLowerCase() !== 'cookie') {
|
|
48
|
+
extra[k] = v;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return pageFetch(backend, provider, 'include', extra);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Replay `provider` and judge the response. Without `withoutSecrets`, replays
|
|
55
|
+
* as the logged-in session — run FROM INSIDE the live tab
|
|
56
|
+
* (`authenticatedPageFetch`) so cookies are the real browser's, which a
|
|
57
|
+
* detached call could never reproduce without manually re-deriving them.
|
|
58
|
+
* WITH `withoutSecrets` (the auth-bound check — "does this still work with
|
|
59
|
+
* no credentials, that is, is it actually public"), replay instead through the
|
|
60
|
+
* detached Node-side `replayProvider`: the whole point of that check is to
|
|
61
|
+
* prove NO secret rides along, and the live page's own global `fetch` can't
|
|
62
|
+
* give that guarantee — a site (or a script installed via test_user_script)
|
|
63
|
+
* can wrap `fetch` and re-attach a token from localStorage/a service worker
|
|
64
|
+
* even under `credentials:'omit'`. A fresh, page-uninvolved fetch has no
|
|
65
|
+
* such surface.
|
|
66
|
+
*/
|
|
67
|
+
export async function replayProviderInPage(backend, provider, secrets, opts) {
|
|
68
|
+
if (opts?.withoutSecrets) {
|
|
69
|
+
return replayProvider(provider, {});
|
|
70
|
+
}
|
|
71
|
+
const result = await authenticatedPageFetch(backend, provider, secrets);
|
|
72
|
+
if (result.error !== undefined || result.status === undefined) {
|
|
73
|
+
throw new Error(`in-browser replay failed: ${result.error ?? 'no response'}`);
|
|
74
|
+
}
|
|
75
|
+
return evaluateReplay(result.status, result.body ?? '', provider);
|
|
76
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ProviderParams } from '@reclaimprotocol/attestor-core';
|
|
2
|
+
import type { ProofResult } from './attestor.ts';
|
|
3
|
+
/** Attestor HTTP provider params — the recipe the agent synthesis produces. */
|
|
4
|
+
export type HttpParams = ProviderParams<'http'>;
|
|
5
|
+
/** Captured secrets for the replay, exactly as `/reclaim/prove` wants them. */
|
|
6
|
+
export interface HttpSecretParams {
|
|
7
|
+
cookieStr?: string;
|
|
8
|
+
authorisationHeader?: string;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
paramValues?: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
export interface PopcornProveOptions {
|
|
13
|
+
/** Pod API base (the session's `apiUrl`). */
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
params: HttpParams;
|
|
16
|
+
secretParams: HttpSecretParams;
|
|
17
|
+
/** Session context, stringified into the claim context if given. */
|
|
18
|
+
context?: Record<string, unknown>;
|
|
19
|
+
fetchImpl?: typeof fetch;
|
|
20
|
+
}
|
|
21
|
+
export interface ValidateExtractionResult {
|
|
22
|
+
valid: boolean;
|
|
23
|
+
extractedValue?: string;
|
|
24
|
+
error?: string;
|
|
25
|
+
steps?: string[];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* The cloud "replay": validate that a drafted recipe's selectors extract the
|
|
29
|
+
* expected value from a captured response, inside the TEE — `POST {apiUrl}
|
|
30
|
+
* /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
|
|
31
|
+
* a pass means the selectors won't diverge at proof time. No proof/secrets.
|
|
32
|
+
*/
|
|
33
|
+
export declare function popcornValidateExtraction(opts: {
|
|
34
|
+
apiUrl: string;
|
|
35
|
+
responseBody: string;
|
|
36
|
+
expectedValue: string;
|
|
37
|
+
xPath?: string;
|
|
38
|
+
jsonPath?: string;
|
|
39
|
+
regex?: string;
|
|
40
|
+
fetchImpl?: typeof fetch;
|
|
41
|
+
}): Promise<ValidateExtractionResult>;
|
|
42
|
+
/**
|
|
43
|
+
* Prove a single request through the Popcorn TEE. Returns the shared
|
|
44
|
+
* {@link ProofResult}; on any failure `verified` is false and `error` carries
|
|
45
|
+
* the reason (never throws for an expected proof failure).
|
|
46
|
+
*/
|
|
47
|
+
export declare function popcornProve(opts: PopcornProveOptions): Promise<ProofResult>;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PopcornPodProver — run the zkTLS proof inside a Popcorn browser session's
|
|
3
|
+
* OWN TEE pod instead of the in-process attestor SDK (`runAttestorProof`).
|
|
4
|
+
* POSTs the recipe + captured secrets to the pod's `/reclaim/prove` (the same
|
|
5
|
+
* attestor TEE+MPC protocol) and re-shapes the response into the shared
|
|
6
|
+
* {@link ProofResult}, so every caller consumes proof results identically
|
|
7
|
+
* regardless of which path produced them.
|
|
8
|
+
*
|
|
9
|
+
* This is the pod's OWN api for creating a proof from a live session — used
|
|
10
|
+
* by both the app's server-driven verification runner and the MCP's
|
|
11
|
+
* `run_proof_via_pod` tool. Proving here (rather than locally) matters
|
|
12
|
+
* specifically when the session is country-routed: Popcorn allocated the
|
|
13
|
+
* browser with an exit country, and this is the SAME enclave holding that
|
|
14
|
+
* browser's TLS session, so the proof reaches the target from the identical
|
|
15
|
+
* exit IP the browser used — a local attestor SDK proof runs from the
|
|
16
|
+
* app/agent's OWN network instead.
|
|
17
|
+
*
|
|
18
|
+
* Contract (verified against ../popcorn-images oapi.go + ../reclaim-tee):
|
|
19
|
+
* POST {apiUrl}/reclaim/prove body { provider_params_json, config_json? }
|
|
20
|
+
* provider_params_json = JSON of { name, params, secretParams, context? }
|
|
21
|
+
* → 200 { session_id, claim, signature } (ReclaimProveResult, snake_case):
|
|
22
|
+
* claim has provider/parameters/owner/timestamp_s/context/identifier/epoch;
|
|
23
|
+
* signature has attestor_address + claim_signature(b64) +
|
|
24
|
+
* result_signature(b64). A proof failure/timeout is a 500 whose `message`
|
|
25
|
+
* carries the reason.
|
|
26
|
+
*
|
|
27
|
+
* The pod API is unauthenticated (gateway/session is the trust boundary); call
|
|
28
|
+
* it over the same internal host used for CDP. Consumers verify
|
|
29
|
+
* `claim_signature` only (see `@reclaimprotocol/client`'s `verifyNestedProof`),
|
|
30
|
+
* so this hands back a {@link AttestorClaim} whose hex `claimSignature` the
|
|
31
|
+
* shared `wrapProofAsJws` (app-side) turns into the nested result proof.
|
|
32
|
+
* `result_signature` is unused by any consumer.
|
|
33
|
+
*/
|
|
34
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
35
|
+
/**
|
|
36
|
+
* The cloud "replay": validate that a drafted recipe's selectors extract the
|
|
37
|
+
* expected value from a captured response, inside the TEE — `POST {apiUrl}
|
|
38
|
+
* /reclaim/validate-extraction`. Reuses the prover's own redaction engine, so
|
|
39
|
+
* a pass means the selectors won't diverge at proof time. No proof/secrets.
|
|
40
|
+
*/
|
|
41
|
+
export async function popcornValidateExtraction(opts) {
|
|
42
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
43
|
+
const base = opts.apiUrl.replace(/\/$/, '');
|
|
44
|
+
try {
|
|
45
|
+
const res = await doFetch(`${base}/reclaim/validate-extraction`, {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
headers: { 'Content-Type': 'application/json' },
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
responseBody: opts.responseBody,
|
|
50
|
+
expectedValue: opts.expectedValue,
|
|
51
|
+
...(opts.xPath ? { xPath: opts.xPath } : {}),
|
|
52
|
+
...(opts.jsonPath ? { jsonPath: opts.jsonPath } : {}),
|
|
53
|
+
...(opts.regex ? { regex: opts.regex } : {}),
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
const detail = await res.text().catch(() => '');
|
|
58
|
+
return { valid: false, error: `${res.status}: ${detail.slice(0, 300)}` };
|
|
59
|
+
}
|
|
60
|
+
const body = (await res.json());
|
|
61
|
+
return body;
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
65
|
+
return { valid: false, error };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** Decode standard base64 (Go `base64.StdEncoding` from Popcorn) to bytes. */
|
|
69
|
+
function b64ToBytes(b64) {
|
|
70
|
+
return new Uint8Array(Buffer.from(b64, 'base64'));
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Prove a single request through the Popcorn TEE. Returns the shared
|
|
74
|
+
* {@link ProofResult}; on any failure `verified` is false and `error` carries
|
|
75
|
+
* the reason (never throws for an expected proof failure).
|
|
76
|
+
*/
|
|
77
|
+
export async function popcornProve(opts) {
|
|
78
|
+
const { apiUrl, params, secretParams, context } = opts;
|
|
79
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
80
|
+
const base = apiUrl.replace(/\/$/, '');
|
|
81
|
+
const providerParamsJson = JSON.stringify({
|
|
82
|
+
name: 'http',
|
|
83
|
+
params,
|
|
84
|
+
secretParams,
|
|
85
|
+
...(context ? { context: JSON.stringify(context) } : {}),
|
|
86
|
+
});
|
|
87
|
+
const fail = (error) => ({
|
|
88
|
+
proof: undefined,
|
|
89
|
+
extractedValue: '',
|
|
90
|
+
extractedParameters: {},
|
|
91
|
+
verified: false,
|
|
92
|
+
error,
|
|
93
|
+
});
|
|
94
|
+
let resp;
|
|
95
|
+
let raw = '';
|
|
96
|
+
try {
|
|
97
|
+
const res = await doFetch(`${base}/reclaim/prove`, {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: { 'Content-Type': 'application/json' },
|
|
100
|
+
body: JSON.stringify({ provider_params_json: providerParamsJson }),
|
|
101
|
+
});
|
|
102
|
+
// Read the body as text first: a proof failure/timeout is a 500 whose
|
|
103
|
+
// `message` we want to surface verbatim, and on an unexpected 200 shape we
|
|
104
|
+
// echo the raw body instead of a blind "no claim/signature".
|
|
105
|
+
raw = await res.text().catch(() => '');
|
|
106
|
+
if (!res.ok) {
|
|
107
|
+
return fail(`/reclaim/prove ${res.status}: ${raw.slice(0, 300)}`);
|
|
108
|
+
}
|
|
109
|
+
resp = JSON.parse(raw);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return fail(err instanceof Error ? err.message : String(err));
|
|
113
|
+
}
|
|
114
|
+
const claim = resp.claim;
|
|
115
|
+
const sig = resp.signature;
|
|
116
|
+
if (!claim || !sig?.attestor_address || !sig?.claim_signature) {
|
|
117
|
+
return fail('popcorn /reclaim/prove returned no claim/signature: '
|
|
118
|
+
+ raw.slice(0, 300));
|
|
119
|
+
}
|
|
120
|
+
// claim.context is the JSON string carrying { extractedParameters, ... } —
|
|
121
|
+
// the same place the in-process path reads them from.
|
|
122
|
+
let extractedParameters = {};
|
|
123
|
+
if (typeof claim.context === 'string') {
|
|
124
|
+
try {
|
|
125
|
+
const ctx = JSON.parse(claim.context);
|
|
126
|
+
extractedParameters = ctx.extractedParameters ?? {};
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// leave empty if context isn't valid JSON
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const attestorClaim = {
|
|
133
|
+
claim: {
|
|
134
|
+
provider: claim.provider ?? '',
|
|
135
|
+
parameters: claim.parameters ?? '',
|
|
136
|
+
owner: claim.owner ?? '',
|
|
137
|
+
timestampS: claim.timestamp_s ?? 0,
|
|
138
|
+
context: claim.context ?? '',
|
|
139
|
+
identifier: claim.identifier ?? '',
|
|
140
|
+
epoch: claim.epoch ?? 0,
|
|
141
|
+
},
|
|
142
|
+
attestorAddress: sig.attestor_address,
|
|
143
|
+
// Consumer-side verification expects hex; Popcorn signs in base64.
|
|
144
|
+
claimSignature: '0x' + bytesToHex(b64ToBytes(sig.claim_signature)),
|
|
145
|
+
};
|
|
146
|
+
return {
|
|
147
|
+
proof: resp,
|
|
148
|
+
verified: true,
|
|
149
|
+
extractedParameters,
|
|
150
|
+
extractedValue: Object.values(extractedParameters)[0] ?? '',
|
|
151
|
+
...(claim.identifier ? { identifier: claim.identifier } : {}),
|
|
152
|
+
...(claim.owner ? { owner: claim.owner } : {}),
|
|
153
|
+
attestorClaim,
|
|
154
|
+
};
|
|
155
|
+
}
|
package/lib/proof/replay.d.ts
CHANGED
|
@@ -5,4 +5,32 @@ export interface ReplayResult {
|
|
|
5
5
|
matchesOriginal: boolean;
|
|
6
6
|
hints?: string[];
|
|
7
7
|
}
|
|
8
|
+
/** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
|
|
9
|
+
* SDK's behavior so replay and proof both hit the same final URL. */
|
|
10
|
+
export declare function expandTemplate(s: string, params: Record<string, string> | undefined): string;
|
|
11
|
+
/** Build the request the wire replay actually sends: `{{name}}` placeholders
|
|
12
|
+
* in the URL/headers/body expanded from `provider.paramValues`, secrets
|
|
13
|
+
* layered onto the public headers. Shared by every replay transport (Node
|
|
14
|
+
* `fetch` here, or a `fetch` run inside the live browser tab) so they all
|
|
15
|
+
* send the identical request. */
|
|
16
|
+
export interface ReplayRequest {
|
|
17
|
+
url: string;
|
|
18
|
+
method: string;
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
body?: string;
|
|
21
|
+
}
|
|
22
|
+
export declare function buildReplayRequest(provider: ReclaimProvider, secrets: Record<string, string>): ReplayRequest;
|
|
23
|
+
/**
|
|
24
|
+
* Judge a replayed response against `provider`'s matchers — the part of
|
|
25
|
+
* replay that's identical regardless of HOW the request was sent (detached
|
|
26
|
+
* Node `fetch` or a live in-browser `fetch`). Separated from the actual
|
|
27
|
+
* network call so both transports share one evaluation.
|
|
28
|
+
*/
|
|
29
|
+
export declare function evaluateReplay(status: number, body: string, provider: ReclaimProvider): ReplayResult;
|
|
30
|
+
/** Detached Node-side replay: sends the request directly from this process,
|
|
31
|
+
* NOT from any attached browser tab — its cookies/secrets are exactly the
|
|
32
|
+
* `secrets` passed in, never a real browser session. Used by tests and by
|
|
33
|
+
* any caller with no live browser to replay through (the MCP's authoring
|
|
34
|
+
* backend instead runs the SAME request inside the attached tab — see
|
|
35
|
+
* `proof/page-replay.ts` — so replay reflects real session cookies). */
|
|
8
36
|
export declare function replayProvider(provider: ReclaimProvider, secrets: Record<string, string>): Promise<ReplayResult>;
|
package/lib/proof/replay.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
|
|
2
2
|
* SDK's behavior so replay and proof both hit the same final URL. */
|
|
3
|
-
function expandTemplate(s, params) {
|
|
3
|
+
export function expandTemplate(s, params) {
|
|
4
4
|
if (!params) {
|
|
5
5
|
return s;
|
|
6
6
|
}
|
|
@@ -9,7 +9,7 @@ function expandTemplate(s, params) {
|
|
|
9
9
|
return v !== undefined ? v : match;
|
|
10
10
|
});
|
|
11
11
|
}
|
|
12
|
-
export
|
|
12
|
+
export function buildReplayRequest(provider, secrets) {
|
|
13
13
|
const params = provider.paramValues;
|
|
14
14
|
const url = expandTemplate(provider.url, params);
|
|
15
15
|
const expandedHeaders = {};
|
|
@@ -17,21 +17,29 @@ export async function replayProvider(provider, secrets) {
|
|
|
17
17
|
expandedHeaders[k] = expandTemplate(v, params);
|
|
18
18
|
}
|
|
19
19
|
const headers = { ...expandedHeaders, ...secrets };
|
|
20
|
-
const
|
|
20
|
+
const body = provider.body !== undefined
|
|
21
|
+
? expandTemplate(provider.body, params)
|
|
22
|
+
: undefined;
|
|
23
|
+
return {
|
|
24
|
+
url,
|
|
21
25
|
method: provider.method,
|
|
22
26
|
headers,
|
|
23
|
-
|
|
27
|
+
...(body !== undefined ? { body } : {}),
|
|
24
28
|
};
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Judge a replayed response against `provider`'s matchers — the part of
|
|
32
|
+
* replay that's identical regardless of HOW the request was sent (detached
|
|
33
|
+
* Node `fetch` or a live in-browser `fetch`). Separated from the actual
|
|
34
|
+
* network call so both transports share one evaluation.
|
|
35
|
+
*/
|
|
36
|
+
export function evaluateReplay(status, body, provider) {
|
|
37
|
+
const params = provider.paramValues;
|
|
30
38
|
const hints = [];
|
|
31
|
-
if (
|
|
39
|
+
if (status === 401 || status === 403) {
|
|
32
40
|
hints.push('auth/permission failed');
|
|
33
41
|
}
|
|
34
|
-
if (
|
|
42
|
+
if (status >= 500) {
|
|
35
43
|
hints.push('upstream server error');
|
|
36
44
|
}
|
|
37
45
|
for (const m of provider.responseMatches) {
|
|
@@ -45,22 +53,35 @@ export async function replayProvider(provider, secrets) {
|
|
|
45
53
|
value: expandTemplate(m.value, params),
|
|
46
54
|
};
|
|
47
55
|
if (!matchesBody(body, expanded)) {
|
|
48
|
-
if (
|
|
56
|
+
if (status >= 200 && status < 300) {
|
|
49
57
|
hints.push(`matcher ${expanded.type} did not match — ` +
|
|
50
58
|
'value may have changed or matcher is too tight');
|
|
51
59
|
}
|
|
52
|
-
const result = {
|
|
53
|
-
status: res.status,
|
|
54
|
-
body,
|
|
55
|
-
matchesOriginal: false,
|
|
56
|
-
};
|
|
60
|
+
const result = { status, body, matchesOriginal: false };
|
|
57
61
|
if (hints.length > 0) {
|
|
58
62
|
result.hints = hints;
|
|
59
63
|
}
|
|
60
64
|
return result;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
|
-
return { status
|
|
67
|
+
return { status, body, matchesOriginal: status < 400 };
|
|
68
|
+
}
|
|
69
|
+
/** Detached Node-side replay: sends the request directly from this process,
|
|
70
|
+
* NOT from any attached browser tab — its cookies/secrets are exactly the
|
|
71
|
+
* `secrets` passed in, never a real browser session. Used by tests and by
|
|
72
|
+
* any caller with no live browser to replay through (the MCP's authoring
|
|
73
|
+
* backend instead runs the SAME request inside the attached tab — see
|
|
74
|
+
* `proof/page-replay.ts` — so replay reflects real session cookies). */
|
|
75
|
+
export async function replayProvider(provider, secrets) {
|
|
76
|
+
const req = buildReplayRequest(provider, secrets);
|
|
77
|
+
const res = await fetch(req.url, {
|
|
78
|
+
method: req.method,
|
|
79
|
+
headers: req.headers,
|
|
80
|
+
redirect: 'manual',
|
|
81
|
+
...(req.body !== undefined ? { body: req.body } : {}),
|
|
82
|
+
});
|
|
83
|
+
const body = await res.text();
|
|
84
|
+
return evaluateReplay(res.status, body, provider);
|
|
64
85
|
}
|
|
65
86
|
function matchesBody(body, m) {
|
|
66
87
|
if (m.type === 'regex') {
|
|
@@ -137,9 +137,10 @@ export function elementAnchor(html, targets) {
|
|
|
137
137
|
/** The tightest element that DIRECTLY holds a target. Prefers a COMPLETE-value
|
|
138
138
|
* occurrence — an attribute whose value IS the target, or a text child that IS
|
|
139
139
|
* the target — over one where the target is merely embedded in a longer string
|
|
140
|
-
* (
|
|
141
|
-
* field is the semantic source and yields a portable anchor; the
|
|
142
|
-
* is incidental. Smallest byte span wins within the chosen
|
|
140
|
+
* (for example, a userid inside a `1234567+user@…` noreply address). The
|
|
141
|
+
* complete field is the semantic source and yields a portable anchor; the
|
|
142
|
+
* embedded one is incidental. Smallest byte span wins within the chosen
|
|
143
|
+
* tier. */
|
|
143
144
|
function findElement(root, targets) {
|
|
144
145
|
const complete = [];
|
|
145
146
|
const embedded = [];
|
|
@@ -196,8 +197,8 @@ function* candidateXPaths(el) {
|
|
|
196
197
|
}
|
|
197
198
|
// A uniquely-identified DIRECT child pins the parent without a positional
|
|
198
199
|
// path (`//span[span[@id="labels"]]`). Self-verification discards it when the
|
|
199
|
-
// child id/attr isn't unique —
|
|
200
|
-
// — so it never silently matches the wrong node.
|
|
200
|
+
// child id/attr isn't unique — for example, GitHub reuses `id="labels"` per
|
|
201
|
+
// email row — so it never silently matches the wrong node.
|
|
201
202
|
for (const child of el.children) {
|
|
202
203
|
if (!isTag(child)) {
|
|
203
204
|
continue;
|
|
@@ -234,8 +235,9 @@ function distinctiveClass(cls) {
|
|
|
234
235
|
.filter(Boolean)
|
|
235
236
|
.sort((x, y) => y.length - x.length)[0];
|
|
236
237
|
}
|
|
237
|
-
/** Positional path from `anc` (exclusive) down to `el` (inclusive),
|
|
238
|
-
* `/ul[1]/li[3]/a[1]
|
|
238
|
+
/** Positional path from `anc` (exclusive) down to `el` (inclusive), for
|
|
239
|
+
* example `/ul[1]/li[3]/a[1]`. Empty string when `anc` is not an ancestor
|
|
240
|
+
* of `el`. */
|
|
239
241
|
function relativeSteps(anc, el) {
|
|
240
242
|
const parts = [];
|
|
241
243
|
let node = el;
|
|
@@ -311,8 +313,8 @@ function absolutePath(el) {
|
|
|
311
313
|
}
|
|
312
314
|
/** True when `expression` selects EXACTLY `el` (one match) against `doc`. The
|
|
313
315
|
* attestor applies the redaction regex to EVERY xPath match and throws if any
|
|
314
|
-
* fails, so a non-unique selector (
|
|
315
|
-
* unusable — it must resolve to the single target element. */
|
|
316
|
+
* fails, so a non-unique selector (for example, a class shared by sibling
|
|
317
|
+
* rows) is unusable — it must resolve to the single target element. */
|
|
316
318
|
function matchesUniquely(doc, expression, el) {
|
|
317
319
|
try {
|
|
318
320
|
const engine = xpath;
|
package/lib/provider/draft.d.ts
CHANGED
|
@@ -14,7 +14,8 @@ export interface DraftTarget {
|
|
|
14
14
|
}
|
|
15
15
|
/** The realized extraction for one target — its value paired with the
|
|
16
16
|
* redaction that reveals it (absent when matched literally). Lets a consumer
|
|
17
|
-
* validate each value against its own selector (
|
|
17
|
+
* validate each value against its own selector (for example, the cloud
|
|
18
|
+
* replay). */
|
|
18
19
|
export interface Extraction {
|
|
19
20
|
name: string;
|
|
20
21
|
value: string;
|
|
@@ -30,12 +31,14 @@ export interface DraftResult {
|
|
|
30
31
|
* match or redaction — either a neighbouring target's value or a target's own
|
|
31
32
|
* value left un-templated. The matcher builds each target's window
|
|
32
33
|
* independently and drops the per-user WORDS it can see, but it cannot know a
|
|
33
|
-
* neighbouring value is itself user-specific (
|
|
34
|
-
* with an id — a `<b>{{id}}</b>` sitting beside a
|
|
35
|
-
* Cross-checking every value against every match here
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
34
|
+
* neighbouring value is itself user-specific (for example, a name sharing a
|
|
35
|
+
* text node with an id — a `<b>{{id}}</b>` sitting beside a
|
|
36
|
+
* `<td>{{name}}</td>`). Cross-checking every value against every match here
|
|
37
|
+
* catches
|
|
38
|
+
* that: a portable provider contains NO captured value as a literal — each
|
|
39
|
+
* appears only as its `{{param}}`. Throwing discards the draft so the agent
|
|
40
|
+
* re-captures or re-proposes rather than shipping a provider pinned to this
|
|
41
|
+
* one user. */
|
|
39
42
|
export declare function assertPortableMatches(matches: ResponseMatch[], redactions: ResponseRedaction[], targets: DraftTarget[]): void;
|
|
40
43
|
/** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
|
|
41
44
|
* request URL. The whole point of `hash` is that the attestor never sees
|
|
@@ -57,4 +60,6 @@ export declare function assertNoOprfUrlConflict(url: string, targets: DraftTarge
|
|
|
57
60
|
* header (same class of problem as {@link assertNoOprfUrlConflict} for
|
|
58
61
|
* OPRF-hashed targets, which has no such budget at all). */
|
|
59
62
|
export declare function assertNoSecretUrlConflict(url: string, secrets: Record<string, string>, writeRedactionMode?: ReclaimProvider['writeRedactionMode']): void;
|
|
60
|
-
export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode
|
|
63
|
+
export declare function draftProvider(req: CapturedRequest, targets: DraftTarget[], name: string, hash?: OprfMode, opts?: {
|
|
64
|
+
shortenNames?: boolean;
|
|
65
|
+
}): DraftResult;
|
package/lib/provider/draft.js
CHANGED
|
@@ -63,12 +63,14 @@ function sanitizeRequestHeaders(headers) {
|
|
|
63
63
|
* match or redaction — either a neighbouring target's value or a target's own
|
|
64
64
|
* value left un-templated. The matcher builds each target's window
|
|
65
65
|
* independently and drops the per-user WORDS it can see, but it cannot know a
|
|
66
|
-
* neighbouring value is itself user-specific (
|
|
67
|
-
* with an id — a `<b>{{id}}</b>` sitting beside a
|
|
68
|
-
* Cross-checking every value against every match here
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
66
|
+
* neighbouring value is itself user-specific (for example, a name sharing a
|
|
67
|
+
* text node with an id — a `<b>{{id}}</b>` sitting beside a
|
|
68
|
+
* `<td>{{name}}</td>`). Cross-checking every value against every match here
|
|
69
|
+
* catches
|
|
70
|
+
* that: a portable provider contains NO captured value as a literal — each
|
|
71
|
+
* appears only as its `{{param}}`. Throwing discards the draft so the agent
|
|
72
|
+
* re-captures or re-proposes rather than shipping a provider pinned to this
|
|
73
|
+
* one user. */
|
|
72
74
|
export function assertPortableMatches(matches, redactions, targets) {
|
|
73
75
|
// A baked email LOCAL-PART ("jane.doe@…") pins the provider to one user
|
|
74
76
|
// even when only the domain is captured as a param — the classic result of
|
|
@@ -192,7 +194,32 @@ function deriveParamName(providerName) {
|
|
|
192
194
|
const last = tokens[tokens.length - 1] ?? 'value';
|
|
193
195
|
return last.toLowerCase();
|
|
194
196
|
}
|
|
195
|
-
|
|
197
|
+
/** A `paramName` doubles as a regex NAMED CAPTURE GROUP (`(?<name>...)`) and a
|
|
198
|
+
* `paramValues` object key — both require a plain identifier. Nothing
|
|
199
|
+
* downstream validates this: an invalid name builds a syntactically broken
|
|
200
|
+
* regex that silently fails to extract at verification time instead of
|
|
201
|
+
* erroring here, at draft time, where it's actually fixable. */
|
|
202
|
+
const VALID_PARAM_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
203
|
+
/** Resolve a target's `paramName`: shortened (the default — last alphanumeric
|
|
204
|
+
* token, lowercased) or, with `shorten: false`, the name verbatim — the
|
|
205
|
+
* caller's choice, validated so it's still safe as a capture-group
|
|
206
|
+
* identifier. */
|
|
207
|
+
function resolveParamName(targetName, shorten) {
|
|
208
|
+
if (!shorten) {
|
|
209
|
+
if (!VALID_PARAM_NAME.test(targetName)) {
|
|
210
|
+
throw new Error(`target name "${targetName}" isn't safe as a param name with `
|
|
211
|
+
+ 'shortenNames:false — it becomes a regex capture-group name AND '
|
|
212
|
+
+ 'a paramValues key, so it must start with a letter/underscore and '
|
|
213
|
+
+ 'contain only letters, digits, and underscores (we recommend '
|
|
214
|
+
+ 'lower_snake_case, for example, "employer_name"). Rename it, or drop '
|
|
215
|
+
+ '`shortenNames: false` to auto-derive a safe one.');
|
|
216
|
+
}
|
|
217
|
+
return targetName;
|
|
218
|
+
}
|
|
219
|
+
return deriveParamName(targetName);
|
|
220
|
+
}
|
|
221
|
+
export function draftProvider(req, targets, name, hash, opts) {
|
|
222
|
+
const shortenNames = opts?.shortenNames ?? true;
|
|
196
223
|
if (!req.responseBody) {
|
|
197
224
|
throw new Error(`captured request ${req.requestId} has no response body`);
|
|
198
225
|
}
|
|
@@ -207,14 +234,21 @@ export function draftProvider(req, targets, name, hash) {
|
|
|
207
234
|
const extractions = [];
|
|
208
235
|
const effectiveHashes = [];
|
|
209
236
|
let paramValues;
|
|
210
|
-
const seenParams = new
|
|
237
|
+
const seenParams = new Map();
|
|
211
238
|
for (const t of targets) {
|
|
212
|
-
const paramName =
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
239
|
+
const paramName = resolveParamName(t.name, shortenNames);
|
|
240
|
+
const priorName = seenParams.get(paramName);
|
|
241
|
+
if (priorName !== undefined) {
|
|
242
|
+
throw new Error(`targets "${priorName}" and "${t.name}" both resolve to the same `
|
|
243
|
+
+ `param name "${paramName}"`
|
|
244
|
+
+ (shortenNames
|
|
245
|
+
? ' (names are shortened to their last alphanumeric token) — '
|
|
246
|
+
+ 'give them more distinct names, for example '
|
|
247
|
+
+ '"firstName"/"surname" instead of "first_name"/"last_name"'
|
|
248
|
+
: ' — give them distinct names, or drop `shortenNames: false` '
|
|
249
|
+
+ 'to auto-derive distinct ones'));
|
|
216
250
|
}
|
|
217
|
-
seenParams.
|
|
251
|
+
seenParams.set(paramName, t.name);
|
|
218
252
|
// Precedence: explicit per-target hash → explicit request-wide hash →
|
|
219
253
|
// the anti-leak floor (force-OPRF values whose SHAPE is unambiguous PII).
|
|
220
254
|
// The model makes the contextual calls (which named fields are sensitive)
|