// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // module's tools and its provisioner import it, and nothing outside mongodb does. // // Commands run through `mongosh`, not a wire-protocol driver: the module may take NO npm dependency // beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface // than this should carry — so it shells out to the shell the mongodb image ships, the same way // postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary, // `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and // its result comes back as EJSON on stdout. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export interface DatabaseInfo { readonly name: string; readonly sizeBytes: number; } export interface MongoConn { readonly host: string; readonly port: number; readonly user: string; readonly password: string; /** The database the admin user authenticates against — `admin` for the root user. */ readonly authSource: string; } export class MongoClient { constructor(private readonly conn: MongoConn) {} /** * Build from the module's resolved environment. Reads MESH_MONGODB_* first (the documented names), * falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner container. * Throws if it cannot find a host and an admin password. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MongoClient { const url = env.MESH_PROVISION_MONGODB ? safeUrl(env.MESH_PROVISION_MONGODB) : undefined; const host = env.MESH_MONGODB_HOST ?? url?.hostname; const port = Number(env.MESH_MONGODB_PORT ?? url?.port ?? "27017") || 27017; const user = env.MESH_MONGODB_USER ?? (url?.username ? decodeURIComponent(url.username) : "root"); const authSource = env.MESH_MONGODB_AUTHSOURCE ?? url?.searchParams.get("authSource") ?? "admin"; const password = env.MESH_MONGODB_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); if (!host || !password) { throw new Error("mongodb host or admin password is not set — mongodb's own code cannot reach the server"); } return new MongoClient({ host, port, user, password, authSource }); } get host(): string { return this.conn.host; } get port(): number { return this.conn.port; } /** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ private uri(): string { const u = encodeURIComponent(this.conn.user); const p = encodeURIComponent(this.conn.password); const a = encodeURIComponent(this.conn.authSource); return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}`; } /** * Evaluate a JavaScript snippet server-side through `mongosh` and parse the JSON it prints (see * header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method * below ends in `print(EJSON.stringify(...))`. `--quiet` suppresses the shell banner so stdout is * the JSON alone; a non-zero exit (auth failure, bad command) rejects here rather than returning * a partial success. */ async evalJs(js: string): Promise { const { stdout } = await run( "mongosh", [this.uri(), "--quiet", "--eval", js], { maxBuffer: 16 << 20 }, ); const text = stdout.trim(); if (text.length === 0) { throw new Error("mongosh returned no output — the eval printed nothing"); } return JSON.parse(text) as T; } /** * Create a login user and the database it owns, idempotently. The user is created inside the * target database with the `dbOwner` role scoped to that database, so the consumer owns exactly * its own and authenticates with the target database as its authSource. Re-running updates the * password and roles, so a rotated credential converges. */ async createDatabaseAndUser(database: string, user: string, password: string): Promise { const js = ` const target = db.getSiblingDB(${lit(database)}); let existing = null; try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; } const roles = [{ role: "dbOwner", db: ${lit(database)} }]; if (existing) { target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles }); } else { target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles }); } print(EJSON.stringify({ ok: 1 })); `; await this.evalJs<{ ok: number }>(js); } /** Drop a database and its owning user, idempotently. Dropping the database evicts its data; the * user is removed first so a re-grant of the same login starts clean. */ async dropDatabaseAndUser(database: string, user: string): Promise { const js = ` const target = db.getSiblingDB(${lit(database)}); try { target.dropUser(${lit(user)}); } catch (e) {} target.dropDatabase(); print(EJSON.stringify({ ok: 1 })); `; await this.evalJs<{ ok: number }>(js); } /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ async listDatabases(): Promise { const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( `print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`, ); return (res.databases ?? []) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .sort((a, b) => a.name.localeCompare(b.name)); } /** * Run a read-only `find` against a collection in a named database, for the mongodb_query tool. * `find` mutates nothing; the limit is capped so a tool call cannot stream an unbounded result. */ async find( database: string, collection: string, filter: Readonly>, limit: number, ): Promise[]> { const capped = Math.max(1, Math.min(limit, 1000)); const js = `print(EJSON.stringify(` + `db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + `.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + `));`; return this.evalJs[]>(js); } } /** Generate a URL-safe password. */ export function generatePassword(): string { return randomBytes(24).toString("base64url"); } /** Embed a value as a JavaScript literal inside a mongosh snippet — JSON.stringify escapes quotes, * backslashes and control characters, so a string cannot break out of the snippet. */ function lit(val: unknown): string { return JSON.stringify(val); } function readSecretFile(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim(); } catch { return undefined; } } function safeUrl(raw: string): URL | undefined { try { return new URL(raw); } catch { return undefined; } }