@malloy-publisher/server 0.0.229 → 0.0.231

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 (51) hide show
  1. package/dist/app/api-doc.yaml +18 -9
  2. package/dist/app/assets/{EnvironmentPage-QOoHiVeJ.js → EnvironmentPage-wa_EPkwK.js} +1 -1
  3. package/dist/app/assets/{HomePage-C71GOfVW.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-CrgCAwLe.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/{MainPage-BG5__FN3.js → MainPage-CuJLrPNI.js} +1 -1
  6. package/dist/app/assets/{MaterializationsPage-DE6PnrDR.js → MaterializationsPage-D_67x2ee.js} +1 -1
  7. package/dist/app/assets/{ModelPage-CcBjcbLm.js → ModelPage-D5JtAWqR.js} +1 -1
  8. package/dist/app/assets/{PackagePage-JTy3ztkB.js → PackagePage-BRwtqUSG.js} +1 -1
  9. package/dist/app/assets/{RouteError-Cymbp47a.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/{ThemeEditorPage-C_nMnHr8.js → ThemeEditorPage-CTCeBneA.js} +1 -1
  11. package/dist/app/assets/{WorkbookPage-CPQu-DQx.js → WorkbookPage-SN6f1RBm.js} +1 -1
  12. package/dist/app/assets/{core-Coi3caGs.es-CSOmajHS.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +1 -1
  13. package/dist/app/assets/{index-DlWCXghy.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/{index-DxArlgRD.js → index-C_tJstcx.js} +4 -4
  16. package/dist/app/assets/{index-CcuuST2X.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-CkmABCAw.js → index-DU4r7GdU.js} +435 -422
  18. package/dist/app/index.html +1 -1
  19. package/dist/package_load_worker.mjs +108 -7
  20. package/dist/server.mjs +576 -50
  21. package/package.json +12 -12
  22. package/scripts/bake-duckdb-extensions.js +5 -2
  23. package/src/config.ts +40 -0
  24. package/src/ducklake_version.spec.ts +163 -0
  25. package/src/ducklake_version.ts +153 -0
  26. package/src/errors.ts +12 -0
  27. package/src/malloy_pin_prereqs.spec.ts +25 -0
  28. package/src/mcp/skills/skills_bundle.json +1 -1
  29. package/src/server.ts +7 -0
  30. package/src/service/authorize.spec.ts +22 -0
  31. package/src/service/authorize.ts +267 -10
  32. package/src/service/authorize_integration.spec.ts +1068 -26
  33. package/src/service/connection.spec.ts +71 -0
  34. package/src/service/connection.ts +312 -25
  35. package/src/service/connection_config.spec.ts +21 -0
  36. package/src/service/connection_config.ts +15 -1
  37. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  38. package/src/service/environment.ts +43 -9
  39. package/src/service/environment_store.spec.ts +197 -12
  40. package/src/service/environment_store.ts +88 -14
  41. package/src/service/extension_fetch_policy.spec.ts +256 -0
  42. package/src/service/materialization_scheduler.spec.ts +29 -0
  43. package/src/service/materialization_service.spec.ts +119 -1
  44. package/src/service/model.spec.ts +67 -0
  45. package/src/service/model.ts +656 -31
  46. package/src/service/package.spec.ts +38 -0
  47. package/src/service/package.ts +12 -1
  48. package/src/storage/duckdb/MaterializationRepository.spec.ts +39 -2
  49. package/src/storage/duckdb/MaterializationRepository.ts +12 -0
  50. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  51. package/dist/app/assets/index-CM2qhQCI.js +0 -18
@@ -8,6 +8,7 @@ import { components } from "../api";
8
8
  import {
9
9
  buildProxiedSslQuery,
10
10
  createEnvironmentConnections,
11
+ resolveProxiedTls,
11
12
  testConnectionConfig,
12
13
  } from "./connection";
13
14
  import { assembleEnvironmentConnections } from "./connection_config";
@@ -1917,3 +1918,73 @@ describe("buildProxiedSslQuery", () => {
1917
1918
  }
1918
1919
  });
1919
1920
  });
1921
+
1922
+ describe("resolveProxiedTls", () => {
1923
+ it("routes verify-full to a raw ssl object with servername = the real host", () => {
1924
+ // verify-full (chain + hostname) can't ride the connectionString through a
1925
+ // tunnel — it needs ssl.servername set to the real DB host so pg checks the
1926
+ // cert's SAN against it, not against the 127.0.0.1 socket address.
1927
+ const { query, ssl } = resolveProxiedTls(
1928
+ "conn",
1929
+ "db.internal.example.com",
1930
+ "verify-full",
1931
+ );
1932
+ expect(query).toBe("");
1933
+ expect(ssl).toEqual({
1934
+ servername: "db.internal.example.com",
1935
+ rejectUnauthorized: true,
1936
+ });
1937
+ });
1938
+
1939
+ it("throws for verify-full with no host (servername would be undefined)", () => {
1940
+ expect(() => resolveProxiedTls("conn", undefined, "verify-full")).toThrow(
1941
+ /verify-full.*no host/i,
1942
+ );
1943
+ });
1944
+
1945
+ it("routes the connectionString-param modes through buildProxiedSslQuery with no ssl object", () => {
1946
+ // verify-ca needs a CA bundle to build its query, so set one for the loop.
1947
+ const prior = process.env.NODE_EXTRA_CA_CERTS;
1948
+ process.env.NODE_EXTRA_CA_CERTS = "/etc/ssl/certs/bundle.pem";
1949
+ try {
1950
+ for (const mode of ["disable", "no-verify", "verify-ca", undefined]) {
1951
+ const { query, ssl } = resolveProxiedTls("conn", "127.0.0.1", mode);
1952
+ expect(query).toBe(buildProxiedSslQuery("conn", mode));
1953
+ expect(ssl).toBeUndefined();
1954
+ }
1955
+ } finally {
1956
+ if (prior === undefined) delete process.env.NODE_EXTRA_CA_CERTS;
1957
+ else process.env.NODE_EXTRA_CA_CERTS = prior;
1958
+ }
1959
+ });
1960
+
1961
+ it("verify-full: a bare connectionString does not clobber the explicit ssl object (pg merge guard)", async () => {
1962
+ // Load-bearing invariant: pg's ConnectionParameters does
1963
+ // Object.assign({}, config, parse(connectionString)). A verify-full
1964
+ // connectionString carries no sslmode, so pg-connection-string adds no
1965
+ // `ssl` key and our explicit { servername, rejectUnauthorized } survives
1966
+ // the merge. If a future pg bump changes this — or we accidentally emit an
1967
+ // sslmode into the query — this guard fails loudly instead of silently
1968
+ // downgrading verify-full.
1969
+ const { query, ssl } = resolveProxiedTls(
1970
+ "conn",
1971
+ "db.internal.example.com",
1972
+ "verify-full",
1973
+ );
1974
+ const { default: ConnectionParameters } = await import(
1975
+ "pg/lib/connection-parameters.js"
1976
+ );
1977
+ const params = new (ConnectionParameters as unknown as new (
1978
+ c: unknown,
1979
+ ) => {
1980
+ ssl: unknown;
1981
+ })({
1982
+ connectionString: `postgresql://u:p@127.0.0.1:5432/db${query}`,
1983
+ ssl,
1984
+ });
1985
+ expect(params.ssl).toEqual({
1986
+ servername: "db.internal.example.com",
1987
+ rejectUnauthorized: true,
1988
+ });
1989
+ });
1990
+ });
@@ -9,6 +9,7 @@ import "@malloydata/db-postgres";
9
9
  import {
10
10
  PooledPostgresConnection,
11
11
  type PostgresConnection,
12
+ type PostgresSSLConfig,
12
13
  } from "@malloydata/db-postgres";
13
14
  // Registers the "publisher" connection type (proxies SQL to a remote Publisher
14
15
  // dataplane). No live-class branch is needed in lookupConnection — the default
@@ -30,6 +31,12 @@ import type { LookupConnection } from "@malloydata/malloy/connection";
30
31
  import { AxiosError } from "axios";
31
32
  import fs from "fs/promises";
32
33
  import { components } from "../api";
34
+ import { getExtensionFetchPolicy } from "../config";
35
+ import {
36
+ catalogFormatRangeForEngine,
37
+ isCatalogFormatInRange,
38
+ } from "../ducklake_version";
39
+ import { UnsupportedCatalogFormatError } from "../errors";
33
40
  import { logAxiosError, logger } from "../logger";
34
41
  import { redactPgSecrets } from "../pg_helpers";
35
42
  import {
@@ -71,26 +78,129 @@ export type InternalConnection = ApiConnection & {
71
78
  };
72
79
 
73
80
  // Shared utilities
81
+
82
+ // Publisher-owned DuckDB sessions whose extension PRAGMAs have already been
83
+ // pinned. The lookup funnel runs on every resolve, and the attach paths pin
84
+ // too, so this WeakSet keeps the SETs to once per connection object. The
85
+ // DuckLake tier pins with `alwaysDisableAutoinstall` inside its attach, which
86
+ // runs before the funnel ever sees the connection, so the stronger always-off
87
+ // setting is recorded first and never re-evaluated down to policy here.
88
+ const extensionSessionPinned = new WeakSet<Connection>();
89
+
90
+ /**
91
+ * Pin the extension-management PRAGMAs on a Publisher-owned DuckDB session.
92
+ * Publisher installs the extensions it needs explicitly (see
93
+ * {@link installAndLoadExtension}), so DuckDB's own IMPLICIT auto-install
94
+ * (fetching a known extension a query happens to reference) is never
95
+ * load-bearing.
96
+ *
97
+ * Auto-LOAD is left ON (DuckDB's default) so any locally-present extension
98
+ * still lazy-loads — the local-first UX every deployment relies on. Implicit
99
+ * auto-INSTALL is turned OFF when EITHER:
100
+ * - `alwaysDisableAutoinstall` is set — the DuckLake tier's build/serve
101
+ * sessions only ever need the curated extension set, so they never
102
+ * implicitly fetch, regardless of the deployment policy; OR
103
+ * - `EXTENSION_FETCH_POLICY=local-only` — the air-gapped / pinned-image
104
+ * posture, where no session may reach the network.
105
+ *
106
+ * Under the default `on-demand` policy a generic session is left untouched
107
+ * (DuckDB's default), so existing standalone/OSS users see no behaviour change.
108
+ *
109
+ * Called for EVERY Publisher-owned DuckDB session — the environment lookup
110
+ * funnel, the per-package sandbox, and the attach entry points — so the pin has
111
+ * no gap regardless of whether a session has attached databases. On a failed
112
+ * SET: under `local-only` we FAIL CLOSED (throw), because the whole promise of
113
+ * that mode is that no session can reach the network; under the tier's
114
+ * on-demand always-off case the pin is defense-in-depth (the install path
115
+ * already avoids implicit fetches), so we log and continue rather than fail an
116
+ * otherwise-valid attach.
117
+ */
118
+ export async function applyExtensionSessionSettings(
119
+ connection: DuckDBConnection,
120
+ {
121
+ alwaysDisableAutoinstall = false,
122
+ }: { alwaysDisableAutoinstall?: boolean } = {},
123
+ ): Promise<void> {
124
+ const policy = getExtensionFetchPolicy();
125
+ const disableAutoinstall =
126
+ alwaysDisableAutoinstall || policy === "local-only";
127
+ if (!disableAutoinstall || extensionSessionPinned.has(connection)) {
128
+ return;
129
+ }
130
+ try {
131
+ await connection.runSQL("SET autoinstall_known_extensions=false;");
132
+ // Keep autoload on: it is DuckDB's default, but set it explicitly next to
133
+ // an install lockdown so a locally-present/baked extension still loads.
134
+ await connection.runSQL("SET autoload_known_extensions=true;");
135
+ extensionSessionPinned.add(connection);
136
+ } catch (error) {
137
+ const detail = error instanceof Error ? error.message : String(error);
138
+ if (policy === "local-only") {
139
+ throw new Error(
140
+ `Failed to disable DuckDB implicit extension auto-install under ` +
141
+ `EXTENSION_FETCH_POLICY=local-only; refusing to open a session that ` +
142
+ `could still fetch from the network. Underlying error: ${detail}`,
143
+ );
144
+ }
145
+ logger.warn("Failed to pin DuckDB extension session settings", {
146
+ error: detail,
147
+ });
148
+ }
149
+ }
150
+
74
151
  async function installAndLoadExtension(
75
152
  connection: DuckDBConnection,
76
153
  extensionName: string,
77
154
  fromCommunity = false,
78
155
  ): Promise<void> {
156
+ const policy = getExtensionFetchPolicy();
157
+
158
+ // `local-only` never fetches: skip INSTALL entirely and rely on the
159
+ // extension already being present on disk (baked into the image). Auto-LOAD
160
+ // stays on (see applyExtensionSessionSettings), so a present extension still
161
+ // lazy-loads; a missing one fails the LOAD below and is reported loudly.
162
+ if (policy === "on-demand") {
163
+ try {
164
+ // Plain INSTALL is local-first: it no-ops when the extension is already
165
+ // present and only fetches when it is missing. We dropped FORCE, which
166
+ // re-fetched from the community CDN whenever the network was up — even
167
+ // for a baked extension — silently drifting the running binary past the
168
+ // offline-verified build.
169
+ const installCommand = fromCommunity
170
+ ? `INSTALL ${extensionName} FROM community;`
171
+ : `INSTALL ${extensionName};`;
172
+ await connection.runSQL(installCommand);
173
+ logger.info(`${extensionName} extension installed`);
174
+ } catch (error) {
175
+ logger.info(
176
+ `${extensionName} extension already installed or install skipped`,
177
+ { error },
178
+ );
179
+ }
180
+ }
181
+
79
182
  try {
80
- const installCommand = fromCommunity
81
- ? `FORCE INSTALL '${extensionName}' FROM community;`
82
- : `INSTALL ${extensionName};`;
83
- await connection.runSQL(installCommand);
84
- logger.info(`${extensionName} extension installed`);
183
+ await connection.runSQL(`LOAD ${extensionName};`);
184
+ logger.info(`${extensionName} extension loaded`);
85
185
  } catch (error) {
86
- logger.info(
87
- `${extensionName} extension already installed or install skipped`,
88
- { error },
89
- );
186
+ if (policy === "local-only") {
187
+ const detail = error instanceof Error ? error.message : String(error);
188
+ logger.warn("DuckDB extension unavailable under local-only policy", {
189
+ extension: extensionName,
190
+ policy,
191
+ error: detail,
192
+ });
193
+ throw new Error(
194
+ `DuckDB extension '${extensionName}' is not available locally and ` +
195
+ `EXTENSION_FETCH_POLICY=local-only forbids fetching it. Bake the ` +
196
+ `'${extensionName}' extension into the image (or a pre-populated ` +
197
+ `~/.duckdb/extensions directory), or set ` +
198
+ `EXTENSION_FETCH_POLICY=on-demand to allow fetching missing ` +
199
+ `extensions on first use. Underlying LOAD error: ${detail}`,
200
+ );
201
+ }
202
+ throw error;
90
203
  }
91
-
92
- await connection.runSQL(`LOAD ${extensionName};`);
93
- logger.info(`${extensionName} extension loaded`);
94
204
  }
95
205
 
96
206
  async function isDatabaseAttached(
@@ -339,11 +449,141 @@ async function attachPostgres(
339
449
  logger.info(`Successfully attached PostgreSQL database: ${attachedDb.name}`);
340
450
  }
341
451
 
452
+ /** Rows out of a DuckDBConnection.runSQL result, in either shape it returns. */
453
+ function runSQLRows(result: unknown): Record<string, unknown>[] {
454
+ if (Array.isArray(result)) return result as Record<string, unknown>[];
455
+ const rows = (result as { rows?: unknown }).rows;
456
+ return Array.isArray(rows) ? (rows as Record<string, unknown>[]) : [];
457
+ }
458
+
459
+ /**
460
+ * Runtime range-preflight for a DuckLake ATTACH. Reads the catalog's recorded
461
+ * format version from its `ducklake_metadata` table via a plain postgres ATTACH
462
+ * (this does NOT invoke the DuckLake extension on the catalog), and checks it
463
+ * against the format range the running DuckDB engine supports (see
464
+ * `ducklake_version.ts`). On a mismatch it throws an
465
+ * {@link UnsupportedCatalogFormatError} (→ HTTP 422) naming the format found,
466
+ * the supported range, and where to migrate — instead of letting the real
467
+ * DuckLake ATTACH fail deep inside DuckDB as an opaque 500.
468
+ *
469
+ * Non-load-bearing on its own read: any failure of the preflight itself
470
+ * (missing `ducklake_metadata` table, non-postgres catalog, query timeout,
471
+ * connect failure, an engine with no matrix row) logs and returns so the real
472
+ * ATTACH below stays the source of truth for unrelated errors. Only a
473
+ * successfully-read, definitively-out-of-range format raises the clean 422.
474
+ *
475
+ * The temp attach uses a per-call unique name so a failed DETACH can't wedge
476
+ * the fixed name (which would make every later preflight for this connection
477
+ * fail its ATTACH with "already exists" and silently skip), and so two
478
+ * concurrent first-touch lookups can't cross-DETACH each other.
479
+ */
480
+ let ducklakePreflightSeq = 0;
481
+ async function preflightDuckLakeCatalogFormat(
482
+ connection: DuckDBConnection,
483
+ dbName: string,
484
+ pgConnString: string,
485
+ ): Promise<void> {
486
+ const tempDb = `${dbName}_fmt_preflight_${++ducklakePreflightSeq}`;
487
+ let catalogFormat: string | undefined;
488
+ try {
489
+ await connection.runSQL(
490
+ `ATTACH '${escapeSQL(pgConnString)}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
491
+ );
492
+ const result = await connection.runSQL(
493
+ `SELECT value FROM ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
494
+ );
495
+ const value = runSQLRows(result)[0]?.value;
496
+ catalogFormat = typeof value === "string" ? value : undefined;
497
+ } catch (error) {
498
+ logger.warn(
499
+ "DuckLake catalog-format preflight read failed; falling back to ATTACH",
500
+ {
501
+ dbName,
502
+ error: redactPgSecrets(
503
+ error instanceof Error ? error.message : String(error),
504
+ ),
505
+ },
506
+ );
507
+ return;
508
+ } finally {
509
+ try {
510
+ await connection.runSQL(`DETACH ${tempDb};`);
511
+ } catch {
512
+ // The ATTACH may have failed, so there may be nothing to detach.
513
+ }
514
+ }
515
+
516
+ if (!catalogFormat) {
517
+ // Table present but no version row, or unreadable value: let ATTACH decide.
518
+ return;
519
+ }
520
+
521
+ // Derive the supported range from the engine DuckDB actually reports. An
522
+ // unknown engine (no matrix row) yields null → skip (the CI version-contract
523
+ // check is what guarantees the pinned engine is always covered).
524
+ let engineVersion = "";
525
+ try {
526
+ const versionResult = await connection.runSQL("SELECT version() AS v;");
527
+ engineVersion = String(runSQLRows(versionResult)[0]?.v ?? "");
528
+ } catch (error) {
529
+ logger.warn(
530
+ "DuckLake catalog-format preflight: could not read engine version; skipping",
531
+ {
532
+ dbName,
533
+ error: error instanceof Error ? error.message : String(error),
534
+ },
535
+ );
536
+ return;
537
+ }
538
+
539
+ const range = catalogFormatRangeForEngine(engineVersion);
540
+ if (!range) {
541
+ logger.warn(
542
+ "DuckLake catalog-format preflight: engine not covered by the format matrix; skipping",
543
+ { dbName, engineVersion },
544
+ );
545
+ return;
546
+ }
547
+
548
+ if (!isCatalogFormatInRange(catalogFormat, range)) {
549
+ logger.warn(
550
+ "DuckLake catalog format outside supported range; rejecting",
551
+ {
552
+ dbName,
553
+ catalogFormat,
554
+ supportedMin: range.min,
555
+ supportedMax: range.max,
556
+ engineVersion: range.engineVersion,
557
+ },
558
+ );
559
+ throw new UnsupportedCatalogFormatError(
560
+ `DuckLake catalog '${dbName}' has format version ${catalogFormat}, ` +
561
+ `which is outside the range this Publisher supports ` +
562
+ `(${range.min} ≤ format ≤ ${range.max}). The DuckLake ` +
563
+ `extension bundled with DuckDB v${range.engineVersion} attaches only ` +
564
+ `catalogs in that range without migration. Migrate the catalog to a ` +
565
+ `supported format (see the DuckLake catalog-migration docs at ` +
566
+ `https://ducklake.select/docs), or run a Publisher built against a ` +
567
+ `DuckDB engine whose supported range includes ${catalogFormat}.`,
568
+ );
569
+ }
570
+
571
+ logger.info(
572
+ `DuckLake catalog '${dbName}' format ${catalogFormat} is within supported range ` +
573
+ `${range.min}..${range.max} (engine v${range.engineVersion})`,
574
+ );
575
+ }
576
+
342
577
  async function attachDuckLake(
343
578
  connection: DuckDBConnection,
344
579
  dbName: string,
345
580
  ducklakeConfig: components["schemas"]["DucklakeConnection"],
346
581
  ): Promise<void> {
582
+ // Tier build/serve DuckLake sessions never implicitly auto-install (Publisher
583
+ // installs the DuckLake stack explicitly below) — regardless of the policy.
584
+ await applyExtensionSessionSettings(connection, {
585
+ alwaysDisableAutoinstall: true,
586
+ });
347
587
  await installAndLoadExtension(connection, "ducklake");
348
588
  await installAndLoadExtension(connection, "postgres");
349
589
  await installAndLoadExtension(connection, "aws");
@@ -387,6 +627,9 @@ async function attachDuckLake(
387
627
  );
388
628
  const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
389
629
  logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
630
+ // Range-preflight the catalog's recorded format version so an unsupported
631
+ // catalog fails as a clean, actionable 422 rather than a deep DuckDB 500.
632
+ await preflightDuckLakeCatalogFormat(connection, dbName, pgConnString);
390
633
  const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
391
634
  logger.info(
392
635
  `Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
@@ -632,6 +875,12 @@ async function attachDatabasesToDuckDB(
632
875
  azure: attachAzureStorage,
633
876
  };
634
877
 
878
+ // Generic (non-tier) attach session: defer to the deployment policy — off
879
+ // under local-only, left at DuckDB's default under on-demand so existing
880
+ // users see no behaviour change. Publisher still installs each attached
881
+ // type's extension explicitly below.
882
+ await applyExtensionSessionSettings(duckdbConnection);
883
+
635
884
  // Pre-load extensions needed by any attached database type, once per connection
636
885
  const hasAzure = attachedDatabases.some((db) => db.type === "azure");
637
886
  if (hasAzure) {
@@ -926,13 +1175,14 @@ function buildSnowflakePrivateKeyConnection(
926
1175
  }
927
1176
 
928
1177
  // 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
1178
+ // params. @malloydata/db-postgres forwards a connectionString straight to pg
1179
+ // which parses sslmode. The tunnel terminates at 127.0.0.1, so pg's hostname
1180
+ // check can't apply from the connectionString: `verify-ca` validates the cert
1181
+ // chain against the trusted CA bundle (NODE_EXTRA_CA_CERTS, e.g. the baked
933
1182
  // 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).
1183
+ // `disable` uses no TLS. `verify-full` (chain + hostname) can't be expressed as
1184
+ // a connectionString param through a tunnel it needs a raw `ssl` object with
1185
+ // `servername` set to the real host; see resolveProxiedTls below.
936
1186
  export function buildProxiedSslQuery(name: string, sslmode?: string): string {
937
1187
  const caBundle = process.env.NODE_EXTRA_CA_CERTS;
938
1188
  // Default: encrypt (no-verify) so a force-SSL target (the common RDS case)
@@ -958,11 +1208,39 @@ export function buildProxiedSslQuery(name: string, sslmode?: string): string {
958
1208
  return `?uselibpqcompat=true&sslmode=verify-ca&sslrootcert=${encodeURIComponent(caBundle)}`;
959
1209
  default:
960
1210
  throw new Error(
961
- `Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca).`,
1211
+ `Connection proxy on '${name}' has unsupported sslmode '${mode}' (expected disable | no-verify | verify-ca; verify-full is applied via the ssl object in resolveProxiedTls, not this query).`,
962
1212
  );
963
1213
  }
964
1214
  }
965
1215
 
1216
+ // Resolve a proxied connection's TLS mode into either a connectionString query
1217
+ // suffix (disable/no-verify/verify-ca — parsed by pg from the connectionString)
1218
+ // or a raw `ssl` object. `verify-full` takes the ssl path: full verification
1219
+ // (chain + hostname) through a tunnel needs `servername` set to the real DB host,
1220
+ // which a connectionString sslmode param can't carry. The socket targets the
1221
+ // 127.0.0.1 IP literal, so pg preserves our `servername` (it only overwrites it
1222
+ // with `host` when host is a DNS name), verifying the cert's SAN against the real
1223
+ // host. Verification runs against Node's ambient trust anchors — its bundled
1224
+ // Mozilla CA roots plus NODE_EXTRA_CA_CERTS (e.g. the baked Amazon RDS roots) —
1225
+ // not the OS system store (unless Node is run with --use-system-ca).
1226
+ export function resolveProxiedTls(
1227
+ name: string,
1228
+ host: string | undefined,
1229
+ sslmode?: string,
1230
+ ): { query: string; ssl?: PostgresSSLConfig } {
1231
+ if (sslmode === "verify-full") {
1232
+ if (!host) {
1233
+ // validateConnectionShape requires host on a proxied connection, so this
1234
+ // is unreachable in practice — guard so servername is never undefined.
1235
+ throw new Error(
1236
+ `Connection proxy on '${name}' uses sslmode 'verify-full' but has no host to verify against.`,
1237
+ );
1238
+ }
1239
+ return { query: "", ssl: { servername: host, rejectUnauthorized: true } };
1240
+ }
1241
+ return { query: buildProxiedSslQuery(name, sslmode) };
1242
+ }
1243
+
966
1244
  function buildProxiedPostgresConnection(
967
1245
  metadata: EnvironmentConnectionMetadata,
968
1246
  endpoint: ProxyEndpoint,
@@ -983,11 +1261,12 @@ function buildProxiedPostgresConnection(
983
1261
  ? `${enc(pg.userName)}${pg.password ? `:${enc(pg.password)}` : ""}@`
984
1262
  : "";
985
1263
  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}`;
1264
+ const { query, ssl } = resolveProxiedTls(name, pg.host, pg.sslmode);
1265
+ const connectionString = `postgresql://${auth}${endpoint.host}:${endpoint.port}${db}${query}`;
988
1266
  return new PooledPostgresConnection({
989
1267
  name,
990
1268
  connectionString,
1269
+ ...(ssl ? { ssl } : {}),
991
1270
  // Pool sizing mirrors buildSnowflakePrivateKeyConnection.
992
1271
  poolMin: 1,
993
1272
  poolMax: 20,
@@ -1248,13 +1527,21 @@ export function buildEnvironmentMalloyConfig(
1248
1527
  return {
1249
1528
  lookupConnection: async (name?: string): Promise<Connection> => {
1250
1529
  const metadata = getMetadataForLookup(assembled.metadata, name);
1530
+ const connection = await resolveConnection(name, metadata);
1531
+ // Pin every environment-level DuckDB session against implicit
1532
+ // auto-install (policy-driven) at this one exit — independent of
1533
+ // the attach paths, so the guarantee holds even if a resolution
1534
+ // branch is ever added that doesn't attach. The DuckLake tier
1535
+ // already pinned always-off inside its attach; the WeakSet makes
1536
+ // this a no-op there. (The per-package `:memory:` sandbox is a
1537
+ // separate MalloyConfig and is pinned in package.ts.)
1538
+ if (isDuckDBConnection(connection)) {
1539
+ await applyExtensionSessionSettings(connection);
1540
+ }
1251
1541
  // Every resolution branch funnels through this one exit so a
1252
1542
  // configured fingerprint is applied no matter which wrapper
1253
1543
  // produced the connection (see applyConnectionFingerprint).
1254
- return applyConnectionFingerprint(
1255
- await resolveConnection(name, metadata),
1256
- metadata,
1257
- );
1544
+ return applyConnectionFingerprint(connection, metadata);
1258
1545
  },
1259
1546
  };
1260
1547
  },
@@ -572,6 +572,27 @@ describe("SSH proxy validation", () => {
572
572
  }
573
573
  });
574
574
 
575
+ it("accepts sslmode=verify-full on a proxied connection with no CA bundle (verifies against Node's bundled CA roots + NODE_EXTRA_CA_CERTS)", () => {
576
+ // Unlike verify-ca (which passes an explicit sslrootcert path and so
577
+ // fails fast without NODE_EXTRA_CA_CERTS), verify-full trusts Node's
578
+ // bundled Mozilla CA roots + NODE_EXTRA_CA_CERTS — so it must NOT require
579
+ // a bundle at load.
580
+ const prior = process.env.NODE_EXTRA_CA_CERTS;
581
+ delete process.env.NODE_EXTRA_CA_CERTS;
582
+ try {
583
+ const conn: ApiConnection = {
584
+ ...validSshProxy,
585
+ postgresConnection: {
586
+ ...validSshProxy.postgresConnection!,
587
+ sslmode: "verify-full",
588
+ },
589
+ };
590
+ expect(() => assembleEnvironmentConnections([conn])).not.toThrow();
591
+ } finally {
592
+ if (prior !== undefined) process.env.NODE_EXTRA_CA_CERTS = prior;
593
+ }
594
+ });
595
+
575
596
  it("rejects sslmode set on a non-proxied connection (silent-ignore footgun)", () => {
576
597
  const conn: ApiConnection = {
577
598
  name: "pg-direct",
@@ -11,7 +11,12 @@ type AttachedDatabase = components["schemas"]["AttachedDatabase"];
11
11
  // than in connection.ts, which imports this module) so both the config-load
12
12
  // validator and the connect-time builder derive from one list. Mirrors the
13
13
  // `sslmode` enum in api-doc.yaml.
14
- export const PROXIED_SSLMODES = ["disable", "no-verify", "verify-ca"] as const;
14
+ export const PROXIED_SSLMODES = [
15
+ "disable",
16
+ "no-verify",
17
+ "verify-ca",
18
+ "verify-full",
19
+ ] as const;
15
20
 
16
21
  export type CoreConnectionEntry = {
17
22
  is: string;
@@ -356,6 +361,15 @@ function validateConnectionShape(connection: ApiConnection): void {
356
361
  );
357
362
  }
358
363
  }
364
+ // No precondition for `verify-full`: unlike `verify-ca` (which passes an
365
+ // explicit `sslrootcert` path), it verifies against Node's ambient trust
366
+ // anchors (its bundled Mozilla CA roots + NODE_EXTRA_CA_CERTS), so
367
+ // requiring a bundle here would wrongly reject targets with a
368
+ // publicly-trusted CA. An untrusted CA surfaces as a clear pg error at
369
+ // connect time instead. This means verify-full's CA trust is broader
370
+ // than verify-ca's pinned bundle — intentional (it adds the hostname
371
+ // check without narrowing trust), so unlike libpq, verify-full here is
372
+ // not a strict superset of verify-ca.
359
373
  }
360
374
 
361
375
  // The proxied path builds a connectionString to the local tunnel endpoint;
@@ -0,0 +1,110 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import sinon from "sinon";
6
+ import { components } from "../api";
7
+ import { buildEnvironmentMalloyConfig } from "./connection";
8
+
9
+ type ApiConnection = components["schemas"]["Connection"];
10
+
11
+ // A DuckLake connection whose Postgres catalog points at an unroutable host.
12
+ // If anything on the boot path tried to reach the catalog, these tests would
13
+ // hang or fail — which is precisely the regression they guard against.
14
+ const UNREACHABLE_DUCKLAKE: ApiConnection = {
15
+ name: "ducklake_lazy",
16
+ type: "ducklake",
17
+ ducklakeConnection: {
18
+ catalog: {
19
+ postgresConnection: {
20
+ host: "192.0.2.1", // TEST-NET-1: guaranteed non-routable
21
+ port: 5432,
22
+ userName: "nobody",
23
+ password: "nobody",
24
+ databaseName: "catalog",
25
+ },
26
+ },
27
+ storage: {
28
+ bucketUrl: "s3://test-bucket",
29
+ s3Connection: {
30
+ accessKeyId: "test",
31
+ secretAccessKey: "test",
32
+ },
33
+ },
34
+ },
35
+ } as ApiConnection;
36
+
37
+ describe("DuckLake lazy attach", () => {
38
+ const envPath = path.join(process.cwd(), "test-ducklake-lazy-attach");
39
+
40
+ beforeEach(async () => {
41
+ await fs.mkdir(envPath, { recursive: true });
42
+ });
43
+
44
+ afterEach(async () => {
45
+ sinon.restore();
46
+ await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
47
+ });
48
+
49
+ it("does not attach the catalog while building the environment config (boot path)", async () => {
50
+ // Stub the DuckDB session so no real database or network work happens; the
51
+ // stub also lets us observe whether ANY SQL ran during config build.
52
+ const runSQL = sinon
53
+ .stub(DuckDBConnection.prototype, "runSQL")
54
+ .resolves({ rows: [] } as never);
55
+
56
+ // Building the config is the worker boot path. It must be synchronous and
57
+ // must not open, construct, or touch the DuckLake session at all.
58
+ const config = buildEnvironmentMalloyConfig(
59
+ [UNREACHABLE_DUCKLAKE],
60
+ envPath,
61
+ );
62
+
63
+ expect(config.apiConnections.map((c) => c.name)).toContain(
64
+ "ducklake_lazy",
65
+ );
66
+ // The definitive assertion: zero SQL was issued building the config, so
67
+ // the (unreachable) catalog could not have been contacted on boot.
68
+ expect(runSQL.callCount).toBe(0);
69
+ });
70
+
71
+ it("cannot let an unavailable catalog block startup", async () => {
72
+ sinon
73
+ .stub(DuckDBConnection.prototype, "runSQL")
74
+ .resolves({ rows: [] } as never);
75
+
76
+ // Even with an unreachable catalog, constructing the environment config
77
+ // resolves promptly and never awaits the catalog. If attach were on the
78
+ // boot path, this would hang against 192.0.2.1 until a connect timeout.
79
+ const config = buildEnvironmentMalloyConfig(
80
+ [UNREACHABLE_DUCKLAKE],
81
+ envPath,
82
+ );
83
+ expect(config).toBeDefined();
84
+ expect(config.malloyConfig).toBeDefined();
85
+ });
86
+
87
+ it("attaches the catalog only on first connection lookup (serve path)", async () => {
88
+ const runSQL = sinon
89
+ .stub(DuckDBConnection.prototype, "runSQL")
90
+ .resolves({ rows: [] } as never);
91
+
92
+ const config = buildEnvironmentMalloyConfig(
93
+ [UNREACHABLE_DUCKLAKE],
94
+ envPath,
95
+ );
96
+ expect(runSQL.callCount).toBe(0);
97
+
98
+ // The lazy attach fires here, on the first lookup — not before.
99
+ await config.malloyConfig.connections.lookupConnection("ducklake_lazy");
100
+
101
+ const issued = runSQL.getCalls().map((c) => String(c.args[0]));
102
+ const attachedDuckLake = issued.some((sql) =>
103
+ sql.includes("ducklake:postgres:"),
104
+ );
105
+ expect(runSQL.callCount).toBeGreaterThan(0);
106
+ expect(attachedDuckLake).toBe(true);
107
+
108
+ await config.releaseConnections().catch(() => {});
109
+ });
110
+ });