@reclaimprotocol/agent 0.1.38 → 0.1.40

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.
@@ -11,24 +11,33 @@ import { badGateway, badRequest, conflict } from '@hapi/boom'
11
11
  import assert from 'node:assert'
12
12
  import { createHash } from 'node:crypto'
13
13
  import { existsSync, readFileSync } from 'node:fs'
14
- import {
15
- mkdir,
16
- mkdtemp,
17
- rename,
18
- rm,
19
- writeFile
20
- } from 'node:fs/promises'
21
- import { dirname, isAbsolute, join } from 'node:path'
14
+ import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
15
+ import { connect } from 'node:net'
16
+ import { isAbsolute, join, relative, sep } from 'node:path'
22
17
  import { defineTool, type RegisteredTool } from '../../server.ts'
18
+ import { assertNoSymlinks, upsertEnvVar } from '../authenticate/eth-key.ts'
23
19
 
24
20
  const REPO = 'reclaimprotocol/builder-example'
25
- const BACKEND_PACKAGE = join('packages', 'backend', 'package.json')
21
+ /** GitHub tree paths are always `/`-separated, on every OS. */
22
+ const BACKEND_PACKAGE = 'packages/backend/package.json'
26
23
  const CLIENT_PACKAGE = '@reclaimprotocol/client'
27
24
  /** The example is ~50 small files; anything far past that isn't it. */
28
25
  const MAX_FILES = 500
29
26
  const MAX_BYTES = 10_000_000
27
+ const BACKEND_ENV_FILE = 'packages/backend/.env'
28
+ const DEFAULT_BACKEND_PORT = 3000
29
+ /** Ports probed for the backend: 3000, then 3001 through 3020. */
30
+ const BACKEND_PORT_TRIES = 21
31
+ const LOOPBACK_HOSTS = ['127.0.0.1', '::1']
32
+ const PROBE_TIMEOUT_MS = 500
33
+ /** Files a workspace may already have. An existing copy is kept, not a
34
+ * conflict. The env-file tools add their own entries to `.gitignore`. */
35
+ const KEEP_EXISTING = new Set(['AGENTS.md', '.gitignore'])
30
36
 
31
- export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
37
+ export function exampleAppTool(
38
+ fetchFn: typeof fetch = fetch,
39
+ isPortFree: (port: number) => Promise<boolean> = isLoopbackPortFree,
40
+ ): RegisteredTool {
32
41
  return defineTool<{ projectDir: string }>(
33
42
  {
34
43
  name: 'create_example_app',
@@ -37,12 +46,14 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
37
46
  + 'a Fastify backend and a Vite frontend that create sessions, '
38
47
  + 'verify callbacks, and render every verification state) into '
39
48
  + '`projectDir`, and sets its backend to the latest '
40
- + '`@reclaimprotocol/client`. Use it when the workspace has no app '
49
+ + '`@reclaimprotocol/client`. It picks a free backend port (3000, else '
50
+ + 'the next free one, written to `packages/backend/.env`). Use it '
51
+ + 'when the workspace has no app '
41
52
  + 'yet, with the workspace root you were started in as '
42
53
  + '`projectDir`; don\'t clone the repository yourself. Refuses '
43
54
  + 'when a file it would write already exists with different '
44
- + 'content. Returns the commit, file count, client version, and '
45
- + 'next steps.',
55
+ + 'content. Returns the commit, file count, client version, '
56
+ + '`backendPort`, and next steps.',
46
57
  inputSchema: {
47
58
  type: 'object',
48
59
  required: ['projectDir'],
@@ -56,11 +67,12 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
56
67
  },
57
68
  annotations: { openWorldHint: true, destructiveHint: false },
58
69
  },
59
- async({ projectDir }) => {
70
+ async(args) => {
60
71
  assert(
61
- isAbsolute(projectDir),
72
+ isAbsolute(args.projectDir),
62
73
  badRequest('`projectDir` must be an absolute path.'),
63
74
  )
75
+ const projectDir = await realpath(args.projectDir)
64
76
  const commit = await latestCommit()
65
77
  const files = await listFiles(commit)
66
78
  const clientVersion = await latestClientVersion()
@@ -73,7 +85,17 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
73
85
  : bytes,
74
86
  }
75
87
  }))
76
- const conflicts = staged
88
+ for(const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
89
+ assertNoSymlinks(projectDir, path)
90
+ }
91
+
92
+ const kept = staged
93
+ .filter(({ path }) => {
94
+ return KEEP_EXISTING.has(path) && existsSync(join(projectDir, path))
95
+ })
96
+ .map(({ path }) => path)
97
+ const toWrite = staged.filter(({ path }) => !kept.includes(path))
98
+ const conflicts = toWrite
77
99
  .filter(({ path, bytes }) => {
78
100
  const dest = join(projectDir, path)
79
101
  return existsSync(dest) && !readFileSync(dest).equals(bytes)
@@ -88,44 +110,72 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
88
110
  ),
89
111
  )
90
112
 
91
- // Stage inside projectDir, so the moves below stay on one filesystem.
92
- const tmp = await mkdtemp(join(projectDir, '.reclaim-example-'))
93
- try {
94
- for(const { path, bytes } of staged) {
95
- await mkdir(dirname(join(tmp, path)), { recursive: true })
96
- await writeFile(join(tmp, path), bytes)
97
- }
98
-
99
- for(const { path } of staged) {
100
- const dest = join(projectDir, path)
101
- await mkdir(dirname(dest), { recursive: true })
102
- await rename(join(tmp, path), dest)
103
- }
104
- } finally {
105
- await rm(tmp, { recursive: true, force: true })
113
+ for(const { path, bytes } of toWrite) {
114
+ await installFile(projectDir, path, bytes)
106
115
  }
107
116
 
117
+ const backendPort = await findBackendPort()
118
+ // Always written, so a re-run never leaves a stale port behind.
119
+ upsertEnvVar(projectDir, BACKEND_ENV_FILE, 'PORT', String(backendPort))
120
+
121
+ const dev = backendPort === DEFAULT_BACKEND_PORT
122
+ ? 'npm run dev'
123
+ : `API_URL=http://localhost:${backendPort} npm run dev`
124
+
108
125
  return {
109
126
  projectDir,
110
127
  repository: `https://github.com/${REPO}`,
111
128
  commit,
112
- files: staged.length,
129
+ files: toWrite.length,
130
+ kept,
113
131
  clientVersion,
132
+ backendPort,
114
133
  next: [
115
134
  'Call issue_org_token with this projectDir and envFile '
116
135
  + '"packages/backend/.env".',
117
- 'Run `npm install`, then `npm run dev`, from projectDir. The '
118
- + 'backend listens on port 3000; the app opens at '
119
- + 'http://localhost:5173.',
120
- 'Call proxy_callback_events({ port: 3000, path: '
136
+ 'Run `npm install` from projectDir, then start the dev '
137
+ + `server with \`${dev}\`. The backend listens on port `
138
+ + `${backendPort}`
139
+ + (backendPort === DEFAULT_BACKEND_PORT
140
+ ? '.'
141
+ : ` (port ${DEFAULT_BACKEND_PORT} is in use; the frontend `
142
+ + 'proxy needs `API_URL`).'),
143
+ 'Run the dev server in the background and send its output to a '
144
+ + 'log file in projectDir, for example `npm run dev > '
145
+ + 'reclaim-dev.log 2>&1`, so callback errors stay readable even '
146
+ + 'when a shell output filter buffers output.',
147
+ 'Give the developer the frontend URL Vite prints in that log. '
148
+ + 'Don\'t assume port 5173: Vite moves to the next free port '
149
+ + 'when it is taken.',
150
+ `Call proxy_callback_events({ port: ${backendPort}, path: `
121
151
  + '"/reclaim/callback" }).',
152
+ 'If get_org_keypair shows `canEncryptResult: true`, the '
153
+ + 'callback needs `orgEthPrivateKey`; see the plan.',
122
154
  'Put the provider in packages/backend/config.json and restart '
123
155
  + 'the backend.',
156
+ 'The backend stores verifications in memory: restarting it '
157
+ + 'drops sessions in progress, so start a new verification '
158
+ + 'after any restart.',
124
159
  ],
125
160
  }
126
161
  },
127
162
  )
128
163
 
164
+ async function findBackendPort() {
165
+ for(let i = 0; i < BACKEND_PORT_TRIES; i++) {
166
+ const port = DEFAULT_BACKEND_PORT + i
167
+ if(await isPortFree(port)) {
168
+ return port
169
+ }
170
+ }
171
+
172
+ throw conflict(
173
+ `No free port from ${DEFAULT_BACKEND_PORT} to `
174
+ + `${DEFAULT_BACKEND_PORT + BACKEND_PORT_TRIES - 1}. Ask the developer `
175
+ + 'to stop another dev server.',
176
+ )
177
+ }
178
+
129
179
  async function latestCommit() {
130
180
  const res = await fetchFn(
131
181
  `https://api.github.com/repos/${REPO}/commits/main`,
@@ -190,7 +240,7 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
190
240
 
191
241
  async function latestClientVersion() {
192
242
  const res = await fetchFn(
193
- `https://registry.npmjs.org/${CLIENT_PACKAGE.replace('/', '%2f')}/latest`,
243
+ `https://registry.npmjs.org/${encodeURIComponent(CLIENT_PACKAGE)}/latest`,
194
244
  )
195
245
  assert(res.ok, badGateway(`npm ${CLIENT_PACKAGE}: HTTP ${res.status}`))
196
246
  const body: unknown = await res.json()
@@ -204,6 +254,54 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
204
254
  }
205
255
  }
206
256
 
257
+ /**
258
+ * Write one file under `root` without following a symlink out of it or
259
+ * replacing anything: each parent is created one level at a time and must be
260
+ * a real directory, the file is created exclusively (`wx` fails on any path
261
+ * that appeared since the conflict scan, symlinks included), and its real
262
+ * path is checked to still be inside `root` afterwards. A file already there
263
+ * with the same bytes (a re-run) is left as is.
264
+ */
265
+ async function installFile(root: string, path: string, bytes: Buffer) {
266
+ const parts = path.split('/')
267
+ let dir = root
268
+ for(const part of parts.slice(0, -1)) {
269
+ dir = join(dir, part)
270
+ await mkdir(dir).catch((err: unknown) => {
271
+ if(!isEexist(err)) {
272
+ throw err
273
+ }
274
+ })
275
+ assertNoSymlinks(root, relative(root, dir))
276
+ }
277
+
278
+ const dest = join(dir, parts.at(-1)!)
279
+ try {
280
+ await writeFile(dest, bytes, { flag: 'wx' })
281
+ } catch(err) {
282
+ if(!isEexist(err)) {
283
+ throw err
284
+ }
285
+
286
+ assertNoSymlinks(root, path)
287
+ assert(
288
+ readFileSync(dest).equals(bytes),
289
+ conflict(`${path} appeared with different content; nothing replaced`),
290
+ )
291
+ return
292
+ }
293
+
294
+ const real = await realpath(dest)
295
+ if(!real.startsWith(root + sep)) {
296
+ await rm(dest, { force: true })
297
+ throw badRequest(`${path} resolved outside the workspace; removed it`)
298
+ }
299
+ }
300
+
301
+ function isEexist(err: unknown) {
302
+ return err instanceof Error && 'code' in err && err.code === 'EEXIST'
303
+ }
304
+
207
305
  /** The backend's package.json with the client range set to `^version`. */
208
306
  function withClientVersion(bytes: Buffer, version: string) {
209
307
  const text = bytes.toString('utf8')
@@ -225,3 +323,24 @@ function gitBlobSha(bytes: Buffer) {
225
323
  .update(bytes)
226
324
  .digest('hex')
227
325
  }
326
+
327
+ /** True when nothing accepts connections on `port` at 127.0.0.1 or ::1.
328
+ * Connecting, rather than binding, also catches servers bound to `0.0.0.0`:
329
+ * macOS lets a loopback bind succeed alongside them. Exported for tests. */
330
+ export async function isLoopbackPortFree(port: number) {
331
+ const results = await Promise.all(LOOPBACK_HOSTS.map((host) => {
332
+ return new Promise<boolean>((resolve) => {
333
+ const socket = connect({ port, host, timeout: PROBE_TIMEOUT_MS })
334
+ socket.once('connect', () => {
335
+ socket.destroy()
336
+ resolve(false)
337
+ })
338
+ socket.once('timeout', () => {
339
+ socket.destroy()
340
+ resolve(true)
341
+ })
342
+ socket.once('error', () => resolve(true))
343
+ })
344
+ }))
345
+ return results.every(Boolean)
346
+ }
@@ -15,12 +15,17 @@
15
15
  * signing key only — unrelated to org auth (the org secret) or encryption
16
16
  * (the eth decrypt key).
17
17
  */
18
+ import { badRequest } from '@hapi/boom'
18
19
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
19
20
  import assert from 'node:assert'
20
21
  import {
21
- chmodSync,
22
+ closeSync,
23
+ constants,
22
24
  existsSync,
25
+ fchmodSync,
26
+ lstatSync,
23
27
  mkdirSync,
28
+ openSync,
24
29
  readFileSync,
25
30
  writeFileSync,
26
31
  } from 'node:fs'
@@ -29,7 +34,8 @@ import {
29
34
  isAbsolute,
30
35
  join,
31
36
  relative,
32
- resolve
37
+ resolve,
38
+ sep
33
39
  } from 'node:path'
34
40
  import { privateKey, privateKeyFile } from '../../../consts.ts'
35
41
  import { findProjectRoot } from '../../../paths.ts'
@@ -178,7 +184,7 @@ export function ensureGitignored(projectDir: string, entry: string) {
178
184
  }
179
185
 
180
186
  const prefix = current && !current.endsWith('\n') ? '\n' : ''
181
- writeFileSync(path, current + prefix + entry + '\n')
187
+ writeInside(projectDir, path, current + prefix + entry + '\n')
182
188
  }
183
189
 
184
190
  /**
@@ -205,13 +211,60 @@ export function upsertEnvVar(
205
211
  next = existing + prefix + line + '\n'
206
212
  }
207
213
 
208
- mkdirSync(dirname(envPath), { recursive: true })
209
- writeFileSync(envPath, next, { mode: 0o600 })
210
- chmodSync(envPath, 0o600)
214
+ writeInside(projectDir, envPath, next, { secret: true })
211
215
  ensureGitignored(projectDir, relative(projectDir, envPath))
212
216
  return envPath
213
217
  }
214
218
 
219
+ /** Remove the `name=` line from a dotenv file, if there is one. */
220
+ export function removeEnvVar(
221
+ projectDir: string,
222
+ envFile: string,
223
+ name: string,
224
+ ) {
225
+ const envPath = resolve(projectDir, envFile)
226
+ if(!existsSync(envPath)) {
227
+ return
228
+ }
229
+
230
+ const existing = readFileSync(envPath, 'utf8')
231
+ const re = new RegExp(`^\\s*${name}\\s*=.*(\\n|$)`, 'm')
232
+ writeInside(projectDir, envPath, existing.replace(re, ''), { secret: true })
233
+ }
234
+
235
+ /**
236
+ * Before `name` is overwritten, append its current value to
237
+ * `<envFile>.backup` (same directory) as a timestamped comment plus the
238
+ * `NAME=value` line. Appends, so repeated rotations keep history. Writes with
239
+ * mode 0600 and adds the backup to `.gitignore`. Returns the backup's absolute
240
+ * path, or `undefined` when the env file holds no value for `name`. Never
241
+ * returns or logs the value.
242
+ */
243
+ export function backupEnvVar(
244
+ projectDir: string,
245
+ envFile: string,
246
+ name: string,
247
+ reason: string,
248
+ ): string | undefined {
249
+ const envPath = resolve(projectDir, envFile)
250
+ assertNoSymlinks(projectDir, relative(projectDir, envPath))
251
+ const value = readDotenvKey(envPath, name)
252
+ if(!value) {
253
+ return undefined
254
+ }
255
+
256
+ const backupPath = `${envPath}.backup`
257
+ const stamp = new Date().toISOString()
258
+ writeInside(
259
+ projectDir,
260
+ backupPath,
261
+ `# ${stamp} ${reason}\n${name}=${value}\n`,
262
+ { secret: true, append: true },
263
+ )
264
+ ensureGitignored(projectDir, relative(projectDir, backupPath))
265
+ return backupPath
266
+ }
267
+
215
268
  /**
216
269
  * Persist a raw eth private key to the project `.env` as
217
270
  * `RECLAIM_PRIVATE_KEY=0x…` (upsert; mode 0600; `.env` added to `.gitignore`).
@@ -226,3 +279,58 @@ export function writeEthKey(
226
279
  const path = upsertEnvVar(projectDir, ENV_FILE, ENV_VAR, hex)
227
280
  return { path, address: deriveFromPrivateKey(hex).address }
228
281
  }
282
+
283
+ /** Throws when `path` (relative to `root`), or any of its existing parents
284
+ * under `root`, is a symlink: writing through one could land outside the
285
+ * project. */
286
+ export function assertNoSymlinks(root: string, path: string) {
287
+ assert(
288
+ path && !path.startsWith('..') && !isAbsolute(path),
289
+ badRequest(`${path} is not inside ${root}`),
290
+ )
291
+ let current = root
292
+ for(const part of path.split(/[\\/]/)) {
293
+ current += sep + part
294
+ const stat = lstatSync(current, { throwIfNoEntry: false })
295
+ assert(
296
+ !stat?.isSymbolicLink(),
297
+ badRequest(
298
+ `${current} is a symlink; Reclaim tools only write to real files `
299
+ + 'and directories inside the project',
300
+ ),
301
+ )
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Write `data` to `path` inside `projectDir` without following a symlink: the
307
+ * file and its parents must be real, and the open uses `O_NOFOLLOW`, so a
308
+ * symlink that appears after the check still fails. `secret` writes with
309
+ * mode 0600; `append` adds to the file instead of replacing it.
310
+ */
311
+ function writeInside(
312
+ projectDir: string,
313
+ path: string,
314
+ data: string,
315
+ { secret = false, append = false } = {},
316
+ ) {
317
+ const rel = relative(projectDir, path)
318
+ assertNoSymlinks(projectDir, rel)
319
+ mkdirSync(dirname(path), { recursive: true })
320
+ assertNoSymlinks(projectDir, rel)
321
+ const fd = openSync(
322
+ path,
323
+ constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW
324
+ | (append ? constants.O_APPEND : constants.O_TRUNC),
325
+ secret ? 0o600 : 0o666,
326
+ )
327
+ try {
328
+ if(secret) {
329
+ fchmodSync(fd, 0o600)
330
+ }
331
+
332
+ writeFileSync(fd, data)
333
+ } finally {
334
+ closeSync(fd)
335
+ }
336
+ }
@@ -1,16 +1,24 @@
1
+ import { conflict } from '@hapi/boom'
1
2
  import { deriveFromPrivateKey, generateKeypair } from '@reclaimprotocol/client'
2
- import type { ReclaimClient } from '@reclaimprotocol/client/api'
3
+ import { isProblemError, type ReclaimClient } from '@reclaimprotocol/client/api'
3
4
  import assert from 'node:assert'
4
5
  import { isAbsolute, relative, resolve } from 'node:path'
5
6
  import { defineTool, type RegisteredTool } from '../../server.ts'
6
- import { normalizeEthHex, readDotenvKey, upsertEnvVar } from './eth-key.ts'
7
+ import {
8
+ backupEnvVar,
9
+ normalizeEthHex,
10
+ readDotenvKey,
11
+ removeEnvVar,
12
+ upsertEnvVar,
13
+ } from './eth-key.ts'
7
14
 
8
15
  /**
9
16
  * Creates the organization's eth key pair for session binding and encrypted
10
17
  * results: writes the private key to the app's server env file and registers
11
18
  * only the public key with Builder, so the private key never passes through
12
19
  * the agent's transcript. Reuses a key the env file already holds, so a
13
- * repeat call (for example, to turn on encryption) keeps the same key.
20
+ * repeat call (for example, to turn on encryption) keeps the same key. With
21
+ * `replace`, always generates a fresh key and rotates the org's key to it.
14
22
  */
15
23
  export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
16
24
  return defineTool<{
@@ -19,6 +27,7 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
19
27
  envFile?: string
20
28
  envVar?: string
21
29
  encryptResults?: boolean
30
+ replace?: boolean
22
31
  }>(
23
32
  {
24
33
  name: 'issue_org_keypair',
@@ -30,8 +39,12 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
30
39
  + 'private key to a server env file in the project (reusing a key '
31
40
  + 'already there) and registers only the public key. Returns the '
32
41
  + 'address, file, and variable; never the key. Returns 409 when the '
33
- + 'org already has a different key: ask the developer for that key '
34
- + 'rather than replacing it.',
42
+ + 'org already has a different key. Set `replace: true` only when '
43
+ + 'the developer asked to rotate or replace the key: it generates '
44
+ + 'a new key and swaps it in one step, and apps holding the old '
45
+ + 'key can no longer decrypt new results. The old key is first '
46
+ + 'appended to `<envFile>.backup` (gitignored); tell the developer '
47
+ + 'its path.',
35
48
  inputSchema: {
36
49
  type: 'object',
37
50
  required: ['orgId', 'projectDir'],
@@ -55,11 +68,18 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
55
68
  encryptResults: {
56
69
  type: 'boolean',
57
70
  description:
58
- 'Encrypt delivered results to this key. Default false.',
71
+ 'Encrypt delivered results to this key. Default false, or the '
72
+ + 'org\'s current setting with `replace`.',
73
+ },
74
+ replace: {
75
+ type: 'boolean',
76
+ description:
77
+ 'Rotate: generate a new key and replace the org\'s current '
78
+ + 'one. Default false.',
59
79
  },
60
80
  },
61
81
  },
62
- annotations: { openWorldHint: true, destructiveHint: false },
82
+ annotations: { openWorldHint: true, destructiveHint: true },
63
83
  },
64
84
  async(args) => {
65
85
  assert(
@@ -75,24 +95,60 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
75
95
  new Error('`envFile` must be inside `projectDir`.'),
76
96
  )
77
97
 
78
- const existing = readDotenvKey(envPath, envVar)
98
+ const replace = args.replace ?? false
99
+ // Rotation always generates a fresh key: the env file's key may or
100
+ // may not be the one the org holds, and reuse would be a no-op.
101
+ const existing = replace ? undefined : readDotenvKey(envPath, envVar)
79
102
  const keypair = existing
80
103
  ? deriveFromPrivateKey(normalizeEthHex(existing))
81
104
  : generateKeypair()
82
- // Register first: a 409 (the org has another key) must not leave a
83
- // key Builder doesn't know in the env file.
84
- const canEncryptResult = args.encryptResults ?? false
85
- await client.call('SetOrgKeypair', {
86
- params: { orgId: args.orgId },
87
- body: {
88
- publicKey: keypair.publicKey,
89
- canEncryptResult,
90
- replace: false,
91
- },
92
- })
105
+ // A rotation keeps the org's current setting unless the caller sets it.
106
+ const canEncryptResult = args.encryptResults
107
+ ?? (replace && await currentCanEncryptResult())
108
+ // Back up and save the key before the org switches to it, so a
109
+ // failed write can't leave the org on a key no app holds. A failed
110
+ // registration (for example, a 409) puts the env file back.
111
+ const old = readDotenvKey(envPath, envVar)
112
+ const backupFile = old && !existing
113
+ ? backupEnvVar(
114
+ args.projectDir, envFile, envVar, 'replaced by issue_org_keypair',
115
+ )
116
+ : undefined
93
117
  const path = upsertEnvVar(
94
118
  args.projectDir, envFile, envVar, keypair.privateKey,
95
119
  )
120
+ try {
121
+ await client.call('SetOrgKeypair', {
122
+ params: { orgId: args.orgId },
123
+ body: {
124
+ publicKey: keypair.publicKey,
125
+ canEncryptResult,
126
+ replace,
127
+ },
128
+ })
129
+ } catch(err) {
130
+ if(old) {
131
+ upsertEnvVar(args.projectDir, envFile, envVar, old)
132
+ } else {
133
+ removeEnvVar(args.projectDir, envFile, envVar)
134
+ }
135
+
136
+ if(!replace && isProblemError(err) && err.output.statusCode === 409) {
137
+ throw conflict(
138
+ 'The org already has a different key. Ask the developer to '
139
+ + 'choose: (a) reuse it: they paste its private key into '
140
+ + `\`${envFile}\` as \`${envVar}\` themselves, never into the `
141
+ + 'chat; (b) rotate it: call `issue_org_keypair` again with '
142
+ + '`replace: true`, after which apps holding the old key can no '
143
+ + 'longer decrypt new results; (c) turn off result encryption: '
144
+ + 'call `update_org_keypair` with `canEncryptResult: false`, '
145
+ + 'which keeps the key.',
146
+ )
147
+ }
148
+
149
+ throw err
150
+ }
151
+
96
152
  return {
97
153
  orgId: args.orgId,
98
154
  address: keypair.address,
@@ -100,6 +156,28 @@ export function issueOrgKeypairTool(client: ReclaimClient): RegisteredTool {
100
156
  envFile: path,
101
157
  envVar,
102
158
  reused: Boolean(existing),
159
+ backupFile: backupFile ?? null,
160
+ ...(replace && !backupFile && !old
161
+ ? {
162
+ note: 'Nothing was backed up: the old private key is not in '
163
+ + 'this file. Another app may hold it.',
164
+ }
165
+ : {}),
166
+ }
167
+
168
+ async function currentCanEncryptResult() {
169
+ try {
170
+ const { data } = await client.call('GetOrgKeypair', {
171
+ params: { orgId: args.orgId },
172
+ })
173
+ return data.canEncryptResult
174
+ } catch(err) {
175
+ if(isProblemError(err) && err.output.statusCode === 404) {
176
+ return false
177
+ }
178
+
179
+ throw err
180
+ }
103
181
  }
104
182
  },
105
183
  )
@@ -2,7 +2,7 @@ import type { ReclaimClient } from '@reclaimprotocol/client/api'
2
2
  import assert from 'node:assert'
3
3
  import { isAbsolute, relative, resolve } from 'node:path'
4
4
  import { defineTool, type RegisteredTool } from '../../server.ts'
5
- import { upsertEnvVar } from './eth-key.ts'
5
+ import { backupEnvVar, readDotenvKey, upsertEnvVar } from './eth-key.ts'
6
6
  import { saveOrgKey } from './org-key-store.ts'
7
7
 
8
8
  /**
@@ -24,7 +24,9 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
24
24
  + 'server env file in the project, so the app can read it at runtime. '
25
25
  + 'Also saves it on this machine for the org-key tools. Returns the '
26
26
  + 'org, file, variable, and last four characters; never the key. '
27
- + 'Replaces an existing variable of the same name in the file. '
27
+ + 'Replaces an existing variable of the same name in the file, first '
28
+ + 'appending the old value to `<envFile>.backup` (gitignored); tell '
29
+ + 'the developer its path. '
28
30
  + 'Adds the file to .gitignore. Use when the app needs a key, or '
29
31
  + 'before `create_verification_session`.',
30
32
  inputSchema: {
@@ -69,6 +71,14 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
69
71
  const { data } = await client.call('IssueOrgToken', {
70
72
  params: { orgId: args.orgId },
71
73
  })
74
+ const old = readDotenvKey(
75
+ resolve(args.projectDir, envFile), envVar,
76
+ )
77
+ const backupFile = old && old !== data.orgSecret
78
+ ? backupEnvVar(
79
+ args.projectDir, envFile, envVar, 'replaced by issue_org_token',
80
+ )
81
+ : undefined
72
82
  const path = upsertEnvVar(
73
83
  args.projectDir, envFile, envVar, data.orgSecret,
74
84
  )
@@ -78,6 +88,7 @@ export function issueOrgTokenTool(client: ReclaimClient): RegisteredTool {
78
88
  envFile: path,
79
89
  envVar,
80
90
  last4: data.last4,
91
+ backupFile: backupFile ?? null,
81
92
  }
82
93
  },
83
94
  )
@@ -132,10 +132,11 @@ export function assertPortableMatches(
132
132
  // proving an email's domain substring. Reject it regardless of the declared
133
133
  // targets (the local part is usually not itself a target, so the value scan
134
134
  // below wouldn't catch it). Strip params + named groups + regex escapes
135
- // first, then look for `localpart@` as a surviving literal.
135
+ // first, then look for `localpart@` as a surviving literal. A placeholder
136
+ // never contains a brace, and `[^{}]` keeps the scan linear on `{{{{…`.
136
137
  const EMAIL_LOCAL_BAKED = /[A-Za-z0-9._%+-]{3,}@/
137
138
  const deregex = (s: string): string => s
138
- .replace(/\{\{[^}]*\}\}/g, '')
139
+ .replace(/\{\{[^{}]*\}\}/g, '')
139
140
  .replace(/\(\?<[^>]*>[^)]*\)/g, '')
140
141
  .replace(/\\(.)/g, '$1')
141
142
  const scanEmail = (text: string, where: string) => {
@@ -168,7 +169,7 @@ export function assertPortableMatches(
168
169
  return
169
170
  }
170
171
 
171
- const stripParams = (s: string): string => s.replace(/\{\{[^}]*\}\}/g, '')
172
+ const stripParams = (s: string): string => s.replace(/\{\{[^{}]*\}\}/g, '')
172
173
  const scan = (text: string, where: string, own: string | null) => {
173
174
  const bare = stripParams(text)
174
175
  for(const v of guarded) {
@@ -789,7 +789,7 @@ function stableOpeningTail(openTag: string): string {
789
789
  * content (the JSON body the attestor exposes when a jsonPath is paired with
790
790
  * the script's xPath); otherwise undefined. */
791
791
  function scriptJsonContent(markup: string): string | undefined {
792
- const match = /^<script\b[^>]*>([\s\S]*)<\/script>\s*$/i.exec(markup)
792
+ const match = /^<script\b[^>]*>([\s\S]*)<\/script\b[^>]*>\s*$/i.exec(markup)
793
793
  if(!match) {
794
794
  return undefined
795
795
  }