@reclaimprotocol/client 0.1.27 → 0.1.28

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 (73) hide show
  1. package/README.md +634 -42
  2. package/lib/api-client.d.ts +31 -0
  3. package/lib/api-client.js +112 -0
  4. package/lib/api-consts.d.ts +4 -0
  5. package/lib/api-consts.js +4 -0
  6. package/lib/api-errors.d.ts +13 -0
  7. package/lib/api-errors.js +30 -0
  8. package/lib/api.d.ts +7 -0
  9. package/lib/api.js +3 -0
  10. package/lib/attestor-proof.d.ts +6 -0
  11. package/lib/attestor-proof.js +45 -18
  12. package/lib/client.d.ts +1 -6
  13. package/lib/client.js +12 -33
  14. package/lib/consts.d.ts +1 -1
  15. package/lib/consts.js +1 -1
  16. package/lib/eip191.d.ts +4 -2
  17. package/lib/eip191.js +17 -1
  18. package/lib/errors.d.ts +38 -4
  19. package/lib/errors.js +99 -16
  20. package/lib/index.d.ts +9 -7
  21. package/lib/index.js +8 -4
  22. package/lib/jwks.d.ts +25 -1
  23. package/lib/jwks.js +69 -4
  24. package/lib/legacy-proof.d.ts +57 -0
  25. package/lib/legacy-proof.js +211 -0
  26. package/lib/proof-hash.d.ts +35 -0
  27. package/lib/proof-hash.js +216 -0
  28. package/lib/proof-recipe.d.ts +18 -0
  29. package/lib/proof-recipe.js +37 -0
  30. package/lib/proof-trust-constants.d.ts +3 -0
  31. package/lib/proof-trust-constants.js +43 -0
  32. package/lib/proof-types.d.ts +62 -0
  33. package/lib/proof-types.js +1 -0
  34. package/lib/proof-verification-error.d.ts +13 -0
  35. package/lib/proof-verification-error.js +11 -0
  36. package/lib/reclaim.d.ts +27 -5
  37. package/lib/reclaim.js +140 -14
  38. package/lib/result.d.ts +58 -6
  39. package/lib/result.js +252 -61
  40. package/lib/routes.gen.d.ts +54 -6
  41. package/lib/routes.gen.js +15 -3
  42. package/lib/tee-binding.d.ts +82 -0
  43. package/lib/tee-binding.js +112 -0
  44. package/lib/types/openapi.gen.d.ts +1343 -206
  45. package/lib/verification-clients.d.ts +17 -0
  46. package/lib/verification-clients.js +14 -0
  47. package/lib/verify-attestor-tee.d.ts +76 -0
  48. package/lib/verify-attestor-tee.js +243 -0
  49. package/lib/verify-tee.d.ts +32 -0
  50. package/lib/verify-tee.js +347 -0
  51. package/package.json +5 -1
  52. package/src/api.ts +15 -0
  53. package/src/attestor-proof.ts +57 -20
  54. package/src/client.ts +15 -38
  55. package/src/consts.ts +1 -2
  56. package/src/eip191.ts +31 -3
  57. package/src/errors.ts +132 -18
  58. package/src/index.ts +50 -5
  59. package/src/jwks.ts +128 -5
  60. package/src/legacy-proof.ts +367 -0
  61. package/src/proof-hash.ts +316 -0
  62. package/src/proof-recipe.ts +75 -0
  63. package/src/proof-trust-constants.ts +45 -0
  64. package/src/proof-types.ts +57 -0
  65. package/src/proof-verification-error.ts +13 -0
  66. package/src/reclaim.ts +206 -19
  67. package/src/result.ts +403 -68
  68. package/src/routes.gen.ts +15 -3
  69. package/src/tee-binding.ts +192 -0
  70. package/src/types/openapi.gen.ts +1343 -206
  71. package/src/verification-clients.ts +22 -0
  72. package/src/verify-attestor-tee.ts +365 -0
  73. package/src/verify-tee.ts +507 -0
@@ -0,0 +1,367 @@
1
+ import { createSignDataForClaim } from '@reclaimprotocol/attestor-core'
2
+ import { recoverEip191Address } from './eip191.ts'
3
+ import { fetchReclaimJwks, trustedAttestorAddresses } from './jwks.ts'
4
+ import { assertValidProofHashes, getHashFromProof } from './proof-hash.ts'
5
+ import type {
6
+ HashRequirement,
7
+ LegacyProof,
8
+ TrustedData,
9
+ } from './proof-types.ts'
10
+ import {
11
+ type AttestorTeeAttestationConfig,
12
+ runAttestorTeeVerification,
13
+ } from './verify-attestor-tee.ts'
14
+ import { runTeeVerification, type TeeAttestationConfig } from './verify-tee.ts'
15
+
16
+ export type { LegacyProof } from './proof-types.ts'
17
+
18
+ /** A proof hash requirement accepted by legacy `verifyProof`. */
19
+ export type ProofHashRequirement = string | HashRequirement
20
+
21
+ export interface ResolvedSessionProofValidation {
22
+ hashes?: ProofHashRequirement[]
23
+ providerId?: string
24
+ providerVersion?: string
25
+ allowedTags?: string[]
26
+ }
27
+
28
+ /** Exact provider requirements returned by a caller-controlled resolver. */
29
+ export interface ResolvedProviderProofValidation {
30
+ hashes: ProofHashRequirement[]
31
+ }
32
+
33
+ /** Options intentionally preserve legacy `verifyProof` vocabulary. */
34
+ export interface LegacyVerifyProofOptions {
35
+ hashes?: ProofHashRequirement[]
36
+ providerId?: string
37
+ providerVersion?: string
38
+ allowedTags?: string[]
39
+ dangerouslyDisableContentValidation?: true
40
+ /** Read `providerHash` from context, while still verifying signatures. */
41
+ hasNoPii?: true
42
+ teeAttestation?: TeeAttestationConfig
43
+ attestorTeeAttestation?: AttestorTeeAttestationConfig
44
+ /** Explicit trusted attestor ETH addresses. */
45
+ attestorAddresses?: string[]
46
+ /** Trusted Builder JWKS registry containing attestor-purpose keys. */
47
+ attestorJwksUrl?: string
48
+ fetchImpl?: typeof fetch
49
+ /** Builder session id. Requires a resolver without hashes or a provider. */
50
+ sessionId?: string
51
+ resolveSession?: (
52
+ sessionId: string,
53
+ proofs: LegacyProof[],
54
+ ) => Promise<ResolvedSessionProofValidation>
55
+ | ResolvedSessionProofValidation
56
+ /** Resolve an exact provider without coupling this verifier to HTTP. */
57
+ resolveProvider?: (
58
+ providerId: string,
59
+ providerVersion: string | undefined,
60
+ allowedTags: string[] | undefined,
61
+ proofs: LegacyProof[],
62
+ ) => Promise<ResolvedProviderProofValidation[]>
63
+ | ResolvedProviderProofValidation[]
64
+ expectedSessionId?: string
65
+ requireSessionBinding?: boolean
66
+ expectedContext?: Record<string, unknown>
67
+ }
68
+
69
+ export interface LegacyProofOutcome {
70
+ valid: boolean
71
+ reason?: string
72
+ proofHashes?: string[]
73
+ isTeeAttestationVerified?: boolean
74
+ isAttestorTeeAttestationVerified?: boolean
75
+ data?: TrustedData[]
76
+ publicData?: unknown[]
77
+ }
78
+
79
+ /** Return the provider hashes encoded in an exact legacy proof. */
80
+ export function proofHashes(proof: LegacyProof): string[] {
81
+ return getHashFromProof(proof)
82
+ }
83
+
84
+ /**
85
+ * Verify exact legacy proofs without depending on `@reclaimprotocol/js-sdk`.
86
+ * The signed claim is never renamed or reconstructed.
87
+ */
88
+ export async function verifyProof(
89
+ proofOrProofs: LegacyProof | LegacyProof[],
90
+ config: LegacyVerifyProofOptions,
91
+ ): Promise<LegacyProofOutcome> {
92
+ const proofs = Array.isArray(proofOrProofs) ? proofOrProofs : [proofOrProofs]
93
+ if(!proofs.length) {
94
+ return { valid: false, reason: 'no-proofs' }
95
+ }
96
+
97
+ try {
98
+ const effective = await resolveValidation(config, proofs)
99
+ assertSessionAndContext(proofs, effective)
100
+ const attestors = await resolveTrustedAttestors(effective)
101
+ assertVerifiedSignatures(proofs, attestors)
102
+ await assertContent(proofs, effective)
103
+
104
+ let isTeeAttestationVerified: boolean | undefined
105
+ if(effective.teeAttestation) {
106
+ await runTeeVerification(proofs, effective.teeAttestation)
107
+ isTeeAttestationVerified = true
108
+ }
109
+
110
+ let isAttestorTeeAttestationVerified: boolean | undefined
111
+ if(effective.attestorTeeAttestation) {
112
+ await runAttestorTeeVerification(
113
+ proofs,
114
+ effective.attestorTeeAttestation,
115
+ )
116
+ isAttestorTeeAttestationVerified = true
117
+ }
118
+
119
+ return {
120
+ valid: true,
121
+ proofHashes: collectProofHashes(proofs, effective.hasNoPii),
122
+ isTeeAttestationVerified,
123
+ isAttestorTeeAttestationVerified,
124
+ data: proofs.map(trustedData),
125
+ publicData: proofs.map((proof) => proof.publicData ?? {}),
126
+ }
127
+ } catch(error) {
128
+ return {
129
+ valid: false,
130
+ reason: failureReason(error),
131
+ }
132
+ }
133
+ }
134
+
135
+ async function resolveValidation(
136
+ config: LegacyVerifyProofOptions,
137
+ proofs: LegacyProof[],
138
+ ) {
139
+ let effective = config
140
+ if(config.sessionId && !hasContentValidation(config)) {
141
+ if(!config.resolveSession) {
142
+ throw new Error('session-resolver-required')
143
+ }
144
+
145
+ effective = {
146
+ ...config,
147
+ ...await config.resolveSession(config.sessionId, proofs),
148
+ }
149
+ }
150
+
151
+ return effective
152
+ }
153
+
154
+ async function resolveTrustedAttestors(config: LegacyVerifyProofOptions) {
155
+ const addresses = config.attestorAddresses?.map(normalized) ?? []
156
+ if(config.attestorJwksUrl) {
157
+ const registry = await fetchReclaimJwks(
158
+ config.attestorJwksUrl,
159
+ config.fetchImpl,
160
+ )
161
+ addresses.push(
162
+ ...(registry['x-reclaim-attestors'] ?? []),
163
+ ...trustedAttestorAddresses(registry.keys),
164
+ )
165
+ }
166
+
167
+ const unique = [...new Set(addresses)]
168
+ if(!unique.length) {
169
+ throw new Error('attestor-trust-required')
170
+ }
171
+
172
+ return unique
173
+ }
174
+
175
+ function assertVerifiedSignatures(
176
+ proofs: LegacyProof[],
177
+ trustedAttestors: string[],
178
+ ) {
179
+ for(const proof of proofs) {
180
+ if(!proof.signatures?.length || !proof.witnesses?.length) {
181
+ throw new Error('proof-signature-missing')
182
+ }
183
+
184
+ const signData = createSignDataForClaim(proof.claimData)
185
+ const signers = proof.signatures.map((signature) => (
186
+ normalized(recoverEip191Address(signData, signature))
187
+ ))
188
+ const witnesses = proof.witnesses.map(({ id }) => normalized(id))
189
+ if(witnesses.some((witness) => !trustedAttestors.includes(witness))) {
190
+ throw new Error('attestor-not-allowed')
191
+ }
192
+
193
+ if(!signers.some((signer) => trustedAttestors.includes(signer))) {
194
+ throw new Error('attestor-not-allowed')
195
+ }
196
+
197
+ if(!signers.every((signer) => witnesses.includes(signer))) {
198
+ throw new Error('witness-signature-mismatch')
199
+ }
200
+ }
201
+ }
202
+
203
+ async function assertContent(
204
+ proofs: LegacyProof[],
205
+ config: LegacyVerifyProofOptions,
206
+ ) {
207
+ if(config.dangerouslyDisableContentValidation) {
208
+ return
209
+ }
210
+
211
+ if(config.hashes) {
212
+ assertValidProofHashes(proofs, config.hashes, config.hasNoPii)
213
+ return
214
+ }
215
+
216
+ if(config.providerId) {
217
+ if(!config.resolveProvider) {
218
+ throw new Error('provider-resolver-required')
219
+ }
220
+
221
+ const candidates = await config.resolveProvider(
222
+ config.providerId,
223
+ config.providerVersion,
224
+ config.allowedTags,
225
+ proofs,
226
+ )
227
+ if(!candidates.length) {
228
+ throw new Error('provider-not-found')
229
+ }
230
+
231
+ let lastError: unknown
232
+ for(const candidate of candidates) {
233
+ try {
234
+ assertValidProofHashes(proofs, candidate.hashes, config.hasNoPii)
235
+ return
236
+ } catch(error) {
237
+ lastError = error
238
+ }
239
+ }
240
+
241
+ throw lastError
242
+ }
243
+
244
+ throw new Error('proof-validation-required')
245
+ }
246
+
247
+ function assertSessionAndContext(
248
+ proofs: LegacyProof[],
249
+ config: LegacyVerifyProofOptions,
250
+ ) {
251
+ const expectedSessionId = config.sessionId ?? config.expectedSessionId
252
+ for(const proof of proofs) {
253
+ if(expectedSessionId) {
254
+ const ids = sessionIds(proof)
255
+ if(ids.some((id) => id !== expectedSessionId)) {
256
+ throw new Error('session-mismatch')
257
+ }
258
+
259
+ if((config.sessionId || config.requireSessionBinding) && !ids.length) {
260
+ throw new Error('session-missing')
261
+ }
262
+ }
263
+
264
+ if(!contextMatches(proof, config.expectedContext)) {
265
+ throw new Error('context-mismatch')
266
+ }
267
+ }
268
+ }
269
+
270
+ function trustedData(proof: LegacyProof): TrustedData {
271
+ const context = contextFrom(proof)
272
+ const extractedParameters = isRecord(context.extractedParameters)
273
+ ? Object.fromEntries(Object.entries(context.extractedParameters).map(
274
+ ([key, value]) => [key, String(value)],
275
+ ))
276
+ : {}
277
+ delete context.extractedParameters
278
+ return { context, extractedParameters }
279
+ }
280
+
281
+ function collectProofHashes(
282
+ proofs: LegacyProof[],
283
+ hasNoPii: true | undefined,
284
+ ): string[] | undefined {
285
+ try {
286
+ return proofs.flatMap((proof) => getHashFromProof(proof, hasNoPii))
287
+ } catch{
288
+ return undefined
289
+ }
290
+ }
291
+
292
+ function hasContentValidation(config: LegacyVerifyProofOptions): boolean {
293
+ return Boolean(
294
+ config.hashes
295
+ || config.providerId
296
+ || config.dangerouslyDisableContentValidation,
297
+ )
298
+ }
299
+
300
+ function failureReason(error: unknown): string {
301
+ const message = error instanceof Error ? error.message : String(error)
302
+ const lower = message.toLowerCase()
303
+ if(
304
+ lower.includes('multiple')
305
+ || lower.includes('duplicate')
306
+ || lower.includes('more than once')
307
+ ) {
308
+ return 'duplicate-proof-hash'
309
+ }
310
+
311
+ return message || 'proof-not-verified'
312
+ }
313
+
314
+ function normalized(value: string): string {
315
+ return value.trim().toLowerCase()
316
+ }
317
+
318
+ function contextFrom(proof: LegacyProof): Record<string, unknown> {
319
+ const raw = proof.claimData.context
320
+ if(!raw) {
321
+ return {}
322
+ }
323
+
324
+ try {
325
+ const context: unknown = JSON.parse(raw)
326
+ return isRecord(context) ? { ...context } : {}
327
+ } catch{
328
+ return {}
329
+ }
330
+ }
331
+
332
+ function sessionIds(proof: LegacyProof): string[] {
333
+ const context = contextFrom(proof)
334
+ const ids = [context.reclaimSessionId]
335
+ try {
336
+ const parameters = JSON.parse(
337
+ proof.claimData.parameters,
338
+ ) as Record<string, unknown>
339
+ ids.push(parameters.proxySessionId, parameters.sessionId)
340
+ } catch{
341
+ // A non-HTTP claim is valid when session binding wasn't requested.
342
+ }
343
+
344
+ return ids.filter(
345
+ (value): value is string => (
346
+ typeof value === 'string' && value.length > 0
347
+ ),
348
+ )
349
+ }
350
+
351
+ function contextMatches(
352
+ proof: LegacyProof,
353
+ expected?: Record<string, unknown>,
354
+ ): boolean {
355
+ if(!expected) {
356
+ return true
357
+ }
358
+
359
+ const context = contextFrom(proof)
360
+ return Object.entries(expected).every(([key, value]) => (
361
+ JSON.stringify(context[key]) === JSON.stringify(value)
362
+ ))
363
+ }
364
+
365
+ function isRecord(value: unknown): value is Record<string, unknown> {
366
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value))
367
+ }
@@ -0,0 +1,316 @@
1
+ import { keccak_256 } from '@noble/hashes/sha3.js'
2
+ import { canonicalJson as canonical } from './canonical.ts'
3
+ import { bytesToHex } from './encoding.ts'
4
+ import type { HashRequirement, LegacyProof } from './proof-types.ts'
5
+
6
+ export interface HttpProofParams {
7
+ url: string
8
+ method: string
9
+ body?: string
10
+ responseMatches: Array<{
11
+ type?: string
12
+ value?: string
13
+ invert?: boolean
14
+ isOptional?: boolean
15
+ }>
16
+ responseRedactions: Array<{
17
+ xPath?: string
18
+ jsonPath?: string
19
+ regex?: string
20
+ hash?: string
21
+ }>
22
+ }
23
+
24
+ export interface ProofRequestSpec extends HttpProofParams {
25
+ urlType: string
26
+ bodySniff: { enabled: boolean, template: string }
27
+ required?: boolean
28
+ multiple?: boolean
29
+ templateParams?: string[]
30
+ templateParamsMode?: 'separate' | 'merge'
31
+ }
32
+
33
+ /** Exact legacy canonical HTTP-provider hashes, including optional matches. */
34
+ export function hashProofClaimParams(params: HttpProofParams): string[] {
35
+ return canonicalProofParams(params).map((serialized) => (
36
+ '0x' + bytesToHex(keccak_256(new TextEncoder().encode(serialized)))
37
+ ))
38
+ }
39
+
40
+ export function getHashFromProof(
41
+ proof: LegacyProof,
42
+ hasNoPii = false,
43
+ ): string[] {
44
+ if(hasNoPii) {
45
+ const context = JSON.parse(
46
+ proof.claimData.context,
47
+ ) as Record<string, unknown>
48
+ if(typeof context.providerHash !== 'string' || !context.providerHash) {
49
+ throw new Error('Provider hash must be present in proof context')
50
+ }
51
+
52
+ return [context.providerHash]
53
+ }
54
+
55
+ const parsed: unknown = JSON.parse(proof.claimData.parameters)
56
+ if(!isHttpProofParams(parsed)) {
57
+ throw new Error('Proof has no HTTP provider params to hash')
58
+ }
59
+
60
+ return hashProofClaimParams(parsed)
61
+ }
62
+
63
+ export function hashRequestSpec(request: ProofRequestSpec): HashRequirement {
64
+ return {
65
+ value: hashProofClaimParams({
66
+ ...request,
67
+ body: request.bodySniff.enabled ? request.bodySniff.template : '',
68
+ }),
69
+ required: request.required,
70
+ multiple: request.multiple,
71
+ }
72
+ }
73
+
74
+ export function hashRequirementsFromSpecs(
75
+ requestData: ProofRequestSpec[],
76
+ allowedInjectedRequestData: ProofRequestSpec[],
77
+ proofs: LegacyProof[],
78
+ ): HashRequirement[] {
79
+ const templates = expandTemplates(
80
+ allowedInjectedRequestData,
81
+ templateParametersFromProofs(proofs),
82
+ )
83
+ return [...requestData, ...templates].map(hashRequestSpec)
84
+ }
85
+
86
+ export function assertValidProofHashes(
87
+ proofs: LegacyProof[],
88
+ requirements: Array<string | HashRequirement>,
89
+ hasNoPii = false,
90
+ ) {
91
+ const unmatched = new Map(proofs.map((proof, index) => (
92
+ [index, getHashFromProof(proof, hasNoPii)]
93
+ )))
94
+ for(const raw of requirements) {
95
+ const requirement = typeof raw === 'string' ? { value: raw } : raw
96
+ const expected = (Array.isArray(requirement.value)
97
+ ? requirement.value
98
+ : [requirement.value]).map(normalized)
99
+ let found = false
100
+ for(const [index, hashes] of unmatched) {
101
+ if(!hashes.map(normalized).some((hash) => expected.includes(hash))) {
102
+ continue
103
+ }
104
+
105
+ unmatched.delete(index)
106
+ if(found && requirement.multiple === false) {
107
+ throw new Error(
108
+ `Proof by hash '${expected.join(',')}' is not allowed `
109
+ + 'to appear more than once',
110
+ )
111
+ }
112
+
113
+ found = true
114
+ }
115
+
116
+ if(!found && requirement.required !== false) {
117
+ throw new Error(
118
+ `Proof by required hash '${expected.join(',')}' was not found`,
119
+ )
120
+ }
121
+ }
122
+
123
+ if(unmatched.size) {
124
+ throw new Error(`${unmatched.size} extra proof(s) could not be validated`)
125
+ }
126
+ }
127
+
128
+ function canonicalProofParams(params: HttpProofParams): string[] {
129
+ const count = params.responseMatches.length
130
+ const values: string[] = []
131
+ for(let mask = 0; mask < 2 ** count; mask++) {
132
+ let valid = true
133
+ const matches: Array<Record<string, unknown>> = []
134
+ const redactions: Array<Record<string, unknown>> = []
135
+ for(let index = 0; index < count; index++) {
136
+ const match = params.responseMatches[index]
137
+ const redaction = params.responseRedactions[index]
138
+ if((mask & (1 << index)) === 0) {
139
+ if(match && !match.isOptional) {
140
+ valid = false
141
+ }
142
+
143
+ continue
144
+ }
145
+
146
+ if(match) {
147
+ matches.push({
148
+ value: match.value ?? '',
149
+ type: match.type ?? 'contains',
150
+ invert: match.invert || undefined,
151
+ })
152
+ }
153
+
154
+ if(redaction) {
155
+ redactions.push({
156
+ xPath: redaction.xPath ?? '',
157
+ jsonPath: redaction.jsonPath ?? '',
158
+ regex: redaction.regex ?? '',
159
+ hash: normalizedRedactionHash(redaction.hash),
160
+ })
161
+ }
162
+ }
163
+
164
+ if(valid && matches.length) {
165
+ values.push(canonical({
166
+ url: params.url ?? '',
167
+ method: params.method ?? 'GET',
168
+ body: params.body ?? '',
169
+ responseMatches: matches,
170
+ responseRedactions: redactions,
171
+ }))
172
+ }
173
+ }
174
+
175
+ return values.length ? values : [canonical({
176
+ url: params.url ?? '',
177
+ method: params.method ?? 'GET',
178
+ body: params.body ?? '',
179
+ responseMatches: [],
180
+ responseRedactions: [],
181
+ })]
182
+ }
183
+
184
+ function expandTemplates(
185
+ templates: ProofRequestSpec[],
186
+ parameters: Record<string, string[]>,
187
+ ): ProofRequestSpec[] {
188
+ const result: ProofRequestSpec[] = []
189
+ for(const template of templates) {
190
+ const names = template.templateParams ?? []
191
+ if(!names.length) {
192
+ result.push(template)
193
+ continue
194
+ }
195
+
196
+ const present = names.filter((name) => parameters[name]?.length)
197
+ if(present.length !== names.length) {
198
+ if(template.required === false && !present.length) {
199
+ continue
200
+ }
201
+
202
+ throw new Error('Not all template variables are present')
203
+ }
204
+
205
+ const length = parameters[names[0]].length
206
+ if(!names.every((name) => parameters[name].length === length)) {
207
+ throw new Error('Not all template variables have same length')
208
+ }
209
+
210
+ const expanded = Array.from({ length }, (_, index) => substitute(
211
+ template,
212
+ Object.fromEntries(names.map((name) => [name, parameters[name][index]])),
213
+ ))
214
+ if(template.templateParamsMode !== 'merge') {
215
+ result.push(...expanded)
216
+ continue
217
+ }
218
+
219
+ result.push({
220
+ ...template,
221
+ responseMatches: expanded.flatMap((item) => item.responseMatches),
222
+ responseRedactions: expanded.flatMap((item) => item.responseRedactions),
223
+ })
224
+ }
225
+
226
+ return result
227
+ }
228
+
229
+ function substitute(
230
+ template: ProofRequestSpec,
231
+ parameters: Record<string, string>,
232
+ ): ProofRequestSpec {
233
+ const replace = (value: string) => Object.entries(parameters).reduce(
234
+ (result, [name, replacement]) => (
235
+ result.split(`\${${name}}`).join(replacement)
236
+ ),
237
+ value,
238
+ )
239
+ return {
240
+ ...template,
241
+ responseMatches: template.responseMatches.map((match) => ({
242
+ ...match,
243
+ value: replace(match.value ?? ''),
244
+ })),
245
+ responseRedactions: template.responseRedactions.map((redaction) => ({
246
+ ...redaction,
247
+ xPath: replace(redaction.xPath ?? ''),
248
+ jsonPath: replace(redaction.jsonPath ?? ''),
249
+ regex: replace(redaction.regex ?? ''),
250
+ })),
251
+ }
252
+ }
253
+
254
+ function templateParametersFromProofs(proofs: LegacyProof[]) {
255
+ const combined: Record<string, unknown> = {}
256
+ for(const proof of proofs) {
257
+ for(const raw of [proof.claimData.parameters, proof.claimData.context]) {
258
+ try {
259
+ const parsed = JSON.parse(raw) as Record<string, unknown>
260
+ Object.assign(combined, parsed.paramValues, parsed.extractedParameters)
261
+ } catch{
262
+ // A proof without template parameters is valid.
263
+ }
264
+ }
265
+ }
266
+
267
+ const result: Record<string, string[]> = {}
268
+ for(const [name, raw] of Object.entries(combined)) {
269
+ let value = raw
270
+ if(typeof value === 'string') {
271
+ try {
272
+ value = JSON.parse(value)
273
+ } catch{ /* plain string */ }
274
+ }
275
+
276
+ if(Array.isArray(value) && value.length) {
277
+ result[name] = value.map(String)
278
+ }
279
+ }
280
+
281
+ return result
282
+ }
283
+
284
+ function isHttpProofParams(value: unknown): value is HttpProofParams {
285
+ if(!value || typeof value !== 'object' || Array.isArray(value)) {
286
+ return false
287
+ }
288
+
289
+ const params = value as Record<string, unknown>
290
+ return typeof params.url === 'string'
291
+ && typeof params.method === 'string'
292
+ && ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(params.method)
293
+ && (
294
+ params.body === null
295
+ || params.body === undefined
296
+ || typeof params.body === 'string'
297
+ )
298
+ && Array.isArray(params.responseMatches)
299
+ && params.responseMatches.length > 0
300
+ && Array.isArray(params.responseRedactions)
301
+ }
302
+
303
+ function normalized(value: string) {
304
+ return value.toLowerCase().trim()
305
+ }
306
+
307
+ /**
308
+ * All OPRF transports prove the same provider-level intent: the selected
309
+ * value is hidden behind an OPRF. Verification Clients may use the transport
310
+ * they implement, so transport spelling must not change the provider hash.
311
+ */
312
+ function normalizedRedactionHash(value: string | undefined) {
313
+ return value === 'oprf' || value === 'oprf-mpc' || value === 'oprf-raw'
314
+ ? '_oprf'
315
+ : value || undefined
316
+ }