@malloy-publisher/server 0.0.217 → 0.0.219

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/app/api-doc.yaml +114 -0
  2. package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-gehnjfC6.js} +1 -1
  3. package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-8LQBytE4.js} +1 -1
  4. package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-DDaZLJw-.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-D7P1Kp6O.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-Do_vhxOc.js} +1 -1
  7. package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-Cz9fVwSG.js} +1 -1
  8. package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-UONCloyN.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-Bhzqvbq_.js} +1 -1
  10. package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-BiGj7BML.es-kMHAa8tP.js} +1 -1
  11. package/dist/app/assets/{index-gjr27uMq.js → index-VzRbxcF7.js} +3 -3
  12. package/dist/app/assets/{index-C_AT6ZZw.js → index-ddq4-5hu.js} +1 -1
  13. package/dist/app/assets/{index-bAdd7U9-.js → index-qOQF9CXq.js} +1 -1
  14. package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-B2kmxDh7.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  17. package/dist/server.mjs +19069 -70
  18. package/package.json +1 -1
  19. package/src/dto/connection.dto.ts +43 -0
  20. package/src/service/connection.spec.ts +70 -0
  21. package/src/service/connection.ts +149 -2
  22. package/src/service/connection_config.spec.ts +254 -0
  23. package/src/service/connection_config.ts +127 -0
  24. package/src/service/model.ts +25 -3
  25. package/src/service/package.ts +21 -2
  26. package/src/service/package_worker_path.spec.ts +103 -0
  27. package/src/service/proxy.spec.ts +414 -0
  28. package/src/service/proxy.ts +248 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.217",
4
+ "version": "0.0.219",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -1,10 +1,12 @@
1
1
  import { Type } from "class-transformer";
2
2
  import {
3
+ IsDefined,
3
4
  IsEnum,
4
5
  IsArray,
5
6
  IsNumber,
6
7
  IsOptional,
7
8
  IsString,
9
+ ValidateIf,
8
10
  ValidateNested,
9
11
  } from "class-validator";
10
12
  import "reflect-metadata";
@@ -199,6 +201,42 @@ export class DuckdbConnectionDto {
199
201
  attachedDatabases?: AttachedDatabase[];
200
202
  }
201
203
 
204
+ export class SshProxyConfigDto {
205
+ @IsString()
206
+ host!: string;
207
+
208
+ @IsOptional()
209
+ @IsNumber()
210
+ port?: number;
211
+
212
+ @IsString()
213
+ username!: string;
214
+
215
+ @IsString()
216
+ privateKey!: string;
217
+
218
+ @IsOptional()
219
+ @IsString()
220
+ privateKeyPass?: string;
221
+
222
+ @IsOptional()
223
+ @IsString()
224
+ hostKey?: string;
225
+ }
226
+
227
+ export class ConnectionProxyDto {
228
+ @IsEnum(["ssh"])
229
+ type!: "ssh";
230
+
231
+ // ssh config is required for the ssh proxy type (the only type today); keep
232
+ // the conditional so a future proxy type isn't forced to supply `ssh`.
233
+ @ValidateIf((o) => o.type === "ssh")
234
+ @IsDefined()
235
+ @ValidateNested()
236
+ @Type(() => SshProxyConfigDto)
237
+ ssh?: SshProxyConfigDto;
238
+ }
239
+
202
240
  export class ConnectionDto implements ApiConnection {
203
241
  @IsOptional()
204
242
  @IsString()
@@ -256,4 +294,9 @@ export class ConnectionDto implements ApiConnection {
256
294
  @ValidateNested()
257
295
  @Type(() => DuckdbConnectionDto)
258
296
  duckdbConnection?: DuckdbConnectionDto;
297
+
298
+ @IsOptional()
299
+ @ValidateNested()
300
+ @Type(() => ConnectionProxyDto)
301
+ proxy?: ConnectionProxyDto;
259
302
  }
@@ -1,10 +1,12 @@
1
1
  import { DuckDBConnection } from "@malloydata/db-duckdb";
2
2
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
3
  import fs from "fs/promises";
4
+ import os from "os";
4
5
  import path from "path";
5
6
  import sinon from "sinon";
6
7
  import { components } from "../api";
7
8
  import {
9
+ buildProxiedSslQuery,
8
10
  createEnvironmentConnections,
9
11
  testConnectionConfig,
10
12
  } from "./connection";
@@ -1846,3 +1848,71 @@ describe("connection integration tests", () => {
1846
1848
  );
1847
1849
  });
1848
1850
  });
1851
+
1852
+ describe("buildProxiedSslQuery", () => {
1853
+ const ORIG_CA = process.env.NODE_EXTRA_CA_CERTS;
1854
+ afterEach(() => {
1855
+ if (ORIG_CA === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
1856
+ else process.env.NODE_EXTRA_CA_CERTS = ORIG_CA;
1857
+ });
1858
+
1859
+ it("defaults to no-verify when sslmode is unset (force-SSL target isn't rejected for plaintext)", () => {
1860
+ expect(buildProxiedSslQuery("conn")).toBe("?sslmode=no-verify");
1861
+ });
1862
+
1863
+ it("returns no-verify for explicit no-verify", () => {
1864
+ expect(buildProxiedSslQuery("conn", "no-verify")).toBe(
1865
+ "?sslmode=no-verify",
1866
+ );
1867
+ });
1868
+
1869
+ it("emits an explicit sslmode=disable, never an empty query", () => {
1870
+ // Regression: an empty query lets pg fall back to the deployment's PGSSLMODE
1871
+ // env, which would verify and fail the 127.0.0.1 hostname check.
1872
+ expect(buildProxiedSslQuery("conn", "disable")).toBe("?sslmode=disable");
1873
+ });
1874
+
1875
+ it("builds a chain-verifying, hostname-skipped query for verify-ca", () => {
1876
+ process.env.NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/rds bundle.pem";
1877
+ const q = buildProxiedSslQuery("conn", "verify-ca");
1878
+ expect(q).toContain("uselibpqcompat=true");
1879
+ expect(q).toContain("sslmode=verify-ca");
1880
+ expect(q).toContain(
1881
+ `sslrootcert=${encodeURIComponent("/etc/ssl/certs/rds bundle.pem")}`,
1882
+ );
1883
+ });
1884
+
1885
+ it("throws for verify-ca when no CA bundle is available", () => {
1886
+ delete process.env.NODE_EXTRA_CA_CERTS;
1887
+ expect(() => buildProxiedSslQuery("conn", "verify-ca")).toThrow(
1888
+ /no trusted CA bundle/i,
1889
+ );
1890
+ });
1891
+
1892
+ it("throws for an unsupported sslmode", () => {
1893
+ expect(() => buildProxiedSslQuery("conn", "require")).toThrow(
1894
+ /unsupported sslmode/i,
1895
+ );
1896
+ });
1897
+
1898
+ it("verify-ca connectionString parses to an ssl config (pg-connection-string version guard)", async () => {
1899
+ // uselibpqcompat=true (pg-connection-string >= 2.7) is what turns verify-ca
1900
+ // into "verify chain, skip hostname". If pg is ever downgraded below that
1901
+ // floor, sslmode would be ignored — this guard fails loudly instead.
1902
+ const caPath = path.join(os.tmpdir(), `proxy-ca-${process.pid}.pem`);
1903
+ await fs.writeFile(
1904
+ caPath,
1905
+ "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n",
1906
+ );
1907
+ process.env.NODE_EXTRA_CA_CERTS = caPath;
1908
+ try {
1909
+ const q = buildProxiedSslQuery("conn", "verify-ca");
1910
+ const { parse } = await import("pg-connection-string");
1911
+ const parsed = parse(`postgresql://127.0.0.1:5432/db${q}`);
1912
+ expect(parsed.ssl).toBeTruthy();
1913
+ expect(typeof parsed.ssl).toBe("object");
1914
+ } finally {
1915
+ await fs.rm(caPath, { force: true });
1916
+ }
1917
+ });
1918
+ });
@@ -6,7 +6,10 @@ import "@malloydata/db-duckdb/native";
6
6
  import "@malloydata/db-mysql";
7
7
  import type { MySQLConnection } from "@malloydata/db-mysql";
8
8
  import "@malloydata/db-postgres";
9
- import type { PostgresConnection } from "@malloydata/db-postgres";
9
+ import {
10
+ PooledPostgresConnection,
11
+ type PostgresConnection,
12
+ } from "@malloydata/db-postgres";
10
13
  // Registers the "publisher" connection type (proxies SQL to a remote Publisher
11
14
  // dataplane). No live-class branch is needed in lookupConnection — the default
12
15
  // path resolves it through the registry like any other registered type.
@@ -41,6 +44,7 @@ import {
41
44
  normalizeSnowflakePrivateKey,
42
45
  } from "./connection_config";
43
46
  import { CloudStorageCredentials } from "./gcs_s3_utils";
47
+ import { openProxy, type ProxyEndpoint } from "./proxy";
44
48
 
45
49
  type AttachedDatabase = components["schemas"]["AttachedDatabase"];
46
50
  type ApiConnection = components["schemas"]["Connection"];
@@ -921,6 +925,75 @@ function buildSnowflakePrivateKeyConnection(
921
925
  });
922
926
  }
923
927
 
928
+ // TLS mode for a proxied postgres connection, as `pg` connectionString query
929
+ // params. @malloydata/db-postgres exposes no ssl config, but it forwards a
930
+ // connectionString straight to pg — which parses sslmode. The tunnel terminates
931
+ // at 127.0.0.1, so pg's hostname check can't apply: `verify-ca` validates the
932
+ // cert chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
933
+ // Amazon RDS roots) WITHOUT the hostname; `no-verify` encrypts without checking;
934
+ // `disable` uses no TLS. Full verify-full through a tunnel needs a servername
935
+ // override (a raw ssl object) — awaits an upstream passthrough (malloydata/malloy#2960).
936
+ export function buildProxiedSslQuery(name: string, sslmode?: string): string {
937
+ const caBundle = process.env.NODE_EXTRA_CA_CERTS;
938
+ // Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
939
+ // isn't rejected for plaintext. Operators opt up to verify-ca when the
940
+ // target's CA is in the trust bundle, or down to disable for non-TLS DBs.
941
+ const mode = sslmode ?? "no-verify";
942
+ switch (mode) {
943
+ case "disable":
944
+ // Explicit: with no sslmode in the connectionString, pg falls back to
945
+ // the PGSSLMODE env (set on the non-proxied path), which would verify
946
+ // and fail the 127.0.0.1 hostname check. Force plaintext explicitly.
947
+ return "?sslmode=disable";
948
+ case "no-verify":
949
+ return "?sslmode=no-verify";
950
+ case "verify-ca":
951
+ // Env-set backstop; validateConnectionShape does the readable-file check
952
+ // at config load (so a bad bundle fails fast instead of at connect time).
953
+ if (!caBundle) {
954
+ throw new Error(
955
+ `Connection proxy on '${name}' uses sslmode 'verify-ca' but no trusted CA bundle is available (NODE_EXTRA_CA_CERTS is unset). Add the CA bundle to the image or use sslmode 'no-verify'.`,
956
+ );
957
+ }
958
+ return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
959
+ default:
960
+ throw new Error(
961
+ `Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca).`,
962
+ );
963
+ }
964
+ }
965
+
966
+ function buildProxiedPostgresConnection(
967
+ metadata: EnvironmentConnectionMetadata,
968
+ endpoint: ProxyEndpoint,
969
+ ): PooledPostgresConnection {
970
+ const name = metadata.apiConnection.name!;
971
+ const pg = metadata.apiConnection.postgresConnection;
972
+ if (!pg) {
973
+ throw new Error(
974
+ `Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
975
+ );
976
+ }
977
+ // Build a connectionString to the LOCAL tunnel endpoint carrying the TLS mode.
978
+ // This is NOT the tenant's connectionString (rejected in validateConnectionShape
979
+ // because it can't be tunneled) — we construct it ourselves and it targets
980
+ // 127.0.0.1:<localport>, so there's no ambiguity about where it connects.
981
+ const enc = encodeURIComponent;
982
+ const auth = pg.userName
983
+ ? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
984
+ : "";
985
+ const db = pg.databaseName ? `/${enc(pg.databaseName)}` : "";
986
+ const ssl = buildProxiedSslQuery(name, pg.sslmode);
987
+ const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${ssl}`;
988
+ return new PooledPostgresConnection({
989
+ name,
990
+ connectionString,
991
+ // Pool sizing mirrors buildSnowflakePrivateKeyConnection.
992
+ poolMin: 1,
993
+ poolMax: 20,
994
+ });
995
+ }
996
+
924
997
  function buildDuckLakeConnection(
925
998
  metadata: EnvironmentConnectionMetadata,
926
999
  entry: CoreConnectionEntry,
@@ -987,6 +1060,11 @@ export function buildEnvironmentMalloyConfig(
987
1060
  const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
988
1061
  const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
989
1062
  const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
1063
+ const proxyConnectionCache = new Map<
1064
+ string,
1065
+ Promise<PooledPostgresConnection>
1066
+ >();
1067
+ const proxyEndpoints = new Map<string, ProxyEndpoint>();
990
1068
  const attachPromises = new WeakMap<Connection, Promise<void>>();
991
1069
 
992
1070
  const malloyConfig = new MalloyConfig(assembled.pojo, {
@@ -1044,6 +1122,64 @@ export function buildEnvironmentMalloyConfig(
1044
1122
  return connection;
1045
1123
  }
1046
1124
 
1125
+ if (metadata?.proxy) {
1126
+ let connectionPromise = proxyConnectionCache.get(name!);
1127
+ if (!connectionPromise) {
1128
+ const connType = metadata.apiConnection.type;
1129
+ if (connType !== "postgres") {
1130
+ throw new Error(
1131
+ `SSH proxy is not yet supported for connection type '${connType}'. ` +
1132
+ `Only 'postgres' is implemented.`,
1133
+ );
1134
+ }
1135
+ // validateConnectionShape already enforces explicit host/port
1136
+ // at config load (the connectionString form can't be tunneled);
1137
+ // this is a defense-in-depth guard for any path that reaches
1138
+ // lookup without that validation.
1139
+ const pgConfig = metadata.apiConnection.postgresConnection;
1140
+ if (!pgConfig?.host || !pgConfig?.port) {
1141
+ throw new Error(
1142
+ `SSH proxy for connection '${name}' requires explicit host and ` +
1143
+ `port on the postgres connection; the connectionString form ` +
1144
+ `is not supported with a proxy.`,
1145
+ );
1146
+ }
1147
+ connectionPromise = (async () => {
1148
+ const endpoint = await openProxy(metadata.proxy!, {
1149
+ host: pgConfig.host!,
1150
+ port: pgConfig.port!,
1151
+ });
1152
+ try {
1153
+ const connection = buildProxiedPostgresConnection(
1154
+ metadata,
1155
+ endpoint,
1156
+ );
1157
+ // Register only after the build succeeds: a throw between
1158
+ // openProxy and here would otherwise leave an orphaned
1159
+ // tunnel in proxyEndpoints that a retry can't reach (the
1160
+ // outer .catch evicts the cache but not the endpoint).
1161
+ proxyEndpoints.set(name!, endpoint);
1162
+ return connection;
1163
+ } catch (err) {
1164
+ await endpoint.close().catch(() => {});
1165
+ throw err;
1166
+ }
1167
+ })();
1168
+ // Drop a rejected build from the cache so a later lookup can
1169
+ // retry, rather than replaying a transient SSH failure (bastion
1170
+ // restart, network blip) until the environment is rebuilt.
1171
+ connectionPromise.catch(() => {
1172
+ if (
1173
+ proxyConnectionCache.get(name!) === connectionPromise
1174
+ ) {
1175
+ proxyConnectionCache.delete(name!);
1176
+ }
1177
+ });
1178
+ proxyConnectionCache.set(name!, connectionPromise);
1179
+ }
1180
+ return connectionPromise;
1181
+ }
1182
+
1047
1183
  if (metadata?.hasSnowflakePrivateKey) {
1048
1184
  let connectionPromise = snowflakeJwtCache.get(name!);
1049
1185
  if (!connectionPromise) {
@@ -1086,6 +1222,7 @@ export function buildEnvironmentMalloyConfig(
1086
1222
  ...duckLakeCache.values(),
1087
1223
  ...snowflakeJwtCache.values(),
1088
1224
  ...azureDuckDBCache.values(),
1225
+ ...proxyConnectionCache.values(),
1089
1226
  ];
1090
1227
  const closeResults = await Promise.allSettled([
1091
1228
  malloyConfig.shutdown("close"),
@@ -1094,11 +1231,21 @@ export function buildEnvironmentMalloyConfig(
1094
1231
  await connection.close();
1095
1232
  }),
1096
1233
  ]);
1234
+ // Close proxy tunnels only AFTER the build promises above have settled:
1235
+ // openProxy may still be in flight during a teardown race, and the
1236
+ // endpoint is registered inside that promise — capturing the map
1237
+ // earlier would miss it and leak the tunnel. Closing after the DB
1238
+ // connections also keeps the tunnel alive while they drain.
1239
+ const endpointResults = await Promise.allSettled(
1240
+ [...proxyEndpoints.values()].map((ep) => ep.close()),
1241
+ );
1097
1242
  duckLakeCache.clear();
1098
1243
  snowflakeJwtCache.clear();
1099
1244
  azureDuckDBCache.clear();
1245
+ proxyConnectionCache.clear();
1246
+ proxyEndpoints.clear();
1100
1247
 
1101
- const failures = closeResults.filter(
1248
+ const failures = [...closeResults, ...endpointResults].filter(
1102
1249
  (result): result is PromiseRejectedResult =>
1103
1250
  result.status === "rejected",
1104
1251
  );
@@ -349,3 +349,257 @@ describe("assembleEnvironmentConnections — publisher", () => {
349
349
  });
350
350
  });
351
351
  });
352
+
353
+ describe("SSH proxy validation", () => {
354
+ const validSshProxy: ApiConnection = {
355
+ name: "pg-via-bastion",
356
+ type: "postgres",
357
+ postgresConnection: {
358
+ host: "127.0.0.1",
359
+ port: 5432,
360
+ databaseName: "mydb",
361
+ userName: "user",
362
+ password: "pass",
363
+ },
364
+ proxy: {
365
+ type: "ssh",
366
+ ssh: {
367
+ host: "bastion.example.com",
368
+ username: "ec2-user",
369
+ privateKey:
370
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----",
371
+ },
372
+ },
373
+ };
374
+
375
+ it("allows a postgres+ssh-proxy connection with no env flag required", () => {
376
+ // The SSH proxy is a normal connection capability — deliberately NOT gated
377
+ // by PUBLISHER_ALLOW_PROXY_CONNECTIONS (that flag is for the `publisher`
378
+ // HTTP multi-hop type). Prove it assembles with the flag unset.
379
+ const priorFlag = process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
380
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
381
+ try {
382
+ const { pojo } = assembleEnvironmentConnections([validSshProxy]);
383
+ expect(pojo.connections["pg-via-bastion"].is).toBe("postgres");
384
+ } finally {
385
+ if (priorFlag === undefined) {
386
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
387
+ } else {
388
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = priorFlag;
389
+ }
390
+ }
391
+ });
392
+
393
+ it("rejects a non-postgres (bigquery) connection with a proxy", () => {
394
+ const conn: ApiConnection = {
395
+ name: "bq-via-bastion",
396
+ type: "bigquery",
397
+ bigqueryConnection: {
398
+ serviceAccountKeyJson: JSON.stringify({
399
+ type: "service_account",
400
+ project_id: "proj",
401
+ private_key: "key",
402
+ client_email: "sa@proj.iam.gserviceaccount.com",
403
+ }),
404
+ },
405
+ proxy: {
406
+ type: "ssh",
407
+ ssh: {
408
+ host: "bastion.example.com",
409
+ username: "ec2-user",
410
+ privateKey: "key",
411
+ },
412
+ },
413
+ };
414
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
415
+ "Connection proxy is not supported for type 'bigquery' (only 'postgres' today)",
416
+ );
417
+ });
418
+
419
+ it("rejects an ssh proxy with no ssh config object", () => {
420
+ const conn: ApiConnection = {
421
+ name: "pg-no-ssh-config",
422
+ type: "postgres",
423
+ postgresConnection: {
424
+ host: "127.0.0.1",
425
+ databaseName: "mydb",
426
+ },
427
+ proxy: {
428
+ type: "ssh",
429
+ },
430
+ };
431
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
432
+ "Connection proxy on 'pg-no-ssh-config' has type 'ssh' but no 'ssh' config object",
433
+ );
434
+ });
435
+
436
+ it("rejects a proxied postgres connection that also carries a connectionString", () => {
437
+ const conn: ApiConnection = {
438
+ ...validSshProxy,
439
+ name: "pg-with-connstring",
440
+ postgresConnection: {
441
+ connectionString: "postgresql://real-db.example.com:5432/mydb",
442
+ host: "127.0.0.1",
443
+ port: 5432,
444
+ databaseName: "mydb",
445
+ },
446
+ };
447
+ // The tunnel forwards to host/port; a connectionString would be silently
448
+ // ignored and could point at a different database, so it's rejected.
449
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
450
+ "does not support the connectionString form",
451
+ );
452
+ });
453
+
454
+ it("rejects a proxied postgres connection missing explicit host/port", () => {
455
+ const conn: ApiConnection = {
456
+ ...validSshProxy,
457
+ name: "pg-no-host-port",
458
+ postgresConnection: {
459
+ databaseName: "mydb",
460
+ userName: "user",
461
+ },
462
+ };
463
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
464
+ "requires explicit host and port",
465
+ );
466
+ });
467
+
468
+ it("accepts a multi-line hostKey and sslmode=no-verify", () => {
469
+ const conn: ApiConnection = {
470
+ ...validSshProxy,
471
+ postgresConnection: {
472
+ ...validSshProxy.postgresConnection!,
473
+ sslmode: "no-verify",
474
+ },
475
+ proxy: {
476
+ type: "ssh",
477
+ ssh: {
478
+ ...validSshProxy.proxy!.ssh!,
479
+ hostKey:
480
+ "bastion ssh-ed25519 AAAAdecoy\nbastion ssh-rsa AAAAreal",
481
+ },
482
+ },
483
+ };
484
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
485
+ });
486
+
487
+ it("rejects a hostKey that parses to zero keys (comment/blank only) — fail-closed at config load", () => {
488
+ const conn: ApiConnection = {
489
+ ...validSshProxy,
490
+ proxy: {
491
+ type: "ssh",
492
+ ssh: {
493
+ ...validSshProxy.proxy!.ssh!,
494
+ hostKey: "# ssh-keyscan bastion timed out\n \n",
495
+ },
496
+ },
497
+ };
498
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
499
+ "no usable host-key line",
500
+ );
501
+ });
502
+
503
+ it("rejects a whitespace-only hostKey (must not silently connect unpinned)", () => {
504
+ const conn: ApiConnection = {
505
+ ...validSshProxy,
506
+ proxy: {
507
+ type: "ssh",
508
+ ssh: {
509
+ ...validSshProxy.proxy!.ssh!,
510
+ hostKey: " ",
511
+ },
512
+ },
513
+ };
514
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
515
+ "no usable host-key line",
516
+ );
517
+ });
518
+
519
+ it("treats an empty-string hostKey as unpinned (omission-equivalent)", () => {
520
+ const conn: ApiConnection = {
521
+ ...validSshProxy,
522
+ proxy: {
523
+ type: "ssh",
524
+ ssh: { ...validSshProxy.proxy!.ssh!, hostKey: "" },
525
+ },
526
+ };
527
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
528
+ });
529
+
530
+ it("rejects an unsupported sslmode", () => {
531
+ const conn = {
532
+ ...validSshProxy,
533
+ postgresConnection: {
534
+ ...validSshProxy.postgresConnection!,
535
+ sslmode: "require",
536
+ },
537
+ } as unknown as ApiConnection;
538
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
539
+ "unsupported sslmode 'require'",
540
+ );
541
+ });
542
+
543
+ it("rejects an empty-string sslmode at config load (not deferred to connect)", () => {
544
+ const conn = {
545
+ ...validSshProxy,
546
+ postgresConnection: {
547
+ ...validSshProxy.postgresConnection!,
548
+ sslmode: "",
549
+ },
550
+ } as unknown as ApiConnection;
551
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
552
+ "unsupported sslmode",
553
+ );
554
+ });
555
+
556
+ it("rejects sslmode=verify-ca when no CA bundle is available", () => {
557
+ const prior = process.env.NODE_EXTRA_CA_CERTS;
558
+ delete process.env.NODE_EXTRA_CA_CERTS;
559
+ try {
560
+ const conn: ApiConnection = {
561
+ ...validSshProxy,
562
+ postgresConnection: {
563
+ ...validSshProxy.postgresConnection!,
564
+ sslmode: "verify-ca",
565
+ },
566
+ };
567
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
568
+ "verify-ca",
569
+ );
570
+ } finally {
571
+ if (prior !== undefined) process.env.NODE_EXTRA_CA_CERTS = prior;
572
+ }
573
+ });
574
+
575
+ it("rejects sslmode set on a non-proxied connection (silent-ignore footgun)", () => {
576
+ const conn: ApiConnection = {
577
+ name: "pg-direct",
578
+ type: "postgres",
579
+ postgresConnection: {
580
+ host: "db.example.com",
581
+ port: 5432,
582
+ databaseName: "mydb",
583
+ userName: "user",
584
+ password: "pass",
585
+ sslmode: "verify-ca",
586
+ },
587
+ };
588
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
589
+ "only supported for proxied connections",
590
+ );
591
+ });
592
+
593
+ it("rejects a proxied database name with URI-reserved characters", () => {
594
+ const conn: ApiConnection = {
595
+ ...validSshProxy,
596
+ postgresConnection: {
597
+ ...validSshProxy.postgresConnection!,
598
+ databaseName: "sales@prod",
599
+ },
600
+ };
601
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
602
+ "URI-reserved characters",
603
+ );
604
+ });
605
+ });