From ed50130a6a1acbd78c1627055561d1de6b585ad0 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 01:17:41 +0200 Subject: [PATCH 1/3] mesh-catalog: its consumer and tools run in the node's runtime, and its preparation is a run-once process (hq ADR 0198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh-catalog container goes with its Dockerfile, build bases, bus credential and mesh-state directory. Its words are the database URL file where the mesh writes it. `pg` is a dependency in its package.json, which the builder now installs and inlines into the bundle (mesh-controller: a TypeScript bundle installs its module's own packages). `prepares: true` needs a container running the module's own artifact, so it becomes what ADR 0198 §3 says it is: prepare/index.js run by node as a run-once process, with the same words and no bus, before the runtime is started with the version that needs it, and again when the database URL changes. --- modules/mesh-catalog/Dockerfile | 55 ------------------------ modules/mesh-catalog/module.json | 60 +++++++++++---------------- modules/mesh-catalog/pg.d.ts | 6 +-- modules/mesh-catalog/prepare/index.ts | 5 ++- 4 files changed, 30 insertions(+), 96 deletions(-) delete mode 100644 modules/mesh-catalog/Dockerfile diff --git a/modules/mesh-catalog/Dockerfile b/modules/mesh-catalog/Dockerfile deleted file mode 100644 index ed67a61..0000000 --- a/modules/mesh-catalog/Dockerfile +++ /dev/null @@ -1,55 +0,0 @@ -# mesh-catalog's runtime: the tool runtime, carrying the catalogue's compiled graph, its consumer -# of what the builder announces, and its tools. -# -# **Built from this module's own directory and nothing else.** The sdk is in the base image, so -# nothing is copied out of a neighbouring checkout — which is what lets the mesh build this from a -# repository and a path (novox/hq ADR 0069) rather than only on a workstation with the siblings. -# -# Two bases, named rather than pinned: the image this is COMPILED in, and the image it RUNS in. -# They are different images on purpose — the first carries a compiler and the second must not, or -# every running container would carry one it never invokes. The mesh answers both with the copies it -# holds, because a fingerprint written here would name one particular copy and no other mesh has it -# (novox/hq issue 044). Declared in module.json's `build.on`; deliberately no defaults, so a build -# nobody told stops here and says which module to build first. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -# node_modules — the module is compiled against exactly the sdk it will run against. -WORKDIR /app/modules/mesh-catalog -COPY . . -# The compiler is invoked by its real path rather than through node_modules/.bin, whose entries are -# symlinks to a launcher that requires its library relatively — resolved away when the base image -# was assembled. -RUN node /app/node_modules/typescript/bin/tsc pg.d.ts store.ts index.ts tools/index.ts prepare/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -# **A module may need something the base image does not carry.** The base holds what every module -# needs — the sdk, the broker client — and a postgres driver is not that: the one other module that -# reaches a database shells out to psql instead. So the catalogue brings its own. -# -# Installed into an empty directory rather than into the module's, because the module's package.json -# also names `@novox/mesh-sdk`, which is not on any registry — it is in the base image. Asking npm to -# resolve this module's dependencies would therefore fail on the one it already has. -RUN mkdir -p /deps && cd /deps && \ - npm install --omit=dev --no-audit --no-fund --no-package-lock pg@8 - -FROM ${RUNTIME_BASE} -COPY --from=build /app/modules/mesh-catalog/dist /app/modules/mesh-catalog/dist -# Beside the compiled code, so `pg` resolves from it while `@novox/mesh-sdk` keeps walking up to the -# base image's own node_modules — the module gets its extra dependency without shadowing the sdk it -# was compiled against. -COPY --from=build /deps/node_modules /app/modules/mesh-catalog/node_modules -# Both entrypoints, loaded in serve mode. -# -# **A consumer cannot be started with `run`.** That mode imports an entrypoint without binding a -# broker — it is for a step that does its work offline and exits — and the catalogue's whole job is -# to listen for what the builder announces. Serve binds the broker first, then imports these, so -# `on()` has something to subscribe to. -ENV MESH_TOOL_MODULES=/app/modules/mesh-catalog/dist/index.js,/app/modules/mesh-catalog/dist/tools/index.js - -# And what prepares this module's state, for the runtime's `prepare` mode (novox/hq ADR 0135). Named -# here, beside the entrypoints above, because the module knows which of its files prepares its state -# and nothing else could: the mesh asks one word and this says what answers it. -ENV MESH_PREPARE=/app/modules/mesh-catalog/dist/prepare/index.js diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index 371f10b..b5f6995 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -25,9 +25,6 @@ "secrets": { "postgres-database": "${dir:state}/database.secret" }, - "own-secrets": { - "broker": "${dir:mesh-state}/broker" - }, "consumes": [ "mesh-build-machine.built", "mesh-controller.built-before" @@ -38,14 +35,7 @@ "rebuild-needed", "catching-up" ], - "prepares": true, "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -60,43 +50,41 @@ "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n" }, { - "id": "runtime", - "type": "container", - "name": "mesh-catalog", - "network": "host", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:state}:/run/state", - "${dir:state}/database.url:/run/secrets/database-url:ro" + "id": "prepare", + "type": "process", + "name": "mesh-catalog-prepare", + "artifact": "code", + "run": [ + "node", + "prepare/index.js" ], + "run-once": true, "env": { - "MESH_BROKER_FILE": "/run/secrets/broker", - "DATABASE_URL_FILE": "/run/secrets/database-url" + "DATABASE_URL_FILE": "${dir:state}/database.url" }, - "artifact": "runtime", "restart-on": [ "database-url" ] } ], "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], "artifacts": [ { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "prepare/index.js" + ], + "loads": [ + "index.js", + "tools/index.js" + ], + "env": { + "DATABASE_URL_FILE": "${dir:state}/database.url" + } } ] } diff --git a/modules/mesh-catalog/pg.d.ts b/modules/mesh-catalog/pg.d.ts index 023b330..4e50786 100644 --- a/modules/mesh-catalog/pg.d.ts +++ b/modules/mesh-catalog/pg.d.ts @@ -1,9 +1,9 @@ // Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` // package. Rather than pull that in at tsc time, this declares the exact slice model-usage uses — // the same precedent anthropic-manager sets for `tweetnacl-sealedbox-js` (a local ambient .d.ts, -// listed in tsconfig `include`, default-imported). The real `pg` is installed into the module's -// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without -// deciding what runs. +// listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the +// builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the +// code without deciding what runs. declare module "pg" { /** One checked-out connection. Needed because registering a module-version and its edges is one * act: a half-written registration is a graph that lies about what something was built against. */ diff --git a/modules/mesh-catalog/prepare/index.ts b/modules/mesh-catalog/prepare/index.ts index ed05f93..4cd3739 100644 --- a/modules/mesh-catalog/prepare/index.ts +++ b/modules/mesh-catalog/prepare/index.ts @@ -7,8 +7,9 @@ // nothing anywhere said so. // // Nothing here connects to the broker. Preparation runs before the version that would use it, so -// there is nothing yet to talk to; the runtime's `prepare` mode imports this and awaits it, and this -// process exiting non-zero is how the host knows not to start the runtime. +// there is nothing yet to talk to: the host runs this file as a run-once process, with the module's +// words and no bus (novox/hq ADR 0198 §3), before the node's runtime is started with the version +// that needs it, and this process exiting non-zero is how the host knows the step did not complete. import { Graph } from "../store.js"; const graph = Graph.fromEnv(); -- 2.54.0 From da8a46cfe84cd007e5df46cc0c0406cf3299619d Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 01:17:41 +0200 Subject: [PATCH 2/3] mongodb: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- modules/mongodb/Dockerfile | 37 ------- modules/mongodb/client.ts | 149 +++++++++++++-------------- modules/mongodb/index.ts | 6 +- modules/mongodb/module.json | 71 +++++-------- modules/mongodb/package.json | 3 +- modules/mongodb/provisioner/index.ts | 3 +- modules/mongodb/tools/index.ts | 4 +- 7 files changed, 106 insertions(+), 167 deletions(-) delete mode 100644 modules/mongodb/Dockerfile diff --git a/modules/mongodb/Dockerfile b/modules/mongodb/Dockerfile deleted file mode 100644 index 05f3aef..0000000 --- a/modules/mongodb/Dockerfile +++ /dev/null @@ -1,37 +0,0 @@ -# mongodb's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -# node_modules — the module is compiled against exactly the sdk it will run against. The compiler -# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image -# resolved away. -WORKDIR /app/modules/mongodb -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -FROM ${RUNTIME_BASE} -# mongodb's client shells out to `mongosh`, installed from MongoDB's own apt repo so its shared -# libraries come with it — copying the bare binary out of the mongo image leaves it unable to load. -RUN apt-get update && apt-get install -y --no-install-recommends gnupg curl ca-certificates \ - && curl -fsSL https://pgp.mongodb.com/server-7.0.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg \ - && echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/debian bookworm/mongodb-org/7.0 main" > /etc/apt/sources.list.d/mongodb.list \ - && apt-get update && apt-get install -y --no-install-recommends mongodb-mongosh \ - && rm -rf /var/lib/apt/lists/* -COPY --from=build /app/modules/mongodb/dist /app/modules/mongodb/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. A container that instead ran only its -# provisioner (`run`) served no tools and emitted no events; a container that named no command -# ran no provisioner at all. -ENV MESH_TOOL_MODULES=/app/modules/mongodb/dist/index.js,/app/modules/mongodb/dist/tools/index.js,/app/modules/mongodb/dist/provisioner/index.js diff --git a/modules/mongodb/client.ts b/modules/mongodb/client.ts index ae8c666..946de94 100644 --- a/modules/mongodb/client.ts +++ b/modules/mongodb/client.ts @@ -1,19 +1,16 @@ // 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. +// **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 { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const run = promisify(execFile); +import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb"; export interface DatabaseInfo { readonly name: string; @@ -59,32 +56,26 @@ export class MongoClient { return this.conn.port; } - /** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ + /** 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}`; + return `mongodb://${u}:${p}@${this.conn.host}:${this.conn.port}/?authSource=${a}&directConnection=true`; } /** - * 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. + * 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. */ - 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"); + private async admin(work: (client: Driver) => Promise): Promise { + const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 }); + try { + await client.connect(); + return await work(client); + } finally { + await client.close(); } - return JSON.parse(text) as T; } /** @@ -94,19 +85,16 @@ export class MongoClient { * 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); + 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 }); + } + }); } /** @@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 })); * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). */ async canAuthenticateAs(database: string, user: string, password: string): Promise { - // Connected without credentials, then authenticated inside the eval from the environment, so - // the consumer's password is neither on argv nor in the message of a failed command. - const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; - const js = - "const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + - "t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + - "print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; - let stdout: string; + // 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 { - ({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { - env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, - timeout: 30_000, - })); + 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) { - const text = `${(err as { stderr?: string }).stderr ?? ""}${(err as { stdout?: string }).stdout ?? ""}`; - if (/Authentication failed|AuthenticationFailed/i.test(text)) return false; - throw new Error(`mongosh could not check ${user}: ${text.trim().slice(0, 500) || String((err as Error).message).split("\n")[0]}`); + 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(); } - const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[]; - return roles.some((r) => r.role === "dbOwner" && r.db === database); } /** 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); + 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 { - const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( - `print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`, - ); + 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)); @@ -162,6 +148,8 @@ print(EJSON.stringify({ ok: 1 })); /** * 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, @@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 })); 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); + 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[]; } } +/** 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"); } -/** 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 { diff --git a/modules/mongodb/index.ts b/modules/mongodb/index.ts index ebca8d5..24dd511 100644 --- a/modules/mongodb/index.ts +++ b/modules/mongodb/index.ts @@ -1,9 +1,9 @@ -// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs -// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where +// mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner +// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where // the lifecycle actually happens (novox/hq ADR 0041/0042): // module.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.database.deprovisioned — that database was removed -// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a // database and who lost one — observability the provider itself is best placed to log. import { on } from "@novox/mesh-sdk/events"; diff --git a/modules/mongodb/module.json b/modules/mongodb/module.json index 8aa8a40..1f70756 100644 --- a/modules/mongodb/module.json +++ b/modules/mongodb/module.json @@ -39,17 +39,9 @@ "mongodb-database": "${dir:grants}" }, "own-secrets": { - "root": "${dir:state}/root.secret", - "broker": "${dir:mesh-state}/broker" + "root": "${dir:state}/root.secret" }, - "secrets-owner": "999:999", "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -71,6 +63,14 @@ "type": "network", "name": "mongodb" }, + { + "id": "server-root", + "type": "file", + "path": "${dir:state}/server-root.secret", + "mode": "0400", + "owner": "999:999", + "content": "${secret:root}" + }, { "id": "server", "type": "container", @@ -86,46 +86,31 @@ ], "volumes": [ "${dir:data}:/data/db", - "${dir:state}/root.secret:/run/secrets/root:ro" + "${dir:state}/server-root.secret:/run/secrets/root:ro" ] - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-mongodb", - "network": "mongodb", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:grants}:${dir:grants}:ro", - "${dir:state}/root.secret:/run/secrets/root:ro" - ], - "env": { - "MESH_PROVISION_MONGODB": "mongodb://root@mongodb-server:27017/admin?authSource=admin", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/root", - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RECEIVES": "${dir:grants}/mesh.json" - }, - "artifact": "runtime" } ], "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], "artifacts": [ { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MONGODB": "mongodb://root@127.0.0.1:${port:27017}/admin?authSource=admin", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/root.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json" + } } ] } diff --git a/modules/mongodb/package.json b/modules/mongodb/package.json index 479e7ea..be35a99 100644 --- a/modules/mongodb/package.json +++ b/modules/mongodb/package.json @@ -5,7 +5,8 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-sdk": "^0.1.1", + "mongodb": "^6.21.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mongodb/provisioner/index.ts b/modules/mongodb/provisioner/index.ts index aaa279d..3b905a1 100644 --- a/modules/mongodb/provisioner/index.ts +++ b/modules/mongodb/provisioner/index.ts @@ -11,8 +11,7 @@ // same-named database under exactly that login — a name the consumer cannot learn is a database it // cannot reach. // -// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see -// client.ts). +// The commands run through MongoClient, the official driver inside this bundle (see client.ts). import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; diff --git a/modules/mongodb/tools/index.ts b/modules/mongodb/tools/index.ts index b45b804..e200037 100644 --- a/modules/mongodb/tools/index.ts +++ b/modules/mongodb/tools/index.ts @@ -1,6 +1,6 @@ // mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They -// return structured data; the mesh serves them through the sdk's tool harness. Both call through -// MongoClient.evalJs(), the module's one execution boundary (see client.ts). +// return structured data; the mesh serves them through the sdk's tool harness. Both call the server +// through MongoClient, the driver inside this bundle (see client.ts). import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { MongoClient } from "../client.js"; -- 2.54.0 From cf57d3fd8f0d6ba438baf2185336571e1ba367e1 Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 4 Oct 2026 01:17:41 +0200 Subject: [PATCH 3/3] mssql: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mesh-mssql container goes with its Dockerfile (and the sqlcmd it fetched), build bases and bus credential. The client speaks TDS through the mssql driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4): one session per call as one sqlcmd invocation was, FOR JSON rendering rows exactly as before, the consumer's password checked as a bound parameter. A caller's statement still runs only as the reader login (issue 193); the one-line rule and -x guarded against sqlcmd's own commands and variable substitution, which no longer stand between the caller and the server. The server is reached on loopback at the port the machine published (${port:1433}). The reader test drives a fake session in place of a fake sqlcmd. --- modules/mssql/Dockerfile | 43 ------- modules/mssql/client.ts | 205 ++++++++++++++++-------------- modules/mssql/index.ts | 6 +- modules/mssql/module.json | 61 +++------ modules/mssql/mssql.d.ts | 32 +++++ modules/mssql/package.json | 5 +- modules/mssql/test/reader.test.ts | 115 ++++++++--------- modules/mssql/tsconfig.json | 2 +- 8 files changed, 216 insertions(+), 253 deletions(-) delete mode 100644 modules/mssql/Dockerfile create mode 100644 modules/mssql/mssql.d.ts diff --git a/modules/mssql/Dockerfile b/modules/mssql/Dockerfile deleted file mode 100644 index 07ac078..0000000 --- a/modules/mssql/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# mssql's runtime: the tool runtime, carrying this module's compiled code. -# -# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in -# the base images, published like any other artifact — which is what makes this buildable by the -# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that -# happens to have the siblings. -# -# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the -# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`. -ARG BUILD_BASE -ARG RUNTIME_BASE - -FROM ${BUILD_BASE} AS build -# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own -# node_modules — the module is compiled against exactly the sdk it will run against. The compiler -# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image -# resolved away. -WORKDIR /app/modules/mssql -COPY . . -RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \ - --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist - -# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed -# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked -# against its digest, so a build that receives anything else stops here. -FROM ${BUILD_BASE} AS sqlcmd -ARG SQLCMD_VERSION=v1.10.0 -ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987 -RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \ - && curl -fsSL -o /tmp/sqlcmd.tar.bz2 \ - "https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \ - && echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \ - && tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd - -FROM ${RUNTIME_BASE} -COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd -COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist -# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a -# provider's provisioner runs its reconcile loop in the same process, with the broker connected — -# the convention novox/hq issues 060/061 settled. A container that instead ran only its -# provisioner (`run`) served no tools and emitted no events; a container that named no command -# ran no provisioner at all. -ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js diff --git a/modules/mssql/client.ts b/modules/mssql/client.ts index 5bbb78a..a17998b 100644 --- a/modules/mssql/client.ts +++ b/modules/mssql/client.ts @@ -1,22 +1,70 @@ // mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // module's tools and its provisioner import it, and nothing outside mssql does. // -// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm -// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query -// protocol is more surface than this should carry — so it shells out to the client the mssql -// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu -// through doveadm. One boundary, `run()`, and every method is built on it. +// **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to +// `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's +// runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the +// `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One +// boundary, `session()`, and every method is built on it: a connection as one login to one database, +// opened for one call and closed after, as one sqlcmd invocation was. // -// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and -// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's -// column-aligned text, since SQL Server owns the quoting and typing. +// Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's +// answer is shaped exactly as it was: SQL Server owns the quoting and typing. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; +import sql from "mssql"; -const run = promisify(execFile); +/** Where a session connects, and as whom. */ +export interface Target { + readonly host: string; + readonly port: number; + readonly user: string; + readonly password: string; + readonly database: string; +} + +/** One login's connection to one database: run a batch, answer the rows of its last result set. */ +export interface Session { + /** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */ + run(text: string, params?: Record): Promise[]>; + close(): Promise; +} + +/** How a session is opened — the driver, or a test's fake. */ +export type Connect = (to: Target) => Promise; + +/** + * The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what + * sqlcmd's `-C` did), one connection, closed with the session. + */ +export const connectWithDriver: Connect = async (to) => { + const pool = new sql.ConnectionPool({ + server: to.host, + port: to.port, + user: to.user, + password: to.password, + database: to.database, + options: { encrypt: true, trustServerCertificate: true }, + pool: { min: 0, max: 1 }, + connectionTimeout: 15_000, + requestTimeout: 60_000, + }); + await pool.connect(); + return { + async run(text, params = {}) { + const request = pool.request(); + const names = Object.keys(params); + for (const name of names) request.input(name, sql.NVarChar, params[name]); + // A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a + // parameterised query (sp_executesql) is not. + const result = names.length > 0 ? await request.query(text) : await request.batch(text); + const sets = (result.recordsets ?? []) as Record[][]; + return sets.length > 0 ? sets[sets.length - 1] : []; + }, + close: () => pool.close(), + }; +}; export interface QueryResult { /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ @@ -45,20 +93,17 @@ export interface MssqlConn { */ export const READER = "mesh_mssql_reader"; -/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */ +/** Who a session logs in as. */ interface Invocation { readonly user: string; readonly password: string; - /** - * A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's - * environment — the administrator's password among it. (Its own commands are kept out by the - * caller's text never beginning a line; see readOnlyQuery.) - */ - readonly caller: boolean; } export class MssqlClient { - constructor(private readonly conn: MssqlConn) {} + constructor( + private readonly conn: MssqlConn, + private readonly connect: Connect = connectWithDriver, + ) {} /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */ private readerReady?: Promise; @@ -90,63 +135,41 @@ export class MssqlClient { return this.conn.port; } - /** - * Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is - * passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a - * failed statement an error here rather than a success with a warning, and `-C` trusts the - * server's self-signed certificate the mssql image ships with. - */ - async exec(sql: string, database = "master"): Promise { - await this.sqlcmd(sql, database); + /** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */ + async exec(text: string, database = "master"): Promise { + await this.session(text, database); } /** * Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is - * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd - * prints (split across output lines for a large result, and reassembled here) is parsed. An - * empty result yields no output at all — an empty array. + * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers + * (split across rows for a large result, and reassembled here) is parsed. An empty result yields + * no rows — an empty array. `params` are bound as `@name`, never written into the text. */ async query( select: string, database = "master", - variables: Record = {}, + params: Record = {}, ): Promise[]> { const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; - const stdout = await this.sqlcmd(wrapped, database, variables); - return parseJsonRows(stdout); + return parseJsonRows(await this.session(wrapped, database, params)); } - /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ - private async sqlcmd( - sql: string, + /** The one execution boundary: open a session as `as`, run `text`, close it. */ + private async session( + text: string, database: string, - variables: Record = {}, - as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false }, - ): Promise { - // `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long - // JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin - // cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships - // with postgres's — the module owns its own code (ADR 0039) and shells out to it. - const { stdout } = await run( - "sqlcmd", - [ - "-S", `${this.conn.host},${this.conn.port}`, - "-U", as.user, - "-d", database, - ...(as.caller ? ["-x"] : []), - "-C", - "-b", - "-h", "-1", - "-y", "0", - "-Y", "0", - "-W", - "-Q", sql, - ], - // `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting - // variables: a value that must not appear on argv, or in the message of a failed command. - { env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 }, - ); - return stdout; + params: Record = {}, + as: Invocation = { user: this.conn.user, password: this.conn.password }, + ): Promise[]> { + const session = await this.connect({ + host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database, + }); + try { + return await session.run(text, params); + } finally { + await session.close(); + } } /** @@ -173,7 +196,7 @@ export class MssqlClient { `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, ); if (dbs.length === 0) { - // CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation. + // CREATE DATABASE must stand alone in its batch; it runs as its own session. await this.exec(`CREATE DATABASE ${ident(database)}`); } @@ -206,15 +229,14 @@ export class MssqlClient { * nothing logs in and no failed-login is recorded (novox/hq issue 120). */ async holdsLogin(database: string, login: string, password: string): Promise { - // The password reaches sqlcmd as a scripting variable from the environment, never inside the - // query text, so it is neither on argv nor in the message of a failed command. It is the mesh's - // minted value, which carries no quote. + // The password is a bound parameter, never inside the query text, so it is in no message of a + // failed statement. const server = await this.query( `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + - `AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` + + `AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` + `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, "master", - { MESHHOLDSPW: password }, + { meshholdspw: password }, ); if (Number(server[0]?.ok) !== 1) return false; // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the @@ -294,35 +316,27 @@ export class MssqlClient { * (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call * is refused. + * + * The text goes to the server as it is, over the driver: there is no client between that reads a + * line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this + * process's environment, so neither the one-line rule nor `-x` has anything left to guard. */ - async readOnlyQuery(database: string, sql: string): Promise { + async readOnlyQuery(database: string, text: string): Promise { const password = this.conn.readerPassword; if (!password) throw readerMissing(); - // **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own - // command rather than SQL, and `:!!` starts a program in this container, which holds the - // administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the - // version shipped here, so instead no line of a caller's text can begin one: the text follows - // this module's own on the first line, and a line break in it is refused. Proven on a throwaway - // server: the same text at the start of a line ran a program; mid-line it is a syntax error. - if (/[\r\n]/.test(sql)) { - throw new Error( - "mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " + - "'!!' as a command of its own, which can start a program (novox/hq issue 193)", - ); - } this.readerReady ??= this.ensureReader().catch((err) => { this.readerReady = undefined; // asked again next call, not failed for the process's life throw err; }); await this.readerReady; - const stdout = await this.sqlcmd( - `SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, + const rows = await this.session( + `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, database, {}, - { user: READER, password, caller: true }, + { user: READER, password }, ); - const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? ""; - return { command, rows: parseJsonRows(stdout) }; + const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? ""; + return { command, rows: parseJsonRows(rows) }; } } @@ -372,18 +386,13 @@ function safeUrl(raw: string): URL | undefined { } /** - * Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result - * into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so - * the document is reassembled by concatenating the non-empty lines. No output (an empty result, or - * a pure DDL batch) means no rows. + * Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into + * ~2033-character chunks, one per row of a single column, so the document is reassembled by + * concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none. */ -function parseJsonRows(stdout: string): Record[] { - const joined = stdout - .split(/\r?\n/) - .map((l) => l.trimEnd()) - .filter((l) => l.length > 0) - .join(""); - if (joined.length === 0) return []; +function parseJsonRows(rows: Record[]): Record[] { + const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join(""); + if (joined.trim().length === 0) return []; const parsed = JSON.parse(joined); return Array.isArray(parsed) ? (parsed as Record[]) : [parsed as Record]; } diff --git a/modules/mssql/index.ts b/modules/mssql/index.ts index 0232587..faef29f 100644 --- a/modules/mssql/index.ts +++ b/modules/mssql/index.ts @@ -1,9 +1,9 @@ -// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs -// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where +// mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner +// (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where // the lifecycle actually happens (novox/hq ADR 0041/0042): // module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.deprovisioned — that database was removed -// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a +// Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a // database and who lost one — observability the provider itself is best placed to log. import { on } from "@novox/mesh-sdk/events"; diff --git a/modules/mssql/module.json b/modules/mssql/module.json index 0b86a84..28802a7 100644 --- a/modules/mssql/module.json +++ b/modules/mssql/module.json @@ -38,16 +38,9 @@ }, "own-secrets": { "sa": "${dir:state}/sa.secret", - "broker": "${dir:mesh-state}/broker", "reader": "${dir:state}/reader.secret" }, "resources": [ - { - "id": "mesh-state", - "type": "directory", - "mode": "0700", - "place": "mesh" - }, { "id": "state", "type": "directory", @@ -93,46 +86,30 @@ "${dir:data}:/var/opt/mssql" ], "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" - }, - { - "id": "runtime", - "type": "container", - "name": "mesh-mssql", - "network": "mssql", - "volumes": [ - "${dir:mesh-state}/broker:/run/secrets/broker:ro", - "${dir:grants}:/var/lib/mssql/grants:ro", - "${dir:state}/sa.secret:/run/secrets/sa:ro", - "${dir:state}/reader.secret:/run/secrets/reader:ro" - ], - "env": { - "MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master", - "MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa", - "MESH_BROKER_FILE": "/run/secrets/broker", - "MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json", - "MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader" - }, - "artifact": "runtime" } ], "build": { - "on": [ - { - "arg": "BUILD_BASE", - "module": "mesh-tools", - "artifact": "build" - }, - { - "arg": "RUNTIME_BASE", - "module": "mesh-tools", - "artifact": "runtime" - } - ], "artifacts": [ { - "name": "runtime", - "kind": "image", - "from": "Dockerfile" + "name": "code", + "kind": "bundle", + "language": "typescript", + "entrypoints": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "loads": [ + "index.js", + "tools/index.js", + "provisioner/index.js" + ], + "env": { + "MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master", + "MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret", + "MESH_RECEIVES": "${dir:grants}/mesh.json", + "MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret" + } } ] } diff --git a/modules/mssql/mssql.d.ts b/modules/mssql/mssql.d.ts new file mode 100644 index 0000000..d58a508 --- /dev/null +++ b/modules/mssql/mssql.d.ts @@ -0,0 +1,32 @@ +// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This +// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module +// type-checks without deciding what runs: the real `mssql` is the package.json dependency the +// builder installs and inlines into the bundle (novox/hq ADR 0198 §4). +declare module "mssql" { + interface Result { + recordsets: unknown; + } + interface Request { + input(name: string, type: unknown, value: unknown): Request; + query(text: string): Promise; + batch(text: string): Promise; + } + class ConnectionPool { + constructor(config: { + server: string; + port?: number; + user?: string; + password?: string; + database?: string; + options?: { encrypt?: boolean; trustServerCertificate?: boolean }; + pool?: { min?: number; max?: number }; + connectionTimeout?: number; + requestTimeout?: number; + }); + connect(): Promise; + request(): Request; + close(): Promise; + } + const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown }; + export default sql; +} diff --git a/modules/mssql/package.json b/modules/mssql/package.json index 3a9be3b..f98fb4a 100644 --- a/modules/mssql/package.json +++ b/modules/mssql/package.json @@ -5,11 +5,12 @@ "type": "module", "private": true, "scripts": { - "build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", + "build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" }, "dependencies": { - "@novox/mesh-sdk": "^0.1.1" + "@novox/mesh-sdk": "^0.1.1", + "mssql": "^11.0.2" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/mssql/test/reader.test.ts b/modules/mssql/test/reader.test.ts index 2d7c7e7..c33fc1b 100644 --- a/modules/mssql/test/reader.test.ts +++ b/modules/mssql/test/reader.test.ts @@ -1,96 +1,83 @@ // What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the -// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line -// that follows the module's own — a line break is refused before sqlcmd starts — and with no -// transaction wrapped around it as text. Without the reader's password the statement is refused. +// reader login and never as the administrator, with no transaction wrapped around it as text, and +// without the reader's password the statement is refused. // -// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot -// write is the server's to enforce and was proven against a real server; this holds the module to -// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it. +// The driver is a fake session that records each call's login, database, text and bound +// parameters. That the reader cannot write is the server's to enforce and was proven against a real +// server; this holds the module to asking for it. Run against the compiled module (npm test builds +// first), the way the runtime loads it. -import { test, before, after } from "node:test"; +import { test } from "node:test"; import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { MssqlClient, READER } from "../dist/client.js"; +import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js"; -let dir: string; -let log: string; -const originalPath = process.env.PATH; +interface Call extends Target { + text: string; + params: Record; +} -before(async () => { - dir = await mkdtemp(join(tmpdir(), "mssql-reader-")); - log = join(dir, "calls.jsonl"); - await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node -const fs = require("node:fs"); -const args = process.argv.slice(2); -const at = (flag) => args[args.indexOf(flag) + 1]; -fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ - user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"), - password: process.env.SQLCMDPASSWORD, -}) + "\\n"); -const sql = at("-Q"); -if (/FROM sys.server_principals/.test(sql)) process.stdout.write(""); -else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n'); -`); - await chmod(join(dir, "sqlcmd"), 0o755); - process.env.PATH = `${dir}:${originalPath}`; -}); - -after(async () => { - process.env.PATH = originalPath; - await rm(dir, { recursive: true, force: true }); -}); - -async function calls(): Promise[]> { - const text = await readFile(log, "utf8").catch(() => ""); - await writeFile(log, ""); - return text.split("\n").filter(Boolean).map((line) => JSON.parse(line)); +function recording(): { connect: Connect; calls: Call[] } { + const calls: Call[] = []; + const connect: Connect = async (to) => ({ + async run(text, params = {}) { + calls.push({ ...to, text, params }); + if (/FROM sys.server_principals/.test(text)) return []; + // FOR JSON answers its document split across rows of one column. + if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }]; + return []; + }, + async close() {}, + }); + return { connect, calls }; } const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; -test("a caller's statement runs as the reader, without variables, on the module's first line", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); +test("a caller's statement runs as the reader, as it was written, on the database it names", async () => { + const { connect, calls } = recording(); + const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect); const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); - const asked = (await calls()).at(-1)!; + const asked = calls.at(-1)!; assert.equal(asked.user, READER, "the statement never runs as the administrator"); assert.equal(asked.password, "reader-secret"); - assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text"); - const [first] = String(asked.sql).split("\n"); - assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line"); - assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); - assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]); + assert.equal(asked.database, "inventory"); + assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"), + "the caller's text reaches the server unaltered"); + assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); + assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled"); assert.equal(result.command, "SELECT"); }); -test("a line break in a caller's statement is refused before sqlcmd starts", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); - for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) { - await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/); - } - assert.deepEqual(await calls(), []); -}); - test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => { - const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); + const { connect, calls } = recording(); + const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect); await client.readOnlyQuery("inventory", "SELECT 1 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x"); - const made = await calls(); - const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql)); + const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text); assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s))); assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`)); assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call"); - assert.equal(made.filter((c) => c.user === READER).length, 2); + assert.equal(calls.filter((c) => c.user === READER).length, 2); }); test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => { - const client = new MssqlClient(conn); + const { connect, calls } = recording(); + const client = new MssqlClient(conn, connect); await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/); - assert.deepEqual(await calls(), []); + assert.deepEqual(calls, []); +}); + +test("a consumer's password is checked as a bound parameter, never in the text", async () => { + const { connect, calls } = recording(); + const client = new MssqlClient(conn, connect); + await client.holdsLogin("shop", "shop_login", "minted-secret"); + const asked = calls[0]; + assert.equal(asked.params.meshholdspw, "minted-secret"); + assert.doesNotMatch(asked.text, /minted-secret/); + assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/); }); diff --git a/modules/mssql/tsconfig.json b/modules/mssql/tsconfig.json index 51f4046..20a5be3 100644 --- a/modules/mssql/tsconfig.json +++ b/modules/mssql/tsconfig.json @@ -8,5 +8,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] + "include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] } -- 2.54.0