/** * The pure half of the operator session layer (`pnpm operator ...`). * * Everything here is free of `node:*` imports and subprocesses so it can be * unit-tested inside the Workers Vitest pool: host normalization, the SQL that * mints and expires a pairing code, Set-Cookie parsing, session-file shape and * permission checks, and `operatorFetch` itself (with an injectable fetch). * * The side-effecting half — wrangler, the filesystem, the login/logout flow — * lives in ./operator-session.ts, which re-exports this module. Later scripts * should import from there. */ /** A control-plane session as persisted on disk. The cookie is a live credential. */ export type OperatorSession = { /** Normalized host, e.g. `inbox.example.com` or `localhost:3000`. */ host: string; /** A single `name=value` pair — the Better Auth session-token cookie, nothing else. */ cookie: string; /** ISO-8601 time the session was minted by `login`. */ createdAt: string; /** Sanitized label recorded as the pairing code's `issued_by`. */ label: string; /** Owner email the session belongs to (as confirmed by get-session). */ email?: string; /** Session expiry as reported by the server at login time, when known. */ expiresAt?: string; }; /** Directory mode for `~/.config/reccado/sessions`: owner-only. */ export const SESSION_DIR_MODE = 0o700; /** File mode for a session file: owner read/write only. */ export const SESSION_FILE_MODE = 0o600; /** Bounds for the pairing code's lifetime. It only has to survive one HTTP round trip. */ export const MIN_TTL_MINUTES = 1; export const MAX_TTL_MINUTES = 60; export const DEFAULT_TTL_MINUTES = 10; /** Minimum pairing-code entropy, in bytes (128 bits). */ export const PAIRING_CODE_BYTES = 16; export const DEFAULT_LABEL = "operator-cli"; /** Better Auth's session cookie, with or without the `__Secure-` / `__Host-` prefix. */ const SESSION_COOKIE_NAME = /^(?:__Secure-|__Host-)?better-auth\.session_token$/; /** The command an operator runs to get a session; used in every "not signed in" message. */ export function loginCommandHint(host: string, env?: string): string { return `pnpm operator login${env ? ` --env ${env}` : ""} --host ${host}`; } /** * Thrown by `operatorFetch` when the control plane answers 401: the stored * session is missing, expired or revoked. The message tells the operator what * to run; callers can `instanceof`-check it to exit cleanly instead of dumping * a stack trace. */ export class OperatorAuthError extends Error { readonly status = 401; constructor( readonly host: string, readonly path: string, env?: string, ) { super(`Not signed in to ${host} (401 on ${path}). Run: ${loginCommandHint(host, env)}`); this.name = "OperatorAuthError"; } } /** Thrown by `operatorJson` for any non-2xx response other than 401. */ export class OperatorHttpError extends Error { constructor( readonly status: number, readonly path: string, readonly body: string, ) { super(`HTTP ${status} on ${path}: ${body.slice(0, 500)}`); this.name = "OperatorHttpError"; } } /** Thrown for bad operator input (host, email, ttl, label) before anything is minted. */ export class OperatorInputError extends Error { constructor(message: string) { super(message); this.name = "OperatorInputError"; } } /** * Accepts `inbox.example.com`, `https://inbox.example.com/`, `localhost:3000` * and returns the bare lowercased host[:port]. Anything with a path, userinfo * or odd characters is rejected: the host becomes a filename and an Origin. */ export function normalizeHost(input: string): string { let host = input.trim().toLowerCase(); host = host.replace(/^https?:\/\//, "").replace(/\/+$/, ""); if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\d{1,5})?$/.test(host) || host.includes("..")) { throw new OperatorInputError( `Invalid host "${input}". Pass a bare hostname like inbox.example.com.`, ); } return host; } /** localhost / 127.0.0.1 targets use http and the local D1. */ export function isLocalHost(host: string): boolean { const name = host.replace(/:\d+$/, ""); return name === "localhost" || name === "127.0.0.1"; } /** The scheme+host the worker sees as its own origin (and the Origin header we send). */ export function originFor(host: string): string { return `${isLocalHost(host) ? "http" : "https"}://${host}`; } /** Strict enough to be safe inside a SQL literal even before escaping. */ const EMAIL_RE = /^[a-z0-9._%+-]+@[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?\.[a-z]{2,}$/; export function normalizeEmail(input: string): string { const email = input.trim().toLowerCase(); if (!EMAIL_RE.test(email)) { throw new OperatorInputError(`Invalid owner email "${input}".`); } return email; } /** * Reduces a free-form label to `[A-Za-z0-9._:-]`, at most 48 chars, so it is * safe in SQL, logs and `issued_by`. Empty after sanitizing → the default. */ export function sanitizeLabel(label: string | undefined): string { const cleaned = (label ?? "") .replace(/[^A-Za-z0-9._:-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 48); return cleaned || DEFAULT_LABEL; } export function assertTtlMinutes(ttl: number): number { if (!Number.isInteger(ttl) || ttl < MIN_TTL_MINUTES || ttl > MAX_TTL_MINUTES) { throw new OperatorInputError( `--ttl must be an integer between ${MIN_TTL_MINUTES} and ${MAX_TTL_MINUTES} minutes.`, ); } return ttl; } /** Hex, at least 128 bits. The only shape of code this layer ever mints or expires. */ export function assertPairingCode(code: string): string { if (!/^[0-9a-f]+$/.test(code) || code.length < PAIRING_CODE_BYTES * 2) { throw new OperatorInputError("Pairing code must be lowercase hex of at least 128 bits."); } return code; } /** `issued_by` for codes this CLI mints, so they are distinguishable from 'cron' / 'manual'. */ export function issuedByFor(label: string): string { return `cli:${sanitizeLabel(label)}`; } /** SQL single-quoted literal. Every value is also pre-validated to a safe charset. */ function sqlLiteral(value: string): string { return `'${value.replace(/'/g, "''")}'`; } /** * The INSERT that mints a pairing code. * * By default the row is only inserted if the email is ALREADY a registered * owner (`owner_identities`): the pairing endpoint links any email it is handed * as a new owner, so a typo in `--email` would otherwise silently grant owner * access to a stranger's address. `allowNewOwner` drops that guard for the * genuine bootstrap/rescue case. The caller checks the affected-row count. */ export function buildMintSql(input: { code: string; email: string; label: string; ttlMinutes: number; allowNewOwner?: boolean; }): string { const code = sqlLiteral(assertPairingCode(input.code)); const email = sqlLiteral(normalizeEmail(input.email)); const issuedBy = sqlLiteral(issuedByFor(input.label)); const ttl = assertTtlMinutes(input.ttlMinutes); const guard = input.allowNewOwner ? "" : `\nWHERE EXISTS (SELECT 1 FROM owner_identities WHERE kind = 'email' AND identity = ${email})`; return ( "INSERT INTO owner_pairing_codes (code, created_at, expires_at, issued_by)\n" + `SELECT ${code}, strftime('%Y-%m-%dT%H:%M:%fZ','now'), strftime('%Y-%m-%dT%H:%M:%fZ','now','+${ttl} minutes'), ${issuedBy}` + `${guard};` ); } /** Expires a still-unspent code minted by this CLI (best-effort cleanup after a failure). */ export function buildExpireSql(code: string): string { return ( `UPDATE owner_pairing_codes SET expires_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')\n` + `WHERE code = ${sqlLiteral(assertPairingCode(code))} AND consumed_at IS NULL;` ); } /** * Splits a comma-joined Set-Cookie header (what `Headers.get` returns) into * individual cookies, without splitting inside `Expires=Wed, 21 Oct ...`. */ export function splitSetCookieHeader(combined: string): string[] { return combined .split(/,(?=\s*[A-Za-z0-9!#$%&'*+.^_`|~-]+=)/) .map((part) => part.trim()) .filter(Boolean); } /** All Set-Cookie values on a response, however the runtime exposes them. */ export function setCookieValues(headers: Headers): string[] { const getter = (headers as Headers & { getSetCookie?: () => string[] }).getSetCookie; if (typeof getter === "function") { const values = getter.call(headers); if (values.length > 0) return values; } const combined = headers.get("set-cookie"); return combined ? splitSetCookieHeader(combined) : []; } /** * Picks the Better Auth session-token cookie out of a list of Set-Cookie * values and returns it as a `name=value` pair ready for a Cookie header. * Ignores every other cookie (session_data cache, etc.) and attributes. Returns * undefined when absent or when the value is empty (a deletion). */ export function parseSessionCookie(setCookies: readonly string[]): string | undefined { for (const header of setCookies) { const pair = header.split(";", 1)[0]?.trim() ?? ""; const eq = pair.indexOf("="); if (eq <= 0) continue; const name = pair.slice(0, eq).trim(); const value = pair.slice(eq + 1).trim(); if (SESSION_COOKIE_NAME.test(name) && value && value !== '""') { return `${name}=${value}`; } } return undefined; } /** Filename-safe form of a host (`localhost:3000` → `localhost_3000`). */ export function sessionFileName(host: string): string { return `${normalizeHost(host).replace(/:/g, "_")}.json`; } /** `/reccado/sessions`, where configHome is $XDG_CONFIG_HOME or ~/.config. */ export function sessionDirPath(opts: { home: string; xdgConfigHome?: string }): string { const base = opts.xdgConfigHome?.trim() || `${opts.home.replace(/\/+$/, "")}/.config`; return `${base.replace(/\/+$/, "")}/reccado/sessions`; } export function sessionFilePath( host: string, opts: { home: string; xdgConfigHome?: string }, ): string { return `${sessionDirPath(opts)}/${sessionFileName(host)}`; } /** * Why a session file/dir with this mode must not be trusted, or undefined if it * is fine. Any group/other bit on a file holding a live cookie is a leak. */ export function insecureModeReason(mode: number, kind: "file" | "dir"): string | undefined { const perms = mode & 0o777; if ((perms & 0o077) !== 0) { const want = kind === "file" ? SESSION_FILE_MODE : SESSION_DIR_MODE; return `session ${kind} has mode ${perms.toString(8).padStart(3, "0")}; expected ${want.toString(8)} (owner-only)`; } return undefined; } /** Validates a parsed session file. Throws on anything that is not ours or not for this host. */ export function parseSessionFile(raw: string, expectedHost: string): OperatorSession { let data: unknown; try { data = JSON.parse(raw); } catch { throw new OperatorInputError("Session file is not valid JSON."); } const obj = data as Partial | null; if ( !obj || typeof obj.host !== "string" || typeof obj.cookie !== "string" || typeof obj.createdAt !== "string" || typeof obj.label !== "string" ) { throw new OperatorInputError("Session file is missing host/cookie/createdAt/label."); } const host = normalizeHost(expectedHost); if (normalizeHost(obj.host) !== host) { throw new OperatorInputError(`Session file is for ${obj.host}, not ${host}.`); } if (!parseSessionCookie([obj.cookie])) { throw new OperatorInputError("Session file does not hold a Better Auth session cookie."); } return { host, cookie: obj.cookie, createdAt: obj.createdAt, label: obj.label, ...(typeof obj.email === "string" ? { email: obj.email } : {}), ...(typeof obj.expiresAt === "string" ? { expiresAt: obj.expiresAt } : {}), }; } export type FetchLike = (input: string, init?: RequestInit) => Promise; /** * Builds the request headers for a control-plane call: the session cookie, an * Origin matching the deployment (mutating /api routes enforce it), and a JSON * content-type when a body is present and the caller did not set one. */ export function operatorHeaders( session: Pick, init: RequestInit = {}, ): Headers { const headers = new Headers(init.headers); headers.set("cookie", session.cookie); headers.set("origin", originFor(session.host)); if (init.body != null && !headers.has("content-type")) { headers.set("content-type", "application/json"); } if (!headers.has("accept")) headers.set("accept", "application/json"); return headers; } /** * Calls the deployment's control plane as the operator. * * `path` must be an absolute path on the session's own host (`/api/domains`); * full URLs are refused so the cookie can never be sent anywhere else. Returns * the Response for any status except 401, which throws OperatorAuthError. */ export async function operatorFetch( session: Pick, path: string, init: RequestInit = {}, opts: { fetchImpl?: FetchLike; env?: string } = {}, ): Promise { if (!path.startsWith("/") || path.startsWith("//")) { throw new OperatorInputError(`operatorFetch path must start with "/": got "${path}".`); } const fetchImpl = opts.fetchImpl ?? ((input, reqInit) => fetch(input, reqInit)); const response = await fetchImpl(`${originFor(session.host)}${path}`, { ...init, headers: operatorHeaders(session, init), redirect: "manual", }); if (response.status === 401) { throw new OperatorAuthError(session.host, path, opts.env); } return response; } /** * `operatorFetch` + JSON: serializes a non-string `body`, throws * OperatorHttpError on non-2xx, and returns the parsed JSON (or null for 204). */ export async function operatorJson( session: Pick, path: string, init: Omit & { body?: unknown } = {}, opts: { fetchImpl?: FetchLike; env?: string } = {}, ): Promise { const { body, ...rest } = init; const requestInit: RequestInit = { ...rest }; if (body !== undefined) { requestInit.body = typeof body === "string" ? body : JSON.stringify(body); } const response = await operatorFetch(session, path, requestInit, opts); const text = await response.text(); if (!response.ok) { throw new OperatorHttpError(response.status, path, text); } return (text ? JSON.parse(text) : null) as T; } /** Replaces every occurrence of each secret with `[redacted]` (for wrangler stderr, errors). */ export function redact(text: string, secrets: readonly (string | undefined)[]): string { let out = text; for (const secret of secrets) { if (secret && secret.length >= 8) out = out.split(secret).join("[redacted]"); } return out; } /** * Reads the affected-row count from `wrangler d1 execute --json` output * (`[{ results, success, meta: { changes } }]`). Tolerates leading non-JSON * noise on stdout. Returns undefined if no count is present. */ export function parseD1Changes(stdout: string): number | undefined { const start = stdout.indexOf("["); if (start === -1) return undefined; let parsed: unknown; try { parsed = JSON.parse(stdout.slice(start)); } catch { return undefined; } if (!Array.isArray(parsed)) return undefined; let total: number | undefined; for (const entry of parsed) { const changes = (entry as { meta?: { changes?: unknown } })?.meta?.changes; if (typeof changes === "number") total = (total ?? 0) + changes; } return total; } /** The response shape of POST /api/auth/pairing (mirrors src/api/better-auth.ts). */ export type PairingResult = | { ok: true; claim: "linked" | "already_owner" } | { ok: false; claim: string }; export function parsePairingResult(body: string): PairingResult | undefined { try { const parsed = JSON.parse(body) as { ok?: unknown; claim?: unknown }; if (typeof parsed.ok !== "boolean" || typeof parsed.claim !== "string") return undefined; return parsed as PairingResult; } catch { return undefined; } } /** What `whoami` reports, from GET /api/auth/get-session (Better Auth returns null when signed out). */ export type SessionInfo = { email: string; expiresAt?: string } | null; export function parseGetSession(body: string): SessionInfo { try { const parsed = JSON.parse(body) as { user?: { email?: unknown }; session?: { expiresAt?: unknown }; } | null; const email = parsed?.user?.email; if (typeof email !== "string") return null; const expiresAt = parsed?.session?.expiresAt; return typeof expiresAt === "string" ? { email, expiresAt } : { email }; } catch { return null; } }