@reclaimprotocol/agent 0.1.0-dev.1

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 (191) hide show
  1. package/README.md +287 -0
  2. package/lib/cdp/capture.d.ts +22 -0
  3. package/lib/cdp/capture.js +159 -0
  4. package/lib/cdp/client.d.ts +9 -0
  5. package/lib/cdp/client.js +11 -0
  6. package/lib/cdp/eviction.d.ts +16 -0
  7. package/lib/cdp/eviction.js +38 -0
  8. package/lib/cdp/launcher.d.ts +14 -0
  9. package/lib/cdp/launcher.js +102 -0
  10. package/lib/cdp/tabs.d.ts +6 -0
  11. package/lib/cdp/tabs.js +10 -0
  12. package/lib/cli/args.d.ts +14 -0
  13. package/lib/cli/args.js +19 -0
  14. package/lib/cli/commands.d.ts +1 -0
  15. package/lib/cli/commands.js +19 -0
  16. package/lib/cli.d.ts +1 -0
  17. package/lib/cli.js +7 -0
  18. package/lib/config/store.d.ts +45 -0
  19. package/lib/config/store.js +46 -0
  20. package/lib/index.d.ts +3 -0
  21. package/lib/index.js +7 -0
  22. package/lib/logger.d.ts +2 -0
  23. package/lib/logger.js +7 -0
  24. package/lib/mcp/api/operations.gen.d.ts +152 -0
  25. package/lib/mcp/api/operations.gen.js +2300 -0
  26. package/lib/mcp/api/tools.d.ts +3 -0
  27. package/lib/mcp/api/tools.js +36 -0
  28. package/lib/mcp/remap_errors.d.ts +2 -0
  29. package/lib/mcp/remap_errors.js +27 -0
  30. package/lib/mcp/server.d.ts +38 -0
  31. package/lib/mcp/server.js +76 -0
  32. package/lib/mcp/start.d.ts +1 -0
  33. package/lib/mcp/start.js +63 -0
  34. package/lib/mcp/tools/agent/analyze.d.ts +3 -0
  35. package/lib/mcp/tools/agent/analyze.js +26 -0
  36. package/lib/mcp/tools/agent/attach.d.ts +11 -0
  37. package/lib/mcp/tools/agent/attach.js +72 -0
  38. package/lib/mcp/tools/agent/build.d.ts +33 -0
  39. package/lib/mcp/tools/agent/build.js +74 -0
  40. package/lib/mcp/tools/agent/capture.d.ts +10 -0
  41. package/lib/mcp/tools/agent/capture.js +142 -0
  42. package/lib/mcp/tools/agent/eval.d.ts +5 -0
  43. package/lib/mcp/tools/agent/eval.js +127 -0
  44. package/lib/mcp/tools/agent/find.d.ts +3 -0
  45. package/lib/mcp/tools/agent/find.js +60 -0
  46. package/lib/mcp/tools/agent/proof.d.ts +3 -0
  47. package/lib/mcp/tools/agent/proof.js +214 -0
  48. package/lib/mcp/tools/agent/propose.d.ts +9 -0
  49. package/lib/mcp/tools/agent/propose.js +75 -0
  50. package/lib/mcp/tools/agent/publish.d.ts +3 -0
  51. package/lib/mcp/tools/agent/publish.js +122 -0
  52. package/lib/mcp/tools/agent/replay.d.ts +3 -0
  53. package/lib/mcp/tools/agent/replay.js +46 -0
  54. package/lib/mcp/tools/agent/session.d.ts +10 -0
  55. package/lib/mcp/tools/agent/session.js +29 -0
  56. package/lib/mcp/tools/credentials/authenticate.d.ts +3 -0
  57. package/lib/mcp/tools/credentials/authenticate.js +108 -0
  58. package/lib/mcp/tools/credentials/build.d.ts +16 -0
  59. package/lib/mcp/tools/credentials/build.js +33 -0
  60. package/lib/mcp/tools/credentials/cache.d.ts +70 -0
  61. package/lib/mcp/tools/credentials/cache.js +114 -0
  62. package/lib/mcp/tools/credentials/env-file.d.ts +25 -0
  63. package/lib/mcp/tools/credentials/env-file.js +71 -0
  64. package/lib/mcp/tools/credentials/import.d.ts +2 -0
  65. package/lib/mcp/tools/credentials/import.js +52 -0
  66. package/lib/mcp/tools/credentials/issue.d.ts +2 -0
  67. package/lib/mcp/tools/credentials/issue.js +84 -0
  68. package/lib/mcp/tools/credentials/list-cached.d.ts +2 -0
  69. package/lib/mcp/tools/credentials/list-cached.js +55 -0
  70. package/lib/mcp/tools/credentials/register.d.ts +18 -0
  71. package/lib/mcp/tools/credentials/register.js +30 -0
  72. package/lib/mcp/tools/credentials/resolve-owner-key.d.ts +12 -0
  73. package/lib/mcp/tools/credentials/resolve-owner-key.js +68 -0
  74. package/lib/mcp/tools/credentials/session-cache.d.ts +14 -0
  75. package/lib/mcp/tools/credentials/session-cache.js +19 -0
  76. package/lib/mcp/tools/credentials/status.d.ts +3 -0
  77. package/lib/mcp/tools/credentials/status.js +62 -0
  78. package/lib/mcp-bin.d.ts +1 -0
  79. package/lib/mcp-bin.js +7 -0
  80. package/lib/old/client.d.ts +67 -0
  81. package/lib/old/client.js +146 -0
  82. package/lib/old/flag.d.ts +11 -0
  83. package/lib/old/flag.js +13 -0
  84. package/lib/old/identity.d.ts +22 -0
  85. package/lib/old/identity.js +47 -0
  86. package/lib/old/instructions.d.ts +7 -0
  87. package/lib/old/instructions.js +29 -0
  88. package/lib/old/login-cdp.d.ts +23 -0
  89. package/lib/old/login-cdp.js +125 -0
  90. package/lib/old/to-register.d.ts +47 -0
  91. package/lib/old/to-register.js +133 -0
  92. package/lib/old/tools/authenticate.d.ts +18 -0
  93. package/lib/old/tools/authenticate.js +127 -0
  94. package/lib/old/tools/build.d.ts +9 -0
  95. package/lib/old/tools/build.js +16 -0
  96. package/lib/old/tools/me-providers.d.ts +10 -0
  97. package/lib/old/tools/me-providers.js +32 -0
  98. package/lib/old/tools/publish.d.ts +16 -0
  99. package/lib/old/tools/publish.js +97 -0
  100. package/lib/paths.d.ts +11 -0
  101. package/lib/paths.js +32 -0
  102. package/lib/pkg.d.ts +3 -0
  103. package/lib/pkg.js +13 -0
  104. package/lib/proof/attestor.d.ts +39 -0
  105. package/lib/proof/attestor.js +207 -0
  106. package/lib/proof/replay.d.ts +8 -0
  107. package/lib/proof/replay.js +73 -0
  108. package/lib/provider/constraints.d.ts +2 -0
  109. package/lib/provider/constraints.js +122 -0
  110. package/lib/provider/draft.d.ts +6 -0
  111. package/lib/provider/draft.js +68 -0
  112. package/lib/provider/graphql.d.ts +2 -0
  113. package/lib/provider/graphql.js +35 -0
  114. package/lib/provider/matcher.d.ts +36 -0
  115. package/lib/provider/matcher.js +436 -0
  116. package/lib/provider/normalize.d.ts +4 -0
  117. package/lib/provider/normalize.js +51 -0
  118. package/lib/provider/schema.d.ts +76 -0
  119. package/lib/provider/schema.js +1 -0
  120. package/lib/provider/secrets.d.ts +5 -0
  121. package/lib/provider/secrets.js +22 -0
  122. package/lib/provider/to-version.d.ts +22 -0
  123. package/lib/provider/to-version.js +68 -0
  124. package/lib/types/openapi.d.ts +4 -0
  125. package/lib/types/openapi.js +1 -0
  126. package/package.json +88 -0
  127. package/scripts/download-zk-files.ts +42 -0
  128. package/scripts/prepend-shebang.ts +17 -0
  129. package/skill/SKILL.md +248 -0
  130. package/src/cdp/capture.ts +215 -0
  131. package/src/cdp/client.ts +20 -0
  132. package/src/cdp/eviction.ts +52 -0
  133. package/src/cdp/launcher.ts +133 -0
  134. package/src/cdp/tabs.ts +22 -0
  135. package/src/cli/args.ts +20 -0
  136. package/src/cli/commands.ts +27 -0
  137. package/src/cli.ts +7 -0
  138. package/src/config/store.ts +86 -0
  139. package/src/index.ts +12 -0
  140. package/src/logger.ts +8 -0
  141. package/src/mcp/api/operations.gen.ts +2397 -0
  142. package/src/mcp/api/tools.ts +45 -0
  143. package/src/mcp/remap_errors.ts +35 -0
  144. package/src/mcp/server.ts +119 -0
  145. package/src/mcp/start.ts +78 -0
  146. package/src/mcp/tools/agent/analyze.ts +34 -0
  147. package/src/mcp/tools/agent/attach.ts +95 -0
  148. package/src/mcp/tools/agent/build.ts +99 -0
  149. package/src/mcp/tools/agent/capture.ts +202 -0
  150. package/src/mcp/tools/agent/eval.ts +161 -0
  151. package/src/mcp/tools/agent/find.ts +73 -0
  152. package/src/mcp/tools/agent/proof.ts +272 -0
  153. package/src/mcp/tools/agent/propose.ts +111 -0
  154. package/src/mcp/tools/agent/publish.ts +155 -0
  155. package/src/mcp/tools/agent/replay.ts +58 -0
  156. package/src/mcp/tools/agent/session.ts +46 -0
  157. package/src/mcp/tools/credentials/authenticate.ts +135 -0
  158. package/src/mcp/tools/credentials/build.ts +37 -0
  159. package/src/mcp/tools/credentials/cache.ts +161 -0
  160. package/src/mcp/tools/credentials/env-file.ts +101 -0
  161. package/src/mcp/tools/credentials/import.ts +72 -0
  162. package/src/mcp/tools/credentials/issue.ts +107 -0
  163. package/src/mcp/tools/credentials/list-cached.ts +66 -0
  164. package/src/mcp/tools/credentials/register.ts +49 -0
  165. package/src/mcp/tools/credentials/resolve-owner-key.ts +78 -0
  166. package/src/mcp/tools/credentials/session-cache.ts +31 -0
  167. package/src/mcp/tools/credentials/status.ts +82 -0
  168. package/src/mcp-bin.ts +7 -0
  169. package/src/old/client.ts +236 -0
  170. package/src/old/flag.ts +13 -0
  171. package/src/old/identity.ts +56 -0
  172. package/src/old/instructions.ts +30 -0
  173. package/src/old/login-cdp.ts +153 -0
  174. package/src/old/to-register.ts +234 -0
  175. package/src/old/tools/authenticate.ts +152 -0
  176. package/src/old/tools/build.ts +21 -0
  177. package/src/old/tools/me-providers.ts +42 -0
  178. package/src/old/tools/publish.ts +135 -0
  179. package/src/paths.ts +37 -0
  180. package/src/pkg.ts +24 -0
  181. package/src/proof/attestor.ts +286 -0
  182. package/src/proof/replay.ts +102 -0
  183. package/src/provider/constraints.ts +150 -0
  184. package/src/provider/draft.ts +105 -0
  185. package/src/provider/graphql.ts +42 -0
  186. package/src/provider/matcher.ts +616 -0
  187. package/src/provider/normalize.ts +65 -0
  188. package/src/provider/schema.ts +92 -0
  189. package/src/provider/secrets.ts +29 -0
  190. package/src/provider/to-version.ts +90 -0
  191. package/src/types/openapi.ts +7 -0
@@ -0,0 +1,286 @@
1
+ // src/proof/attestor.ts
2
+ //
3
+ // Adapter over @reclaimprotocol/attestor-core's createClaimOnAttestor.
4
+ //
5
+ // responseMatches are the regex/contains checks the attestor runs on the
6
+ // revealed slice; jsonPath/xPath live on responseRedactions (what the prover
7
+ // reveals), never on responseMatches.
8
+
9
+ import {
10
+ createClaimOnAttestor, type Logger, type ProviderParams,
11
+ } from '@reclaimprotocol/attestor-core'
12
+ import { resolveOwnerPrivateKey } from '../mcp/tools/credentials/cache.ts'
13
+ import type { ReclaimProvider } from '../provider/schema.ts'
14
+
15
+ export interface AttestorLogEntry {
16
+ level: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal'
17
+ msg: string
18
+ ctx?: Record<string, unknown>
19
+ }
20
+
21
+ export interface ProofResult {
22
+ proof: unknown
23
+ extractedValue: string
24
+ extractedParameters: Record<string, string>
25
+ identifier?: string
26
+ owner?: string
27
+ verified: boolean
28
+ error?: string
29
+ /** Set to `'missing-owner-key'` when no credential was available to
30
+ * sign with — distinguishes this recoverable case from other proof
31
+ * errors so the skill can point the dev at `issue_credentials` /
32
+ * `list_cached_credentials` rather than guessing. */
33
+ errorKind?: 'missing-owner-key'
34
+ /** Pino-style log entries captured from the attestor SDK during the
35
+ * proof attempt. Full stream on the result; the MCP layer trims it
36
+ * for the LLM-visible response and dumps the whole thing to disk
37
+ * via `runLogPath`. */
38
+ attestorLogs?: AttestorLogEntry[]
39
+ /** The exact `params` object passed to `createClaimOnAttestor` for
40
+ * this attempt. Only included on error so the LLM/dev can confirm
41
+ * what the attestor actually saw vs what they thought they sent
42
+ * (catches mutation bugs / template-substitution surprises). Never
43
+ * carries the captured secrets — those live in `secretParams` which
44
+ * is not echoed here. */
45
+ sentParams?: unknown
46
+ }
47
+
48
+ /** Trim a captured log stream to the last N entries — for shrinking the
49
+ * LLM-visible response while the full stream stays in the on-disk dump. */
50
+ export function tailLogs(
51
+ entries: AttestorLogEntry[],
52
+ n = LOG_TAIL_LINES,
53
+ ): AttestorLogEntry[] {
54
+ if(entries.length <= n) {
55
+ return entries
56
+ }
57
+
58
+ return entries.slice(entries.length - n)
59
+ }
60
+
61
+ /** Filter to just warn/error/fatal — for the LLM-visible response on
62
+ * successful proofs, where chatty trace/info would just waste context. */
63
+ export function warnsAndErrors(
64
+ entries: AttestorLogEntry[],
65
+ ): AttestorLogEntry[] {
66
+ return entries.filter(
67
+ (e) => e.level === 'warn' || e.level === 'error' || e.level === 'fatal',
68
+ )
69
+ }
70
+
71
+ /** How many log entries to keep from the attestor SDK on an error. The
72
+ * early ones describe TLS setup; the late ones (right before the
73
+ * throw) usually pinpoint the actual failure. Keeping the tail covers
74
+ * the latter; the cap stops a chatty session from blowing the LLM's
75
+ * context window. */
76
+ const LOG_TAIL_LINES = 80
77
+
78
+ /** Build a pino-compatible capturing logger. Each `trace`/`debug`/…
79
+ * call records an entry; `child` returns a derived capturer bound to
80
+ * the parent's entries array so nested SDK loggers all flow into one
81
+ * ordered list. */
82
+ function makeCaptureLogger(): {
83
+ logger: unknown
84
+ entries: AttestorLogEntry[]
85
+ } {
86
+ const entries: AttestorLogEntry[] = []
87
+ const make = (baseCtx: Record<string, unknown>): unknown => {
88
+ const log =
89
+ (level: AttestorLogEntry['level']) => (...args: unknown[]) => {
90
+ if(args.length === 0) {
91
+ return
92
+ }
93
+
94
+ let msg = ''
95
+ let ctx: Record<string, unknown> | undefined
96
+
97
+ if(args[0] instanceof Error) {
98
+ // pino-style `logger.error(err, 'msg')` — Error objects
99
+ // don't JSON-serialize on their own, so extract the
100
+ // useful fields manually.
101
+ const err = args[0]
102
+ ctx = {
103
+ error: { name: err.name, message: err.message, stack: err.stack },
104
+ }
105
+ msg = typeof args[1] === 'string' ? args[1] : err.message
106
+ } else if(typeof args[0] === 'string') {
107
+ msg = args[0]
108
+ } else if(args[0] && typeof args[0] === 'object') {
109
+ ctx = args[0] as Record<string, unknown>
110
+ msg = typeof args[1] === 'string' ? args[1] : ''
111
+ }
112
+
113
+ const merged =
114
+ Object.keys(baseCtx).length || ctx
115
+ ? { ...baseCtx, ...(ctx ?? {}) }
116
+ : undefined
117
+ const entry: AttestorLogEntry = { level, msg }
118
+ if(merged) {
119
+ entry.ctx = merged
120
+ }
121
+
122
+ entries.push(entry)
123
+ }
124
+
125
+ return {
126
+ trace: log('trace'),
127
+ debug: log('debug'),
128
+ info: log('info'),
129
+ warn: log('warn'),
130
+ error: log('error'),
131
+ fatal: log('fatal'),
132
+ child: (childCtx: Record<string, unknown>) => {
133
+ return make({ ...baseCtx, ...childCtx })
134
+ },
135
+ }
136
+ }
137
+
138
+ return { logger: make({}), entries }
139
+ }
140
+
141
+ /** Default public attestor websocket URL. Resolved per-call so an env-var
142
+ * change or per-request override takes effect without restarting the
143
+ * MCP server. */
144
+ const FALLBACK_ATTESTOR_URL = 'wss://attestor.reclaimprotocol.org:444/ws'
145
+ function resolveAttestorUrl(override?: string): string {
146
+ return (
147
+ override ?? process.env['RECLAIM_ATTESTOR_URL'] ?? FALLBACK_ATTESTOR_URL
148
+ )
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Main entry point
153
+ // ---------------------------------------------------------------------------
154
+
155
+ export async function runAttestorProof(
156
+ provider: ReclaimProvider,
157
+ secrets: Record<string, string>,
158
+ attestorUrl?: string,
159
+ ownerAddress?: string,
160
+ ): Promise<ProofResult> {
161
+ // The owner is chosen up front (resolve_owner_key) and passed as
162
+ // `ownerAddress`; here we just fetch its private key and sign.
163
+ const ownerPrivateKey = ownerAddress
164
+ ? resolveOwnerPrivateKey(ownerAddress)
165
+ : undefined
166
+ if(!ownerPrivateKey) {
167
+ return {
168
+ proof: undefined,
169
+ extractedValue: '',
170
+ extractedParameters: {},
171
+ verified: false,
172
+ error:
173
+ `No private key available for ownerAddress "${ownerAddress ?? ''}". ` +
174
+ 'Call resolve_owner_key to pick a credential (or ' +
175
+ 'issue_credentials / import_credentials to set one up), then ' +
176
+ 'retry run_proof with its address as ownerAddress.',
177
+ errorKind: 'missing-owner-key',
178
+ }
179
+ }
180
+
181
+ try {
182
+ const params: ProviderParams<'http'> = {
183
+ url: provider.url,
184
+ method: provider.method,
185
+ ...(provider.headers !== undefined && { headers: provider.headers }),
186
+ ...(provider.body !== undefined && { body: provider.body }),
187
+ responseMatches: provider.responseMatches,
188
+ responseRedactions: provider.responseRedactions ?? [],
189
+ ...(provider.paramValues !== undefined && {
190
+ paramValues: provider.paramValues,
191
+ }),
192
+ }
193
+
194
+ // Diagnostic logger capture is gated behind RECLAIM_AGENT_TRACE so the
195
+ // happy path doesn't pay the cost (the attestor SDK emits a *lot* of
196
+ // trace/debug entries during ZK setup). Flip RECLAIM_AGENT_TRACE=1 to
197
+ // turn it back on when something's broken; logs flow into the proof
198
+ // response and into the on-disk proof-runs dump.
199
+ const traceEnabled = process.env['RECLAIM_AGENT_TRACE'] === '1'
200
+ const capture = traceEnabled ? makeCaptureLogger() : undefined
201
+ const attestorLogs = capture?.entries
202
+
203
+ let claim: unknown
204
+ try {
205
+ claim = await createClaimOnAttestor({
206
+ name: 'http',
207
+ params,
208
+ secretParams: {
209
+ headers: secrets,
210
+ },
211
+ ownerPrivateKey,
212
+ client: { url: resolveAttestorUrl(attestorUrl) },
213
+ zkEngine: 'snarkjs',
214
+ ...(capture !== undefined && { logger: capture.logger as Logger }),
215
+ })
216
+ } catch(err) {
217
+ const errorResult: ProofResult = {
218
+ proof: undefined,
219
+ extractedValue: '',
220
+ extractedParameters: {},
221
+ verified: false,
222
+ error: err instanceof Error ? err.message : String(err),
223
+ sentParams: params,
224
+ }
225
+ if(attestorLogs !== undefined) {
226
+ errorResult.attestorLogs = attestorLogs
227
+ }
228
+
229
+ return errorResult
230
+ }
231
+
232
+ // ClaimTunnelResponse carries the claim under `.claim`.
233
+ const claimRecord = claim as Record<string, unknown>
234
+ const claimData =
235
+ claimRecord['claim'] as Record<string, unknown> | undefined
236
+
237
+ // claimData.context is a JSON-encoded string carrying
238
+ // { extractedParameters: {...}, providerHash, ... }.
239
+ let context: Record<string, unknown> = {}
240
+ const rawContext = claimData?.['context']
241
+ if(typeof rawContext === 'string') {
242
+ try {
243
+ context = JSON.parse(rawContext) as Record<string, unknown>
244
+ } catch{
245
+ // Leave context empty if it isn't valid JSON.
246
+ }
247
+ }
248
+
249
+ const extractedParameters =
250
+ (context['extractedParameters'] as Record<string, string> | undefined) ??
251
+ {}
252
+ const extractedValue = Object.values(extractedParameters)[0] ?? ''
253
+
254
+ const result: ProofResult = {
255
+ proof: claim,
256
+ extractedValue,
257
+ extractedParameters,
258
+ verified: true,
259
+ }
260
+ const identifier = claimData?.['identifier']
261
+ if(typeof identifier === 'string') {
262
+ result.identifier = identifier
263
+ }
264
+
265
+ const owner = claimData?.['owner']
266
+ if(typeof owner === 'string') {
267
+ result.owner = owner
268
+ }
269
+
270
+ // Only set when RECLAIM_AGENT_TRACE=1. Full stream — the MCP layer
271
+ // trims for the LLM-visible response; the on-disk dump gets it all.
272
+ if(attestorLogs !== undefined && attestorLogs.length > 0) {
273
+ result.attestorLogs = attestorLogs
274
+ }
275
+
276
+ return result
277
+ } catch(err) {
278
+ return {
279
+ proof: undefined,
280
+ extractedValue: '',
281
+ extractedParameters: {},
282
+ verified: false,
283
+ error: err instanceof Error ? err.message : String(err),
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,102 @@
1
+ import type { ReclaimProvider, ResponseMatch } from '../provider/schema.ts'
2
+
3
+ export interface ReplayResult {
4
+ status: number
5
+ body: string
6
+ matchesOriginal: boolean
7
+ hints?: string[]
8
+ }
9
+
10
+ /** Expand `{{name}}` placeholders from `paramValues`. Matches the attestor
11
+ * SDK's behavior so replay and proof both hit the same final URL. */
12
+ function expandTemplate(
13
+ s: string,
14
+ params: Record<string, string> | undefined,
15
+ ): string {
16
+ if(!params) {
17
+ return s
18
+ }
19
+
20
+ return s.replace(/\{\{([^{}]+)\}\}/g, (match, name) => {
21
+ const v = params[name]
22
+ return v !== undefined ? v : match
23
+ })
24
+ }
25
+
26
+ export async function replayProvider(
27
+ provider: ReclaimProvider,
28
+ secrets: Record<string, string>,
29
+ ): Promise<ReplayResult> {
30
+ const params = provider.paramValues
31
+ const url = expandTemplate(provider.url, params)
32
+ const expandedHeaders: Record<string, string> = {}
33
+ for(const [k, v] of Object.entries(provider.headers ?? {})) {
34
+ expandedHeaders[k] = expandTemplate(v, params)
35
+ }
36
+
37
+ const headers = { ...expandedHeaders, ...secrets }
38
+ const fetchInit: RequestInit = {
39
+ method: provider.method,
40
+ headers,
41
+ redirect: 'manual',
42
+ }
43
+ if(provider.body !== undefined) {
44
+ fetchInit.body = expandTemplate(provider.body, params)
45
+ }
46
+
47
+ const res = await fetch(url, fetchInit)
48
+ const body = await res.text()
49
+ const hints: string[] = []
50
+ if(res.status === 401 || res.status === 403) {
51
+ hints.push('auth/permission failed')
52
+ }
53
+
54
+ if(res.status >= 500) {
55
+ hints.push('upstream server error')
56
+ }
57
+
58
+ for(const m of provider.responseMatches) {
59
+ // The attestor substitutes paramValues into responseMatches.value
60
+ // at proof time (`{{name}}` → the value, or → the OPRF nullifier
61
+ // when the matching redaction has `hash`). Replay can't OPRF, but
62
+ // it can do the plain substitution so a paramValues-templated match
63
+ // is meaningful here.
64
+ const expanded: ResponseMatch = {
65
+ ...m,
66
+ value: expandTemplate(m.value, params),
67
+ }
68
+ if(!matchesBody(body, expanded)) {
69
+ if(res.status >= 200 && res.status < 300) {
70
+ hints.push(
71
+ `matcher ${expanded.type} did not match — ` +
72
+ 'value may have changed or matcher is too tight',
73
+ )
74
+ }
75
+
76
+ const result: ReplayResult = {
77
+ status: res.status,
78
+ body,
79
+ matchesOriginal: false,
80
+ }
81
+ if(hints.length > 0) {
82
+ result.hints = hints
83
+ }
84
+
85
+ return result
86
+ }
87
+ }
88
+
89
+ return { status: res.status, body, matchesOriginal: res.status < 400 }
90
+ }
91
+
92
+ function matchesBody(body: string, m: ResponseMatch): boolean {
93
+ if(m.type === 'regex') {
94
+ return new RegExp(m.value).test(body)
95
+ }
96
+
97
+ if(m.type === 'contains') {
98
+ return body.includes(m.value)
99
+ }
100
+
101
+ return false
102
+ }
@@ -0,0 +1,150 @@
1
+ import { looksLikeGraphqlEndpoint } from './graphql.ts'
2
+ import type { CapturedRequest, RequestConcern } from './schema.ts'
3
+
4
+ const SIGNATURE_HEADERS = [
5
+ 'x-signature',
6
+ 'x-amz-signature',
7
+ 'x-hub-signature',
8
+ 'x-hmac',
9
+ 'signature',
10
+ 'x-request-signature',
11
+ ]
12
+
13
+ const CSRF_HEADERS = [
14
+ 'x-csrf-token',
15
+ 'x-xsrf-token',
16
+ 'csrf-token',
17
+ 'xsrf-token',
18
+ ]
19
+
20
+ export function detectConcerns(req: CapturedRequest): RequestConcern[] {
21
+ const concerns: RequestConcern[] = []
22
+ const reqHeadersLower = lowerKeys(req.requestHeaders)
23
+ const respHeadersLower = lowerKeys(req.responseHeaders)
24
+
25
+ // Signed requests
26
+ for(const h of SIGNATURE_HEADERS) {
27
+ if(reqHeadersLower[h]) {
28
+ concerns.push({
29
+ code: 'signedRequest',
30
+ message: `Request includes a "${h}" header — ` +
31
+ 'providers will break when the signing scheme rotates.',
32
+ })
33
+ break
34
+ }
35
+ }
36
+
37
+ // Bot challenge
38
+ const cfMitigated = respHeadersLower['cf-mitigated']
39
+ const server = respHeadersLower['server'] ?? ''
40
+ if(
41
+ (req.status === 403 || req.status === 503) &&
42
+ (cfMitigated === 'challenge' || /cloudflare/i.test(server))
43
+ ) {
44
+ concerns.push({
45
+ code: 'botChallenge',
46
+ message:
47
+ 'Response looks like a bot-mitigation challenge (Cloudflare). ' +
48
+ 'Capture again after solving the challenge in the browser.',
49
+ })
50
+ }
51
+
52
+ // GraphQL
53
+ if(looksLikeGraphqlEndpoint(req.url)) {
54
+ concerns.push({
55
+ code: 'graphql',
56
+ message:
57
+ 'Endpoint is GraphQL — ' +
58
+ 'match on operationName + variables, not URL path.',
59
+ })
60
+ }
61
+
62
+ // CSRF dependency
63
+ for(const h of CSRF_HEADERS) {
64
+ if(reqHeadersLower[h]) {
65
+ concerns.push({
66
+ code: 'csrfDependency',
67
+ message: `Request includes a "${h}" header — ` +
68
+ 'a prior request fetched this token. ' +
69
+ 'Replay will fail unless the token is still valid; ' +
70
+ 'expect to re-capture for proof.',
71
+ })
72
+ break
73
+ }
74
+ }
75
+
76
+ // Short-lived token (JWT exp < 30 min from finishedAt)
77
+ const auth = reqHeadersLower['authorization']
78
+ if(auth) {
79
+ const jwtMatch = auth.match(
80
+ /^[Bb]earer\s+([A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)/,
81
+ )
82
+ if(jwtMatch) {
83
+ const exp = jwtExpSeconds(jwtMatch[1])
84
+ const issuedAtMs = req.finishedAt || Date.now()
85
+ if(exp && exp * 1000 - issuedAtMs < 30 * 60 * 1000) {
86
+ concerns.push({
87
+ code: 'shortLivedToken',
88
+ message:
89
+ 'Authorization is a JWT expiring in under 30 minutes — ' +
90
+ 'provider may stop working soon. Re-capture closer to proof time.',
91
+ })
92
+ }
93
+ }
94
+ }
95
+
96
+ // Encrypted / binary body
97
+ if(looksBinary(req.contentType, req.responseBody)) {
98
+ concerns.push({
99
+ code: 'encryptedBody',
100
+ message: `Response content-type "${req.contentType}" is binary/opaque —` +
101
+ ' Reclaim providers need plain-text (JSON/HTML) responses.',
102
+ })
103
+ }
104
+
105
+ return concerns
106
+ }
107
+
108
+ function lowerKeys(h: Record<string, string>): Record<string, string> {
109
+ return Object.fromEntries(
110
+ Object.entries(h).map(([k, v]) => [k.toLowerCase(), v]),
111
+ )
112
+ }
113
+
114
+ function jwtExpSeconds(jwt: string): number | undefined {
115
+ const parts = jwt.split('.')
116
+ if(parts.length !== 3) {
117
+ return undefined
118
+ }
119
+
120
+ try {
121
+ const decoded = JSON.parse(
122
+ Buffer.from(parts[1], 'base64url').toString('utf8'),
123
+ )
124
+ return typeof decoded.exp === 'number' ? decoded.exp : undefined
125
+ } catch{
126
+ return undefined
127
+ }
128
+ }
129
+
130
+ function looksBinary(contentType: string, body: string | undefined): boolean {
131
+ if(!body) {
132
+ return false
133
+ }
134
+
135
+ if(/octet-stream|protobuf|msgpack|cbor/i.test(contentType)) {
136
+ return true
137
+ }
138
+
139
+ // Heuristic: significant control-char density in first 256 bytes.
140
+ const sample = body.slice(0, 256)
141
+ let ctrl = 0
142
+ for(const ch of sample) {
143
+ const code = ch.charCodeAt(0)
144
+ if(code < 9 || (code > 13 && code < 32)) {
145
+ ctrl++
146
+ }
147
+ }
148
+
149
+ return ctrl / sample.length > 0.1
150
+ }
@@ -0,0 +1,105 @@
1
+ import { synthesizeMatcher } from './matcher.ts'
2
+ import type {
3
+ CapturedRequest,
4
+ OprfMode,
5
+ ReclaimProvider,
6
+ SecretParamRef,
7
+ } from './schema.ts'
8
+ import { splitSecrets } from './secrets.ts'
9
+
10
+ export interface DraftResult {
11
+ provider: ReclaimProvider
12
+ secretRefs: SecretParamRef[]
13
+ }
14
+
15
+ /** Replace any existing `accept-encoding` (case-insensitive) with a
16
+ * canonical `Accept-Encoding: identity`. Strips browser-captured
17
+ * encodings like `gzip, deflate, br, zstd` that would otherwise make
18
+ * the attestor match against compressed response bytes. */
19
+ function withIdentityEncoding(
20
+ headers: Record<string, string>,
21
+ ): Record<string, string> {
22
+ const out: Record<string, string> = {}
23
+ for(const [k, v] of Object.entries(headers)) {
24
+ if(k.toLowerCase() === 'accept-encoding') {
25
+ continue
26
+ }
27
+
28
+ out[k] = v
29
+ }
30
+
31
+ out['Accept-Encoding'] = 'identity'
32
+ return out
33
+ }
34
+
35
+ /** Derive a `paramValues` key (and capture-group name) from the provider
36
+ * name's last alphanumeric token. `"GitHub Username"` → `"username"`,
37
+ * `"x-followers"` → `"followers"`. */
38
+ function deriveParamName(providerName: string): string {
39
+ const tokens = providerName.split(/[^A-Za-z0-9]+/).filter(Boolean)
40
+ const last = tokens[tokens.length - 1] ?? 'value'
41
+ return last.toLowerCase()
42
+ }
43
+
44
+ export function draftProvider(
45
+ req: CapturedRequest,
46
+ target: string,
47
+ name: string,
48
+ hash?: OprfMode,
49
+ ): DraftResult {
50
+ if(!req.responseBody) {
51
+ throw new Error(`captured request ${req.requestId} has no response body`)
52
+ }
53
+
54
+ const paramName = deriveParamName(name)
55
+ const synthesis = synthesizeMatcher(
56
+ req.responseBody,
57
+ req.contentType,
58
+ target,
59
+ paramName,
60
+ hash,
61
+ )
62
+ if(!synthesis) {
63
+ throw new Error(
64
+ `target value "${target}" not found in` +
65
+ ` response body for request ${req.requestId}`,
66
+ )
67
+ }
68
+
69
+ const { secrets, publics } = splitSecrets(req.requestHeaders)
70
+ // Force the server to respond uncompressed. Captured browser requests
71
+ // usually carry `accept-encoding: gzip, deflate, br, zstd`; the attestor
72
+ // matches against the raw response bytes (no transparent decompression
73
+ // in the TLS replay path), so gzip/br/zstd payloads make every
74
+ // xPath/jsonPath/regex redaction miss against unparseable bytes.
75
+ // Replacing here also benefits `replay_request` — Node's `fetch` does
76
+ // auto-decompress, but pinning the encoding keeps replay and proof
77
+ // looking at the same body shape end-to-end.
78
+ const headersForProvider = withIdentityEncoding(publics)
79
+ const secretRefs: SecretParamRef[] = Object.keys(secrets).map((name) => ({
80
+ name,
81
+ source: name.toLowerCase() === 'cookie' ? 'cookie' : 'header',
82
+ redacted: true,
83
+ }))
84
+
85
+ const provider: ReclaimProvider = {
86
+ name,
87
+ url: req.url,
88
+ method: req.method as ReclaimProvider['method'],
89
+ headers: headersForProvider,
90
+ responseMatches: [synthesis.match],
91
+ }
92
+ if(synthesis.redaction) {
93
+ provider.responseRedactions = [synthesis.redaction]
94
+ }
95
+
96
+ if(synthesis.paramValues) {
97
+ provider.paramValues = synthesis.paramValues
98
+ }
99
+
100
+ if(req.requestBody) {
101
+ provider.body = req.requestBody
102
+ }
103
+
104
+ return { provider, secretRefs }
105
+ }
@@ -0,0 +1,42 @@
1
+ export function looksLikeGraphqlEndpoint(url: string): boolean {
2
+ try {
3
+ const u = new URL(url)
4
+ return u.pathname.includes('/graphql')
5
+ } catch{
6
+ return false
7
+ }
8
+ }
9
+
10
+ export function extractGraphqlOp(body: string | undefined): string | undefined {
11
+ if(!body) {
12
+ return undefined
13
+ }
14
+
15
+ let parsed: unknown
16
+ try {
17
+ parsed = JSON.parse(body)
18
+ } catch{
19
+ return undefined
20
+ }
21
+
22
+ if(typeof parsed !== 'object' || parsed === null) {
23
+ return undefined
24
+ }
25
+
26
+ const obj = parsed as Record<string, unknown>
27
+
28
+ if(typeof obj.operationName === 'string' && obj.operationName.length > 0) {
29
+ return obj.operationName
30
+ }
31
+
32
+ if(typeof obj.query === 'string') {
33
+ const m = obj.query.match(
34
+ /^\s*(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/,
35
+ )
36
+ if(m) {
37
+ return m[1]
38
+ }
39
+ }
40
+
41
+ return undefined
42
+ }