The last three: mesh-catalog, mongodb and mssql code moves into bundles the node's runtime serves (hq ADR 0198, to-be 38 WP4c) #250

Merged
mesh-admin merged 3 commits from feat/0198-the-last-three-module-code-moves into main 2026-10-03 23:28:59 +00:00
19 changed files with 352 additions and 516 deletions
-55
View File
@@ -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
+24 -36
View File
@@ -25,9 +25,6 @@
"secrets": { "secrets": {
"postgres-database": "${dir:state}/database.secret" "postgres-database": "${dir:state}/database.secret"
}, },
"own-secrets": {
"broker": "${dir:mesh-state}/broker"
},
"consumes": [ "consumes": [
"mesh-build-machine.built", "mesh-build-machine.built",
"mesh-controller.built-before" "mesh-controller.built-before"
@@ -38,14 +35,7 @@
"rebuild-needed", "rebuild-needed",
"catching-up" "catching-up"
], ],
"prepares": true,
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "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" "content": "postgresql://${bound:postgres-database:as}:${secret:postgres-database}@${bound:postgres-database:at}:${bound:postgres-database:port}/${bound:postgres-database:as}\n"
}, },
{ {
"id": "runtime", "id": "prepare",
"type": "container", "type": "process",
"name": "mesh-catalog", "name": "mesh-catalog-prepare",
"network": "host", "artifact": "code",
"volumes": [ "run": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro", "node",
"${dir:state}:/run/state", "prepare/index.js"
"${dir:state}/database.url:/run/secrets/database-url:ro"
], ],
"run-once": true,
"env": { "env": {
"MESH_BROKER_FILE": "/run/secrets/broker", "DATABASE_URL_FILE": "${dir:state}/database.url"
"DATABASE_URL_FILE": "/run/secrets/database-url"
}, },
"artifact": "runtime",
"restart-on": [ "restart-on": [
"database-url" "database-url"
] ]
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "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"
}
} }
] ]
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// Ambient types for `pg` (node-postgres), which ships its types only via the separate `@types/pg` // 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 — // 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, // 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 // listed in tsconfig `include`, default-imported). The real `pg` is the package.json dependency the
// runtime image (package.json `dependencies`; novox/hq ADR 0052), so this types the code without // builder installs and inlines into the module's bundle (novox/hq ADR 0198 §4), so this types the
// deciding what runs. // code without deciding what runs.
declare module "pg" { declare module "pg" {
/** One checked-out connection. Needed because registering a module-version and its edges is one /** 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. */ * act: a half-written registration is a graph that lies about what something was built against. */
+3 -2
View File
@@ -7,8 +7,9 @@
// nothing anywhere said so. // nothing anywhere said so.
// //
// Nothing here connects to the broker. Preparation runs before the version that would use it, 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 // there is nothing yet to talk to: the host runs this file as a run-once process, with the module's
// process exiting non-zero is how the host knows not to start the runtime. // 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"; import { Graph } from "../store.js";
const graph = Graph.fromEnv(); const graph = Graph.fromEnv();
-37
View File
@@ -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
+71 -80
View File
@@ -1,19 +1,16 @@
// mongodb's admin client — mongodb's own code, living in the module (novox/hq ADR 0039). Both this // 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. // 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 // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// beyond @novox/mesh-sdk, and hand-rolling the MongoDB wire protocol + SCRAM auth is more surface // `mongosh`, which the module's container installed from MongoDB's apt repository; the module's code
// than this should carry — so it shells out to the shell the mongodb image ships, the same way // now runs in the node's runtime, on machines whose system carries no mongosh, so it speaks to the
// postgres drives itself through `psql`, minio through `mc` and mailu through doveadm. One boundary, // server through the official `mongodb` driver its package.json names — installed and inlined into
// `evalJs()`, and every method is built on it: a snippet of JavaScript is evaluated server-side and // the bundle by the builder. One connection per call, as one mongosh invocation was: the module is
// its result comes back as EJSON on stdout. // called rarely, and a pool held open across calls would hold a credential the mesh may rotate.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import { MongoClient as Driver, MongoServerError, BSON, type Document } from "mongodb";
import { promisify } from "node:util";
const run = promisify(execFile);
export interface DatabaseInfo { export interface DatabaseInfo {
readonly name: string; readonly name: string;
@@ -59,32 +56,26 @@ export class MongoClient {
return this.conn.port; return this.conn.port;
} }
/** The admin connection URI mongosh authenticates with, credentials percent-encoded. */ /** The admin connection URI, credentials percent-encoded. */
private uri(): string { private uri(): string {
const u = encodeURIComponent(this.conn.user); const u = encodeURIComponent(this.conn.user);
const p = encodeURIComponent(this.conn.password); const p = encodeURIComponent(this.conn.password);
const a = encodeURIComponent(this.conn.authSource); 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 * The one execution boundary: connect as the administrator, do `work`, and close — a failure to
* header). The snippet MUST `print()` exactly one JSON document as its only stdout — every method * connect or to authenticate rejects here rather than returning a partial success.
* 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<T>(js: string): Promise<T> { private async admin<T>(work: (client: Driver) => Promise<T>): Promise<T> {
const { stdout } = await run( const client = new Driver(this.uri(), { serverSelectionTimeoutMS: 10_000 });
"mongosh", try {
[this.uri(), "--quiet", "--eval", js], await client.connect();
{ maxBuffer: 16 << 20 }, return await work(client);
); } finally {
const text = stdout.trim(); await client.close();
if (text.length === 0) {
throw new Error("mongosh returned no output — the eval printed nothing");
} }
return JSON.parse(text) as T;
} }
/** /**
@@ -94,19 +85,16 @@ export class MongoClient {
* password and roles, so a rotated credential converges. * password and roles, so a rotated credential converges.
*/ */
async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> { async createDatabaseAndUser(database: string, user: string, password: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
let existing = null; const roles = [{ role: "dbOwner", db: database }];
try { existing = target.getUser(${lit(user)}); } catch (e) { existing = null; } const found = await target.command({ usersInfo: user });
const roles = [{ role: "dbOwner", db: ${lit(database)} }]; if (Array.isArray(found.users) && found.users.length > 0) {
if (existing) { await target.command({ updateUser: user, pwd: password, roles });
target.updateUser(${lit(user)}, { pwd: ${lit(password)}, roles: roles }); } else {
} else { await target.command({ createUser: user, pwd: password, roles });
target.createUser({ user: ${lit(user)}, pwd: ${lit(password)}, roles: roles }); }
} });
print(EJSON.stringify({ ok: 1 }));
`;
await this.evalJs<{ ok: number }>(js);
} }
/** /**
@@ -115,45 +103,43 @@ print(EJSON.stringify({ ok: 1 }));
* authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120). * authentication failure or a missing role; an unreachable server rejects (novox/hq issue 120).
*/ */
async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> { async canAuthenticateAs(database: string, user: string, password: string): Promise<boolean> {
// Connected without credentials, then authenticated inside the eval from the environment, so // Credentials as options, never in a URI, so the consumer's password is in no message a failed
// the consumer's password is neither on argv nor in the message of a failed command. // connection prints.
const uri = `mongodb://${this.conn.host}:${this.conn.port}/?serverSelectionTimeoutMS=10000`; const client = new Driver(`mongodb://${this.conn.host}:${this.conn.port}/?directConnection=true`, {
const js = auth: { username: user, password },
"const t = db.getSiblingDB(process.env.MESH_HOLDS_DB);" + authSource: database,
"t.auth(process.env.MESH_HOLDS_USER, process.env.MESH_HOLDS_PW);" + serverSelectionTimeoutMS: 10_000,
"print(EJSON.stringify(t.runCommand({ connectionStatus: 1 }).authInfo.authenticatedUserRoles))"; });
let stdout: string;
try { try {
({ stdout } = await run("mongosh", [uri, "--quiet", "--eval", js], { await client.connect();
env: { ...process.env, MESH_HOLDS_DB: database, MESH_HOLDS_USER: user, MESH_HOLDS_PW: password }, const status = await client.db(database).command({ connectionStatus: 1 });
timeout: 30_000, const roles = (status.authInfo?.authenticatedUserRoles ?? []) as { role: string; db: string }[];
}));
} 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]}`);
}
const roles = JSON.parse(stdout.trim()) as { role: string; db: string }[];
return roles.some((r) => r.role === "dbOwner" && r.db === database); 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 /** 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. */ * user is removed first so a re-grant of the same login starts clean. */
async dropDatabaseAndUser(database: string, user: string): Promise<void> { async dropDatabaseAndUser(database: string, user: string): Promise<void> {
const js = ` await this.admin(async (client) => {
const target = db.getSiblingDB(${lit(database)}); const target = client.db(database);
try { target.dropUser(${lit(user)}); } catch (e) {} try {
target.dropDatabase(); await target.command({ dropUser: user });
print(EJSON.stringify({ ok: 1 })); } catch (err) {
`; if (!(err instanceof MongoServerError && err.code === 11)) throw err; // 11: UserNotFound
await this.evalJs<{ ok: number }>(js); }
await target.dropDatabase();
});
} }
/** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */ /** List the databases on the server, with on-disk size, for the mongodb_list_databases tool. */
async listDatabases(): Promise<DatabaseInfo[]> { async listDatabases(): Promise<DatabaseInfo[]> {
const res = await this.evalJs<{ databases: { name: string; sizeOnDisk?: number }[] }>( const res = await this.admin((client) => client.db("admin").admin().listDatabases());
`print(EJSON.stringify(db.adminCommand({ listDatabases: 1 })));`,
);
return (res.databases ?? []) return (res.databases ?? [])
.map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) })) .map((d) => ({ name: String(d.name), sizeBytes: Number(d.sizeOnDisk ?? 0) }))
.sort((a, b) => a.name.localeCompare(b.name)); .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. * 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. * `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( async find(
database: string, database: string,
@@ -170,26 +158,29 @@ print(EJSON.stringify({ ok: 1 }));
limit: number, limit: number,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const capped = Math.max(1, Math.min(limit, 1000)); const capped = Math.max(1, Math.min(limit, 1000));
const js = const docs = await this.admin((client) =>
`print(EJSON.stringify(` + client
`db.getSiblingDB(${lit(database)}).getCollection(${lit(collection)})` + .db(database)
`.find(${JSON.stringify(filter)}).limit(${capped}).toArray()` + .collection(collection)
`));`; .find(BSON.EJSON.deserialize(filter as Document, { relaxed: true }) as Document)
return this.evalJs<Record<string, unknown>[]>(js); .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. */ /** Generate a URL-safe password. */
export function generatePassword(): string { export function generatePassword(): string {
return randomBytes(24).toString("base64url"); 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 { function readSecretFile(path: string | undefined): string | undefined {
if (!path) return undefined; if (!path) return undefined;
try { try {
+3 -3
View File
@@ -1,9 +1,9 @@
// mongodb's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mongodb's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mongodb.database.provisioned — a consumer's database + owning user was created // module.mongodb.database.provisioned — a consumer's database + owning user was created
// module.mongodb.database.deprovisioned — that database was removed // 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. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+28 -43
View File
@@ -39,17 +39,9 @@
"mongodb-database": "${dir:grants}" "mongodb-database": "${dir:grants}"
}, },
"own-secrets": { "own-secrets": {
"root": "${dir:state}/root.secret", "root": "${dir:state}/root.secret"
"broker": "${dir:mesh-state}/broker"
}, },
"secrets-owner": "999:999",
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -71,6 +63,14 @@
"type": "network", "type": "network",
"name": "mongodb" "name": "mongodb"
}, },
{
"id": "server-root",
"type": "file",
"path": "${dir:state}/server-root.secret",
"mode": "0400",
"owner": "999:999",
"content": "${secret:root}"
},
{ {
"id": "server", "id": "server",
"type": "container", "type": "container",
@@ -86,46 +86,31 @@
], ],
"volumes": [ "volumes": [
"${dir:data}:/data/db", "${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": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "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"
}
} }
] ]
} }
+2 -1
View File
@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mongodb": "^6.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+1 -2
View File
@@ -11,8 +11,7 @@
// same-named database under exactly that login — a name the consumer cannot learn is a database it // same-named database under exactly that login — a name the consumer cannot learn is a database it
// cannot reach. // cannot reach.
// //
// The commands run through MongoClient.evalJs(), which is the module's one execution boundary (see // The commands run through MongoClient, the official driver inside this bundle (see client.ts).
// client.ts).
import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner";
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
+2 -2
View File
@@ -1,6 +1,6 @@
// mongodb's tools — mongodb's own code (novox/hq ADR 0039), importing mongodb's own client. They // 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 // return structured data; the mesh serves them through the sdk's tool harness. Both call the server
// MongoClient.evalJs(), the module's one execution boundary (see client.ts). // through MongoClient, the driver inside this bundle (see client.ts).
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"; import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MongoClient } from "../client.js"; import { MongoClient } from "../client.js";
-43
View File
@@ -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
+107 -98
View File
@@ -1,22 +1,70 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // 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. // 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 // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query // `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's
// protocol is more surface than this should carry — so it shells out to the client the mssql // runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu // `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One
// through doveadm. One boundary, `run()`, and every method is built on it. // 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 // Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's // answer is shaped exactly as it was: SQL Server owns the quoting and typing.
// column-aligned text, since SQL Server owns the quoting and typing.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import sql from "mssql";
import { promisify } from "node:util";
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<string, string>): Promise<Record<string, unknown>[]>;
close(): Promise<void>;
}
/** How a session is opened — the driver, or a test's fake. */
export type Connect = (to: Target) => Promise<Session>;
/**
* 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<string, unknown>[][];
return sets.length > 0 ? sets[sets.length - 1] : [];
},
close: () => pool.close(),
};
};
export interface QueryResult { export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
@@ -45,20 +93,17 @@ export interface MssqlConn {
*/ */
export const READER = "mesh_mssql_reader"; 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 { interface Invocation {
readonly user: string; readonly user: string;
readonly password: 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 { 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. */ /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>; private readerReady?: Promise<void>;
@@ -90,63 +135,41 @@ export class MssqlClient {
return this.conn.port; return this.conn.port;
} }
/** /** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is async exec(text: string, database = "master"): Promise<void> {
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a await this.session(text, database);
* 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<void> {
await this.sqlcmd(sql, database);
} }
/** /**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is * 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 * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers
* prints (split across output lines for a large result, and reassembled here) is parsed. An * (split across rows for a large result, and reassembled here) is parsed. An empty result yields
* empty result yields no output at all — an empty array. * no rows — an empty array. `params` are bound as `@name`, never written into the text.
*/ */
async query( async query(
select: string, select: string,
database = "master", database = "master",
variables: Record<string, string> = {}, params: Record<string, string> = {},
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database, variables); return parseJsonRows(await this.session(wrapped, database, params));
return parseJsonRows(stdout);
} }
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ /** The one execution boundary: open a session as `as`, run `text`, close it. */
private async sqlcmd( private async session(
sql: string, text: string,
database: string, database: string,
variables: Record<string, string> = {}, params: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false }, as: Invocation = { user: this.conn.user, password: this.conn.password },
): Promise<string> { ): Promise<Record<string, unknown>[]> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long const session = await this.connect({
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
// 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. try {
const { stdout } = await run( return await session.run(text, params);
"sqlcmd", } finally {
[ await session.close();
"-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;
} }
/** /**
@@ -173,7 +196,7 @@ export class MssqlClient {
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
); );
if (dbs.length === 0) { 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)}`); 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). * nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/ */
async holdsLogin(database: string, login: string, password: string): Promise<boolean> { async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the // The password is a bound parameter, never inside the query text, so it is in no message of a
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's // failed statement.
// minted value, which carries no quote.
const server = await this.query( const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + `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`, `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master", "master",
{ MESHHOLDSPW: password }, { meshholdspw: password },
); );
if (Number(server[0]?.ok) !== 1) return false; 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 // 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 * (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 * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
* is refused. * 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<QueryResult> { async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
const password = this.conn.readerPassword; const password = this.conn.readerPassword;
if (!password) throw readerMissing(); 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 ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err; throw err;
}); });
await this.readerReady; await this.readerReady;
const stdout = await this.sqlcmd( const rows = await this.session(
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
database, database,
{}, {},
{ user: READER, password, caller: true }, { user: READER, password },
); );
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? ""; const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(stdout) }; 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 * Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so * ~2033-character chunks, one per row of a single column, so the document is reassembled by
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or * concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none.
* a pure DDL batch) means no rows.
*/ */
function parseJsonRows(stdout: string): Record<string, unknown>[] { function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const joined = stdout const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
.split(/\r?\n/) if (joined.trim().length === 0) return [];
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined); const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>]; return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.database.deprovisioned — that database was removed // 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. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+19 -42
View File
@@ -38,16 +38,9 @@
}, },
"own-secrets": { "own-secrets": {
"sa": "${dir:state}/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "${dir:mesh-state}/broker",
"reader": "${dir:state}/reader.secret" "reader": "${dir:state}/reader.secret"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -93,46 +86,30 @@
"${dir:data}:/var/opt/mssql" "${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" "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": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "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"
}
} }
] ]
} }
+32
View File
@@ -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<Result>;
batch(text: string): Promise<Result>;
}
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<ConnectionPool>;
request(): Request;
close(): Promise<void>;
}
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
export default sql;
}
+3 -2
View File
@@ -5,11 +5,12 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "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'" "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mssql": "^11.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+51 -64
View File
@@ -1,96 +1,83 @@
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the // 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 // reader login and never as the administrator, with no transaction wrapped around it as text, and
// that follows the module's own — a line break is refused before sqlcmd starts — and with no // without the reader's password the statement is refused.
// transaction wrapped around it as text. 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 // The driver is a fake session that records each call's login, database, text and bound
// write is the server's to enforce and was proven against a real server; this holds the module to // parameters. That the reader cannot write is the server's to enforce and was proven against a real
// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it. // 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 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; interface Call extends Target {
let log: string; text: string;
const originalPath = process.env.PATH; params: Record<string, string>;
}
before(async () => { function recording(): { connect: Connect; calls: Call[] } {
dir = await mkdtemp(join(tmpdir(), "mssql-reader-")); const calls: Call[] = [];
log = join(dir, "calls.jsonl"); const connect: Connect = async (to) => ({
await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node async run(text, params = {}) {
const fs = require("node:fs"); calls.push({ ...to, text, params });
const args = process.argv.slice(2); if (/FROM sys.server_principals/.test(text)) return [];
const at = (flag) => args[args.indexOf(flag) + 1]; // FOR JSON answers its document split across rows of one column.
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }];
user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"), return [];
password: process.env.SQLCMDPASSWORD, },
}) + "\\n"); async close() {},
const sql = at("-Q"); });
if (/FROM sys.server_principals/.test(sql)) process.stdout.write(""); return { connect, calls };
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<Record<string, unknown>[]> {
const text = await readFile(log, "utf8").catch(() => "");
await writeFile(log, "");
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
} }
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; 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 () => { test("a caller's statement runs as the reader, as it was written, on the database it names", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); 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.user, READER, "the statement never runs as the administrator");
assert.equal(asked.password, "reader-secret"); assert.equal(asked.password, "reader-secret");
assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text"); assert.equal(asked.database, "inventory");
const [first] = String(asked.sql).split("\n"); assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"),
assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line"); "the caller's text reaches the server unaltered");
assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]); assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled");
assert.equal(result.command, "SELECT"); 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 () => { 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 1 AS x");
await client.readOnlyQuery("inventory", "SELECT 2 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x");
const made = await calls(); const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text);
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); 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.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 CONNECT ANY DATABASE TO [${READER}]`));
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES 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(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 () => { 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/); 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\)/);
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": 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"]
} }