@reclaimprotocol/agent 0.1.4 → 0.1.6

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.
Files changed (126) hide show
  1. package/README.md +5 -5
  2. package/lib/authoring/backend.d.ts +71 -0
  3. package/lib/authoring/backend.js +24 -0
  4. package/lib/authoring/find.d.ts +3 -0
  5. package/lib/authoring/find.js +54 -0
  6. package/lib/authoring/index.d.ts +4 -0
  7. package/lib/authoring/index.js +6 -0
  8. package/lib/authoring/propose.d.ts +3 -0
  9. package/lib/authoring/propose.js +109 -0
  10. package/lib/authoring/replay.d.ts +10 -0
  11. package/lib/authoring/replay.js +34 -0
  12. package/lib/authoring/tools.d.ts +16 -0
  13. package/lib/authoring/tools.js +193 -0
  14. package/lib/authoring/util.d.ts +27 -0
  15. package/lib/authoring/util.js +21 -0
  16. package/lib/cdp/capture-engine.js +1 -1
  17. package/lib/consts.d.ts +6 -1
  18. package/lib/consts.js +9 -2
  19. package/lib/mcp/api/operations.gen.js +46 -18
  20. package/lib/mcp/server.d.ts +2 -25
  21. package/lib/mcp/server.js +4 -17
  22. package/lib/mcp/start.js +4 -2
  23. package/lib/mcp/tools/agent/analyze.js +3 -3
  24. package/lib/mcp/tools/agent/build.d.ts +4 -4
  25. package/lib/mcp/tools/agent/build.js +13 -15
  26. package/lib/mcp/tools/agent/capture.d.ts +13 -1
  27. package/lib/mcp/tools/agent/capture.js +18 -85
  28. package/lib/mcp/tools/agent/eval.d.ts +5 -2
  29. package/lib/mcp/tools/agent/eval.js +11 -121
  30. package/lib/mcp/tools/agent/find.d.ts +10 -3
  31. package/lib/mcp/tools/agent/find.js +25 -31
  32. package/lib/mcp/tools/agent/proof.d.ts +13 -3
  33. package/lib/mcp/tools/agent/proof.js +81 -132
  34. package/lib/mcp/tools/agent/propose.d.ts +10 -9
  35. package/lib/mcp/tools/agent/propose.js +87 -64
  36. package/lib/mcp/tools/agent/publish.js +2 -2
  37. package/lib/mcp/tools/agent/replay.d.ts +10 -3
  38. package/lib/mcp/tools/agent/replay.js +29 -36
  39. package/lib/mcp/tools/agent/session.d.ts +1 -3
  40. package/lib/mcp/tools/agent/session.js +5 -5
  41. package/lib/mcp/tools/agent/shared.d.ts +8 -0
  42. package/lib/mcp/tools/agent/shared.js +125 -0
  43. package/lib/old/client.d.ts +35 -0
  44. package/lib/old/client.js +18 -0
  45. package/lib/old/login-cdp.d.ts +24 -0
  46. package/lib/old/login-cdp.js +28 -3
  47. package/lib/old/sdk-client.d.ts +39 -0
  48. package/lib/old/sdk-client.js +85 -0
  49. package/lib/old/to-register.d.ts +69 -0
  50. package/lib/old/to-register.js +52 -2
  51. package/lib/old/tools/build.d.ts +6 -1
  52. package/lib/old/tools/build.js +11 -1
  53. package/lib/old/tools/get-provider-info.d.ts +16 -0
  54. package/lib/old/tools/get-provider-info.js +73 -0
  55. package/lib/old/tools/get-version-config.d.ts +17 -0
  56. package/lib/old/tools/get-version-config.js +47 -0
  57. package/lib/old/tools/publish.js +189 -2
  58. package/lib/old/tools/update-provider-metadata.d.ts +16 -0
  59. package/lib/old/tools/update-provider-metadata.js +61 -0
  60. package/lib/proof/attestor.d.ts +6 -3
  61. package/lib/proof/attestor.js +10 -1
  62. package/lib/proof/debug-response-dump.d.ts +4 -0
  63. package/lib/proof/debug-response-dump.js +55 -0
  64. package/lib/proof/patch-domhandler.d.ts +1 -0
  65. package/lib/proof/patch-domhandler.js +59 -0
  66. package/lib/proof/proof-core.d.ts +1 -1
  67. package/lib/proof/proof-core.js +1 -1
  68. package/lib/proof/zk-files.js +7 -6
  69. package/lib/provider/dom-xpath.d.ts +17 -0
  70. package/lib/provider/dom-xpath.js +187 -0
  71. package/lib/provider/draft.d.ts +30 -2
  72. package/lib/provider/draft.js +117 -28
  73. package/lib/provider/index.d.ts +4 -0
  74. package/lib/provider/index.js +7 -0
  75. package/lib/provider/injection-doc.js +6 -0
  76. package/lib/provider/matcher.d.ts +17 -20
  77. package/lib/provider/matcher.js +394 -280
  78. package/lib/provider/schema.d.ts +7 -5
  79. package/lib/tools/registry.d.ts +50 -0
  80. package/lib/tools/registry.js +49 -0
  81. package/package.json +9 -1
  82. package/skill/SKILL.builder.md +55 -32
  83. package/skill/SKILL.old.md +20 -12
  84. package/skill/builder-mode.md +1 -1
  85. package/skill/injection-api.md +117 -0
  86. package/src/authoring/backend.ts +98 -0
  87. package/src/authoring/index.ts +20 -0
  88. package/src/authoring/tools.ts +273 -0
  89. package/src/authoring/util.ts +42 -0
  90. package/src/cdp/capture-engine.ts +1 -1
  91. package/src/consts.ts +15 -2
  92. package/src/mcp/api/operations.gen.ts +46 -18
  93. package/src/mcp/server.ts +9 -39
  94. package/src/mcp/start.ts +4 -1
  95. package/src/mcp/tools/agent/analyze.ts +3 -3
  96. package/src/mcp/tools/agent/build.ts +13 -21
  97. package/src/mcp/tools/agent/capture.ts +31 -115
  98. package/src/mcp/tools/agent/eval.ts +19 -155
  99. package/src/mcp/tools/agent/find.ts +33 -36
  100. package/src/mcp/tools/agent/proof.ts +87 -160
  101. package/src/mcp/tools/agent/propose.ts +93 -85
  102. package/src/mcp/tools/agent/publish.ts +2 -2
  103. package/src/mcp/tools/agent/replay.ts +32 -42
  104. package/src/mcp/tools/agent/session.ts +4 -5
  105. package/src/mcp/tools/agent/shared.ts +152 -0
  106. package/src/old/client.ts +56 -0
  107. package/src/old/login-cdp.ts +28 -3
  108. package/src/old/sdk-client.ts +105 -0
  109. package/src/old/to-register.ts +98 -6
  110. package/src/old/tools/build.ts +12 -0
  111. package/src/old/tools/get-provider-info.ts +91 -0
  112. package/src/old/tools/get-version-config.ts +56 -0
  113. package/src/old/tools/publish.ts +244 -1
  114. package/src/old/tools/update-provider-metadata.ts +72 -0
  115. package/src/proof/attestor.ts +16 -4
  116. package/src/proof/debug-response-dump.ts +70 -0
  117. package/src/proof/patch-domhandler.ts +68 -0
  118. package/src/proof/proof-core.ts +1 -1
  119. package/src/proof/zk-files.ts +7 -6
  120. package/src/provider/dom-xpath.ts +225 -0
  121. package/src/provider/draft.ts +168 -35
  122. package/src/provider/index.ts +8 -0
  123. package/src/provider/injection-doc.ts +6 -0
  124. package/src/provider/matcher.ts +477 -357
  125. package/src/provider/schema.ts +7 -5
  126. package/src/tools/registry.ts +92 -0
@@ -1,53 +1,26 @@
1
+ import { elementAnchor } from "./dom-xpath.js";
1
2
  import { normalizeCandidates } from "./normalize.js";
2
- /**
3
- * Minimum bytes per response redaction. The Reclaim zkTLS protocol uses
4
- * AES-CTR; revealing fewer than one AES block (16 bytes) opens the slice
5
- * to brute-force guessing. Matters only for the text/plain padded-regex
6
- * fallback — xPath / jsonPath redactions reveal a whole TLS record, which
7
- * is always larger.
8
- */
9
- const MIN_REVEAL_BYTES = 16;
10
- /**
11
- * Budget for climbing past the smallest enclosing element in search of an
12
- * ancestor with an `id` or `class` anchor. Capped so we don't end up
13
- * pointing at `<body>` or `<html>` (which would defeat redaction by
14
- * revealing most of the page).
15
- */
16
- const MAX_ANCHOR_CLIMB_BYTES = 512;
17
- /**
18
- * Hard cap on how many bytes the redaction is allowed to reveal. When
19
- * the picked xPath/jsonPath element exceeds this, the redaction is
20
- * extended with a `regex` that narrows to the surrounding key/value
21
- * pattern — so the attestor only ZKs ~50-100 bytes instead of multi-KB
22
- * blobs. Without this, a perfectly anchorable element like
23
- * `<script id="client-env">{...7KB of state...}</script>` would push
24
- * the attestor through many MB of ZK work and OOM Node.
25
- */
26
- const MAX_REVEAL_BYTES = 512;
27
3
  /** Reclaim attestor caps OPRF-hashed values at 62 bytes (TOPRF marker
28
4
  * encoding). Surfaces as a clean error here rather than a confusing
29
5
  * attestor-side failure at proof time. */
30
6
  export const OPRF_MAX_BYTES = 62;
31
7
  /**
32
- * Build the matcher pieces (responseMatches + responseRedactions +
33
- * paramValues) for a target value seen in a captured response.
8
+ * Build the matcher pieces (one responseMatch + one responseRedaction) for a
9
+ * target value seen in a captured response. The design, in one line:
34
10
  *
35
- * All outputs share the same shape:
36
- * - responseMatches: a single `contains` whose value is the surrounding
37
- * structural context with the target replaced by `{{paramName}}`
38
- * - paramValues: `{ [paramName]: target }` — the prover's real value
39
- * - responseRedactions: one entry that narrows the revealed slice
40
- * (xPath / jsonPath / regex), and — when `hash` is requested — also
41
- * carries a `regex` with a named capture group + a `hash` field for
42
- * OPRF
11
+ * the REDACTION selects with xPath/jsonPath and names the value with a
12
+ * capture group; the MATCH is a `contains` of the surrounding structure with
13
+ * the value replaced by `{{paramName}}`.
43
14
  *
44
- * `hash` is opt-in. The skill prompts the dev. Drafting without `hash`
45
- * is the default — the value travels in cleartext to the attestor.
15
+ * The match is what the attestor enforces (against the prover's revealed
16
+ * bytes), so it must anchor the value in context the prover can't relocate — a
17
+ * JSON key (`"login":"{{v}}"`) or the value's HTML element (`<span …>{{v}}`).
18
+ * The redaction's regex only extracts/narrows; xPath/jsonPath do the selecting.
46
19
  *
47
- * Throws when `hash` is set but the target has no safe character class
48
- * (unicode, punctuation, spaces — anything that doesn't fit a digit /
49
- * slug / email regex). Drafting OPRF for a value we can't capture
50
- * reliably would yield a broken provider.
20
+ * `hash` (OPRF) is opt-in — enable it only when the request asks to protect
21
+ * PII. It rides the `contains {{v}}` path: the redaction carries `hash`, match
22
+ * keeps `{{v}}`, and the attestor substitutes the nullifier from paramValues at
23
+ * proof time. Throws when `hash` is set on a free-form value (no safe class).
51
24
  */
52
25
  export function synthesizeMatcher(body, contentType, target, paramName, hash) {
53
26
  if (!body.includes(target) &&
@@ -55,78 +28,135 @@ export function synthesizeMatcher(body, contentType, target, paramName, hash) {
55
28
  return undefined;
56
29
  }
57
30
  if (hash && !inferValueClass(target)) {
58
- throw new Error('OPRF hashing requires a target with a known-safe character class ' +
59
- '(digits, slug, or email). ' +
60
- `Got "${target}" which is free-form. Use a tighter target` +
61
- '(e.g. just the alphanumeric portion of the value)' +
62
- ' or call propose_provider without `hash`.');
63
- }
64
- const pick = pickAnchor(body, contentType, target);
65
- // Two reasons to extend the redaction with a `regex` narrow:
66
- // (a) hash is set — the regex carries the named capture group we OPRF
67
- // (b) the picked element is too big to reveal whole — we narrow the
68
- // slice to just the value's surrounding key/value pattern
69
- const isOversized = pick?.elementBytes !== undefined && pick.elementBytes > MAX_REVEAL_BYTES;
70
- const needsRegex = hash !== undefined || isOversized;
71
- if (pick && needsRegex) {
72
- // Free-form values can use a permissive class when only narrowing;
73
- // `hash` already had the strict check above.
74
- const valueClass = inferValueClass(target) ?? '[^"<>]+';
75
- const captureRegex = buildCaptureRegex(body, target, paramName, valueClass);
76
- const redaction = {
77
- ...pick.redaction,
78
- regex: captureRegex,
79
- };
80
- if (hash) {
81
- redaction.hash = hash;
31
+ throw new Error('OPRF hashing requires a target with a known-safe character class '
32
+ + '(digits, slug, or email). '
33
+ + `Got "${target}" which is free-form. Use a tighter target `
34
+ + '(e.g. just the alphanumeric portion of the value) '
35
+ + 'or call propose_provider without `hash`.');
36
+ }
37
+ const valueClass = inferValueClass(target) ?? '[^"<>\\n]+';
38
+ // JSON body → the value lives in a `"key": value` pair. jsonPath pins the
39
+ // exact property; the `contains` match anchors on the key.
40
+ if (/json/i.test(contentType) || isJsonBody(body)) {
41
+ const r = jsonMatcher(body, target, paramName, valueClass, hash);
42
+ if (r) {
43
+ return r;
82
44
  }
83
- return makeResult(body, target, paramName, redaction);
84
45
  }
85
- if (pick) {
86
- return makeResult(body, target, paramName, pick.redaction);
46
+ // HTML/XML → the value lives in an element. xPath selects it (an id/class
47
+ // ancestor, else the value's own tag); the `contains` match is the element
48
+ // markup with the value swapped for the placeholder. A `<script>` holding
49
+ // JSON routes through jsonPath instead.
50
+ if (/html|xml/i.test(contentType) || looksLikeHtml(body)) {
51
+ const r = htmlMatcher(body, target, paramName, valueClass, hash);
52
+ if (r) {
53
+ return r;
54
+ }
87
55
  }
88
- // No structural anchor — fall back to a regex-only redaction.
89
- if (hash) {
90
- const captureRegex = buildCaptureRegex(body, target, paramName, inferValueClass(target));
91
- return makeResult(body, target, paramName, { regex: captureRegex, hash });
56
+ // No JSON key and no stable xPath anchor: a content-anchored regex, matched
57
+ // by CONTENT so it reproduces regardless of DOM position at proof time (a
58
+ // positional `(//tag)[N]` resolves to the wrong node at proof — see 9fd7770).
59
+ return contentMatcher(body, target, paramName, valueClass, hash);
60
+ }
61
+ /** True when `body` is a JSON document (starts `{`/`[` and parses). */
62
+ function isJsonBody(body) {
63
+ const head = body.trimStart()[0];
64
+ if (head !== '{' && head !== '[') {
65
+ return false;
92
66
  }
93
- const padded = buildPaddedRegex(body, target);
94
- if (padded) {
95
- return makeResult(body, target, paramName, { regex: padded });
67
+ try {
68
+ JSON.parse(body);
69
+ return true;
96
70
  }
97
- return undefined;
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ /** Index of `target` (or an encoding variant) in `text`, with the matched
76
+ * variant. `{ idx: -1 }` when absent. */
77
+ function firstIndex(text, target) {
78
+ for (const value of [target, ...normalizeCandidates(target)]) {
79
+ const idx = text.indexOf(value);
80
+ if (idx >= 0) {
81
+ return { idx, value };
82
+ }
83
+ }
84
+ return { idx: -1, value: target };
98
85
  }
99
- function makeResult(body, target, paramName, redaction) {
86
+ /**
87
+ * Assemble the result for a value that sits after a literal `prefix` and
88
+ * before a `close` delimiter — the shared shape for JSON `"key":"…"` and
89
+ * JSON-in-HTML. The redaction pairs the selector with a capture regex
90
+ * (whitespace in the prefix flexed); the match `contains` `prefix{{v}}close`.
91
+ */
92
+ function keyValueResult(prefix, close, target, paramName, valueClass, selector, hash) {
93
+ const redaction = {
94
+ ...selector,
95
+ regex: `${escapeRegexFlexWs(prefix)}(?<${paramName}>${valueClass})`
96
+ + escapeRegex(close),
97
+ };
98
+ if (hash) {
99
+ redaction.hash = hash;
100
+ }
100
101
  return {
101
- match: {
102
- type: 'contains',
103
- value: buildContextualTemplate(body, target, paramName),
104
- },
102
+ match: { type: 'contains', value: `${prefix}{{${paramName}}}${close}` },
105
103
  redaction,
106
104
  paramValues: { [paramName]: target },
107
105
  };
108
106
  }
109
- function pickAnchor(body, contentType, target) {
110
- if (/json/i.test(contentType)) {
111
- const jsonPath = findJsonPath(body, target);
112
- if (jsonPath) {
113
- return { redaction: { jsonPath } };
114
- }
107
+ // ---------------------------------------------------------------------------
108
+ // JSON: "key": value → jsonPath redaction + `contains "key":"{{v}}"`
109
+ // ---------------------------------------------------------------------------
110
+ /**
111
+ * Matcher for a value inside a JSON `"key": value` pair. jsonPath pins the
112
+ * exact property (so the redaction extracts THIS one, not a same-named key
113
+ * elsewhere); the `contains` match anchors on `"key":`.
114
+ */
115
+ function jsonMatcher(body, target, paramName, valueClass, hash) {
116
+ const jsonPath = findJsonPath(body, target);
117
+ if (!jsonPath) {
118
+ return undefined;
115
119
  }
116
- if (/html|xml/i.test(contentType) || looksLikeHtml(body)) {
117
- const xPath = synthesizeXPath(body, target);
118
- if (xPath) {
119
- return {
120
- redaction: { xPath: xPath.xPath },
121
- elementBytes: xPath.elementBytes,
122
- };
123
- }
120
+ const anchor = jsonKeyPrefix(body, target);
121
+ if (!anchor) {
122
+ return undefined;
124
123
  }
125
- return undefined;
124
+ return keyValueResult(anchor.prefix, anchor.close, target, paramName, valueClass, { jsonPath }, hash);
125
+ }
126
+ /**
127
+ * The literal `"key":"` (or `"key":` for a raw number/bool) immediately before
128
+ * `target` in `text`, plus the closing quote (`"` for a string value, `''`
129
+ * otherwise). Returns undefined when the value isn't preceded by a JSON key.
130
+ */
131
+ function jsonKeyPrefix(text, target) {
132
+ const { idx } = firstIndex(text, target);
133
+ if (idx < 0) {
134
+ return undefined;
135
+ }
136
+ let i = idx - 1;
137
+ const quoted = text[i] === '"';
138
+ if (quoted) {
139
+ i--;
140
+ }
141
+ while (i >= 0 && /\s/.test(text[i] ?? '')) {
142
+ i--;
143
+ }
144
+ if (text[i] !== ':') {
145
+ return undefined;
146
+ }
147
+ i--;
148
+ while (i >= 0 && /\s/.test(text[i] ?? '')) {
149
+ i--;
150
+ }
151
+ if (text[i] !== '"') {
152
+ return undefined;
153
+ }
154
+ const keyStart = text.lastIndexOf('"', i - 1);
155
+ if (keyStart < 0) {
156
+ return undefined;
157
+ }
158
+ return { prefix: text.slice(keyStart, idx), close: quoted ? '"' : '' };
126
159
  }
127
- // ---------------------------------------------------------------------------
128
- // JSON helpers
129
- // ---------------------------------------------------------------------------
130
160
  function findJsonPath(body, target) {
131
161
  let parsed;
132
162
  try {
@@ -173,232 +203,296 @@ function walk(node, path, candidates) {
173
203
  }
174
204
  return undefined;
175
205
  }
206
+ // ---------------------------------------------------------------------------
207
+ // HTML: element → xPath redaction + `contains "<tag …>{{v}}</tag>"`
208
+ // ---------------------------------------------------------------------------
176
209
  function looksLikeHtml(body) {
177
210
  return /<[a-zA-Z][a-zA-Z0-9-]*[\s>]/.test(body.slice(0, 2000));
178
211
  }
179
- /** Find the best xPath anchor for `target` in HTML-shaped `body`.
180
- * Preference order:
181
- * 1. id/class on the smallest enclosing element
182
- * 2. id/class on the nearest ancestor within MAX_ANCHOR_CLIMB_BYTES
183
- * 3. positional `(//tag)[N]` on the smallest enclosing element */
184
- function synthesizeXPath(body, target) {
185
- for (const variant of normalizeCandidates(target)) {
186
- const idx = body.indexOf(variant);
187
- if (idx < 0) {
188
- continue;
189
- }
190
- let el = findEnclosingElement(body, idx, variant.length);
191
- while (el && el.closeEnd - el.openStart < MIN_REVEAL_BYTES) {
192
- const outer = findEnclosingElement(body, el.openStart, el.closeEnd - el.openStart);
193
- if (!outer || outer.openStart >= el.openStart) {
194
- break;
195
- }
196
- el = outer;
197
- }
198
- if (!el) {
199
- continue;
200
- }
201
- const anchored = climbForAnchor(body, el);
202
- const final = anchored ?? el;
203
- return {
204
- xPath: buildXPath(final, body),
205
- elementBytes: final.closeEnd - final.openStart,
206
- };
212
+ /**
213
+ * Matcher for a value inside an HTML element. The element and a verified xPath
214
+ * come from a real parse5 parse (see dom-xpath.ts), then either:
215
+ * - `<script>` JSON: dig the value out with jsonPath (`"key":"{{v}}"` match);
216
+ * - regular element: `contains` the element markup with the value → `{{v}}`.
217
+ */
218
+ function htmlMatcher(body, target, paramName, valueClass, hash) {
219
+ const anchor = elementAnchor(body, [target, ...normalizeCandidates(target)]);
220
+ if (!anchor) {
221
+ return undefined;
207
222
  }
208
- return undefined;
209
- }
210
- function climbForAnchor(body, el) {
211
- if (el.id || el.classes.length > 0) {
212
- return el;
213
- }
214
- let cur = el;
215
- while (true) {
216
- const parent = findEnclosingElement(body, cur.openStart, cur.closeEnd - cur.openStart);
217
- if (!parent || parent.openStart >= cur.openStart) {
218
- return undefined;
219
- }
220
- if (parent.closeEnd - parent.openStart > MAX_ANCHOR_CLIMB_BYTES) {
221
- return undefined;
222
- }
223
- if (parent.id || parent.classes.length > 0) {
224
- return parent;
223
+ const revealSlice = body.slice(anchor.start, anchor.end);
224
+ // JSON embedded in a <script>: xPath selects the script, jsonPath digs out
225
+ // the value, the regex captures it — immune to a same-named key elsewhere.
226
+ const scriptJson = scriptJsonContent(revealSlice);
227
+ if (scriptJson) {
228
+ const jsonPath = findJsonPath(scriptJson, target);
229
+ const kp = jsonPath ? jsonKeyPrefix(scriptJson, target) : undefined;
230
+ if (jsonPath && kp) {
231
+ return keyValueResult(kp.prefix, kp.close, target, paramName, valueClass, { xPath: anchor.xPath, jsonPath }, hash);
225
232
  }
226
- cur = parent;
227
233
  }
234
+ return elementResult(anchor.xPath, revealSlice, target, paramName, valueClass, hash);
228
235
  }
229
- function findEnclosingElement(body, valueIdx, valueLen) {
230
- let i = valueIdx - 1;
231
- while (i >= 0) {
232
- if (body[i] !== '>') {
233
- i--;
234
- continue;
235
- }
236
- const tagEnd = i;
237
- const tagStart = body.lastIndexOf('<', tagEnd);
238
- if (tagStart < 0) {
239
- return undefined;
240
- }
241
- const openTag = body.slice(tagStart, tagEnd + 1);
242
- if (openTag.startsWith('</') ||
243
- openTag.startsWith('<!') ||
244
- openTag.startsWith('<?') ||
245
- openTag.trimEnd().endsWith('/>')) {
246
- i = tagStart - 1;
247
- continue;
248
- }
249
- const tagNameMatch = openTag.match(/^<([a-zA-Z][a-zA-Z0-9-]*)/);
250
- if (!tagNameMatch) {
251
- i = tagStart - 1;
252
- continue;
253
- }
254
- const tagName = tagNameMatch[1].toLowerCase();
255
- const closePattern = new RegExp(`</${tagName}\\s*>`, 'i');
256
- const afterValue = body.slice(valueIdx + valueLen);
257
- const closeMatch = afterValue.match(closePattern);
258
- if (closeMatch?.index === undefined) {
259
- i = tagStart - 1;
260
- continue;
261
- }
262
- const closeEnd = valueIdx + valueLen + closeMatch.index + closeMatch[0].length;
263
- const idMatch = openTag.match(/\bid\s*=\s*["']([^"']+)["']/);
264
- const classMatch = openTag.match(/\bclass\s*=\s*["']([^"']+)["']/);
265
- const classes = classMatch ? classMatch[1].split(/\s+/).filter(Boolean) : [];
266
- const el = {
267
- tag: tagName,
268
- openStart: tagStart,
269
- closeEnd,
270
- classes,
271
- };
272
- if (idMatch?.[1] !== undefined) {
273
- el.id = idMatch[1];
274
- }
275
- return el;
236
+ /**
237
+ * `contains` a PORTABLE window of the element around the value, with the value
238
+ * swapped for `{{paramName}}`. "Portable" = it anchors only on stable structure
239
+ * (allow-listed attributes + tag boundaries) and excludes per-user / volatile
240
+ * bytes (`href`/`value`/`id`/`data-*` attribute values, sibling elements), so a
241
+ * published provider reproduces for ANY user, not just the author. The
242
+ * redaction reveals + captures the value over the same window (xPath + regex,
243
+ * whitespace flexed); `contains` is required for extractBareParams + OPRF.
244
+ */
245
+ function elementResult(xPath, markup, target, paramName, valueClass, hash) {
246
+ const { idx, value } = firstIndex(markup, target);
247
+ if (idx < 0) {
248
+ return undefined;
276
249
  }
277
- return undefined;
250
+ const { left, right } = portableWindow(markup, idx, value.length);
251
+ const redaction = {
252
+ xPath,
253
+ regex: `${escapeRegexFlexWs(left)}(?<${paramName}>${valueClass})`
254
+ + escapeRegexFlexWs(right),
255
+ };
256
+ if (hash) {
257
+ redaction.hash = hash;
258
+ }
259
+ return {
260
+ match: { type: 'contains', value: `${left}{{${paramName}}}${right}` },
261
+ redaction,
262
+ paramValues: { [paramName]: target },
263
+ };
278
264
  }
279
- function buildXPath(el, body) {
280
- if (el.id) {
281
- return `//${el.tag}[@id=${xpathLiteral(el.id)}]`;
265
+ /** Attribute NAMES safe to bake into a match — stable across users. Everything
266
+ * else (`href`, `src`, `value`, `id`, `data-*`, `style`, `content`, nonces, …)
267
+ * carries per-user / per-render data and is excluded from the anchor. */
268
+ const STABLE_ATTRS = new Set([
269
+ 'class', 'type', 'rel', 'role', 'name', 'property', 'itemprop', 'for',
270
+ 'scope', 'lang', 'dir', 'selected', 'checked', 'disabled', 'readonly',
271
+ 'required', 'multiple', 'open', 'headers',
272
+ ]);
273
+ /** A portable literal window `{ left, right }` around the value in `markup`:
274
+ * anchored on stable structure, stripped of volatile/per-user bytes. */
275
+ function portableWindow(markup, valueIdx, valueLen) {
276
+ const valueEnd = valueIdx + valueLen;
277
+ const lastLt = markup.lastIndexOf('<', valueIdx - 1);
278
+ const lastGt = markup.lastIndexOf('>', valueIdx - 1);
279
+ // A `<` with no `>` after it before the value ⇒ value sits in an attribute.
280
+ return lastLt > lastGt
281
+ ? attributeWindow(markup, valueIdx, valueEnd, lastLt)
282
+ : textWindow(markup, valueIdx, valueEnd, lastGt);
283
+ }
284
+ /** Value is element text content. Left anchor: if only punctuation/whitespace
285
+ * sits between the opening tag and the value, fold the stable opening tag in;
286
+ * but if WORDS precede the value in the same text node they may be per-user
287
+ * (e.g. `<li>Full Name ( {{id}} )` — the name), so drop through the last
288
+ * alphanumeric and keep only the trailing punctuation as the anchor. Right:
289
+ * include a closing tag, but stop at a sibling. */
290
+ function textWindow(markup, valueIdx, valueEnd, openTagGt) {
291
+ const openTagStart = markup.lastIndexOf('<', openTagGt);
292
+ const openTag = openTagStart >= 0
293
+ ? markup.slice(openTagStart, openTagGt + 1)
294
+ : '';
295
+ const between = markup.slice(openTagGt + 1, valueIdx);
296
+ const left = /[A-Za-z0-9]/.test(between)
297
+ // Words before the value could be per-user — anchor on the punctuation
298
+ // after the last one (drops the words AND the tag they'd drag in).
299
+ ? between.replace(/[\s\S]*[A-Za-z0-9]/, '')
300
+ // Only punctuation/whitespace — safe to fold the stable opening tag.
301
+ : stableOpeningTail(openTag) + between;
302
+ const nextLt = markup.indexOf('<', valueEnd);
303
+ const inter = nextLt < 0
304
+ ? markup.slice(valueEnd)
305
+ : markup.slice(valueEnd, nextLt);
306
+ // Trailing words after the value may be per-user too — keep only the
307
+ // punctuation up to the first alphanumeric, and drop the closing tag with
308
+ // them (contains is contiguous, so it can't be reached past a word).
309
+ if (/[A-Za-z0-9]/.test(inter)) {
310
+ return { left, right: inter.replace(/[A-Za-z0-9][\s\S]*/, '') };
282
311
  }
283
- if (el.classes.length > 0) {
284
- const distinctive = [...el.classes].sort((a, b) => b.length - a.length)[0];
285
- return `//${el.tag}[contains(@class, ${xpathLiteral(distinctive)})]`;
312
+ // Punctuation/whitespace only: fold the stable closing tag if present.
313
+ if (nextLt >= 0 && markup.startsWith('</', nextLt)) {
314
+ const close = markup.indexOf('>', nextLt);
315
+ const end = close < 0 ? nextLt : close + 1;
316
+ return { left, right: markup.slice(valueEnd, end) };
286
317
  }
287
- return `(//${el.tag})[${tagIndex(body, el)}]`;
318
+ // Sibling opening tag (or end of markup) — stop at it.
319
+ return { left, right: inter };
288
320
  }
289
- function tagIndex(body, el) {
290
- const pattern = new RegExp(`<${el.tag}\\b`, 'gi');
291
- let n = 0;
321
+ /** Value is an attribute value: anchor on the nearest STABLE identifying
322
+ * attribute (`name`/`property`/…) plus the value attribute's `name="`. */
323
+ function attributeWindow(markup, valueIdx, valueEnd, openTagStart) {
324
+ const gt = markup.indexOf('>', valueEnd);
325
+ const openTag = markup.slice(openTagStart, gt < 0 ? markup.length : gt + 1);
326
+ const rel = valueIdx - openTagStart;
327
+ const attrRe = /\s+([a-zA-Z][\w:-]*)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/g;
328
+ let anchorStart = -1;
329
+ let valueAttrStart = -1;
292
330
  let m;
293
- while ((m = pattern.exec(body)) !== null) {
294
- if (m.index > el.openStart) {
331
+ while ((m = attrRe.exec(openTag)) !== null) {
332
+ const start = m.index;
333
+ if (start < rel && rel < start + m[0].length) {
334
+ valueAttrStart = start;
295
335
  break;
296
336
  }
297
- n++;
337
+ if (STABLE_ATTRS.has(m[1].toLowerCase())) {
338
+ anchorStart = start;
339
+ }
298
340
  }
299
- return n;
341
+ const from = anchorStart >= 0 ? anchorStart : valueAttrStart;
342
+ const left = from >= 0 ? openTag.slice(from, rel).replace(/^\s+/, '') : '';
343
+ const quote = markup[valueEnd];
344
+ return { left, right: quote === '"' || quote === "'" ? quote : '' };
300
345
  }
301
- /** Quote a string as an XPath 1.0 string literal. XPath 1.0 literals have no
302
- * escape mechanism, so a value containing both quote characters must be
303
- * assembled with concat(). Backslashes are not special and pass through. */
304
- function xpathLiteral(s) {
305
- if (!s.includes('"')) {
306
- return `"${s}"`;
307
- }
308
- if (!s.includes("'")) {
309
- return `'${s}'`;
310
- }
311
- const parts = s.split('"').map((p) => `"${p}"`);
312
- return `concat(${parts.join(', \'"\', ')})`;
346
+ /** The portable suffix of an opening tag (ending at `>`): the trailing run of
347
+ * allow-listed attributes. Everything up to and including the last volatile
348
+ * attribute is dropped, so `<a href="/user" class="x">` → `class="x">`. */
349
+ function stableOpeningTail(openTag) {
350
+ const attrRe = /\s+([a-zA-Z][\w:-]*)(\s*=\s*("[^"]*"|'[^']*'|[^\s>]*))?/g;
351
+ let cut = -1;
352
+ let m;
353
+ while ((m = attrRe.exec(openTag)) !== null) {
354
+ if (!STABLE_ATTRS.has(m[1].toLowerCase())) {
355
+ cut = m.index + m[0].length;
356
+ }
357
+ }
358
+ return (cut < 0 ? openTag : openTag.slice(cut)).replace(/^\s+/, '');
359
+ }
360
+ /** If `markup` is a single `<script>…</script>` element, return its text
361
+ * content (the JSON body the attestor exposes when a jsonPath is paired with
362
+ * the script's xPath); otherwise undefined. */
363
+ function scriptJsonContent(markup) {
364
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup);
365
+ if (!match) {
366
+ return undefined;
367
+ }
368
+ const content = match[1].trim();
369
+ return content.length > 0 ? content : undefined;
313
370
  }
314
371
  // ---------------------------------------------------------------------------
315
- // Padded-regex fallback for non-JSON, non-HTML content
372
+ // Content-anchored fallback (no stable structural anchor)
316
373
  // ---------------------------------------------------------------------------
317
- function buildPaddedRegex(body, target) {
374
+ /** Longest opening/closing HTML tag folded into the match context. */
375
+ const MAX_TAG_BYTES = 256;
376
+ /** Bytes the left context grows per step while searching for uniqueness. */
377
+ const CONTEXT_STEP = 4;
378
+ /**
379
+ * Matcher for a value with no JSON key and no stable xPath anchor: a
380
+ * content-anchored regex redaction (position-independent) paired with a match
381
+ * carrying surrounding literal context, so it is never a bare — hence
382
+ * spoofable — `{{value}}`. When there is genuinely no literal context, the
383
+ * value-isolating regex itself becomes the (regex-type) match.
384
+ */
385
+ function contentMatcher(body, target, paramName, valueClass, hash) {
386
+ const regex = buildContentRegex(body, target, paramName, valueClass);
387
+ if (!regex) {
388
+ return undefined;
389
+ }
390
+ const redaction = { regex };
391
+ if (hash) {
392
+ redaction.hash = hash;
393
+ // OPRF needs a `contains {{v}}` match — the attestor substitutes the
394
+ // nullifier into it (a regex match can't express that). Anchor on the
395
+ // surrounding literal context so it's never a bare, spoofable `{{v}}`.
396
+ return {
397
+ match: {
398
+ type: 'contains',
399
+ value: buildContextualTemplate(body, target, paramName),
400
+ },
401
+ redaction,
402
+ paramValues: { [paramName]: target },
403
+ };
404
+ }
405
+ // The value-isolating regex IS the match: grown to uniquely capture THIS
406
+ // occurrence, so it's position-independent, self-extracting (the attestor
407
+ // reads its named group — verification's `extractBareParams` can't grab a
408
+ // same-shaped earlier occurrence, as a `contains "key":"{{v}}"` would), and
409
+ // never a bare, spoofable `{{v}}`.
410
+ return {
411
+ match: { type: 'regex', value: regex },
412
+ redaction,
413
+ paramValues: { [paramName]: target },
414
+ };
415
+ }
416
+ /**
417
+ * A value-isolating regex around the first occurrence of `target`, matched by
418
+ * CONTENT so it reproduces regardless of DOM position: leading literal context
419
+ * (grown until the first whole-body match captures THIS occurrence) + a
420
+ * `(?<paramName>valueClass)` group + any trailing delimiter. The revealed span
421
+ * must CONTAIN the match template (buildContextualTemplate), so the trailing
422
+ * delimiter is kept unless it's whitespace (which the template also drops).
423
+ */
424
+ function buildContentRegex(body, target, paramName, valueClass) {
318
425
  for (const cand of normalizeCandidates(target)) {
319
426
  const idx = body.indexOf(cand);
320
427
  if (idx < 0) {
321
428
  continue;
322
429
  }
323
- let start = idx;
324
- let end = idx + cand.length;
325
- const need = MIN_REVEAL_BYTES - cand.length;
326
- if (need > 0) {
327
- const leftPad = Math.floor(need / 2);
328
- const rightPad = need - leftPad;
329
- start = Math.max(0, idx - leftPad);
330
- end = Math.min(body.length, idx + cand.length + rightPad);
331
- if (start === 0) {
332
- end = Math.min(body.length, idx + cand.length + need - (idx - 0));
430
+ const endIdx = idx + cand.length;
431
+ const rawRight = findRightContext(body, endIdx);
432
+ const right = /^\s/.test(rawRight) ? '' : escapeRegexFlexWs(rawRight);
433
+ const baseStart = idx - findLeftContext(body, idx).length;
434
+ for (let start = baseStart;; start -= CONTEXT_STEP) {
435
+ const from = Math.max(0, start);
436
+ const before = escapeRegexFlexWs(body.slice(from, idx));
437
+ const re = `${before}(?<${paramName}>${valueClass})${right}`;
438
+ try {
439
+ if (new RegExp(re).exec(body)?.groups?.[paramName] === cand) {
440
+ return re;
441
+ }
333
442
  }
334
- if (end === body.length) {
335
- start = Math.max(0, body.length - MIN_REVEAL_BYTES);
443
+ catch {
444
+ break;
445
+ }
446
+ if (from === 0) {
447
+ break;
336
448
  }
337
449
  }
338
- const isBoundary = (c) => {
339
- return c === '<' || c === '>' || c === '"' || c === '}' || c === '{';
340
- };
341
- const STRETCH = 8;
342
- for (let i = 0; i < STRETCH && start > 0 && !isBoundary(body[start]); i++) {
343
- start--;
344
- }
345
- for (let i = 0; i < STRETCH && end < body.length && !isBoundary(body[end - 1]); i++) {
346
- end++;
347
- }
348
- const window = body.slice(start, end);
349
- if (window.length < MIN_REVEAL_BYTES) {
350
- continue;
351
- }
352
- return escapeRegex(window);
353
450
  }
354
451
  return undefined;
355
452
  }
356
- function escapeRegex(s) {
357
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
358
- }
359
- // ---------------------------------------------------------------------------
360
- // Capture group regex (only used when hash is requested)
361
- // ---------------------------------------------------------------------------
362
- function buildCaptureRegex(body, target, paramName, valueClass) {
363
- const idx = body.indexOf(target);
364
- if (idx < 0) {
365
- return `(?<${paramName}>${valueClass})`;
366
- }
367
- const endIdx = idx + target.length;
368
- const leftCtx = findLeftContext(body, idx);
369
- const rightCtx = findRightContext(body, endIdx);
370
- // eslint-disable-next-line @stylistic/max-len
371
- return `${escapeRegex(leftCtx)}(?<${paramName}>${valueClass})${escapeRegex(rightCtx)}`;
372
- }
373
- // ---------------------------------------------------------------------------
374
- // responseMatches template (literal `contains`, paramValues-substituted)
375
- // ---------------------------------------------------------------------------
376
- /** Build the literal substring template for `responseMatches.value` —
377
- * the surrounding structural context with the target replaced by
378
- * `{{paramName}}`. The attestor substitutes paramValues at proof time
379
- * (or the OPRF nullifier, when the matching redaction has `hash`). */
453
+ /**
454
+ * The literal `contains` template: surrounding structural context with the
455
+ * value replaced by `{{paramName}}`. Whitespace-adjacent context is dropped (a
456
+ * literal contains can't flex whitespace); stable context (a JSON key, an HTML
457
+ * opening tag) is kept so the match anchors on it.
458
+ */
380
459
  function buildContextualTemplate(body, target, paramName) {
381
460
  const idx = body.indexOf(target);
382
461
  if (idx < 0) {
383
462
  return `{{${paramName}}}`;
384
463
  }
385
464
  const endIdx = idx + target.length;
386
- const leftCtx = findLeftContext(body, idx);
387
- const rightCtx = findRightContext(body, endIdx);
465
+ const leftCtx = /\s$/.test(findLeftContext(body, idx))
466
+ ? '' : findLeftContext(body, idx);
467
+ const rightCtx = /^\s/.test(findRightContext(body, endIdx))
468
+ ? '' : findRightContext(body, endIdx);
388
469
  return `${leftCtx}{{${paramName}}}${rightCtx}`;
389
470
  }
390
- /** Walk left up to 32 bytes, stopping at the first structural delimiter.
391
- * `>` is included (closing of an HTML tag is useful context); `{`, `}`,
392
- * `,`, `;`, `\n` are excluded (would over-specify the template). */
471
+ /**
472
+ * Walk left up to 32 bytes to the nearest meaningful anchor, stopping at the
473
+ * first structural delimiter. On an HTML tag boundary (`>`) fold in the whole
474
+ * opening tag — its class/id is the anchor; `>{{value}}<` alone would let a
475
+ * prover surface the value from any element. A leading run of volatile
476
+ * whitespace is skipped first so the anchor lands on the tag, not on `\s*`.
477
+ */
393
478
  function findLeftContext(body, idx) {
394
479
  const MAX_CONTEXT_BYTES = 32;
395
480
  const stop = Math.max(0, idx - MAX_CONTEXT_BYTES);
396
- for (let i = idx - 1; i >= stop; i--) {
481
+ let scan = idx - 1;
482
+ while (scan >= stop && /\s/.test(body[scan] ?? '')) {
483
+ scan--;
484
+ }
485
+ for (let i = scan; i >= stop; i--) {
397
486
  const c = body[i];
398
487
  if (c === '>') {
488
+ const tagStart = body.lastIndexOf('<', i);
489
+ if (tagStart >= 0 && i - tagStart <= MAX_TAG_BYTES
490
+ && body[tagStart + 1] !== '/') {
491
+ return body.slice(tagStart, idx);
492
+ }
399
493
  return body.slice(i, idx);
400
494
  }
401
- if (c === '{' || c === '}' || c === ',' || c === ';' || c === '\n') {
495
+ if (c === '{' || c === '}' || c === ',' || c === ';') {
402
496
  return body.slice(i + 1, idx);
403
497
  }
404
498
  }
@@ -410,6 +504,10 @@ function findRightContext(body, endIdx) {
410
504
  for (let i = endIdx; i < stop; i++) {
411
505
  const c = body[i];
412
506
  if (c === '<') {
507
+ const tagEnd = body.indexOf('>', i);
508
+ if (tagEnd >= 0 && tagEnd - i <= MAX_TAG_BYTES) {
509
+ return body.slice(endIdx, tagEnd + 1);
510
+ }
413
511
  return body.slice(endIdx, i + 1);
414
512
  }
415
513
  if (c === '{' || c === '}' || c === ',' || c === ';' || c === '\n') {
@@ -418,14 +516,30 @@ function findRightContext(body, endIdx) {
418
516
  }
419
517
  return body.slice(endIdx, stop);
420
518
  }
519
+ // ---------------------------------------------------------------------------
520
+ // Shared helpers
521
+ // ---------------------------------------------------------------------------
522
+ function escapeRegex(s) {
523
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
524
+ }
525
+ /** Escape `s` as a regex literal but match any run of whitespace flexibly
526
+ * (`\s*`): HTML varies inter-token whitespace, so literal whitespace baked
527
+ * into a redaction's capture regex would make it miss at proof time. */
528
+ function escapeRegexFlexWs(s) {
529
+ return s.split(/\s+/).map(escapeRegex).join('\\s*');
530
+ }
421
531
  /** Derive a regex character class from the target's shape. Returns
422
532
  * `undefined` for free-form values — those can't be OPRF'd because we
423
533
  * can't write a regex capture that reliably matches arbitrary unicode /
424
- * punctuation. Caller should refuse the hash request. */
534
+ * punctuation. Caller refuses the hash request. */
425
535
  function inferValueClass(target) {
426
536
  if (/^[0-9]+$/.test(target)) {
427
537
  return '\\d+';
428
538
  }
539
+ // Numbers with thousands separators / decimals, e.g. "1,908" or "12,345.6".
540
+ if (/^[0-9][0-9,.]*[0-9]$/.test(target)) {
541
+ return '[0-9,.]+';
542
+ }
429
543
  if (/^[A-Za-z0-9_-]+$/.test(target)) {
430
544
  return '[A-Za-z0-9_-]+';
431
545
  }