@fiscalmindset/blindfold 0.4.0

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/src/env.ts ADDED
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Loads runtime env vars. NEVER logs any value — only field names.
3
+ *
4
+ * .env is parsed with a minimal hand-rolled loader so we don't pull in
5
+ * dotenv as a runtime dep. Lines are KEY=VALUE; surrounding quotes are
6
+ * stripped; lines starting with # are comments.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import type { BlindfoldEnv } from "./types.ts";
13
+ import { keychainAvailable, keychainGet } from "./keychain.ts";
14
+
15
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
16
+ // Source-relative fallback: HERE = <repo>/packages/blindfold/src → 3 ".." to root.
17
+ const SRC_RELATIVE_ROOT = path.resolve(HERE, "..", "..", "..");
18
+
19
+ let _repoRoot: string | null = null;
20
+ /**
21
+ * Resolve the project root. Walks up from the current working directory looking
22
+ * for a `.env` / `.blindfold` / `.git` marker so the tool works from a
23
+ * subdirectory and when installed under `node_modules`; falls back to the
24
+ * source-relative path for the in-repo layout.
25
+ */
26
+ export function repoRoot(): string {
27
+ if (_repoRoot) return _repoRoot;
28
+ let dir = process.cwd();
29
+ for (let i = 0; i < 12; i++) {
30
+ if (
31
+ fs.existsSync(path.join(dir, ".env")) ||
32
+ fs.existsSync(path.join(dir, ".blindfold")) ||
33
+ fs.existsSync(path.join(dir, ".git"))
34
+ ) {
35
+ _repoRoot = dir;
36
+ return dir;
37
+ }
38
+ const parent = path.dirname(dir);
39
+ if (parent === dir) break;
40
+ dir = parent;
41
+ }
42
+ _repoRoot = SRC_RELATIVE_ROOT;
43
+ return _repoRoot;
44
+ }
45
+
46
+ /** The product's home directory for state + config: ~/.blindfold (v0.2+). */
47
+ export function homeDir(): string {
48
+ return path.join(os.homedir(), ".blindfold");
49
+ }
50
+
51
+ /** Absolute path to the user-level config file (tenant DID + settings). */
52
+ export function configPath(): string {
53
+ return path.join(homeDir(), "config.json");
54
+ }
55
+
56
+ /**
57
+ * Directory holding Blindfold's runtime state (ledger, usage log, egress cache,
58
+ * config). As of v0.2 this defaults to ~/.blindfold so the tool is installable
59
+ * and runs from any directory — independent of the repo checkout. On first run
60
+ * it migrates a legacy in-repo `.blindfold/` (so existing grants/ledger carry
61
+ * over). Overridable via BLINDFOLD_STATE_DIR.
62
+ */
63
+ export function stateDir(): string {
64
+ const override = process.env.BLINDFOLD_STATE_DIR;
65
+ if (override && override.trim()) return path.resolve(override.trim());
66
+ const home = homeDir();
67
+ if (!fs.existsSync(home)) {
68
+ // One-time migration from the old repo-anchored location, if present.
69
+ try {
70
+ const legacy = path.join(repoRoot(), ".blindfold");
71
+ if (fs.existsSync(legacy) && fs.statSync(legacy).isDirectory()) {
72
+ fs.cpSync(legacy, home, { recursive: true });
73
+ } else {
74
+ fs.mkdirSync(home, { recursive: true });
75
+ }
76
+ } catch {
77
+ try { fs.mkdirSync(home, { recursive: true }); } catch { /* best effort */ }
78
+ }
79
+ }
80
+ return home;
81
+ }
82
+
83
+ /**
84
+ * Validate a user-supplied base-URL override. Requires https (localhost may use
85
+ * http) so a mis-set env var can't route tenant-key auth / released secrets to
86
+ * an attacker-controlled plaintext host. Throws on anything invalid.
87
+ */
88
+ export function assertSafeOverrideUrl(raw: string, label: string): void {
89
+ let u: URL;
90
+ try {
91
+ u = new URL(raw);
92
+ } catch {
93
+ throw new Error(`${label} is not a valid URL: ${raw}`);
94
+ }
95
+ const isLocal = ["localhost", "127.0.0.1", "::1", "[::1]"].includes(u.hostname);
96
+ if (u.protocol !== "https:" && !isLocal) {
97
+ throw new Error(`${label} must be https (got ${u.protocol}//${u.hostname}); refusing to trust an insecure endpoint`);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Run `fn` while holding an exclusive lock on `<target>.lock`, so concurrent
103
+ * processes can't corrupt append-only state (ledger fork, egress-allowlist
104
+ * clobber). Spins briefly; reclaims a lock older than 10s as stale.
105
+ */
106
+ export async function withFileLock<T>(target: string, fn: () => T | Promise<T>): Promise<T> {
107
+ const lockPath = `${target}.lock`;
108
+ const deadline = Date.now() + 10_000;
109
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
110
+ // Acquire.
111
+ for (;;) {
112
+ try {
113
+ const fd = fs.openSync(lockPath, "wx");
114
+ fs.writeSync(fd, String(process.pid));
115
+ fs.closeSync(fd);
116
+ break;
117
+ } catch (e: any) {
118
+ if (e?.code !== "EEXIST") throw e;
119
+ // Stale-lock reclaim.
120
+ try {
121
+ const st = fs.statSync(lockPath);
122
+ if (Date.now() - st.mtimeMs > 10_000) {
123
+ fs.rmSync(lockPath, { force: true });
124
+ continue;
125
+ }
126
+ } catch { /* lock vanished; retry acquire */ }
127
+ if (Date.now() > deadline) throw new Error(`timed out acquiring lock ${lockPath}`);
128
+ await new Promise((r) => setTimeout(r, 25));
129
+ }
130
+ }
131
+ try {
132
+ return await fn();
133
+ } finally {
134
+ fs.rmSync(lockPath, { force: true });
135
+ }
136
+ }
137
+
138
+ /** Synchronous variant of withFileLock, for code paths that must stay sync. */
139
+ export function withFileLockSync<T>(target: string, fn: () => T): T {
140
+ const lockPath = `${target}.lock`;
141
+ const deadline = Date.now() + 10_000;
142
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
143
+ const sleep = (ms: number) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* fallback: busy noop */ } };
144
+ for (;;) {
145
+ try {
146
+ const fd = fs.openSync(lockPath, "wx");
147
+ fs.writeSync(fd, String(process.pid));
148
+ fs.closeSync(fd);
149
+ break;
150
+ } catch (e: any) {
151
+ if (e?.code !== "EEXIST") throw e;
152
+ try {
153
+ const st = fs.statSync(lockPath);
154
+ if (Date.now() - st.mtimeMs > 10_000) { fs.rmSync(lockPath, { force: true }); continue; }
155
+ } catch { /* lock vanished; retry */ }
156
+ if (Date.now() > deadline) throw new Error(`timed out acquiring lock ${lockPath}`);
157
+ sleep(25);
158
+ }
159
+ }
160
+ try {
161
+ return fn();
162
+ } finally {
163
+ fs.rmSync(lockPath, { force: true });
164
+ }
165
+ }
166
+
167
+ /** Absolute path to the project's .env file (repo root). */
168
+ export function defaultEnvPath(): string {
169
+ return path.join(repoRoot(), ".env");
170
+ }
171
+
172
+ export function loadEnvFromFile(envPath = path.join(repoRoot(), ".env")): void {
173
+ if (!fs.existsSync(envPath)) return;
174
+ const text = fs.readFileSync(envPath, "utf8");
175
+ for (const raw of text.split(/\r?\n/)) {
176
+ const line = raw.trim();
177
+ if (!line || line.startsWith("#")) continue;
178
+ const eq = line.indexOf("=");
179
+ if (eq <= 0) continue;
180
+ const key = line.slice(0, eq).trim();
181
+ let val = line.slice(eq + 1).trim();
182
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
183
+ val = val.slice(1, -1);
184
+ }
185
+ if (process.env[key] === undefined) process.env[key] = val;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Load user-level config from ~/.blindfold/config.json (and ~/.blindfold/.env),
191
+ * filling any env vars not already set. This is the installed-product source of
192
+ * creds — populated by `blindfold login` — so the CLI works with the repo `.env`
193
+ * absent (e.g. run from any directory, or installed globally). Repo `.env` still
194
+ * takes precedence when present (dev convenience).
195
+ */
196
+ export function loadHomeConfig(): void {
197
+ try {
198
+ const cfg = configPath();
199
+ if (fs.existsSync(cfg)) {
200
+ const obj = JSON.parse(fs.readFileSync(cfg, "utf8")) as Record<string, unknown>;
201
+ for (const [k, v] of Object.entries(obj)) {
202
+ // Never treat the store marker as a value; skip it.
203
+ if (k === "T3N_API_KEY_STORE") continue;
204
+ if (typeof v === "string" && process.env[k] === undefined) process.env[k] = v;
205
+ }
206
+ }
207
+ } catch { /* malformed config must not crash the CLI */ }
208
+ loadEnvFromFile(path.join(homeDir(), ".env"));
209
+ // If the tenant key wasn't in env/files, pull it from the OS keychain (keyed
210
+ // by DID). This is the v0.3 default — the key isn't a readable file at all.
211
+ if (process.env.T3N_API_KEY === undefined && process.env.DID && keychainAvailable()) {
212
+ const k = keychainGet(process.env.DID);
213
+ if (k) process.env.T3N_API_KEY = k;
214
+ }
215
+ }
216
+
217
+ export function loadBlindfoldEnv(): BlindfoldEnv {
218
+ loadEnvFromFile(); // repo .env (dev) — wins when present
219
+ loadHomeConfig(); // ~/.blindfold/config.json (installed product) — fallback
220
+ const t3nApiKey = process.env.T3N_API_KEY ?? "";
221
+ const did = process.env.DID ?? "";
222
+ const port = Number.parseInt(process.env.BLINDFOLD_PORT ?? "8787", 10);
223
+ const t3EnvRaw = (process.env.BLINDFOLD_T3_ENV ?? "testnet").toLowerCase();
224
+ const t3Env = t3EnvRaw === "production" ? "production" : "testnet";
225
+ // Optional node-URL override — point at a healthy/leader node when the SDK's
226
+ // default node is unhealthy (the failure mode behind days of phantom 500s).
227
+ const t3BaseUrl = (process.env.T3_BASE_URL ?? process.env.BLINDFOLD_BASE_URL ?? "").trim();
228
+ if (t3BaseUrl) {
229
+ assertSafeOverrideUrl(t3BaseUrl, process.env.T3_BASE_URL ? "T3_BASE_URL" : "BLINDFOLD_BASE_URL");
230
+ }
231
+ // MOCK is opt-in only — used by the standalone demo and CI tests. The
232
+ // production path is REAL. If T3 creds are missing in REAL mode, callers
233
+ // must surface a clear error (not silently fall back to mock).
234
+ const mock = process.env.BLINDFOLD_MOCK === "1";
235
+ return { t3nApiKey, did, port, t3Env, t3BaseUrl, mock };
236
+ }
237
+
238
+ /** Throw a friendly error if REAL mode is requested but creds are missing. */
239
+ export function assertRealReady(env: BlindfoldEnv): void {
240
+ if (env.mock) return;
241
+ const missing: string[] = [];
242
+ if (!env.t3nApiKey) missing.push("T3N_API_KEY");
243
+ if (!env.did) missing.push("DID");
244
+ if (missing.length > 0) {
245
+ throw new Error(
246
+ `Missing required env: ${missing.join(", ")}. ` +
247
+ `Claim them at https://docs.terminal3.io/developers/adk/get-started/prerequisites/request-test-tokens, ` +
248
+ `then put them in .env. Or set BLINDFOLD_MOCK=1 to use mock mode for the demo only.`,
249
+ );
250
+ }
251
+ }
252
+
253
+ /** Pull a plaintext value out of env. Returns the value AND the env name
254
+ * it came from (so callers can produce errors without quoting the value). */
255
+ export function pluckSecret(envName: string): string {
256
+ const v = process.env[envName];
257
+ if (!v) {
258
+ throw new Error(`environment variable ${envName} is unset or empty`);
259
+ }
260
+ return v;
261
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export { SENTINEL, DEFAULT_PORT, CONTRACT_TAIL, CONTRACT_VERSION, DEFAULT_DASHBOARD_PORT } from "./constants.ts";
2
+ export { startProxy } from "./proxy.ts";
3
+ export { startDashboard } from "./dashboard.ts";
4
+ export { registerSecret, registerContract } from "./register.ts";
5
+ export { release } from "./release.ts";
6
+ export { wrap } from "./wrap.ts";
7
+ export { loadBlindfoldEnv, assertRealReady } from "./env.ts";
8
+ export { readUsage, clearUsage, defaultLogPath } from "./usage-log.ts";
9
+ export type { UsageEvent } from "./usage-log.ts";
10
+ export type { BlindfoldEnv, ForwardRequest, ForwardResponse, RegisterOpts } from "./types.ts";
11
+ export type { ReleaseOpts } from "./release.ts";
package/src/init.ts ADDED
@@ -0,0 +1,391 @@
1
+ /**
2
+ * blindfold init — the zero-knowledge bootstrap wizard.
3
+ *
4
+ * Goal: a developer with no Rust or Terminal 3 knowledge can run one
5
+ * command, answer a couple of prompts, and end up with a working
6
+ * REAL-mode Blindfold setup.
7
+ *
8
+ * The wizard NEVER asks for an API-style secret on the command line
9
+ * (the T3N_API_KEY for the T3 account itself is a one-off bootstrap
10
+ * value and is written to .env). When a step fails, the wizard prints
11
+ * exactly what went wrong and what to try next.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import path from "node:path";
16
+ import readline from "node:readline/promises";
17
+ import { fileURLToPath } from "node:url";
18
+ import { loadBlindfoldEnv, loadEnvFromFile, pluckSecret } from "./env.ts";
19
+ import { recordSealed } from "./sealed-ledger.ts";
20
+ import { openT3Client, type T3Sdk } from "./t3-client.ts";
21
+
22
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
23
+ const REPO_ROOT = path.resolve(HERE, "..", "..", "..");
24
+ const ENV_PATH = path.join(REPO_ROOT, ".env");
25
+ const ENV_EXAMPLE_PATH = path.join(REPO_ROOT, ".env.example");
26
+ const WASM_PATH = path.join(REPO_ROOT, "contract", "target", "wasm32-wasip2", "release", "blindfold_proxy.wasm");
27
+ const T3_CLAIM_URL = "https://docs.terminal3.io/developers/adk/get-started/prerequisites/request-test-tokens";
28
+
29
+ /* ─── tiny styling helpers (no deps) ─────────────────────────────── */
30
+ const colour = process.stdout.isTTY ? (c: string, s: string) => `\x1b[${c}m${s}\x1b[0m` : (_: string, s: string) => s;
31
+ const bold = (s: string) => colour("1", s);
32
+ const green = (s: string) => colour("32", s);
33
+ const yellow = (s: string) => colour("33", s);
34
+ const red = (s: string) => colour("31", s);
35
+ const dim = (s: string) => colour("2", s);
36
+ const cyan = (s: string) => colour("36", s);
37
+
38
+ function header(n: number, total: number, title: string): void {
39
+ process.stdout.write(`\n${dim(`[${n}/${total}]`)} ${bold(title)}\n`);
40
+ }
41
+ function ok(line: string): void { process.stdout.write(` ${green("✓")} ${line}\n`); }
42
+ function info(line: string): void { process.stdout.write(` ${dim("·")} ${line}\n`); }
43
+ function warn(line: string): void { process.stdout.write(` ${yellow("!")} ${line}\n`); }
44
+ function fail(line: string, fixHint?: string): void {
45
+ process.stdout.write(` ${red("✖")} ${line}\n`);
46
+ if (fixHint) {
47
+ for (const ln of fixHint.split("\n")) process.stdout.write(` ${dim("→")} ${cyan(ln)}\n`);
48
+ }
49
+ }
50
+
51
+ function run(cmd: string, args: string[], opts: { cwd?: string; env?: Record<string, string> } = {}): Promise<{ code: number; out: string; err: string }> {
52
+ return new Promise((resolve) => {
53
+ const child = spawn(cmd, args, {
54
+ cwd: opts.cwd ?? REPO_ROOT,
55
+ env: { ...process.env, ...(opts.env ?? {}) },
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ });
58
+ const o: Buffer[] = [];
59
+ const e: Buffer[] = [];
60
+ child.stdout.on("data", (c) => o.push(c));
61
+ child.stderr.on("data", (c) => e.push(c));
62
+ child.on("close", (code) => resolve({ code: code ?? -1, out: Buffer.concat(o).toString("utf8"), err: Buffer.concat(e).toString("utf8") }));
63
+ child.on("error", (err) => resolve({ code: -1, out: "", err: err.message }));
64
+ });
65
+ }
66
+
67
+ async function ask(prompt: string): Promise<string> {
68
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
69
+ try {
70
+ return (await rl.question(prompt)).trim();
71
+ } finally {
72
+ rl.close();
73
+ }
74
+ }
75
+
76
+ export interface InitOpts {
77
+ skipBuild?: boolean;
78
+ skipPublish?: boolean;
79
+ /** Seed a secret. Format: "<KV_KEY>:<ENV_VAR>". May appear many times. */
80
+ seed?: string[];
81
+ /** Non-interactive — fail rather than ask. */
82
+ yes?: boolean;
83
+ /** After success, exec into `blindfold proxy` rather than just printing. */
84
+ start?: boolean;
85
+ }
86
+
87
+ export async function runInit(opts: InitOpts = {}): Promise<void> {
88
+ const total = 5;
89
+ process.stdout.write(`\n${bold("🛡️ Blindfold — first-time setup")}\n${dim("This wizard sets up REAL T3 mode end-to-end. Secrets are read from your .env, never typed onto the command line.")}\n`);
90
+
91
+ /* ── Step 1: preflight (with interactive .env bootstrap) ──────── */
92
+ header(1, total, "Preflight");
93
+
94
+ // 1a. Ensure .env exists with T3 credentials.
95
+ await ensureEnvOrPrompt(opts);
96
+ // After this call, .env exists and we re-load it so loadBlindfoldEnv() sees fresh values.
97
+ loadEnvFromFile(ENV_PATH);
98
+ const env = loadBlindfoldEnv();
99
+ if (!env.t3nApiKey || !env.did) {
100
+ fail("Still missing T3N_API_KEY and/or DID after .env walkthrough.", "Open .env, paste the two values, and re-run `npm run setup`.");
101
+ throw new Error("preflight failed");
102
+ }
103
+ ok(`T3 ${env.t3Env} · tenant ${dim(env.did)}`);
104
+
105
+ // 1b. SDK present?
106
+ const haveSdk = await isSdkInstalled();
107
+ if (!haveSdk) {
108
+ fail("@terminal3/t3n-sdk is not installed.", "Run: npm install @terminal3/t3n-sdk");
109
+ throw new Error("sdk missing");
110
+ }
111
+ ok("@terminal3/t3n-sdk present");
112
+
113
+ // 1c. Rust + wasm32-wasip2 (only if we plan to build).
114
+ let canBuild = !opts.skipBuild;
115
+ if (canBuild) {
116
+ const cargo = await run("which", ["cargo"]);
117
+ if (cargo.code !== 0) {
118
+ warn("cargo (Rust toolchain) not found — auto-skipping contract build.");
119
+ info(`Install rust at https://rustup.rs and re-run \`blindfold init\` to build the contract locally.`);
120
+ canBuild = false;
121
+ } else {
122
+ const targets = await run("rustup", ["target", "list", "--installed"]);
123
+ if (!targets.out.includes("wasm32-wasip2")) {
124
+ info("Installing wasm32-wasip2 target …");
125
+ const add = await run("rustup", ["target", "add", "wasm32-wasip2"]);
126
+ if (add.code !== 0) {
127
+ warn("Could not install wasm32-wasip2 automatically; skipping contract build.");
128
+ info("Run manually: rustup target add wasm32-wasip2");
129
+ canBuild = false;
130
+ }
131
+ }
132
+ if (canBuild) ok("Rust toolchain + wasm32-wasip2 target ready");
133
+ }
134
+ }
135
+
136
+ /* ── Step 2: build contract ───────────────────────────────────── */
137
+ if (canBuild) {
138
+ header(2, total, "Build contract (Rust → WASM)");
139
+ info("cargo build --target wasm32-wasip2 --release");
140
+ const build = await run("cargo", ["build", "--target", "wasm32-wasip2", "--release"], {
141
+ cwd: path.join(REPO_ROOT, "contract"),
142
+ });
143
+ if (build.code !== 0) {
144
+ fail("Contract build failed.", `Inspect with: cd contract && cargo build --target wasm32-wasip2 --release\nstderr tail:\n${build.err.slice(-800)}`);
145
+ throw new Error("contract build failed");
146
+ }
147
+ if (!existsSync(WASM_PATH)) {
148
+ fail(`Build succeeded but artifact missing at ${WASM_PATH}.`, "Did the package name change?");
149
+ throw new Error("artifact missing");
150
+ }
151
+ const wasmBytes = readFileSync(WASM_PATH);
152
+ ok(`Built ${WASM_PATH} (${wasmBytes.byteLength.toLocaleString()} bytes)`);
153
+ } else {
154
+ header(2, total, "Build contract (skipped)");
155
+ }
156
+
157
+ /* ── Step 3: authenticate ─────────────────────────────────────── */
158
+ header(3, total, "Authenticate to T3");
159
+ let t3;
160
+ try {
161
+ t3 = await openT3Client(env);
162
+ if (!t3.isReal) throw new Error("Mock client returned — env misconfigured.");
163
+ ok("Handshake + authenticate succeeded ✨");
164
+ } catch (e) {
165
+ fail("Could not connect to T3.", `Error: ${(e as Error).message}\nCheck T3N_API_KEY + DID match a real T3 account (and BLINDFOLD_T3_ENV matches their environment).`);
166
+ throw e;
167
+ }
168
+
169
+ /* ── Step 4: publish contract + grant ACLs ────────────────────── */
170
+ // Ensure tenant scaffolding (claim, create secrets + authorised-hosts maps) — idempotent.
171
+ await ensureTenantScaffolding(env);
172
+
173
+ if (!opts.skipPublish && canBuild) {
174
+ header(4, total, "Publish the wrapper contract + grant ACLs");
175
+ try {
176
+ const wasm = readFileSync(WASM_PATH);
177
+ const r = await t3.registerContract(new Uint8Array(wasm.buffer, wasm.byteOffset, wasm.byteLength));
178
+ const contractId = Number(r.contractId);
179
+ ok(`Published "blindfold-proxy" · contract_id=${contractId}`);
180
+ await grantContractReads(env, contractId);
181
+ ok(`Granted read access on z:tid:secrets to contract ${contractId}`);
182
+ } catch (e) {
183
+ const msg = (e as Error).message;
184
+ if (/version.*not higher|already.*registered/i.test(msg)) {
185
+ warn(`Already published at this version — skipping. (${msg.slice(0, 100)})`);
186
+ } else {
187
+ fail("Publish failed.", `Error: ${msg.slice(0, 300)}`);
188
+ throw e;
189
+ }
190
+ }
191
+ } else {
192
+ header(4, total, opts.skipPublish ? "Publish (skipped: --skip-publish)" : "Publish (skipped: no contract built)");
193
+ }
194
+
195
+ /* ── Step 5: seed secrets ─────────────────────────────────────── */
196
+ header(5, total, "Seal a secret into the enclave");
197
+ const toSeed = collectSeedPlan(opts);
198
+ if (toSeed.length === 0) {
199
+ info("No --seed flag given.");
200
+ info(`Later, after dropping an OPENAI_API_KEY into .env: ${cyan("blindfold register --name openai_api_key --from-env OPENAI_API_KEY")}`);
201
+ }
202
+ for (const { name, fromEnv } of toSeed) {
203
+ try {
204
+ const value = pluckSecret(fromEnv);
205
+ await t3.seedSecret(name, value);
206
+ recordSealed({
207
+ t: new Date().toISOString(),
208
+ name,
209
+ source: `env:${fromEnv}`,
210
+ length: value.length,
211
+ mode: env.mock ? "mock" : "real",
212
+ tenant_did: env.did,
213
+ map_name: `z:${env.did.replace(/^did:t3n:/, "")}:secrets`,
214
+ });
215
+ ok(`Sealed ${bold(name)} (read from ${fromEnv}, ${value.length} bytes, then dropped). You can DELETE ${fromEnv} from .env now.`);
216
+ } catch (e) {
217
+ fail(`Could not seal "${name}".`, `Error: ${(e as Error).message}\nMake sure ${fromEnv} is set in .env (you can delete it after sealing).`);
218
+ }
219
+ }
220
+
221
+ await t3.close();
222
+
223
+ /* ── Done — either auto-start or print copy-ready command ─────── */
224
+ process.stdout.write(`\n${green(bold("✓ All done."))}\n`);
225
+
226
+ if (opts.start) {
227
+ process.stdout.write(`${dim("Starting the proxy now (Ctrl+C to stop) …")}\n`);
228
+ // Exec into the proxy long-running command. We use spawn-with-stdio-inherited
229
+ // so the dev sees its output directly and Ctrl+C reaches it.
230
+ const proxy = spawn(process.execPath, [process.argv[1] ?? "", "proxy"], {
231
+ cwd: REPO_ROOT,
232
+ stdio: "inherit",
233
+ });
234
+ proxy.on("exit", (code) => process.exit(code ?? 0));
235
+ return;
236
+ }
237
+
238
+ process.stdout.write(`${dim("Next:")}\n`);
239
+ process.stdout.write(` ${cyan("npm run blindfold -- proxy")} ${dim("# leave this running")}\n`);
240
+ process.stdout.write(` ${cyan("npm run dashboard")} ${dim("# open http://127.0.0.1:8799")}\n`);
241
+ process.stdout.write(` ${dim("Then point your agent at:")} ${cyan("OPENAI_BASE_URL=http://127.0.0.1:8787/v1 OPENAI_API_KEY=__BLINDFOLD__")}\n`);
242
+ process.stdout.write(`${dim("Or re-run with")} ${cyan("--start")} ${dim("to launch the proxy automatically.")}\n`);
243
+ }
244
+
245
+ /* ─── helpers ─────────────────────────────────────────────────────── */
246
+
247
+ async function ensureEnvOrPrompt(opts: InitOpts): Promise<void> {
248
+ loadEnvFromFile(ENV_PATH);
249
+ const haveBoth = !!process.env.T3N_API_KEY && !!process.env.DID;
250
+ if (haveBoth) return;
251
+
252
+ // .env missing or incomplete. In --yes mode this is fatal; otherwise walk through.
253
+ if (opts.yes) {
254
+ fail(".env is missing T3N_API_KEY and/or DID.", `Claim them here: ${T3_CLAIM_URL}\nThen put them in .env and re-run.`);
255
+ throw new Error("env missing");
256
+ }
257
+
258
+ if (!existsSync(ENV_PATH) && existsSync(ENV_EXAMPLE_PATH)) {
259
+ const template = readFileSync(ENV_EXAMPLE_PATH, "utf8");
260
+ writeFileSync(ENV_PATH, template);
261
+ info("Created .env from .env.example.");
262
+ }
263
+
264
+ warn(".env is missing your T3 credentials.");
265
+ info(`Claim them (free, takes 30 seconds): ${cyan(T3_CLAIM_URL)}`);
266
+ const proceed = await ask(` Paste them now? [Y/n] `);
267
+ if (proceed.toLowerCase().startsWith("n")) {
268
+ info("OK — open .env, paste both values, and re-run `npm run setup`.");
269
+ process.exit(0);
270
+ }
271
+
272
+ const apiKey = await promptUntilMatches(" T3N_API_KEY (0x… 32-byte hex): ", /^0x[0-9a-fA-F]{64}$/, "expected 0x followed by 64 hex chars");
273
+ const did = await promptUntilMatches(" DID (did:t3n:…): ", /^did:t3n:[0-9a-fA-F]+$/, "expected did:t3n:<hex>");
274
+
275
+ upsertEnvLines(ENV_PATH, { T3N_API_KEY: apiKey, DID: did });
276
+ ok("Wrote .env (T3N_API_KEY + DID).");
277
+ // Reload so the rest of the wizard sees the new values.
278
+ loadEnvFromFile(ENV_PATH);
279
+ process.env.T3N_API_KEY = apiKey;
280
+ process.env.DID = did;
281
+ }
282
+
283
+ async function promptUntilMatches(prompt: string, re: RegExp, hint: string): Promise<string> {
284
+ for (let i = 0; i < 5; i++) {
285
+ const v = await ask(prompt);
286
+ if (re.test(v)) return v;
287
+ warn(`That doesn't look right — ${hint}. Try again.`);
288
+ }
289
+ fail("Too many invalid attempts.", "Run the wizard again when you have the right values.");
290
+ process.exit(1);
291
+ }
292
+
293
+ function upsertEnvLines(envPath: string, kv: Record<string, string>): void {
294
+ let body = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
295
+ for (const [k, v] of Object.entries(kv)) {
296
+ const re = new RegExp(`^${k}=.*$`, "m");
297
+ if (re.test(body)) body = body.replace(re, `${k}=${v}`);
298
+ else body += (body.endsWith("\n") || body === "" ? "" : "\n") + `${k}=${v}\n`;
299
+ }
300
+ writeFileSync(envPath, body);
301
+ }
302
+
303
+ function collectSeedPlan(opts: InitOpts): Array<{ name: string; fromEnv: string }> {
304
+ const seeds = opts.seed ?? [];
305
+ return seeds
306
+ .map((s) => {
307
+ const [name, fromEnv] = s.split(":");
308
+ if (!name || !fromEnv) {
309
+ warn(`Ignoring --seed "${s}" (expected KV_KEY:ENV_VAR, e.g. openai_api_key:OPENAI_API_KEY)`);
310
+ return null;
311
+ }
312
+ return { name, fromEnv };
313
+ })
314
+ .filter((x): x is { name: string; fromEnv: string } => x !== null);
315
+ }
316
+
317
+ /** Fresh-tenant scaffolding: claim, create secrets + authorised-hosts maps. Idempotent. */
318
+ async function ensureTenantScaffolding(env: ReturnType<typeof loadBlindfoldEnv>): Promise<void> {
319
+ // Use raw SDK access since openT3Client doesn't expose maps/tenant.
320
+ const sdk = (await import("@terminal3/t3n-sdk")) as unknown as T3Sdk;
321
+ sdk.setEnvironment(env.t3Env);
322
+ const baseUrl = sdk.NODE_URLS[env.t3Env];
323
+ const addr = sdk.eth_get_address(env.t3nApiKey);
324
+ const t3n = new sdk.T3nClient({ baseUrl, wasmComponent: await sdk.loadWasmComponent(), handlers: { EthSign: sdk.metamask_sign(addr, undefined, env.t3nApiKey) } });
325
+ await t3n.handshake();
326
+ await t3n.authenticate(sdk.createEthAuthInput(addr));
327
+ const tenant = new sdk.TenantClient({ environment: env.t3Env, baseUrl, tenantDid: env.did, t3n });
328
+
329
+ for (const tail of ["secrets", "authorised-hosts"]) {
330
+ try {
331
+ await tenant.maps.create({ tail, visibility: "private", writers: "all" });
332
+ info(`Created tenant map "${tail}"`);
333
+ } catch {
334
+ // Most often "map already exists" — fine.
335
+ }
336
+ }
337
+ }
338
+
339
+ async function grantContractReads(env: ReturnType<typeof loadBlindfoldEnv>, contractId: number): Promise<void> {
340
+ const sdk = (await import("@terminal3/t3n-sdk")) as unknown as T3Sdk;
341
+ sdk.setEnvironment(env.t3Env);
342
+ const baseUrl = sdk.NODE_URLS[env.t3Env];
343
+ const addr = sdk.eth_get_address(env.t3nApiKey);
344
+ const t3n = new sdk.T3nClient({ baseUrl, wasmComponent: await sdk.loadWasmComponent(), handlers: { EthSign: sdk.metamask_sign(addr, undefined, env.t3nApiKey) } });
345
+ await t3n.handshake();
346
+ await t3n.authenticate(sdk.createEthAuthInput(addr));
347
+ const tenant = new sdk.TenantClient({ environment: env.t3Env, baseUrl, tenantDid: env.did, t3n });
348
+ await tenant.maps.update("secrets", { readers: { only: [contractId] } });
349
+ try {
350
+ await tenant.maps.update("authorised-hosts", { readers: { only: [contractId] } });
351
+ } catch {
352
+ /* optional */
353
+ }
354
+ }
355
+
356
+ async function isSdkInstalled(): Promise<boolean> {
357
+ try {
358
+ await import("@terminal3/t3n-sdk");
359
+ return true;
360
+ } catch {
361
+ return false;
362
+ }
363
+ }
364
+
365
+ export async function runVerify(): Promise<void> {
366
+ process.stdout.write(`\n${bold("🛡️ Blindfold — verify")}\n`);
367
+ const env = loadBlindfoldEnv();
368
+ info(`mode: ${env.mock ? red("MOCK") : green("REAL")} · T3 env: ${env.t3Env}`);
369
+ if (env.mock) {
370
+ warn("MOCK mode — there's nothing to verify on the T3 side. Set T3N_API_KEY + DID.");
371
+ return;
372
+ }
373
+ process.stdout.write(` ${dim("·")} attempting handshake + authenticate against T3 …\n`);
374
+ try {
375
+ const t3 = await openT3Client(env);
376
+ if (t3.isReal) ok("REAL T3 round-trip succeeded.");
377
+ await t3.close();
378
+ } catch (e) {
379
+ fail("REAL T3 connection failed.", `Error: ${(e as Error).message}`);
380
+ process.exitCode = 1;
381
+ }
382
+
383
+ try {
384
+ const p = path.join(REPO_ROOT, ".blindfold", "verify.jsonl");
385
+ mkdirSync(path.dirname(p), { recursive: true });
386
+ const line = JSON.stringify({ t: new Date().toISOString(), mode: env.mock ? "mock" : "real", t3Env: env.t3Env, ok: !process.exitCode });
387
+ appendFileSync(p, line + "\n");
388
+ } catch {
389
+ /* non-fatal */
390
+ }
391
+ }