@reclaimprotocol/agent 0.1.18 → 0.1.21

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 (168) hide show
  1. package/README.md +6 -2
  2. package/lib/authoring/backend.d.ts +4 -0
  3. package/lib/authoring/tools.js +4 -1
  4. package/lib/cdp/capture.d.ts +2 -2
  5. package/lib/cdp/capture.js +25 -21
  6. package/lib/cdp/client.d.ts +39 -1
  7. package/lib/cdp/client.js +125 -1
  8. package/lib/mcp/api/operations.gen.js +33 -9
  9. package/lib/mcp/instructions.js +27 -3
  10. package/lib/mcp/remap_errors.js +2 -1
  11. package/lib/mcp/start.js +16 -9
  12. package/lib/mcp/tools/agent/attach.d.ts +18 -4
  13. package/lib/mcp/tools/agent/attach.js +142 -52
  14. package/lib/mcp/tools/agent/build.d.ts +9 -2
  15. package/lib/mcp/tools/agent/build.js +15 -7
  16. package/lib/mcp/tools/agent/capture.d.ts +1 -0
  17. package/lib/mcp/tools/agent/capture.js +8 -1
  18. package/lib/mcp/tools/agent/eval.js +2 -3
  19. package/lib/mcp/tools/agent/how-it-works.d.ts +1 -1
  20. package/lib/mcp/tools/agent/how-it-works.js +1 -1
  21. package/lib/mcp/tools/agent/index.d.ts +1 -1
  22. package/lib/mcp/tools/agent/index.js +1 -1
  23. package/lib/mcp/tools/agent/publish.d.ts +4 -1
  24. package/lib/mcp/tools/agent/publish.js +42 -3
  25. package/lib/mcp/tools/agent/session.d.ts +2 -2
  26. package/lib/mcp/tools/agent/session.js +21 -5
  27. package/lib/mcp/tools/agent/shared.js +4 -6
  28. package/lib/mcp/tools/authenticate/authenticate.d.ts +1 -1
  29. package/lib/mcp/tools/authenticate/authenticate.js +13 -6
  30. package/lib/mcp/tools/authenticate/builder-access.d.ts +8 -0
  31. package/lib/mcp/tools/authenticate/builder-access.js +31 -0
  32. package/lib/mcp/tools/authenticate/index.d.ts +1 -0
  33. package/lib/mcp/tools/authenticate/index.js +1 -0
  34. package/lib/old/login-cdp.js +11 -3
  35. package/lib/old/tools/build.d.ts +8 -1
  36. package/lib/old/tools/build.js +6 -2
  37. package/lib/old/tools/publish.d.ts +10 -1
  38. package/lib/old/tools/publish.js +49 -3
  39. package/lib/provider/draft.d.ts +20 -0
  40. package/lib/provider/draft.js +75 -51
  41. package/lib/provider/index.d.ts +1 -1
  42. package/lib/provider/index.js +1 -1
  43. package/lib/provider/secrets.d.ts +37 -0
  44. package/lib/provider/secrets.js +147 -6
  45. package/lib/provider/to-version.d.ts +7 -0
  46. package/lib/provider/to-version.js +11 -1
  47. package/package.json +2 -2
  48. package/skill/SKILL.builder.md +44 -94
  49. package/skill/SKILL.common.md +102 -0
  50. package/skill/SKILL.old.md +21 -73
  51. package/skill/builder-mode.md +2 -2
  52. package/skill/injection-api.md +95 -10
  53. package/skill/old-mode.md +2 -2
  54. package/src/authoring/backend.ts +4 -0
  55. package/src/authoring/tools.ts +4 -1
  56. package/src/cdp/capture.ts +24 -24
  57. package/src/cdp/client.ts +197 -2
  58. package/src/mcp/api/operations.gen.ts +33 -9
  59. package/src/mcp/instructions.ts +36 -3
  60. package/src/mcp/remap_errors.ts +2 -1
  61. package/src/mcp/start.ts +24 -9
  62. package/src/mcp/tools/agent/attach.ts +198 -57
  63. package/src/mcp/tools/agent/build.ts +20 -6
  64. package/src/mcp/tools/agent/capture.ts +10 -1
  65. package/src/mcp/tools/agent/eval.ts +2 -3
  66. package/src/mcp/tools/agent/how-it-works.ts +1 -1
  67. package/src/mcp/tools/agent/index.ts +1 -1
  68. package/src/mcp/tools/agent/publish.ts +46 -2
  69. package/src/mcp/tools/agent/session.ts +29 -8
  70. package/src/mcp/tools/agent/shared.ts +5 -10
  71. package/src/mcp/tools/authenticate/authenticate.ts +17 -9
  72. package/src/mcp/tools/authenticate/builder-access.ts +42 -0
  73. package/src/mcp/tools/authenticate/index.ts +4 -0
  74. package/src/old/login-cdp.ts +13 -3
  75. package/src/old/tools/build.ts +7 -1
  76. package/src/old/tools/publish.ts +55 -4
  77. package/src/provider/draft.ts +98 -56
  78. package/src/provider/index.ts +9 -1
  79. package/src/provider/secrets.ts +171 -6
  80. package/src/provider/to-version.ts +12 -1
  81. package/lib/authoring/find.d.ts +0 -3
  82. package/lib/authoring/find.js +0 -54
  83. package/lib/authoring/propose.d.ts +0 -3
  84. package/lib/authoring/propose.js +0 -109
  85. package/lib/authoring/replay.d.ts +0 -10
  86. package/lib/authoring/replay.js +0 -34
  87. package/lib/cli/args.d.ts +0 -14
  88. package/lib/cli/args.js +0 -19
  89. package/lib/cli/commands.d.ts +0 -1
  90. package/lib/cli/commands.js +0 -19
  91. package/lib/cli.d.ts +0 -1
  92. package/lib/cli.js +0 -7
  93. package/lib/mcp/credentials/authenticate.d.ts +0 -3
  94. package/lib/mcp/credentials/authenticate.js +0 -108
  95. package/lib/mcp/credentials/build.d.ts +0 -5
  96. package/lib/mcp/credentials/build.js +0 -18
  97. package/lib/mcp/credentials/cache.d.ts +0 -70
  98. package/lib/mcp/credentials/cache.js +0 -131
  99. package/lib/mcp/credentials/env-file.d.ts +0 -25
  100. package/lib/mcp/credentials/env-file.js +0 -71
  101. package/lib/mcp/credentials/import.d.ts +0 -2
  102. package/lib/mcp/credentials/import.js +0 -52
  103. package/lib/mcp/credentials/issue.d.ts +0 -2
  104. package/lib/mcp/credentials/issue.js +0 -84
  105. package/lib/mcp/credentials/keypair.d.ts +0 -17
  106. package/lib/mcp/credentials/keypair.js +0 -48
  107. package/lib/mcp/credentials/link.d.ts +0 -3
  108. package/lib/mcp/credentials/link.js +0 -107
  109. package/lib/mcp/credentials/list-cached.d.ts +0 -2
  110. package/lib/mcp/credentials/list-cached.js +0 -55
  111. package/lib/mcp/credentials/owner-key.d.ts +0 -23
  112. package/lib/mcp/credentials/owner-key.js +0 -58
  113. package/lib/mcp/credentials/paths.d.ts +0 -12
  114. package/lib/mcp/credentials/paths.js +0 -35
  115. package/lib/mcp/credentials/poll.d.ts +0 -27
  116. package/lib/mcp/credentials/poll.js +0 -61
  117. package/lib/mcp/credentials/register.d.ts +0 -18
  118. package/lib/mcp/credentials/register.js +0 -30
  119. package/lib/mcp/credentials/resolve-owner-key.d.ts +0 -12
  120. package/lib/mcp/credentials/resolve-owner-key.js +0 -68
  121. package/lib/mcp/credentials/session-cache.d.ts +0 -14
  122. package/lib/mcp/credentials/session-cache.js +0 -42
  123. package/lib/mcp/credentials/status.d.ts +0 -3
  124. package/lib/mcp/credentials/status.js +0 -76
  125. package/lib/mcp/register.d.ts +0 -38
  126. package/lib/mcp/register.js +0 -80
  127. package/lib/mcp/tools/analyze.d.ts +0 -3
  128. package/lib/mcp/tools/analyze.js +0 -26
  129. package/lib/mcp/tools/attach.d.ts +0 -11
  130. package/lib/mcp/tools/attach.js +0 -72
  131. package/lib/mcp/tools/capture.d.ts +0 -10
  132. package/lib/mcp/tools/capture.js +0 -142
  133. package/lib/mcp/tools/eval.d.ts +0 -5
  134. package/lib/mcp/tools/eval.js +0 -127
  135. package/lib/mcp/tools/find.d.ts +0 -3
  136. package/lib/mcp/tools/find.js +0 -60
  137. package/lib/mcp/tools/owner-key.d.ts +0 -2
  138. package/lib/mcp/tools/owner-key.js +0 -63
  139. package/lib/mcp/tools/proof.d.ts +0 -3
  140. package/lib/mcp/tools/proof.js +0 -214
  141. package/lib/mcp/tools/propose.d.ts +0 -9
  142. package/lib/mcp/tools/propose.js +0 -75
  143. package/lib/mcp/tools/publish.d.ts +0 -3
  144. package/lib/mcp/tools/publish.js +0 -59
  145. package/lib/mcp/tools/replay.d.ts +0 -3
  146. package/lib/mcp/tools/replay.js +0 -46
  147. package/lib/mcp/tools/session.d.ts +0 -10
  148. package/lib/mcp/tools/session.js +0 -29
  149. package/lib/mcp/tools/verification/diagnose.d.ts +0 -11
  150. package/lib/mcp/tools/verification/diagnose.js +0 -132
  151. package/lib/mcp/verifications/build.d.ts +0 -5
  152. package/lib/mcp/verifications/build.js +0 -14
  153. package/lib/mcp/verifications/create.d.ts +0 -3
  154. package/lib/mcp/verifications/create.js +0 -57
  155. package/lib/mcp/verifications/find.d.ts +0 -3
  156. package/lib/mcp/verifications/find.js +0 -37
  157. package/lib/mcp/verifications/get.d.ts +0 -3
  158. package/lib/mcp/verifications/get.js +0 -26
  159. package/lib/mcp/verifications/update.d.ts +0 -3
  160. package/lib/mcp/verifications/update.js +0 -78
  161. package/lib/old/tools/get-version-config.d.ts +0 -17
  162. package/lib/old/tools/get-version-config.js +0 -47
  163. package/lib/routes.gen.d.ts +0 -271
  164. package/lib/routes.gen.js +0 -73
  165. package/lib/types/openapi.d.ts +0 -4
  166. package/lib/types/openapi.gen.d.ts +0 -3967
  167. package/lib/types/openapi.gen.js +0 -5
  168. package/lib/types/openapi.js +0 -1
@@ -9,13 +9,23 @@ import { writeSession } from './session-cache.ts'
9
9
  const SCOPE = 'openid profile email'
10
10
  const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
11
11
 
12
- export function authenticateTool(client: ReclaimClient): RegisteredTool {
12
+ export function authenticateTool(
13
+ client: ReclaimClient,
14
+ name: 'reclaim_authenticate' | 'authenticate_builder' =
15
+ 'reclaim_authenticate',
16
+ ): RegisteredTool {
13
17
  return defineTool<Record<string, never>>(
14
18
  {
15
- name: 'reclaim_authenticate',
19
+ name,
16
20
  description:
17
- 'Authorize the CLI / MCP to make authenticated API calls. Opens the ' +
18
- 'browser to the builder sign-in page (standard OAuth 2.0 ' +
21
+ (name === 'authenticate_builder'
22
+ ? 'Authenticate specifically with Builder. Available regardless '
23
+ + 'of '
24
+ + 'the selected publishing backend. '
25
+ : '')
26
+ + 'Authorize the CLI / MCP to make authenticated API calls. Opens '
27
+ + 'the '
28
+ + 'browser to the builder sign-in page (standard OAuth 2.0 ' +
19
29
  'authorization-code + PKCE with a localhost redirect) and waits for ' +
20
30
  'the user to sign in and approve. On success the access token is ' +
21
31
  'cached in ~/.reclaim/config.json and activated for this session.\n\n' +
@@ -223,12 +233,10 @@ async function exchangeCode(
223
233
  client_id: opts.clientId,
224
234
  }).toString(),
225
235
  })
226
- assert(
227
- res.ok,
228
- new Error(`Token exchange failed (${res.status}): ${await res.text()}`),
229
- )
236
+ const body = await res.text()
237
+ assert(res.ok, new Error(`Token exchange failed (${res.status}): ${body}`))
230
238
 
231
- const json = (await res.json()) as {
239
+ const json = JSON.parse(body) as {
232
240
  access_token?: string
233
241
  expires_in?: number
234
242
  }
@@ -0,0 +1,42 @@
1
+ import type { ReclaimClient } from '@reclaimprotocol/client'
2
+ import { defineTool, type RegisteredTool } from '../../server.ts'
3
+ import { authenticateTool } from './authenticate.ts'
4
+
5
+ /** Legacy-devtools mode needs a distinct Builder login solely for allocating
6
+ * quota-accounted Popcorn sessions. Builder mode uses its native auth tool. */
7
+ export function buildBuilderAuthenticationTools(
8
+ client: ReclaimClient,
9
+ ): RegisteredTool[] {
10
+ return [authenticateTool(client, 'authenticate_builder')]
11
+ }
12
+
13
+ /** Legacy-devtools mode lacks generated Builder API tools, so it needs this
14
+ * narrow organization-discovery bridge for remote-browser allocation. */
15
+ export function buildBuilderOrganizationTools(
16
+ client: ReclaimClient,
17
+ ): RegisteredTool[] {
18
+ return [
19
+ defineTool<{ cursor?: string }>(
20
+ {
21
+ name: 'list_builder_organizations',
22
+ description:
23
+ 'List organizations available to the authenticated Builder user. '
24
+ + 'Use an organization id from this result when calling '
25
+ + 'attach_browser with mode="builder". Available only in '
26
+ + 'legacy-devtools publishing mode; Builder mode uses list_orgs.',
27
+ inputSchema: {
28
+ type: 'object',
29
+ properties: {
30
+ cursor: { type: 'string', maxLength: 500 },
31
+ },
32
+ },
33
+ },
34
+ async({ cursor }) => {
35
+ const { data } = await client.call('ListOrgs', {
36
+ query: { ...(cursor ? { cursor } : {}) },
37
+ })
38
+ return data
39
+ },
40
+ ),
41
+ ]
42
+ }
@@ -1,4 +1,8 @@
1
1
  // Barrel for the credential tool group: local eth proof-owner key (env/PEM)
2
2
  // and the org-token-authenticated builder tools. Import from here.
3
+ export {
4
+ buildBuilderAuthenticationTools,
5
+ buildBuilderOrganizationTools,
6
+ } from './builder-access.ts'
3
7
  export { buildCredentialTools, buildLocalCredentialTools } from './build.ts'
4
8
  export { readSession } from './session-cache.ts'
@@ -138,15 +138,14 @@ export async function captureFirebaseTokenViaCdp(opts: {
138
138
 
139
139
  try {
140
140
  const session = await handles.attachToTab(targetId)
141
- const { Runtime } = session
142
141
  const deadline = Date.now() + timeoutMs
143
142
  while(Date.now() < deadline) {
144
- const r = await Runtime.evaluate({
143
+ const result = await session.send('Runtime.evaluate', {
145
144
  expression: EXTRACT_TOKEN_JS,
146
145
  awaitPromise: true,
147
146
  returnByValue: true,
148
147
  })
149
- const value = r.result?.value
148
+ const value = runtimeResultValue(result)
150
149
  if(typeof value === 'string' && value.length > 0) {
151
150
  return { token: value, closedBrowser: launchedByUs }
152
151
  }
@@ -173,3 +172,14 @@ export async function captureFirebaseTokenViaCdp(opts: {
173
172
  await handles.close().catch(() => {})
174
173
  }
175
174
  }
175
+
176
+ function runtimeResultValue(value: unknown): unknown {
177
+ if(typeof value !== 'object' || !value || !('result' in value)) {
178
+ return undefined
179
+ }
180
+
181
+ const result = value.result
182
+ return typeof result === 'object' && result && 'value' in result
183
+ ? result.value
184
+ : undefined
185
+ }
@@ -1,4 +1,5 @@
1
1
  import type { RegisteredTool } from '../../mcp/server.ts'
2
+ import type { AttachState } from '../../mcp/tools/agent/attach.ts'
2
3
  import type { ReclaimOldClient } from '../client.ts'
3
4
  import type { ReclaimSdkClient } from '../sdk-client.ts'
4
5
  import { authenticateTool } from './authenticate.ts'
@@ -23,14 +24,19 @@ import { verifySdkSetupTool } from './verify-sdk-setup.ts'
23
24
  * `sdkClient` is a SEPARATE, unauthenticated client for the public
24
25
  * production SDK backend (`api.reclaimprotocol.org`) — a different host from
25
26
  * `client`'s old-devtools dashboard backend (`devapi.reclaimprotocol.org`).
27
+ *
28
+ * `attachRef` is the SAME attach state `buildAgentTools` was given (see
29
+ * `mcp/start.ts`) — passing it lets the publish tool remind the caller about
30
+ * a still-open browser session after a successful publish.
26
31
  */
27
32
  export function buildOldBackendTools(
28
33
  client: ReclaimOldClient,
29
34
  sdkClient: ReclaimSdkClient,
35
+ attachRef?: { current?: AttachState },
30
36
  ): RegisteredTool[] {
31
37
  return [
32
38
  authenticateTool(client),
33
- createVersionFromCaptureTool(client),
39
+ createVersionFromCaptureTool(client, attachRef),
34
40
  getMeProvidersTool(client),
35
41
  getProviderInfoTool(sdkClient),
36
42
  updateProviderMetadataTool(client),
@@ -1,5 +1,6 @@
1
1
  import { Boom, isBoom } from '@hapi/boom'
2
2
  import { defineTool, type RegisteredTool } from '../../mcp/server.ts'
3
+ import type { AttachState } from '../../mcp/tools/agent/attach.ts'
3
4
  import { INJECTION_GUIDE } from '../../provider/injection-doc.ts'
4
5
  import {
5
6
  countsFor,
@@ -70,9 +71,16 @@ function rethrowOwnershipError(err: unknown, providerId: string): never {
70
71
  * highest existing version).
71
72
  *
72
73
  * Get a `providerId` from `get_me_providers`.
74
+ *
75
+ * `attachRef`, when given, is checked after a successful publish: if a
76
+ * browser is still attached, a `_notes` entry reminds the caller to confirm
77
+ * with the developer before closing it (see `dispose_browser`) rather than
78
+ * closing it unprompted — the developer may still want the session for
79
+ * further edits.
73
80
  */
74
81
  export function createVersionFromCaptureTool(
75
82
  client: ReclaimOldClient,
83
+ attachRef?: { current?: AttachState },
76
84
  ): RegisteredTool {
77
85
  return defineTool<{
78
86
  provider?: ReclaimProvider
@@ -139,7 +147,11 @@ export function createVersionFromCaptureTool(
139
147
  'default include), `urlType` (REGEX/CONSTANT/TEMPLATE, default ' +
140
148
  'derived from {{placeholders}}), and `isOptional` on each ' +
141
149
  'responseMatch (default false). \n\n' +
142
- 'Captured requests must be GET/POST. OPRF redactions are supported.',
150
+ 'Captured requests must be GET/POST. OPRF redactions are supported. ' +
151
+ 'On success, if a browser is still attached, the response\'s ' +
152
+ '`_notes` reminds you to confirm with the developer before ' +
153
+ 'calling `dispose_browser` — never close it unprompted, they ' +
154
+ 'may want it for further edits.',
143
155
  inputSchema: {
144
156
  type: 'object',
145
157
  properties: {
@@ -288,7 +300,29 @@ export function createVersionFromCaptureTool(
288
300
  'the whole payload), template just that part with ' +
289
301
  '`{{paramName}}` and keep the rest as fixed literal text; see ' +
290
302
  'the "bodySniff" section of the customInjection field\'s guide ' +
291
- 'for the exact mechanics and a worked example.',
303
+ 'for the exact mechanics and a worked example. ' +
304
+ '⚠️ BEFORE populating this to gate ' +
305
+ '`verifyProof(proof, { providerId })`: this whole entry (url, ' +
306
+ 'method, body, responseMatches, responseRedactions) feeds a ' +
307
+ 'client-side hash-equality check, and `url` is hashed VERBATIM ' +
308
+ '— no regex/template resolution, even with `urlType: REGEX` or ' +
309
+ '`TEMPLATE`. If the real captured URL contains ANY per-session ' +
310
+ 'part (a tracking nonce, CSRF token, cache-busting timestamp), ' +
311
+ 'no declared spec can EVER hash-match a real proof, no matter ' +
312
+ 'how correct the rest of this entry is — populating' +
313
+ 'it for such ' +
314
+ 'a provider makes verifyProof reject every real proof,' +
315
+ 'which is ' +
316
+ 'worse than leaving this field empty. Diff the same endpoint\'s' +
317
+ ' captured URL across two separate sessions before wiring this ' +
318
+ 'up. If provider-hash validation keeps causing problems ' +
319
+ '(unstable url, or any other persistent mismatch), the ' +
320
+ 'fallback on the verifying side is `verifyProof(proof, { ' +
321
+ 'dangerouslyDisableContentValidation: true })` — trusts only ' +
322
+ 'the attestor\'s signature, skips the hash-equality check ' +
323
+ 'entirely; see the "Provider-hash validation" section of the ' +
324
+ 'customInjection field\'s guide for the full mechanics and ' +
325
+ 'when to reach for it.',
292
326
  items: {
293
327
  type: 'object',
294
328
  properties: {
@@ -485,6 +519,23 @@ export function createVersionFromCaptureTool(
485
519
  providers.map((p, i) => countsFor(`Request ${i + 1}`, p)),
486
520
  )
487
521
 
522
+ // A browser session left attached after a successful publish is
523
+ // easy to forget about — surface it so the caller asks the
524
+ // developer before closing it (a builder-mode session is
525
+ // quota-accounted and otherwise just sits until its TTL expires).
526
+ const attached = attachRef?.current
527
+ if(attached) {
528
+ notes.push(
529
+ `A "${attached.mode}" browser session is still attached` +
530
+ (attached.browserSessionId
531
+ ? ` (browserSessionId: ${attached.browserSessionId})`
532
+ : '') +
533
+ '. Ask the developer whether they want to keep it open for ' +
534
+ 'further edits — only call dispose_browser once they confirm ' +
535
+ 'they\'re done with it.',
536
+ )
537
+ }
538
+
488
539
  const withNotes = (result: unknown) => {
489
540
  if(notes.length && result && typeof result === 'object') {
490
541
  return { ...result, _notes: notes }
@@ -528,9 +579,9 @@ export function createVersionFromCaptureTool(
528
579
  'reads them when adding a version. ' +
529
580
  (followUp
530
581
  ? 'They were applied by the follow-up version the tool ' +
531
- 'created to set disableRequestReplay.'
582
+ 'created to set disableRequestReplay.'
532
583
  : 'Publish once to create the provider, then call this ' +
533
- 'again WITH the returned providerId to set them.'),
584
+ 'again WITH the returned providerId to set them.'),
534
585
  )
535
586
  }
536
587
 
@@ -1,4 +1,5 @@
1
1
  import { isOprfHashable, OPRF_MAX_BYTES, synthesizeMatcher } from './matcher.ts'
2
+ import { normalizeCandidates } from './normalize.ts'
2
3
  import type {
3
4
  CapturedRequest,
4
5
  OprfMode,
@@ -7,7 +8,11 @@ import type {
7
8
  ResponseRedaction,
8
9
  SecretParamRef,
9
10
  } from './schema.ts'
10
- import { splitSecrets } from './secrets.ts'
11
+ import {
12
+ secretUrlCharBudget,
13
+ splitSecrets,
14
+ stripTransportHeaders,
15
+ } from './secrets.ts'
11
16
 
12
17
  /** OPRF mode for the auto-hash floor — TEE-native (attestor rejects oprf-raw,
13
18
  * isn't set up for gnark oprf). */
@@ -66,51 +71,6 @@ export interface DraftResult {
66
71
  extractions: Extraction[]
67
72
  }
68
73
 
69
- /** Request headers that must NOT survive into the provider params. Three kinds:
70
- * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
71
- * cached ETag/date makes the origin answer 304/412 when the attestor
72
- * re-issues, and the attestor treats non-2xx as failure. We always want the
73
- * full fresh response.
74
- * - Transfer headers the ATTESTOR sets itself (`connection: close`,
75
- * `accept-encoding: identity`). Passing our own duplicates/collides with the
76
- * request the attestor builds, so leave them out — the attestor already
77
- * forces `identity` (uncompressed, so its byte-matching works).
78
- * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
79
- * `transfer-encoding`) and HTTP/2 pseudo-headers (`:authority`/`:method`/…,
80
- * which CDP surfaces on h2 captures). The attestor derives `Host` from the
81
- * URL and sets its own; a baked `Host` produces a DUPLICATE
82
- * (`found: h,h`) that fails the receipt check / 400s the origin. A stale
83
- * `content-length` breaks a re-serialized body. Pseudo-headers aren't valid
84
- * real headers at all. */
85
- const STRIP_REQUEST_HEADERS = new Set([
86
- 'if-none-match',
87
- 'if-modified-since',
88
- 'if-match',
89
- 'if-unmodified-since',
90
- 'if-range',
91
- 'connection',
92
- 'accept-encoding',
93
- 'host',
94
- 'content-length',
95
- 'transfer-encoding',
96
- // Per-session anti-CSRF tokens. Not caught by splitSecrets (no `-token`
97
- // suffix), so without this they baked in as PUBLIC headers and went stale at
98
- // verify time — the attestor replayed one user's captured CSRF for everyone,
99
- // which the origin rejects. A CSRF token is never portable; drop it.
100
- 'csrf',
101
- 'x-csrf',
102
- 'xsrf',
103
- 'x-xsrf',
104
- 'x-csrf-token',
105
- 'xsrf-token',
106
- ])
107
-
108
- /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
109
- * `:scheme`) — never a valid request header the attestor can replay. */
110
- function isPseudoHeader(name: string): boolean {
111
- return name.startsWith(':')
112
- }
113
-
114
74
  /** A header VALUE that is a per-session/request token — a UUID or a long opaque
115
75
  * hex/base64 blob. Such a value is captured-user-specific: baked into the
116
76
  * provider it goes stale and the origin rejects the replay for everyone else.
@@ -124,19 +84,19 @@ function isVolatileHeaderValue(value: string): boolean {
124
84
  || /^[A-Za-z0-9_-]{32,}$/.test(v)
125
85
  }
126
86
 
127
- /** Drop the headers the attestor manages or that would 304 at proof time
128
- * (see {@link STRIP_REQUEST_HEADERS}); keep every other captured header so the
129
- * attestor's replay looks like the browser's request. */
87
+ /** Drop a PUBLIC header whose value is per-session/request volatile (see
88
+ * {@link isVolatileHeaderValue}) — public headers get frozen into the
89
+ * persisted provider, so a volatile one goes stale and fails every other
90
+ * user's replay. Secret headers are re-captured fresh per verification
91
+ * (never persisted), so this doesn't apply to them; keep every other
92
+ * captured public header so the attestor's replay looks like the browser's
93
+ * request. */
130
94
  function sanitizeRequestHeaders(
131
95
  headers: Record<string, string>,
132
96
  ): Record<string, string> {
133
97
  const out: Record<string, string> = {}
134
98
  for(const [k, v] of Object.entries(headers)) {
135
- if(
136
- STRIP_REQUEST_HEADERS.has(k.toLowerCase())
137
- || isPseudoHeader(k)
138
- || isVolatileHeaderValue(v)
139
- ) {
99
+ if(isVolatileHeaderValue(v)) {
140
100
  continue
141
101
  }
142
102
 
@@ -233,6 +193,79 @@ export function assertPortableMatches(
233
193
  }
234
194
  }
235
195
 
196
+ /** Reject a draft that OPRF-hashes a target whose value ALSO appears in the
197
+ * request URL. The whole point of `hash` is that the attestor never sees
198
+ * the value in cleartext — but URL `paramValues` are always signed into the
199
+ * attestor's context in the clear (attestor-core: "those in URL... will be
200
+ * put into context and signed"), so a value that's both hashed in the
201
+ * response AND present in the URL is visible there anyway. This isn't a
202
+ * privacy nuance the author can weigh (unlike {@link assertPortableMatches}
203
+ * bare-literal cases) — it doesn't verify. Throwing here, at draft time,
204
+ * surfaces that immediately instead of a confusing later proof failure. */
205
+ export function assertNoOprfUrlConflict(
206
+ url: string,
207
+ targets: DraftTarget[],
208
+ effectiveHashes: (OprfMode | undefined)[],
209
+ ) {
210
+ for(const [i, t] of targets.entries()) {
211
+ const hash = effectiveHashes[i]
212
+ if(!hash) {
213
+ continue
214
+ }
215
+
216
+ const inUrl = [t.value, ...normalizeCandidates(t.value)]
217
+ .some((v) => url.includes(v))
218
+ if(inUrl) {
219
+ throw new Error(
220
+ `target "${t.name}" is OPRF-hashed (${hash}) but its value also `
221
+ + `appears in the request URL ("${url}") — the attestor signs URL `
222
+ + 'param values into context in the clear, so it would see this '
223
+ + 'value regardless of the hash. Drop `hash` for this target, or '
224
+ + "capture a request whose URL doesn't carry the value.",
225
+ )
226
+ }
227
+ }
228
+ }
229
+
230
+ /** Reject a draft whose URL bakes in a secret header/cookie's value beyond
231
+ * what write-redaction can actually hide there. A secret value used in the
232
+ * URL is NOT an automatic leak — write-redaction (`key-update`/`zk`) can
233
+ * redact it from the attestor's view up to {@link secretUrlCharBudget}
234
+ * characters, summed across every secret value that appears in that URL
235
+ * (not per-value). Below the budget the header buys real privacy; at or
236
+ * over it, the value is effectively visible regardless of hiding the
237
+ * header (same class of problem as {@link assertNoOprfUrlConflict} for
238
+ * OPRF-hashed targets, which has no such budget at all). */
239
+ export function assertNoSecretUrlConflict(
240
+ url: string,
241
+ secrets: Record<string, string>,
242
+ writeRedactionMode?: ReclaimProvider['writeRedactionMode'],
243
+ ) {
244
+ let redactedChars = 0
245
+ const overlapping: string[] = []
246
+ for(const [name, value] of Object.entries(secrets)) {
247
+ const inUrl = [value, ...normalizeCandidates(value)]
248
+ .some((v) => url.includes(v))
249
+ if(inUrl) {
250
+ redactedChars += value.length
251
+ overlapping.push(name)
252
+ }
253
+ }
254
+
255
+ const budget = secretUrlCharBudget(writeRedactionMode)
256
+ if(redactedChars > budget) {
257
+ throw new Error(
258
+ `secret header(s) ${overlapping.map((n) => `"${n}"`).join(', ')} `
259
+ + `also appear in the request URL ("${url}"), totaling `
260
+ + `${redactedChars} character(s) to hide — over the ${budget}-`
261
+ + `character budget write-redaction (${writeRedactionMode
262
+ ?? 'key-update'}) can actually hide there. Drop the header, `
263
+ + "shorten/remove the value's overlap with the URL, or switch to "
264
+ + "'zk' if that raises the budget enough.",
265
+ )
266
+ }
267
+ }
268
+
236
269
  /** Derive a `paramValues` key (and capture-group name) from the provider
237
270
  * name's last alphanumeric token. `"GitHub Username"` → `"username"`,
238
271
  * `"x-followers"` → `"followers"`. */
@@ -262,6 +295,7 @@ export function draftProvider(
262
295
  const matches: ResponseMatch[] = []
263
296
  const redactions: ResponseRedaction[] = []
264
297
  const extractions: Extraction[] = []
298
+ const effectiveHashes: (OprfMode | undefined)[] = []
265
299
  let paramValues: Record<string, string> | undefined
266
300
  const seenParams = new Set<string>()
267
301
  for(const t of targets) {
@@ -279,6 +313,7 @@ export function draftProvider(
279
313
  // The model makes the contextual calls (which named fields are sensitive)
280
314
  // via an explicit per-target hash; this floor only guarantees the obvious.
281
315
  const effectiveHash = t.hash ?? hash ?? defaultSensitiveHash(t.value)
316
+ effectiveHashes.push(effectiveHash)
282
317
  const synthesis = synthesizeMatcher(
283
318
  req.responseBody,
284
319
  req.contentType,
@@ -311,8 +346,15 @@ export function draftProvider(
311
346
 
312
347
  // Portability gate: never ship a draft that bakes a per-user value literally.
313
348
  assertPortableMatches(matches, redactions, targets)
314
-
315
- const { secrets, publics } = splitSecrets(req.requestHeaders)
349
+ // OPRF gate: never ship a draft that hashes a value the URL also reveals.
350
+ assertNoOprfUrlConflict(req.url, targets, effectiveHashes)
351
+
352
+ const { secrets, publics } = splitSecrets(
353
+ stripTransportHeaders(req.requestHeaders)
354
+ )
355
+ // Secret-header gate: never ship a draft whose URL reveals the same value a
356
+ // stripped secret header carries — hiding the header buys nothing then.
357
+ assertNoSecretUrlConflict(req.url, secrets)
316
358
  // Sanitize the public headers before they enter the provider. Two problems
317
359
  // a captured browser request carries: (1) `accept-encoding: gzip, …`, which
318
360
  // makes the attestor match against compressed bytes (it does no transparent
@@ -17,7 +17,15 @@ export type {
17
17
  ResponseRedaction,
18
18
  SecretParamRef,
19
19
  } from './schema.ts'
20
- export { classifyHeader, splitSecrets } from './secrets.ts'
20
+ export {
21
+ assertSecretParamValuesFitUrlBudget,
22
+ classifyHeader,
23
+ classifyParamName,
24
+ secretUrlCharBudget,
25
+ splitSecretParamValues,
26
+ splitSecrets,
27
+ stripTransportHeaders,
28
+ } from './secrets.ts'
21
29
  // Recipe → version `requests[]` (a `RequestSelection`) — the same mapping the
22
30
  // MCP publish uses, so the cloud authoring publish stays byte-identical.
23
31
  export { providerToRequestSelection } from './to-version.ts'
@@ -1,14 +1,99 @@
1
- const SECRET_EXACT = new Set(['cookie', 'authorization', 'proxy-authorization'])
1
+ import type { ReclaimProvider } from './schema.ts'
2
2
 
3
- const SECRET_SUFFIXES = ['-token', '-key', '-secret', '-auth']
3
+ /** Headers safe to reveal to the attestor unredacted — everything else
4
+ * (`Cookie`, `Authorization`, any custom header, even ones that look inert
5
+ * like `Content-Type`) is secret by default. Allowlist, not a denylist:
6
+ * same exact list `reclaim-inapp-sdk` (`_PUBLIC_HEADERS_ALLOW_LIST`,
7
+ * `request.dart`) and `reclaim-portal` (`DEFAULT_PUBLIC_HEADERS`,
8
+ * `claim-builder.ts`) use for their own `witnessParams`/header split. */
9
+ const PUBLIC_HEADERS = new Set([
10
+ 'user-agent',
11
+ 'accept',
12
+ 'accept-language',
13
+ 'accept-encoding',
14
+ 'sec-fetch-mode',
15
+ 'sec-fetch-site',
16
+ 'sec-fetch-user',
17
+ 'origin',
18
+ 'x-requested-with',
19
+ 'sec-ch-ua',
20
+ 'sec-ch-ua-mobile',
21
+ ])
4
22
 
5
23
  export function classifyHeader(name: string): boolean {
6
- const lower = name.toLowerCase()
7
- if(SECRET_EXACT.has(lower)) {
8
- return true
24
+ return !PUBLIC_HEADERS.has(name.toLowerCase())
25
+ }
26
+
27
+ /** Headers that must NOT survive into EITHER the public or secret side —
28
+ * structural/transport, never a credential. Three kinds:
29
+ * - Conditional headers (`if-none-match`/`if-modified-since`/…): a captured
30
+ * cached ETag/date makes the origin answer 304/412 when the attestor
31
+ * re-issues, and the attestor treats non-2xx as failure. We always want
32
+ * the full fresh response.
33
+ * - Transfer headers the ATTESTOR sets itself (`connection: close`,
34
+ * `accept-encoding: identity`). Passing our own duplicates/collides with
35
+ * the request the attestor builds, so leave them out.
36
+ * - Host / framing headers the TRANSPORT owns (`host`, `content-length`,
37
+ * `transfer-encoding`). The attestor derives `Host` from the URL and sets
38
+ * its own; a baked `Host` produces a DUPLICATE that fails the receipt
39
+ * check / 400s the origin. A stale `content-length` breaks a
40
+ * re-serialized body.
41
+ * Must be stripped BEFORE `splitSecrets`, not after: under the allowlist
42
+ * classifier these all fall into `secrets` (none are on `PUBLIC_HEADERS`),
43
+ * and a header like `Host` — whose value trivially mirrors the URL's
44
+ * hostname, by definition — would trip a URL-conflict check on a
45
+ * structural, not a privacy, coincidence. */
46
+ const STRIP_REQUEST_HEADERS = new Set([
47
+ 'if-none-match',
48
+ 'if-modified-since',
49
+ 'if-match',
50
+ 'if-unmodified-since',
51
+ 'if-range',
52
+ 'connection',
53
+ 'accept-encoding',
54
+ 'host',
55
+ 'content-length',
56
+ 'transfer-encoding',
57
+ // Per-session anti-CSRF tokens: never portable (baking one in replays a
58
+ // single captured user's token for everyone, which the origin rejects).
59
+ // The allowlist classifier already routes these to `secrets` — a proof
60
+ // attempt sends the CURRENT user's own captured value, which is correct —
61
+ // but stripping them here means they never even take that path, and
62
+ // definitely never survive into a persisted, public recipe.
63
+ 'csrf',
64
+ 'x-csrf',
65
+ 'xsrf',
66
+ 'x-xsrf',
67
+ 'x-csrf-token',
68
+ 'xsrf-token',
69
+ ])
70
+
71
+ /** True for an HTTP/2 pseudo-header (`:authority`, `:method`, `:path`,
72
+ * `:scheme`, which CDP surfaces on h2 captures) — never a valid request
73
+ * header the attestor can replay. Same BEFORE-the-split reasoning as
74
+ * {@link STRIP_REQUEST_HEADERS}. */
75
+ function isPseudoHeader(name: string): boolean {
76
+ return name.startsWith(':')
77
+ }
78
+
79
+ /** Drop {@link STRIP_REQUEST_HEADERS} and HTTP/2 pseudo-headers. Call this on
80
+ * the RAW captured headers before {@link splitSecrets} — both the draft-time
81
+ * authoring flow and the live per-user verification capture need it, so
82
+ * neither ends up sending a duplicate `Host` or an invalid `:authority` to
83
+ * the attestor via either the public or the secret channel. */
84
+ export function stripTransportHeaders(
85
+ headers: Record<string, string>,
86
+ ): Record<string, string> {
87
+ const out: Record<string, string> = {}
88
+ for(const [k, v] of Object.entries(headers)) {
89
+ if(STRIP_REQUEST_HEADERS.has(k.toLowerCase()) || isPseudoHeader(k)) {
90
+ continue
91
+ }
92
+
93
+ out[k] = v
9
94
  }
10
95
 
11
- return SECRET_SUFFIXES.some((s) => lower.endsWith(s))
96
+ return out
12
97
  }
13
98
 
14
99
  export function splitSecrets(headers: Record<string, string>): {
@@ -27,3 +112,83 @@ export function splitSecrets(headers: Record<string, string>): {
27
112
 
28
113
  return { secrets, publics }
29
114
  }
115
+
116
+ /** Total characters write-redaction can hide from the attestor, summed
117
+ * ACROSS EVERY secret-classified value used in the URL — not a per-value
118
+ * cap. Default mode (`'key-update'`, also what an unset field resolves to)
119
+ * caps at 2; `'zk'` at 12. At or under the budget the overlap is genuinely
120
+ * hidden; over it, the value (or the combination of values) is effectively
121
+ * still visible to the attestor regardless of secret-marking. */
122
+ export function secretUrlCharBudget(
123
+ writeRedactionMode: ReclaimProvider['writeRedactionMode'],
124
+ ): number {
125
+ return writeRedactionMode === 'zk' ? 12 : 2
126
+ }
127
+
128
+ /** A `paramValues`/context key is secret iff its name contains `SECRET`
129
+ * (case-insensitive) — e.g. `SECRET_apiKey`, `context.SECRET_apiKey`. Same
130
+ * naming-convention classifier reclaim-inapp-sdk and reclaim-portal use for
131
+ * their `witnessParams`/`paramValues` maps (no separate boolean flag). */
132
+ export function classifyParamName(name: string): boolean {
133
+ return name.toUpperCase().includes('SECRET')
134
+ }
135
+
136
+ /** Split a `paramValues` map (bare `{{name}}` / `{{context.<name>}}` entries)
137
+ * into public (→ `params.paramValues`, revealed to the attestor) and secret
138
+ * (→ `secretParams.paramValues`) by {@link classifyParamName}. A secret
139
+ * value used in the URL is still hidden — up to {@link secretUrlCharBudget}
140
+ * characters, summed across every such value in that URL. */
141
+ export function splitSecretParamValues(paramValues: Record<string, string>): {
142
+ secrets: Record<string, string>
143
+ publics: Record<string, string>
144
+ } {
145
+ const secrets: Record<string, string> = {}
146
+ const publics: Record<string, string> = {}
147
+ for(const [k, v] of Object.entries(paramValues)) {
148
+ if(classifyParamName(k)) {
149
+ secrets[k] = v
150
+ } else {
151
+ publics[k] = v
152
+ }
153
+ }
154
+
155
+ return { secrets, publics }
156
+ }
157
+
158
+ /** Reject a proof attempt whose URL/geoLocation templates SECRET-named
159
+ * context param(s) whose ACTUAL values (now known — the consumer supplied
160
+ * them at session-create) exceed the write-redaction budget once summed.
161
+ * Structural placement (which params end up in the URL) is knowable at
162
+ * publish time and isn't rejected there — only the real values decide
163
+ * whether it fits, and those only exist once a verification session is
164
+ * underway. Checks the placeholder TOKEN (`{{<key>}}`), not a value search —
165
+ * the URL/geoLocation explicitly reference the param by name. */
166
+ export function assertSecretParamValuesFitUrlBudget(
167
+ url: string,
168
+ geoLocation: string | undefined,
169
+ secretParamValues: Record<string, string>,
170
+ writeRedactionMode: ReclaimProvider['writeRedactionMode'],
171
+ ) {
172
+ const haystack = `${url}\n${geoLocation ?? ''}`
173
+ let redactedChars = 0
174
+ const used: string[] = []
175
+ for(const [key, value] of Object.entries(secretParamValues)) {
176
+ if(haystack.includes(`{{${key}}}`)) {
177
+ redactedChars += value.length
178
+ used.push(key)
179
+ }
180
+ }
181
+
182
+ const budget = secretUrlCharBudget(writeRedactionMode)
183
+ if(redactedChars > budget) {
184
+ throw new Error(
185
+ `secret context param(s) ${used.map((k) => `"${k}"`).join(', ')} `
186
+ + `are templated into the URL/geoLocation ("${url}") with a `
187
+ + `combined value length of ${redactedChars} character(s) — over `
188
+ + `the ${budget}-character budget write-redaction `
189
+ + `(${writeRedactionMode ?? 'key-update'}) can actually hide there. `
190
+ + "Ask the consumer for a shorter value, switch to 'zk' if that "
191
+ + 'raises the budget enough, or move this param to the body.',
192
+ )
193
+ }
194
+ }