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