Files
mesh-catalog/modules/mongodb/client.ts
T
jochen da8a46cfe8 mongodb: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)
The mesh-mongodb container goes with its Dockerfile, build bases and bus credential. Its client shelled out to mongosh, which no machine's system carries, so it now speaks to the server through the official mongodb driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4); the tools answer exactly as before (relaxed Extended JSON). The server is reached on loopback at the port the machine published (${port:27017}). The root secret was owned by the mongo image's user (secrets-owner 999:999), which the runtime's account cannot read; the module's own copy is now the runtime's, and the server is given its own 999-owned copy rendered from the same secret.
2026-10-04 01:17:41 +02:00

200 lines
8.2 KiB
TypeScript

// 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.
//
// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
// now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
// server through the official `mongodb` driver its package.json names — installed and inlined into
// the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
// called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
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, 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}&directConnection=true`;
}
/**
* The one execution boundary: connect as the administrator, do `work`, and close — a failure to
* connect or to authenticate rejects here rather than returning a partial success.
*/
private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
try {
await client.connect();
return await work(client);
} finally {
await client.close();
}
}
/**
* 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<void> {
await this.admin(async (client) => {
const target = client.db(database);
const roles = [{ role: "dbOwner", db: database }];
const found = await target.command({ usersInfo: user });
if (Array.isArray(found.users) && found.users.length > 0) {
await target.command({ updateUser: user, pwd: password, roles });
} else {
await target.command({ createUser: user, pwd: password, roles });
}
});
}
/**
* Whether `user` authenticates against `database` with exactly `password` and holds `dbOwner`
* there: checked by connecting as the consumer, the way it connects. Read-only. `false` only on an
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Credentials as options, never in a URI, so the consumer's password is in no message a failed
// connection prints.
const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
auth: { username: user, password },
authSource: database,
serverSelectionTimeoutMS: 10_000,
});
try {
await client.connect();
const status = await client.db(database).command({ connectionStatus: 1 });
const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database);
} catch (err) {
if (isAuthFailure(err)) return false;
throw new Error(`mongodb could not check ${user}: ${String((err as Error).message).split("\n")[0]}`);
} finally {
await client.close();
}
}
/** 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<void> {
await this.admin(async (client) => {
const target = client.db(database);
try {
await target.command({ dropUser: user });
} catch (err) {
if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
}
await target.dropDatabase();
});
}
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.admin((client) => client.db("admin").admin().listDatabases());
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.
* Documents come back as relaxed Extended JSON — an ObjectId as `{"$oid": …}` — exactly as the
* shell's `EJSON.stringify` rendered them before.
*/
async find(
database: string,
collection: string,
filter: Readonly<Record<string, unknown>>,
limit: number,
): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000));
const docs = await this.admin((client) =>
client
.db(database)
.collection(collection)
.find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
.limit(capped)
.toArray(),
);
return BSON.EJSON.serialize(docs, { relaxed: true }) as Record<string, unknown>[];
}
}
/** An authentication failure, as the server or the driver reports it. */
function isAuthFailure(err: unknown): boolean {
if (err instanceof MongoServerError && err.code === 18) return true; // 18: AuthenticationFailed
return /Authentication failed|AuthenticationFailed/i.test(String((err as Error)?.message ?? ""));
}
/** Generate a URL-safe password. */
export function generatePassword(): string {
return randomBytes(24).toString("base64url");
}
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;
}
}