/** * Operator session layer: an authenticated control-plane (`/api/*`) session * from the terminal, without a browser. * * It automates the pairing-code rescue rung (src/api/better-auth.ts, * `handlePairingRequest`): mint a short-lived single-use code straight into D1 * with `wrangler d1 execute`, spend it at POST /api/auth/pairing, keep the * Better Auth session cookie that comes back, and persist it owner-only under * `~/.config/reccado/sessions/.json`. Whoever can write this deployment's * D1 is already all-powerful over it, so this grants nothing new; it only * replaces hand-typed SQL + curl. * * Public API (for `pnpm operator` and later scripts — onboard, smoke tests): * * const { session } = await login({ env: "dev", host, email }); * const session = loadSession(host); // undefined when signed out * await operatorJson(session, "/api/domains"); // throws OperatorAuthError on 401 * await logout(host); * * Nothing here prints the pairing code or the cookie. Pure helpers live in * ./operator-session-core.ts and are re-exported. */ import { execFileSync } from "node:child_process"; import { randomBytes } from "node:crypto"; import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { dirname } from "node:path"; import { DEFAULT_LABEL, DEFAULT_TTL_MINUTES, type FetchLike, type OperatorSession, OperatorInputError, PAIRING_CODE_BYTES, SESSION_DIR_MODE, SESSION_FILE_MODE, type SessionInfo, assertTtlMinutes, buildExpireSql, buildMintSql, insecureModeReason, isLocalHost, loginCommandHint, normalizeEmail, normalizeHost, operatorFetch, originFor, parseD1Changes, parseGetSession, parsePairingResult, parseSessionCookie, parseSessionFile, redact, sanitizeLabel, sessionFilePath, setCookieValues, } from "./operator-session-core"; export * from "./operator-session-core"; type WranglerBlock = { d1_databases?: Array<{ binding: string; database_name: string }>; routes?: Array; }; type WranglerConfig = WranglerBlock & { env?: Record }; function stripJsonc(input: string): string { return input.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, ""); } /** * The wrangler config the other operator scripts use: the gitignored * `wrangler.generated..json` written by setup:cloud when present, else * `wrangler.jsonc` (same rule as scripts/setup-mailbox.ts). */ export function resolveWranglerConfig(env: string | undefined, cwd = process.cwd()) { const generated = `wrangler.generated.${env ?? "production"}.json`; const path = existsSync(`${cwd}/${generated}`) ? generated : "wrangler.jsonc"; const config = JSON.parse(stripJsonc(readFileSync(`${cwd}/${path}`, "utf8"))) as WranglerConfig; const block = env ? config.env?.[env] : config; if (env && !block) { throw new OperatorInputError(`No env "${env}" in ${path}.`); } return { path, block: block ?? {} }; } /** How to reach INDEX_DB with `wrangler d1 execute` for this target. */ export type D1Target = { args: string[]; label: string }; export function resolveD1Target(opts: { env?: string; local: boolean; cwd?: string }): D1Target { if (opts.local) { return { args: ["d1", "execute", "INDEX_DB", "--local"], label: "local D1 (INDEX_DB)" }; } const { path, block } = resolveWranglerConfig(opts.env, opts.cwd); const name = block.d1_databases?.find((d) => d.binding === "INDEX_DB")?.database_name; if (!name) { throw new OperatorInputError( `Could not resolve the INDEX_DB database name for env "${opts.env ?? "production"}" from ${path}.`, ); } return { args: [ "d1", "execute", name, "--remote", ...(path === "wrangler.jsonc" ? [] : ["--config", path]), ...(opts.env ? ["--env", opts.env] : []), ], label: `remote D1 "${name}"${opts.env ? ` (env ${opts.env})` : ""}`, }; } /** Host: explicit flag > $RECCADO_HOST > the env block's single custom-domain route. */ export function resolveHost(flag: string | undefined, env: string | undefined): string { const explicit = flag?.trim() || process.env.RECCADO_HOST?.trim(); if (explicit) return normalizeHost(explicit); try { const { block } = resolveWranglerConfig(env); const custom = (block.routes ?? []).flatMap((r) => typeof r === "object" && r.custom_domain ? [r.pattern] : [], ); if (custom.length === 1 && custom[0]) return normalizeHost(custom[0]); } catch { // Fall through to the error below. } throw new OperatorInputError("No host. Pass --host or set RECCADO_HOST."); } /** Reads one key out of a dotenv-style file (same convention as setup-mailbox). */ function readDotEnvValue(path: string, key: string): string | undefined { if (!existsSync(path)) return undefined; for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const sep = trimmed.indexOf("="); if (sep !== -1 && trimmed.slice(0, sep).trim() === key) return trimmed.slice(sep + 1).trim(); } return undefined; } /** Email: explicit flag > first entry of OWNER_BOOTSTRAP_EMAILS (process env, then .dev.vars). */ export function resolveOwnerEmail(flag: string | undefined): string { if (flag?.trim()) return normalizeEmail(flag); const list = process.env.OWNER_BOOTSTRAP_EMAILS || readDotEnvValue(".dev.vars", "OWNER_BOOTSTRAP_EMAILS"); const first = list ?.split(",") .map((e) => e.trim()) .find(Boolean); if (first) return normalizeEmail(first); throw new OperatorInputError( "No owner email. Pass --email (OWNER_BOOTSTRAP_EMAILS is not set locally).", ); } export function sessionPathFor(host: string): string { return sessionFilePath(host, { home: homedir(), xdgConfigHome: process.env.XDG_CONFIG_HOME }); } /** * Runs `wrangler d1 execute ... --command= --json` with stdout AND stderr * captured, so nothing wrangler echoes reaches the terminal. Errors are * re-thrown with `secrets` redacted. * * TRADEOFF: the pairing code travels in this child's argv (`--command=`), so it * is visible to same-user `ps` for the second or two wrangler runs. `--file` * would avoid that, but for --remote it goes through D1's import path instead * of a plain query and does not report affected rows, which the owner guard * relies on. Accepted because the code is single-use, expires in minutes, is * spent (or force-expired) within the same command, and anyone who can read * this user's process table can already read wrangler's own OAuth token. */ function runD1(target: D1Target, sql: string, secrets: string[]): string { try { return execFileSync("pnpm", ["wrangler", ...target.args, `--command=${sql}`, "--json"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } catch (error) { const e = error as { stderr?: string; stdout?: string; message?: string }; const detail = redact( `${e.stderr ?? ""}\n${e.stdout ?? ""}`.trim() || String(e.message), secrets, ); throw new Error(`wrangler d1 execute failed on ${target.label}:\n${detail}`); } } /** Writes the session file owner-only: 0700 dir, 0600 file, atomic rename. */ export function writeSessionFile(path: string, session: OperatorSession): void { const dir = dirname(path); mkdirSync(dir, { recursive: true, mode: SESSION_DIR_MODE }); // mkdir's mode is ignored for a directory that already exists; enforce it. chmodSync(dir, SESSION_DIR_MODE); const tmp = `${path}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`; writeFileSync(tmp, `${JSON.stringify(session, null, 2)}\n`, { mode: SESSION_FILE_MODE, flag: "wx", }); chmodSync(tmp, SESSION_FILE_MODE); renameSync(tmp, path); } /** * The stored session for `host`, or undefined if there is none. Refuses (throws) * a symlinked or group/world-accessible file rather than using a cookie that may * have leaked. */ export function loadSession(host: string): OperatorSession | undefined { const normalized = normalizeHost(host); const path = sessionPathFor(normalized); let stat: ReturnType; try { stat = lstatSync(path); } catch { return undefined; } if (!stat.isFile()) { throw new OperatorInputError(`${path} is not a regular file; refusing to read it.`); } const reason = insecureModeReason(stat.mode, "file"); if (reason) { throw new OperatorInputError( `${path}: ${reason}. Delete it and run ${loginCommandHint(normalized)}.`, ); } return parseSessionFile(readFileSync(path, "utf8"), normalized); } /** Loads the session or throws the same "run login" error a 401 would. */ export function requireSession(host: string, env?: string): OperatorSession { const session = loadSession(host); if (!session) { throw new OperatorInputError( `Not signed in to ${normalizeHost(host)}. Run: ${loginCommandHint(normalizeHost(host), env)}`, ); } return session; } export type LoginOptions = { /** Wrangler env (`dev`) whose INDEX_DB receives the code. Omit for the top-level config. */ env?: string; host: string; email: string; ttlMinutes?: number; /** Free-form; sanitized to [A-Za-z0-9._:-] and recorded as issued_by `cli: