@reclaimprotocol/agent 0.1.39 → 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.
- package/lib/mcp/api/operations.gen.d.ts +7 -0
- package/lib/mcp/api/operations.gen.js +49 -3
- package/lib/mcp/instructions.js +10 -8
- package/lib/mcp/tools/agent/example-app.d.ts +5 -1
- package/lib/mcp/tools/agent/example-app.js +87 -24
- package/lib/mcp/tools/authenticate/eth-key.d.ts +15 -0
- package/lib/mcp/tools/authenticate/eth-key.js +72 -6
- package/lib/mcp/tools/authenticate/issue-org-keypair.d.ts +3 -2
- package/lib/mcp/tools/authenticate/issue-org-keypair.js +83 -18
- package/lib/mcp/tools/authenticate/issue-org-token.js +9 -2
- package/package.json +2 -2
- package/skill/SKILL.builder.md +30 -11
- package/skill/SKILL.old.md +23 -5
- package/skill/builder-mode.md +29 -164
- package/skill/old-mode.md +40 -95
- package/skill/start-integration.md +12 -9
- package/skill/verification-client-integration.md +39 -9
- package/src/mcp/api/operations.gen.ts +51 -3
- package/src/mcp/instructions.ts +10 -8
- package/src/mcp/tools/agent/example-app.ts +97 -30
- package/src/mcp/tools/authenticate/eth-key.ts +114 -6
- package/src/mcp/tools/authenticate/issue-org-keypair.ts +97 -19
- package/src/mcp/tools/authenticate/issue-org-token.ts +13 -2
|
@@ -22,26 +22,44 @@ configuration, and callback conventions. Complete the integration in its codebas
|
|
|
22
22
|
relevant checks. The MCP tools configure Builder resources; your file tools
|
|
23
23
|
edit the app.
|
|
24
24
|
|
|
25
|
-
If
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
`
|
|
25
|
+
If `package.json` depends on `@reclaimprotocol/js-sdk`, ask first: "This
|
|
26
|
+
project uses `@reclaimprotocol/js-sdk`. Keep it (I switch this MCP server to
|
|
27
|
+
legacy devtools mode), or migrate to the Builder HTTP API?"
|
|
28
|
+
|
|
29
|
+
- **Keep:** `set_devtools_mode({ mode: "old" })`, then tell them to restart or
|
|
30
|
+
reconnect the MCP server. An explicit `USE_OLD_DEVTOOLS` env var overrides
|
|
31
|
+
it. After the restart, read `get_js_sdk_integration_guide`.
|
|
32
|
+
- **Migrate:** stay in Builder mode, remove `@reclaimprotocol/js-sdk`, and
|
|
33
|
+
replace its `ReclaimProofRequest` and `verifyProof` calls with the session
|
|
34
|
+
flow below.
|
|
30
35
|
|
|
31
36
|
1. `get_devtools_mode` confirms Builder mode. `get_me` or `authenticate` signs
|
|
32
37
|
in the developer. Find the organization with `list_orgs`; call `create_org`
|
|
33
38
|
only when they want a new one.
|
|
34
39
|
2. Receive callbacks. Local development: call
|
|
35
40
|
`proxy_callback_events({ port, path })` to forward the org's events to the
|
|
36
|
-
app's callback route on localhost; no public URL.
|
|
37
|
-
|
|
41
|
+
app's callback route on localhost; no public URL. The local listener works
|
|
42
|
+
only while this MCP server runs. It keeps events in order per session and
|
|
43
|
+
retries a session the app rejects with backoff, so other sessions keep
|
|
44
|
+
flowing. When its status lists `blocked` sessions, read that session's
|
|
45
|
+
callback route response and logs, and fix the app. Deployed app (required
|
|
46
|
+
for production): register a
|
|
38
47
|
public callback URL with `create_org_callback_subscription`, including
|
|
39
48
|
all five session-ending events. If the
|
|
40
49
|
app needs encrypted results or session binding, call `issue_org_keypair`
|
|
41
50
|
(`encryptResults: true` for encrypted results). It writes the private key to
|
|
42
51
|
the backend's env file and registers only the public key. It returns 409
|
|
43
|
-
when the org already has a different key
|
|
44
|
-
|
|
52
|
+
when the org already has a different key. Check `get_org_keypair` first;
|
|
53
|
+
when the org has a key pair, the app must pass `orgEthPrivateKey` to
|
|
54
|
+
`results.receive`, with or without encryption: it decrypts results and
|
|
55
|
+
verifies TEE-bound proofs. Ask the developer to choose: reuse the key (they
|
|
56
|
+
put its private key in the backend's server env file as
|
|
57
|
+
`RECLAIM_ORG_ETH_PRIVATE_KEY` themselves, never in chat), rotate it with
|
|
58
|
+
`issue_org_keypair` and `replace: true` (apps holding the old key stop
|
|
59
|
+
working with new results; the old key is saved to `<envFile>.backup`, so
|
|
60
|
+
tell the developer that path), or turn encryption off with
|
|
61
|
+
`update_org_keypair({ orgId, canEncryptResult: false })`, which keeps the
|
|
62
|
+
key. Name the exact action when you ask.
|
|
45
63
|
3. Org key: reuse the `RECLAIM_ORG_SECRET` the app already has. Otherwise an
|
|
46
64
|
organization owner calls `issue_org_token`. It writes the key to the
|
|
47
65
|
project's env file (default `.env`, variable `RECLAIM_ORG_SECRET`) and
|
|
@@ -254,6 +272,18 @@ Builder snapshots the organization's most recently updated active theme. A
|
|
|
254
272
|
localized consent block tells supported clients to show consent before the
|
|
255
273
|
first provider. Branding and theme data don't affect proof trust.
|
|
256
274
|
|
|
275
|
+
## Try a provider without an app
|
|
276
|
+
|
|
277
|
+
Session creation returns 409 while the org has no callback subscription.
|
|
278
|
+
`read_callback_events` creates the org's pull listener, which satisfies that
|
|
279
|
+
check and holds results for you to read. No tunnel or public URL is needed.
|
|
280
|
+
|
|
281
|
+
1. Call `read_callback_events`.
|
|
282
|
+
2. `create_verification_session`, then give the developer `verificationUrl`.
|
|
283
|
+
3. Poll `get_verification_session` until it is terminal.
|
|
284
|
+
`list_verification_events` shows progress.
|
|
285
|
+
4. Call `read_callback_events({ sessionId })` for the verified result.
|
|
286
|
+
|
|
257
287
|
## Call the direct Builder API
|
|
258
288
|
|
|
259
289
|
In `api=2` mode, send the registered client UUID in `x-reclaim-vc-id` on every
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// AUTO-GENERATED by packages/app/scripts/mcp-tools-gen.ts
|
|
2
|
-
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:
|
|
2
|
+
// from packages/app/openapi.yaml (spec v0.1.0, source sha256:ecdb0ee6d82e).
|
|
3
3
|
// The hash catches drift the version misses (a spec can change without a
|
|
4
4
|
// version bump) — do not remove it as "redundant" with the version.
|
|
5
5
|
// Run `npm run openapi:gen --workspace=packages/app` to regenerate. Do not edit by hand.
|
|
@@ -5116,7 +5116,7 @@ export const MCP_TOOLS = {
|
|
|
5116
5116
|
annotations: {"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
|
|
5117
5117
|
},
|
|
5118
5118
|
"DeleteOrgKeypair": {
|
|
5119
|
-
description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks.",
|
|
5119
|
+
description: "Clear the organization's encryption keypair\n\nRemoves the organization's result encryption public key. Results are then delivered unencrypted until you set a new key. Run it only when the developer asks. To rotate the key, use `issue_org_keypair` with `replace: true` instead of deleting first.",
|
|
5120
5120
|
inputSchema: toZod({
|
|
5121
5121
|
"type": "object",
|
|
5122
5122
|
"properties": {
|
|
@@ -6700,7 +6700,7 @@ export const MCP_TOOLS = {
|
|
|
6700
6700
|
annotations: {"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},
|
|
6701
6701
|
},
|
|
6702
6702
|
"SetOrgKeypair": {
|
|
6703
|
-
description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds.",
|
|
6703
|
+
description: "Set (or rotate) the organization's encryption keypair\n\nRegisters the organization's result encryption public key and turns on encrypted result delivery. Send only the public key; the private key stays with the developer. If the organization already has a different key, the call returns 409 unless you pass replace: true. Pass it only when the developer asked to rotate the key, because results encrypted to the old key can no longer be decrypted. Setting the same key again succeeds. To generate and store the private key in the project, use `issue_org_keypair` instead.",
|
|
6704
6704
|
inputSchema: toZod({
|
|
6705
6705
|
"type": "object",
|
|
6706
6706
|
"properties": {
|
|
@@ -6985,6 +6985,54 @@ export const MCP_TOOLS = {
|
|
|
6985
6985
|
}),
|
|
6986
6986
|
annotations: {"openWorldHint":false},
|
|
6987
6987
|
},
|
|
6988
|
+
"UpdateOrgKeypair": {
|
|
6989
|
+
description: "Turn result encryption on or off\n\nTurns result encryption on or off for the organization's existing key pair without changing the key. Use it when the developer asks to turn encryption on or off. When on, the app's callback needs `orgEthPrivateKey` to decrypt results. Returns 404 when the organization has no key pair; create one with `issue_org_keypair`.",
|
|
6990
|
+
inputSchema: toZod({
|
|
6991
|
+
"type": "object",
|
|
6992
|
+
"properties": {
|
|
6993
|
+
"params": {
|
|
6994
|
+
"type": "object",
|
|
6995
|
+
"properties": {
|
|
6996
|
+
"orgId": {
|
|
6997
|
+
"type": "string",
|
|
6998
|
+
"format": "uuid"
|
|
6999
|
+
}
|
|
7000
|
+
},
|
|
7001
|
+
"required": [
|
|
7002
|
+
"orgId"
|
|
7003
|
+
],
|
|
7004
|
+
"additionalProperties": false
|
|
7005
|
+
},
|
|
7006
|
+
"query": {
|
|
7007
|
+
"type": "object",
|
|
7008
|
+
"properties": {
|
|
7009
|
+
|
|
7010
|
+
}
|
|
7011
|
+
},
|
|
7012
|
+
"body": {
|
|
7013
|
+
"type": "object",
|
|
7014
|
+
"additionalProperties": false,
|
|
7015
|
+
"description": "Body for `PATCH /orgs/{orgId}/keypair`.",
|
|
7016
|
+
"required": [
|
|
7017
|
+
"canEncryptResult"
|
|
7018
|
+
],
|
|
7019
|
+
"properties": {
|
|
7020
|
+
"canEncryptResult": {
|
|
7021
|
+
"type": "boolean",
|
|
7022
|
+
"description": "Set to true to encrypt results to the organization's public key. Set to false to deliver results as plaintext."
|
|
7023
|
+
}
|
|
7024
|
+
}
|
|
7025
|
+
}
|
|
7026
|
+
},
|
|
7027
|
+
"required": [
|
|
7028
|
+
"params",
|
|
7029
|
+
"query",
|
|
7030
|
+
"body"
|
|
7031
|
+
],
|
|
7032
|
+
"additionalProperties": false
|
|
7033
|
+
}),
|
|
7034
|
+
annotations: {"openWorldHint":false},
|
|
7035
|
+
},
|
|
6988
7036
|
"UpdateProvider": {
|
|
6989
7037
|
description: "Update a provider\n\nUpdates a provider's metadata, such as title, description, or tags. Partial update — pass only the fields to change. Requires active membership. To change the recipe (initialUrl or requests), cut a new version with create_provider_version instead.",
|
|
6990
7038
|
inputSchema: toZod({
|
package/src/mcp/instructions.ts
CHANGED
|
@@ -59,7 +59,8 @@ const TOPIC_SUMMARIES: Record<string, string> = {
|
|
|
59
59
|
'overview': 'Terminology, your stance, and the authoring checklist.',
|
|
60
60
|
'contributions': 'Contributing a public provider as a volunteer: the '
|
|
61
61
|
+ 'candidate-and-review workflow and the extra rules it adds.',
|
|
62
|
-
'auth': 'Signing in to the backend,
|
|
62
|
+
'auth': 'Signing in to the backend, which token authorizes what, and '
|
|
63
|
+
+ 'the backend mode (Builder or legacy devtools).',
|
|
63
64
|
'devtools-migration': 'Importing a public legacy provider with the same '
|
|
64
65
|
+ 'UUID.',
|
|
65
66
|
'browser': 'The default local browser, the opt-in modes (attach, the '
|
|
@@ -71,15 +72,16 @@ const TOPIC_SUMMARIES: Record<string, string> = {
|
|
|
71
72
|
+ 'drafted shape.',
|
|
72
73
|
'params': 'Extracted vs consumer-supplied vs secret params, the URL '
|
|
73
74
|
+ 'character budget, and runtime resolution.',
|
|
74
|
-
'prove': 'replay_request, the mandatory auth-bound check, run_proof
|
|
75
|
-
+ 'diagnosing failures.',
|
|
75
|
+
'prove': 'replay_request, the mandatory auth-bound check, run_proof '
|
|
76
|
+
+ '(including the egress country), and diagnosing failures.',
|
|
76
77
|
'publish': 'Creating a provider, provider versions, multi-request '
|
|
77
78
|
+ 'providers, and going live.',
|
|
78
|
-
'discover': 'Reusing existing providers: finding, resolving,
|
|
79
|
-
+ 'creating a verification session.',
|
|
80
|
-
'integration': 'After start_integration: org key, callback
|
|
81
|
-
+ 'proxy_callback_events or deployed webhook), frontend and
|
|
82
|
-
+ 'over the HTTP session API
|
|
79
|
+
'discover': 'Reusing existing providers: finding, resolving, copying one '
|
|
80
|
+
+ 'that needs changes, and creating a verification session.',
|
|
81
|
+
'integration': 'After start_integration: org key and keypair, callback '
|
|
82
|
+
+ '(local proxy_callback_events or deployed webhook), frontend and '
|
|
83
|
+
+ 'backend over the HTTP session API, js-sdk projects, trying a '
|
|
84
|
+
+ 'session without an app; `api=2` and result verification.',
|
|
83
85
|
'user-script': 'The navigation script (jsUserScripts): sign-in redirect, '
|
|
84
86
|
+ 'multi-page walk, test_user_script with keep.',
|
|
85
87
|
'interception': 'injectionType / interceptorType, live interception, the '
|
|
@@ -10,10 +10,12 @@
|
|
|
10
10
|
import { badGateway, badRequest, conflict } from '@hapi/boom'
|
|
11
11
|
import assert from 'node:assert'
|
|
12
12
|
import { createHash } from 'node:crypto'
|
|
13
|
-
import { existsSync,
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
14
|
import { mkdir, realpath, rm, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { connect } from 'node:net'
|
|
15
16
|
import { isAbsolute, join, relative, sep } from 'node:path'
|
|
16
17
|
import { defineTool, type RegisteredTool } from '../../server.ts'
|
|
18
|
+
import { assertNoSymlinks, upsertEnvVar } from '../authenticate/eth-key.ts'
|
|
17
19
|
|
|
18
20
|
const REPO = 'reclaimprotocol/builder-example'
|
|
19
21
|
/** GitHub tree paths are always `/`-separated, on every OS. */
|
|
@@ -22,8 +24,20 @@ const CLIENT_PACKAGE = '@reclaimprotocol/client'
|
|
|
22
24
|
/** The example is ~50 small files; anything far past that isn't it. */
|
|
23
25
|
const MAX_FILES = 500
|
|
24
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'])
|
|
25
36
|
|
|
26
|
-
export function exampleAppTool(
|
|
37
|
+
export function exampleAppTool(
|
|
38
|
+
fetchFn: typeof fetch = fetch,
|
|
39
|
+
isPortFree: (port: number) => Promise<boolean> = isLoopbackPortFree,
|
|
40
|
+
): RegisteredTool {
|
|
27
41
|
return defineTool<{ projectDir: string }>(
|
|
28
42
|
{
|
|
29
43
|
name: 'create_example_app',
|
|
@@ -32,12 +46,14 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
32
46
|
+ 'a Fastify backend and a Vite frontend that create sessions, '
|
|
33
47
|
+ 'verify callbacks, and render every verification state) into '
|
|
34
48
|
+ '`projectDir`, and sets its backend to the latest '
|
|
35
|
-
+ '`@reclaimprotocol/client`.
|
|
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 '
|
|
36
52
|
+ 'yet, with the workspace root you were started in as '
|
|
37
53
|
+ '`projectDir`; don\'t clone the repository yourself. Refuses '
|
|
38
54
|
+ 'when a file it would write already exists with different '
|
|
39
|
-
+ 'content. Returns the commit, file count, client version,
|
|
40
|
-
+ 'next steps.',
|
|
55
|
+
+ 'content. Returns the commit, file count, client version, '
|
|
56
|
+
+ '`backendPort`, and next steps.',
|
|
41
57
|
inputSchema: {
|
|
42
58
|
type: 'object',
|
|
43
59
|
required: ['projectDir'],
|
|
@@ -69,11 +85,17 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
69
85
|
: bytes,
|
|
70
86
|
}
|
|
71
87
|
}))
|
|
72
|
-
for(const
|
|
88
|
+
for(const path of [...staged.map((f) => f.path), BACKEND_ENV_FILE]) {
|
|
73
89
|
assertNoSymlinks(projectDir, path)
|
|
74
90
|
}
|
|
75
91
|
|
|
76
|
-
const
|
|
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,31 +110,72 @@ export function exampleAppTool(fetchFn: typeof fetch = fetch): RegisteredTool {
|
|
|
88
110
|
),
|
|
89
111
|
)
|
|
90
112
|
|
|
91
|
-
for(const { path, bytes } of
|
|
113
|
+
for(const { path, bytes } of toWrite) {
|
|
92
114
|
await installFile(projectDir, path, bytes)
|
|
93
115
|
}
|
|
94
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
|
+
|
|
95
125
|
return {
|
|
96
126
|
projectDir,
|
|
97
127
|
repository: `https://github.com/${REPO}`,
|
|
98
128
|
commit,
|
|
99
|
-
files:
|
|
129
|
+
files: toWrite.length,
|
|
130
|
+
kept,
|
|
100
131
|
clientVersion,
|
|
132
|
+
backendPort,
|
|
101
133
|
next: [
|
|
102
134
|
'Call issue_org_token with this projectDir and envFile '
|
|
103
135
|
+ '"packages/backend/.env".',
|
|
104
|
-
'Run `npm install
|
|
105
|
-
+
|
|
106
|
-
+
|
|
107
|
-
|
|
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: `
|
|
108
151
|
+ '"/reclaim/callback" }).',
|
|
152
|
+
'If get_org_keypair shows `canEncryptResult: true`, the '
|
|
153
|
+
+ 'callback needs `orgEthPrivateKey`; see the plan.',
|
|
109
154
|
'Put the provider in packages/backend/config.json and restart '
|
|
110
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.',
|
|
111
159
|
],
|
|
112
160
|
}
|
|
113
161
|
},
|
|
114
162
|
)
|
|
115
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
|
+
|
|
116
179
|
async function latestCommit() {
|
|
117
180
|
const res = await fetchFn(
|
|
118
181
|
`https://api.github.com/repos/${REPO}/commits/main`,
|
|
@@ -239,23 +302,6 @@ function isEexist(err: unknown) {
|
|
|
239
302
|
return err instanceof Error && 'code' in err && err.code === 'EEXIST'
|
|
240
303
|
}
|
|
241
304
|
|
|
242
|
-
/** Throws when `path`, or any of its existing parents under `root`, is a
|
|
243
|
-
* symlink: writing through one would land outside the workspace. */
|
|
244
|
-
function assertNoSymlinks(root: string, path: string) {
|
|
245
|
-
let current = root
|
|
246
|
-
for(const part of path.split(/[\\/]/)) {
|
|
247
|
-
current += sep + part
|
|
248
|
-
const stat = lstatSync(current, { throwIfNoEntry: false })
|
|
249
|
-
assert(
|
|
250
|
-
!stat?.isSymbolicLink(),
|
|
251
|
-
badRequest(
|
|
252
|
-
`${current} is a symlink; the example app is only written to `
|
|
253
|
-
+ 'real directories inside the workspace',
|
|
254
|
-
),
|
|
255
|
-
)
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
305
|
/** The backend's package.json with the client range set to `^version`. */
|
|
260
306
|
function withClientVersion(bytes: Buffer, version: string) {
|
|
261
307
|
const text = bytes.toString('utf8')
|
|
@@ -277,3 +323,24 @@ function gitBlobSha(bytes: Buffer) {
|
|
|
277
323
|
.update(bytes)
|
|
278
324
|
.digest('hex')
|
|
279
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|