@reclaimprotocol/agent 0.1.36 → 0.1.38

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 (104) hide show
  1. package/README.md +8 -8
  2. package/lib/authoring/backend.d.ts +12 -1
  3. package/lib/authoring/backend.js +3 -0
  4. package/lib/authoring/tools.js +33 -5
  5. package/lib/authoring/util.d.ts +43 -0
  6. package/lib/authoring/util.js +54 -0
  7. package/lib/cdp/chrome-for-testing.d.ts +14 -0
  8. package/lib/cdp/chrome-for-testing.js +43 -0
  9. package/lib/cdp/geo.d.ts +3 -0
  10. package/lib/cdp/geo.js +27 -15
  11. package/lib/cdp/launcher.d.ts +79 -1
  12. package/lib/cdp/launcher.js +244 -24
  13. package/lib/logger.js +3 -1
  14. package/lib/mcp/instructions.js +3 -3
  15. package/lib/mcp/start.js +17 -2
  16. package/lib/mcp/tools/agent/attach.d.ts +6 -0
  17. package/lib/mcp/tools/agent/attach.js +58 -118
  18. package/lib/mcp/tools/agent/build.js +8 -0
  19. package/lib/mcp/tools/agent/capture.js +33 -10
  20. package/lib/mcp/tools/agent/check-attestor.js +15 -7
  21. package/lib/mcp/tools/agent/draft-tools.d.ts +3 -0
  22. package/lib/mcp/tools/agent/draft-tools.js +170 -0
  23. package/lib/mcp/tools/agent/example-app.d.ts +2 -0
  24. package/lib/mcp/tools/agent/example-app.js +167 -0
  25. package/lib/mcp/tools/agent/pod-proof.js +5 -1
  26. package/lib/mcp/tools/agent/proof.d.ts +28 -0
  27. package/lib/mcp/tools/agent/proof.js +72 -0
  28. package/lib/mcp/tools/agent/shared.d.ts +8 -1
  29. package/lib/mcp/tools/agent/shared.js +98 -19
  30. package/lib/mcp/tools/agent/start-integration.d.ts +2 -1
  31. package/lib/mcp/tools/agent/start-integration.js +10 -6
  32. package/lib/mcp/tools/authenticate/authenticate.d.ts +48 -10
  33. package/lib/mcp/tools/authenticate/authenticate.js +212 -116
  34. package/lib/mcp/tools/authenticate/build.d.ts +2 -2
  35. package/lib/mcp/tools/authenticate/build.js +6 -3
  36. package/lib/mcp/tools/authenticate/builder-access.js +5 -2
  37. package/lib/mcp/tools/authenticate/eth-key.d.ts +13 -2
  38. package/lib/mcp/tools/authenticate/eth-key.js +29 -5
  39. package/lib/mcp/tools/authenticate/index.d.ts +1 -1
  40. package/lib/mcp/tools/authenticate/index.js +1 -1
  41. package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +10 -0
  42. package/lib/mcp/tools/authenticate/issue-org-keypair.js +82 -0
  43. package/lib/mcp/tools/authenticate/oauth-config.d.ts +31 -0
  44. package/lib/mcp/tools/authenticate/oauth-config.js +67 -0
  45. package/lib/mcp/tools/authenticate/resolve-owner-key.d.ts +4 -5
  46. package/lib/mcp/tools/authenticate/resolve-owner-key.js +40 -36
  47. package/lib/mcp/tools/authenticate/session-cache.d.ts +25 -1
  48. package/lib/mcp/tools/authenticate/session-cache.js +95 -4
  49. package/lib/mcp/tools/mode/mode.js +6 -6
  50. package/lib/old/login-cdp.js +2 -3
  51. package/lib/proof/attestor.js +2 -3
  52. package/lib/proof/check-attestor.d.ts +12 -2
  53. package/lib/proof/check-attestor.js +40 -10
  54. package/lib/proof/check-matchers.d.ts +36 -0
  55. package/lib/proof/check-matchers.js +161 -0
  56. package/lib/proof/zk-files.d.ts +50 -11
  57. package/lib/proof/zk-files.js +259 -63
  58. package/lib/provider/draft.d.ts +24 -1
  59. package/lib/provider/draft.js +135 -0
  60. package/lib/provider/matcher.js +171 -4
  61. package/package.json +6 -4
  62. package/skill/SKILL.builder.md +7 -2
  63. package/skill/SKILL.common.md +90 -70
  64. package/skill/SKILL.old.md +3 -3
  65. package/skill/builder-mode.md +35 -5
  66. package/skill/old-mode.md +18 -13
  67. package/skill/start-integration.md +50 -33
  68. package/skill/verification-client-integration.md +21 -6
  69. package/src/authoring/backend.ts +12 -1
  70. package/src/authoring/tools.ts +48 -7
  71. package/src/authoring/util.ts +64 -0
  72. package/src/cdp/chrome-for-testing.ts +66 -0
  73. package/src/cdp/geo.ts +36 -21
  74. package/src/cdp/launcher.ts +328 -24
  75. package/src/logger.ts +3 -1
  76. package/src/mcp/instructions.ts +3 -3
  77. package/src/mcp/start.ts +20 -1
  78. package/src/mcp/tools/agent/attach.ts +65 -128
  79. package/src/mcp/tools/agent/build.ts +9 -0
  80. package/src/mcp/tools/agent/capture.ts +54 -11
  81. package/src/mcp/tools/agent/check-attestor.ts +15 -7
  82. package/src/mcp/tools/agent/draft-tools.ts +234 -0
  83. package/src/mcp/tools/agent/example-app.ts +227 -0
  84. package/src/mcp/tools/agent/pod-proof.ts +5 -1
  85. package/src/mcp/tools/agent/proof.ts +110 -1
  86. package/src/mcp/tools/agent/shared.ts +130 -19
  87. package/src/mcp/tools/agent/start-integration.ts +10 -6
  88. package/src/mcp/tools/authenticate/authenticate.ts +276 -131
  89. package/src/mcp/tools/authenticate/build.ts +6 -3
  90. package/src/mcp/tools/authenticate/builder-access.ts +5 -2
  91. package/src/mcp/tools/authenticate/eth-key.ts +41 -7
  92. package/src/mcp/tools/authenticate/index.ts +1 -1
  93. package/src/mcp/tools/authenticate/issue-org-keypair.ts +106 -0
  94. package/src/mcp/tools/authenticate/oauth-config.ts +85 -0
  95. package/src/mcp/tools/authenticate/resolve-owner-key.ts +42 -39
  96. package/src/mcp/tools/authenticate/session-cache.ts +125 -5
  97. package/src/mcp/tools/mode/mode.ts +6 -6
  98. package/src/old/login-cdp.ts +2 -3
  99. package/src/proof/attestor.ts +2 -3
  100. package/src/proof/check-attestor.ts +55 -12
  101. package/src/proof/check-matchers.ts +227 -0
  102. package/src/proof/zk-files.ts +357 -65
  103. package/src/provider/draft.ts +215 -7
  104. package/src/provider/matcher.ts +237 -4
@@ -1,12 +1,15 @@
1
+ import { badRequest } from '@hapi/boom'
2
+ import assert from 'node:assert'
1
3
  import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
2
4
  import { normalizeCandidates } from './normalize.ts'
3
- import type {
4
- CapturedRequest,
5
- OprfMode,
6
- ReclaimProvider,
7
- ResponseMatch,
8
- ResponseRedaction,
9
- SecretParamRef,
5
+ import {
6
+ type CapturedRequest,
7
+ DYNAMIC_GEO,
8
+ type OprfMode,
9
+ type ReclaimProvider,
10
+ type ResponseMatch,
11
+ type ResponseRedaction,
12
+ type SecretParamRef,
10
13
  } from './schema.ts'
11
14
  import {
12
15
  secretUrlCharBudget,
@@ -433,3 +436,208 @@ export function draftProvider(
433
436
 
434
437
  return { provider, secretRefs, extractions }
435
438
  }
439
+
440
+ /** Fields `update_draft` may replace on a drafted provider. */
441
+ export interface DraftEdit {
442
+ responseMatches?: ResponseMatch[]
443
+ responseRedactions?: ResponseRedaction[]
444
+ paramValues?: Record<string, string>
445
+ /** ISO country the request goes out from, or `{{DYNAMIC_GEO}}` /
446
+ * `{{DYNAMIC_GEO_SECRET}}` for the verifying user's own country. */
447
+ geoLocation?: string
448
+ }
449
+
450
+ const GEO_TEMPLATES = new Set([DYNAMIC_GEO, '{{DYNAMIC_GEO_SECRET}}'])
451
+
452
+ const MATCH_TYPES = new Set(['contains', 'regex'])
453
+ const OPRF_MODES = new Set(['oprf', 'oprf-raw', 'oprf-mpc'])
454
+
455
+ /**
456
+ * Apply an agent's edit to a drafted provider: validate the new matchers the
457
+ * way the attestor reads them (regexes compile with its `sgi` flags, every
458
+ * redaction has a selector, a hashed redaction has exactly one named group),
459
+ * then run the same portability gate as {@link draftProvider} so an edit can't
460
+ * bake a captured value in as a literal. Returns the updated provider, its
461
+ * extractions (re-paired with the redaction that names each param), and
462
+ * advisory warnings (for example, a `{{param}}` nothing resolves).
463
+ */
464
+ export function editDraftProvider(
465
+ provider: ReclaimProvider,
466
+ extractions: Extraction[],
467
+ edit: DraftEdit,
468
+ ): {
469
+ provider: ReclaimProvider
470
+ extractions: Extraction[]
471
+ warnings: string[]
472
+ } {
473
+ const matches = edit.responseMatches ?? provider.responseMatches
474
+ const redactions = edit.responseRedactions
475
+ ?? provider.responseRedactions ?? []
476
+ const paramValues = edit.paramValues ?? provider.paramValues ?? {}
477
+ const geoLocation = GEO_TEMPLATES.has(edit.geoLocation ?? '')
478
+ ? edit.geoLocation
479
+ : edit.geoLocation?.toUpperCase()
480
+ assert(
481
+ geoLocation === undefined || GEO_TEMPLATES.has(geoLocation)
482
+ || /^[A-Z]{2}$/.test(geoLocation),
483
+ badRequest(
484
+ 'geoLocation must be a 2-letter ISO country code, {{DYNAMIC_GEO}}, '
485
+ + 'or {{DYNAMIC_GEO_SECRET}}',
486
+ ),
487
+ )
488
+ assert(
489
+ matches.length > 0,
490
+ badRequest('responseMatches must have at least one entry'),
491
+ )
492
+ for(const [i, m] of matches.entries()) {
493
+ assert(
494
+ MATCH_TYPES.has(m.type) && typeof m.value === 'string' && m.value,
495
+ badRequest(
496
+ `responseMatches[${i}] needs type "contains" or "regex" and a `
497
+ + 'non-empty value',
498
+ ),
499
+ )
500
+ if(m.type === 'regex') {
501
+ assertRegex(m.value, `responseMatches[${i}].value`)
502
+ }
503
+ }
504
+
505
+ for(const [i, r] of redactions.entries()) {
506
+ assert(
507
+ r.xPath || r.jsonPath || r.regex,
508
+ badRequest(`responseRedactions[${i}] needs an xPath, jsonPath, or regex`),
509
+ )
510
+ if(r.regex) {
511
+ assertRegex(r.regex, `responseRedactions[${i}].regex`)
512
+ }
513
+
514
+ assert(
515
+ !r.hash || (OPRF_MODES.has(r.hash) && groupNames(r.regex).length === 1),
516
+ badRequest(
517
+ `responseRedactions[${i}] is hashed (${r.hash}), so it needs a hash `
518
+ + 'of oprf, oprf-raw, or oprf-mpc and a regex with exactly one named '
519
+ + 'capture group',
520
+ ),
521
+ )
522
+ }
523
+
524
+ // An edit can't lower privacy: a draft that redacts keeps redacting, and
525
+ // every group it hashed stays hashed with the same mode.
526
+ const original = provider.responseRedactions ?? []
527
+ assert(
528
+ !original.length || redactions.length,
529
+ badRequest(
530
+ 'responseRedactions can\'t be emptied: the attestor would see the '
531
+ + 'whole response',
532
+ ),
533
+ )
534
+ for(const r of original.filter((o) => o.hash)) {
535
+ for(const name of groupNames(r.regex)) {
536
+ const kept = redactions.find((e) => groupNames(e.regex).includes(name))
537
+ assert(
538
+ kept?.hash === r.hash,
539
+ badRequest(
540
+ `capture group ${name} is ${r.hash}-hashed in the draft; keep a `
541
+ + 'redaction for it with the same hash',
542
+ ),
543
+ )
544
+ }
545
+ }
546
+
547
+ const groups = matcherGroups(matches, redactions)
548
+ // A group the draft never extracted has a value nothing below checks, and
549
+ // no extraction would track it: new data points need a fresh draft.
550
+ const known = new Set([
551
+ ...extractions.map((e) => e.name),
552
+ ...matcherGroups(
553
+ provider.responseMatches,
554
+ provider.responseRedactions ?? [],
555
+ ),
556
+ ])
557
+ const unknown = [...groups].filter((name) => !known.has(name))
558
+ assert(
559
+ !unknown.length,
560
+ badRequest(
561
+ `capture group(s) ${unknown.join(', ')} are not drafted extractions; `
562
+ + 'propose a new draft to add a data point',
563
+ ),
564
+ )
565
+
566
+ // Always the captured values, so editing a paramValues entry can't hide
567
+ // one from the portability gate; edited values are checked as well.
568
+ const targets: DraftTarget[] = [
569
+ ...extractions.map(({ name, value }) => ({ name, value })),
570
+ ...Object.entries(paramValues)
571
+ .filter(([name, value]) => !extractions
572
+ .some((e) => e.name === name && e.value === value))
573
+ .map(([name, value]) => ({ name, value })),
574
+ ]
575
+ assertPortableMatches(matches, redactions, targets)
576
+
577
+ // Same OPRF gate as draftProvider, against the URL as the attestor signs it.
578
+ const hashed = redactions.flatMap(({ regex, hash }) => hash
579
+ ? groupNames(regex).flatMap((name) => targets
580
+ .filter((t) => t.name === name)
581
+ .map((t) => ({ ...t, hash })))
582
+ : [])
583
+ const url = provider.url
584
+ .replace(/\{\{([^{}]+)\}\}/g, (t, name: string) => paramValues[name] ?? t)
585
+ assertNoOprfUrlConflict(url, hashed, hashed.map((t) => t.hash))
586
+
587
+ const { responseRedactions: _r, paramValues: _p, ...rest } = provider
588
+ void _r
589
+ void _p
590
+ const next: ReclaimProvider = {
591
+ ...rest,
592
+ responseMatches: matches,
593
+ ...(redactions.length ? { responseRedactions: redactions } : {}),
594
+ ...(Object.keys(paramValues).length ? { paramValues } : {}),
595
+ ...(geoLocation ? { geoLocation } : {}),
596
+ }
597
+ const warnings = matches
598
+ .flatMap((m) => [...m.value.matchAll(/\{\{([^{}]+)\}\}/g)].map((t) => t[1]))
599
+ .filter((name) => !(name in paramValues) && !groups.has(name))
600
+ .map((name) => `{{${name}}} has no paramValues entry or named capture `
601
+ + 'group, so the attestor would look for it literally.')
602
+ const nextExtractions = extractions.map((e) => {
603
+ const redaction = redactions
604
+ .find((r) => groupNames(r.regex).includes(e.name))
605
+ // The captured value, never an edited one, so a later edit's gate still
606
+ // sees it.
607
+ return {
608
+ name: e.name,
609
+ value: e.value,
610
+ ...(redaction ? { redaction } : {}),
611
+ }
612
+ })
613
+ return { provider: next, extractions: nextExtractions, warnings }
614
+ }
615
+
616
+ /** Named capture groups across regex matches and redactions. */
617
+ function matcherGroups(
618
+ matches: ResponseMatch[],
619
+ redactions: ResponseRedaction[],
620
+ ) {
621
+ return new Set([
622
+ ...matches.flatMap((m) => m.type === 'regex' ? groupNames(m.value) : []),
623
+ ...redactions.flatMap((r) => groupNames(r.regex)),
624
+ ])
625
+ }
626
+
627
+ /** Named capture groups in `regex` (none when absent). */
628
+ function groupNames(regex: string | undefined): string[] {
629
+ return [...(regex ?? '').matchAll(/\(\?<([A-Za-z_][A-Za-z0-9_]*)>/g)]
630
+ .map((m) => m[1])
631
+ }
632
+
633
+ /** Throw a 400 unless `regex` compiles the way the attestor compiles it. */
634
+ function assertRegex(regex: string, where: string) {
635
+ let error: string | undefined
636
+ try {
637
+ new RegExp(regex, 'sgi')
638
+ } catch(err) {
639
+ error = err instanceof Error ? err.message : String(err)
640
+ }
641
+
642
+ assert(!error, badRequest(`${where} is not a valid regex: ${error}`))
643
+ }
@@ -1,3 +1,5 @@
1
+ import { badRequest } from '@hapi/boom'
2
+ import assert from 'node:assert'
1
3
  import { elementAnchor } from './dom-xpath.ts'
2
4
  import { normalizeCandidates } from './normalize.ts'
3
5
  import type { OprfMode, ResponseMatch, ResponseRedaction } from './schema.ts'
@@ -81,6 +83,13 @@ export function synthesizeMatcher(
81
83
  }
82
84
  }
83
85
 
86
+ // Inline JSON / JS-object text outside an HTML element (a JS bundle, a
87
+ // non-JSON text body): anchor on the key name right before the value.
88
+ const inline = inlineKeyMatcher(body, target, paramName, valueClass, hash)
89
+ if(inline) {
90
+ return inline
91
+ }
92
+
84
93
  // No JSON key and no stable xPath anchor: a content-anchored regex, matched
85
94
  // by CONTENT so it reproduces regardless of DOM position at proof time (a
86
95
  // positional `(//tag)[N]` resolves to the wrong node at proof — see 9fd7770).
@@ -430,9 +439,186 @@ function htmlMatcher(
430
439
  }
431
440
  }
432
441
 
433
- return elementResult(
442
+ // Inline JS-object / JSON text inside the element (a SvelteKit/Next/Nuxt
443
+ // hydration `<script>` that isn't valid JSON): anchor on the key name
444
+ // nearest the value (`currentTierDescription:"{{v}}"`), scoped by the xPath.
445
+ const inline = inlineKeyMatcher(
446
+ revealSlice, target, paramName, valueClass, hash, anchor.xPath,
447
+ )
448
+ if(inline) {
449
+ return inline
450
+ }
451
+
452
+ const result = elementResult(
434
453
  anchor.xPath, revealSlice, target, paramName, valueClass, hash,
435
454
  )
455
+ const [left = '', right = ''] = result?.match.value
456
+ .split(`{{${paramName}}}`) ?? []
457
+ const hits = result?.redaction?.regex
458
+ ? countMatches(revealSlice, result.redaction.regex)
459
+ : 0
460
+ assert(
461
+ !result || !isGenericWindow(left, right) || hits <= 1,
462
+ badRequest(
463
+ `target value "${target}" sits in a <${elementTag(revealSlice)}> `
464
+ + 'with no key name or markup next to it. The only literal context, '
465
+ + `"${result?.match.value}", matches ${hits} values in that element, `
466
+ + 'so it would not pin this one. Pick a request where the value has '
467
+ + 'a key (for example, a JSON API response) or sits in its own '
468
+ + 'element.',
469
+ ),
470
+ )
471
+ return result
472
+ }
473
+
474
+ /** How many times `regex` matches in `text` (0 for an invalid regex). */
475
+ function countMatches(text: string, regex: string): number {
476
+ try {
477
+ return [...text.matchAll(new RegExp(regex, 'g'))].length
478
+ } catch{
479
+ return 0
480
+ }
481
+ }
482
+
483
+ /** Tag name of the element markup starting `markup` (`script`, `div`, …). */
484
+ function elementTag(markup: string): string {
485
+ return /^<([A-Za-z][\w-]*)/.exec(markup)?.[1] ?? 'element'
486
+ }
487
+
488
+ /** A literal window with no letter or digit on either side (for example,
489
+ * `:"{{v}}",`): it pins a value only when the element holds nothing else. */
490
+ function isGenericWindow(left: string, right: string): boolean {
491
+ return !/[A-Za-z0-9]/.test(left + right)
492
+ }
493
+
494
+ // ---------------------------------------------------------------------------
495
+ // Inline key: `key:"value"` / `"key":"value"` in JS-object or JSON text
496
+ // ---------------------------------------------------------------------------
497
+
498
+ /** How far left of a value to look for its key. */
499
+ const MAX_KEY_LOOKBACK = 128
500
+
501
+ /** The key directly before a value: JSON `"key":`, `'key':`, or a bare JS
502
+ * identifier `key:`, then the value's opening quote (if any) at the end. */
503
+ const KEY_BEFORE_VALUE = new RegExp(
504
+ '(?:"([A-Za-z_$][\\w$-]*)"|\'([A-Za-z_$][\\w$-]*)\''
505
+ + '|(?<![\\w$"\'])([A-Za-z_$][\\w$]*))\\s*:\\s*(["\']?)$',
506
+ )
507
+
508
+ /**
509
+ * Matcher for a value written as `key:"value"` (JS object literal, as SvelteKit
510
+ * and similar frameworks inline into SSR pages) or `"key":"value"` inside text
511
+ * that isn't parseable JSON. The window is the key name plus the value's
512
+ * quotes, so the match pins this field and nothing user-specific:
513
+ * `currentTierDescription:"{{v}}"`.
514
+ *
515
+ * Rejects a key that appears more than once in `scope` with a different value:
516
+ * the regex would extract the first one, which may not be this field. When the
517
+ * value has a key but it's ambiguous, throws rather than falling back to a
518
+ * looser window. Returns undefined when no occurrence has a key before it.
519
+ */
520
+ function inlineKeyMatcher(
521
+ scope: string,
522
+ target: string,
523
+ paramName: string,
524
+ valueClass: string,
525
+ hash?: OprfMode,
526
+ xPath?: string,
527
+ ): MatcherSynthesis | undefined {
528
+ const candidates = [target, ...normalizeCandidates(target)]
529
+ .filter((c) => c && !c.startsWith('"'))
530
+ let ambiguous: { key: string, count: number } | undefined
531
+ for(const cand of new Set(candidates)) {
532
+ for(
533
+ let idx = scope.indexOf(cand);
534
+ idx >= 0;
535
+ idx = scope.indexOf(cand, idx + 1)
536
+ ) {
537
+ const kv = keyBefore(scope, idx, cand)
538
+ if(!kv) {
539
+ continue
540
+ }
541
+
542
+ const others = valuesForKey(scope, kv.key)
543
+ .filter((v) => v !== cand)
544
+ if(others.length) {
545
+ ambiguous ??= { key: kv.key, count: others.length + 1 }
546
+ continue
547
+ }
548
+
549
+ // A single-quoted free-form value must stop at its own quote.
550
+ const cls = kv.close === '\'' && valueClass === '[^"<>\\n]+'
551
+ ? '[^\'<>\\n]+'
552
+ : valueClass
553
+ return keyValueResult(
554
+ kv.prefix, kv.close, cand, paramName, cls,
555
+ xPath ? { xPath } : {}, hash,
556
+ )
557
+ }
558
+ }
559
+
560
+ assert(
561
+ !ambiguous,
562
+ badRequest(
563
+ `target value "${target}" sits under key "${ambiguous?.key}", but that `
564
+ + `key occurs ${ambiguous?.count} times with different values, so a `
565
+ + 'match on it would not pin this value. Pick a request where the key '
566
+ + 'is unique (for example, a JSON API that returns only this object).',
567
+ ),
568
+ )
569
+ return undefined
570
+ }
571
+
572
+ /** The key + opening-quote prefix directly before the value at `idx`, when the
573
+ * value is a complete quoted string (or a bare token) under that key. */
574
+ function keyBefore(
575
+ scope: string,
576
+ idx: number,
577
+ value: string,
578
+ ): { key: string, prefix: string, close: string } | undefined {
579
+ const head = scope.slice(Math.max(0, idx - MAX_KEY_LOOKBACK), idx)
580
+ const m = KEY_BEFORE_VALUE.exec(head)
581
+ const key = m?.[1] ?? m?.[2] ?? m?.[3]
582
+ if(!m || !key || key.length < 2) {
583
+ return undefined
584
+ }
585
+
586
+ // A bare key before a bare value (`score: 5`) is prose as often as code;
587
+ // require a quoted key or a quoted value.
588
+ const quote = m[4] ?? ''
589
+ const after = scope[idx + value.length] ?? ''
590
+ const complete = quote ? after === quote : /^$|[\s,;}\]]/.test(after)
591
+ if(!complete || (!quote && m[3])) {
592
+ return undefined
593
+ }
594
+
595
+ return { key, prefix: m[0], close: quote }
596
+ }
597
+
598
+ /** Every value written under `key` in `scope` (JSON or JS-object syntax),
599
+ * raw — quoted strings without their quotes, bare tokens as-is. */
600
+ function valuesForKey(scope: string, key: string): string[] {
601
+ const re = new RegExp(
602
+ `(?<![\\w$])(["']?)${escapeRegex(key)}\\1\\s*:\\s*`, 'g',
603
+ )
604
+ const values: string[] = []
605
+ for(const m of scope.matchAll(re)) {
606
+ const start = m.index + m[0].length
607
+ const quote = scope[start]
608
+ if(quote === '"' || quote === '\'') {
609
+ let j = start + 1
610
+ while(j < scope.length && scope[j] !== quote) {
611
+ j += scope[j] === '\\' ? 2 : 1
612
+ }
613
+
614
+ values.push(scope.slice(start + 1, j))
615
+ continue
616
+ }
617
+
618
+ values.push(/^[^\s,;}\]]*/.exec(scope.slice(start))?.[0] ?? '')
619
+ }
620
+
621
+ return values
436
622
  }
437
623
 
438
624
  /**
@@ -743,14 +929,33 @@ function buildContentRegex(
743
929
  const from = Math.max(0, start)
744
930
  const before = escapeRegexFlexWs(body.slice(from, idx))
745
931
  const re = `${before}(?<${paramName}>${valueClass})${right}`
932
+ let found = false
746
933
  try {
747
- if(new RegExp(re).exec(body)?.groups?.[paramName] === cand) {
748
- return re
749
- }
934
+ found = new RegExp(re).exec(body)?.groups?.[paramName] === cand
750
935
  } catch{
751
936
  break
752
937
  }
753
938
 
939
+ if(found) {
940
+ const problem = contextProblem(
941
+ body.slice(from, idx), right ? rawRight : '',
942
+ )
943
+ assert(
944
+ !problem,
945
+ badRequest(
946
+ `target value "${target}" has no key name or element next to `
947
+ + `it; the shortest window that pins it, "${body.slice(from, idx)}`
948
+ + `{{${paramName}}}${right ? rawRight : ''}", ${problem}. That `
949
+ + 'would pin the provider to this user or match the wrong value. '
950
+ + 'This happens with positional arrays such as SvelteKit '
951
+ + '`__data.json`. Use a request where the value sits under its '
952
+ + 'own key (the HTML page or another JSON API), or stop and '
953
+ + 'report.',
954
+ ),
955
+ )
956
+ return re
957
+ }
958
+
754
959
  if(from === 0) {
755
960
  break
756
961
  }
@@ -760,6 +965,34 @@ function buildContentRegex(
760
965
  return undefined
761
966
  }
762
967
 
968
+ /** A standalone number token (`33` in `:33},`) — a points balance, count, or
969
+ * id that differs per user; not a digit inside a word or class (`mb-2`). */
970
+ const VOLATILE_NUMBER = /(?<![\w.-])\d+(?:\.\d+)?(?![\w.])/
971
+
972
+ /** The closing quote of a string followed by a separator — the end of a
973
+ * neighbouring VALUE (keys are followed by `:`), usually user-specific. */
974
+ const NEIGHBOUR_STRING = /"\s*[,\]}]/
975
+
976
+ /** Why a content window around a value isn't portable, or undefined when it
977
+ * is: only punctuation (pins nothing), a number next to the value, or a
978
+ * neighbouring string value. */
979
+ function contextProblem(left: string, right: string): string | undefined {
980
+ if(VOLATILE_NUMBER.test(left) || VOLATILE_NUMBER.test(right)) {
981
+ return 'contains a number next to the value, which is likely '
982
+ + 'user-specific (for example, a points balance)'
983
+ }
984
+
985
+ if(NEIGHBOUR_STRING.test(left)) {
986
+ return 'contains a neighbouring value, which is likely user-specific'
987
+ }
988
+
989
+ if(!/[A-Za-z]/.test(left + right)) {
990
+ return 'is only punctuation and matches almost any value'
991
+ }
992
+
993
+ return undefined
994
+ }
995
+
763
996
  /**
764
997
  * The literal `contains` template: surrounding structural context with the
765
998
  * value replaced by `{{paramName}}`. Whitespace-adjacent context is dropped (a