// 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. // // 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. import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; const run = promisify(execFile); export interface QueryResult { /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ readonly command: string; readonly rows: Record[]; } export interface MssqlConn { readonly host: string; readonly port: number; readonly user: string; readonly password: string; } export class MssqlClient { constructor(private readonly conn: MssqlConn) {} /** * Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented * names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner * container. Throws if it cannot find a host and an admin password. */ static fromEnv(env: NodeJS.ProcessEnv = process.env): MssqlClient { const url = env.MESH_PROVISION_MSSQL ? safeUrl(env.MESH_PROVISION_MSSQL) : undefined; const host = env.MESH_MSSQL_HOST ?? url?.hostname; const port = Number(env.MESH_MSSQL_PORT ?? url?.port ?? "1433") || 1433; const user = env.MESH_MSSQL_USER ?? url?.username ?? "sa"; const password = env.MESH_MSSQL_PASSWORD ?? readSecretFile(env.MESH_PROVISION_PASSWORD_FILE); if (!host || !password) { throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server"); } return new MssqlClient({ host, port, user, password }); } get host(): string { return this.conn.host; } get port(): number { 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); } /** * 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. */ async query( select: string, database = "master", variables: 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); } /** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ private async sqlcmd(sql: string, database: string, variables: Record = {}): 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", this.conn.user, "-d", database, "-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: this.conn.password }, maxBuffer: 16 << 20 }, ); return stdout; } /** * Create a login and a database it owns (mapped as a db_owner user), idempotently. The login, * the database and the user all carry the consumer's minted name, so the consumer owns exactly * its own database — a name it cannot learn is a database it cannot reach (ADR 0048). */ async createDatabaseAndLogin(database: string, login: string, password: string): Promise { const logins = await this.query( `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`, ); if (logins.length === 0) { await this.exec( `CREATE LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`, ); } else { await this.exec(`ALTER LOGIN ${ident(login)} WITH PASSWORD = ${literal(password)}`); // A disabled login is refused like a wrong password; the check the provisioner runs reports it // lost, so applying again must enable it or the two would disagree for ever. await this.exec(`ALTER LOGIN ${ident(login)} ENABLE`); } const dbs = await this.query( `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. await this.exec(`CREATE DATABASE ${ident(database)}`); } // Map the login to a db_owner user inside the database it owns. const users = await this.query( `SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)}`, database, ); if (users.length === 0) { await this.exec(`CREATE USER ${ident(login)} FOR LOGIN ${ident(login)}`, database); } else { // Re-point an existing user at the login when its SID is not the login's: a database restored // from elsewhere keeps its user under the old login's SID, orphaned. Only then, so a user that // is already mapped is left alone. const orphaned = await this.query( `SELECT 1 AS ok FROM sys.database_principals WHERE name = ${literal(login)} ` + `AND (sid IS NULL OR sid <> SUSER_SID(${literal(login)}))`, database, ); if (orphaned.length > 0) { await this.exec(`ALTER USER ${ident(login)} WITH LOGIN = ${ident(login)}`, database); } } await this.exec(`ALTER ROLE db_owner ADD MEMBER ${ident(login)}`, database); } /** * Whether `login` exists, is enabled, has exactly `password`, and is a db_owner user of * `database`. Read-only: the password is compared with PWDCOMPARE against the stored hash, so * 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. 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 DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, "master", { 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 // right name and the wrong SID, and cannot be reached through the login. const owner = await this.query( `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.database_principals dp ` + `JOIN sys.server_principals sp ON dp.sid = sp.sid ` + `WHERE dp.name = ${literal(login)} AND sp.name = ${literal(login)}) ` + `AND IS_ROLEMEMBER('db_owner', ${literal(login)}) = 1 THEN 1 ELSE 0 END AS int) AS ok`, database, ); return Number(owner[0]?.ok) === 1; } /** Drop a database and its login, idempotently, after evicting live connections. */ async dropDatabaseAndLogin(database: string, login: string): Promise { const dbs = await this.query( `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, ); if (dbs.length > 0) { // SINGLE_USER WITH ROLLBACK IMMEDIATE evicts every other session before the drop. await this.exec(`ALTER DATABASE ${ident(database)} SET SINGLE_USER WITH ROLLBACK IMMEDIATE`); await this.exec(`DROP DATABASE ${ident(database)}`); } const logins = await this.query( `SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(login)}`, ); if (logins.length > 0) { await this.exec(`DROP LOGIN ${ident(login)}`); } } /** List the user databases (database_id > 4 excludes the system four), with size, for the tool. */ async listDatabases(): Promise<{ name: string; sizeBytes: number; state: string }[]> { const rows = await this.query( "SELECT d.name AS name, d.state_desc AS state, " + "SUM(CAST(f.size AS bigint)) * 8 * 1024 AS size_bytes " + "FROM sys.databases d JOIN sys.master_files f ON d.database_id = f.database_id " + "WHERE d.database_id > 4 GROUP BY d.name, d.state_desc ORDER BY d.name", ); return rows.map((r) => ({ name: String(r.name), sizeBytes: Number(r.size_bytes ?? 0), state: String(r.state ?? ""), })); } /** Run a read-only SELECT against a named database, for the mssql_query tool. */ async readOnlyQuery(database: string, sql: string): Promise { // The read-only guarantee is a wrapping transaction that is always rolled back: any write the // statement attempts is undone. The rows are rendered by FOR JSON inside query(). const rows = await this.query( `BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`, database, ); return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows }; } } /** Generate a URL-safe password. */ export function generatePassword(): string { return randomBytes(24).toString("base64url"); } /** Quote a T-SQL identifier (square brackets, doubled internal `]`). */ export function ident(id: string): string { return "[" + id.replace(/]/g, "]]") + "]"; } /** Quote a T-SQL string literal (single quotes, doubled internal quotes). */ export function literal(val: string): string { return "'" + val.replace(/'/g, "''") + "'"; } /** Strip trailing semicolons and whitespace so FOR JSON can be appended to a caller's SELECT. */ function stripTrailingSemis(sql: string): string { return sql.replace(/[\s;]+$/, ""); } function readSecretFile(path: string | undefined): string | undefined { if (!path) return undefined; try { return readFileSync(path, "utf8").trim(); } catch { return undefined; } } function safeUrl(raw: string): URL | undefined { try { return new URL(raw); } catch { return undefined; } } /** * 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. */ 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 []; const parsed = JSON.parse(joined); return Array.isArray(parsed) ? (parsed as Record[]) : [parsed as Record]; }