@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
@@ -1,10 +1,18 @@
1
1
  import { createPrivateKey } from "crypto";
2
+ import { existsSync } from "fs";
2
3
  import path from "path";
3
4
  import { components } from "../api";
5
+ import { parseHostKeys } from "./proxy";
4
6
 
5
7
  type ApiConnection = components["schemas"]["Connection"];
6
8
  type AttachedDatabase = components["schemas"]["AttachedDatabase"];
7
9
 
10
+ // TLS modes accepted on a proxied postgres connection. Canonical here (rather
11
+ // than in connection.ts, which imports this module) so both the config-load
12
+ // validator and the connect-time builder derive from one list. Mirrors the
13
+ // `sslmode` enum in api-doc.yaml.
14
+ export const PROXIED_SSLMODES = ["disable", "no-verify", "verify-ca"] as const;
15
+
8
16
  export type CoreConnectionEntry = {
9
17
  is: string;
10
18
  [key: string]: unknown;
@@ -22,6 +30,7 @@ export type EnvironmentConnectionMetadata = {
22
30
  isDuckLake: boolean;
23
31
  databasePath?: string;
24
32
  workingDirectory: string;
33
+ proxy?: ApiConnection["proxy"];
25
34
  };
26
35
 
27
36
  export type AssembledEnvironmentConnections = {
@@ -258,6 +267,123 @@ function buildDuckdbEntry(
258
267
  }
259
268
 
260
269
  function validateConnectionShape(connection: ApiConnection): void {
270
+ if (connection.proxy) {
271
+ // A connection proxy makes THIS server open an outbound SSH tunnel to a
272
+ // tenant-configured bastion. It's a normal connection capability,
273
+ // authorized by whoever configures the connection — deliberately NOT gated
274
+ // by an env flag, and kept separate from the `publisher` HTTP multi-hop
275
+ // type's PUBLISHER_ALLOW_PROXY_CONNECTIONS gate below (that flag is about
276
+ // publisher-to-publisher proxying, a distinct operator decision). Optional
277
+ // host-key pinning is fail-closed at connect time when configured (see
278
+ // openProxy); the proxy-specific fields are validated up front below so a
279
+ // permanent misconfig fails at config load, not by repeatedly dialing the
280
+ // tenant's bastion at query time.
281
+ if (connection.proxy.type !== "ssh") {
282
+ throw new Error(
283
+ `Connection '${connection.name}' has an unsupported proxy type '${connection.proxy.type}'. Only 'ssh' is supported.`,
284
+ );
285
+ }
286
+ if (connection.type !== "postgres") {
287
+ throw new Error(
288
+ `Connection proxy is not supported for type '${connection.type}' (only 'postgres' today).`,
289
+ );
290
+ }
291
+ if (!connection.proxy.ssh) {
292
+ throw new Error(
293
+ `Connection proxy on '${connection.name}' has type 'ssh' but no 'ssh' config object.`,
294
+ );
295
+ }
296
+ // The tunnel forwards to an explicit host:port; the connectionString form
297
+ // can't be rewritten to the local endpoint. Reject it outright when a
298
+ // proxy is set — normal postgres gives connectionString precedence over
299
+ // host/port, so a config carrying BOTH would silently tunnel to
300
+ // host/port and ignore the connectionString, connecting to a different
301
+ // database than the operator configured. Require discrete host/port.
302
+ if (connection.postgresConnection?.connectionString) {
303
+ throw new Error(
304
+ `Connection proxy on '${connection.name}' does not support the connectionString form; ` +
305
+ `provide discrete host and port instead (the tunnel forwards to an explicit endpoint).`,
306
+ );
307
+ }
308
+ if (
309
+ !connection.postgresConnection?.host ||
310
+ !connection.postgresConnection?.port
311
+ ) {
312
+ throw new Error(
313
+ `Connection proxy on '${connection.name}' requires explicit host and port on the ` +
314
+ `postgres connection; the connectionString form is not supported with a proxy.`,
315
+ );
316
+ }
317
+
318
+ // hostKey is optional (omitted or empty string => connect unpinned), but a
319
+ // non-empty hostKey that parses to zero keys — only blank lines, whitespace,
320
+ // or `#` comments, e.g. a paste that grabbed just ssh-keyscan's
321
+ // `# host:port ...` header — is a misconfigured pin, not a licence to
322
+ // connect unverified. Reject it here so the operator gets a config error
323
+ // instead of a silently unpinned tunnel. (Truthiness, not trim(): "" is the
324
+ // unpinned signal; " " is a non-empty value that must yield a key.)
325
+ const hostKey = connection.proxy.ssh?.hostKey;
326
+ if (hostKey && parseHostKeys(hostKey).size === 0) {
327
+ throw new Error(
328
+ `Connection proxy on '${connection.name}' has a hostKey with no usable host-key line ` +
329
+ `(only blanks/comments). Provide an OpenSSH known_hosts line or base64 blob, or omit ` +
330
+ `hostKey to connect unpinned.`,
331
+ );
332
+ }
333
+
334
+ // Validate the proxied TLS mode up front. The tunnel is dialed lazily on
335
+ // first lookup and a failed build is retried on every subsequent query, so
336
+ // a permanent sslmode misconfig left to throw at connect time would re-dial
337
+ // the tenant's bastion indefinitely. Fail at config load instead.
338
+ // `!= null` (not truthiness) so a present-but-empty sslmode ("") is caught
339
+ // here as unsupported rather than slipping through to fail at tunnel-build;
340
+ // null/undefined mean unset (server applies the default).
341
+ const sslmode = connection.postgresConnection?.sslmode;
342
+ if (sslmode != null) {
343
+ if (!(PROXIED_SSLMODES as readonly string[]).includes(sslmode)) {
344
+ throw new Error(
345
+ `Connection proxy on '${connection.name}' has unsupported sslmode '${sslmode}' ` +
346
+ `(expected ${PROXIED_SSLMODES.join(" | ")}).`,
347
+ );
348
+ }
349
+ if (sslmode === "verify-ca") {
350
+ const caBundle = process.env.NODE_EXTRA_CA_CERTS;
351
+ if (!caBundle || !existsSync(caBundle)) {
352
+ throw new Error(
353
+ `Connection proxy on '${connection.name}' uses sslmode 'verify-ca' but no readable ` +
354
+ `CA bundle is available (NODE_EXTRA_CA_CERTS is unset or points at a missing file). ` +
355
+ `Add the CA bundle to the image or use sslmode 'no-verify'.`,
356
+ );
357
+ }
358
+ }
359
+ }
360
+
361
+ // The proxied path builds a connectionString to the local tunnel endpoint;
362
+ // pg decodes the database path with decodeURI, which leaves URI-reserved
363
+ // characters percent-encoded — so a db name containing them would resolve
364
+ // to the wrong database. Reject it clearly rather than failing later with a
365
+ // confusing "database does not exist". (user/password use decodeURIComponent
366
+ // on parse and round-trip fine.)
367
+ const dbName = connection.postgresConnection?.databaseName;
368
+ if (dbName && decodeURI(encodeURIComponent(dbName)) !== dbName) {
369
+ throw new Error(
370
+ `Connection proxy on '${connection.name}' has a database name with characters that can't ` +
371
+ `be carried over a proxied connection (${JSON.stringify(dbName)}). Use a database name ` +
372
+ `without URI-reserved characters (; , / ? : @ & = + $ #).`,
373
+ );
374
+ }
375
+ }
376
+
377
+ // sslmode is only honored on the proxied path (the direct path builds TLS from
378
+ // the deployment PGSSLMODE). Reject it on a non-proxied connection so a tenant
379
+ // who sets it doesn't silently get a different TLS posture than they asked for.
380
+ if (!connection.proxy && connection.postgresConnection?.sslmode) {
381
+ throw new Error(
382
+ `Connection '${connection.name}' sets postgresConnection.sslmode but has no proxy; sslmode is ` +
383
+ `only supported for proxied connections (direct connections use the deployment PGSSLMODE).`,
384
+ );
385
+ }
386
+
261
387
  switch (connection.type) {
262
388
  case "postgres":
263
389
  case "mysql":
@@ -445,6 +571,7 @@ export function assembleEnvironmentConnections(
445
571
  isDuckLake,
446
572
  databasePath,
447
573
  workingDirectory: environmentPath,
574
+ proxy: connection.proxy,
448
575
  });
449
576
 
450
577
  switch (connection.type) {
@@ -115,6 +115,18 @@ function quoteMalloyIdentifier(name: string | undefined): string {
115
115
  return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
116
116
  }
117
117
 
118
+ /**
119
+ * A non-fatal render-tag finding from {@link Model.validateRenderTags}: an
120
+ * error-severity issue that affects only how a field renders, never whether the
121
+ * model compiles or a query runs. `target` is the query or view it sits on
122
+ * (e.g. `by_carrier` or `flights -> by_carrier`).
123
+ */
124
+ export interface RenderTagWarning {
125
+ target: string;
126
+ message: string;
127
+ severity: "error" | "warn";
128
+ }
129
+
118
130
  export class Model {
119
131
  private packageName: string;
120
132
  private modelPath: string;
@@ -952,12 +964,13 @@ export class Model {
952
964
  * it does not fail the package load. Such a tag still renders as
953
965
  * "[object Object]" at query time, so the warning is the operator-facing
954
966
  * signal. Lower-severity findings are left for the query-time `renderLogs`
955
- * surface.
967
+ * surface. The findings are returned so the owning Package can surface them
968
+ * as non-fatal `warnings` on its response.
956
969
  */
957
- public async validateRenderTags(): Promise<void> {
970
+ public async validateRenderTags(): Promise<RenderTagWarning[]> {
958
971
  const mm = this.modelMaterializer;
959
972
  if (!mm) {
960
- return;
973
+ return [];
961
974
  }
962
975
  // Dynamic import (like execute_query_tool.ts): the renderer is heavy and
963
976
  // mutates DOM globals on load, so only pull it in when there's a model to
@@ -1003,6 +1016,7 @@ export class Model {
1003
1016
  }
1004
1017
  }
1005
1018
 
1019
+ const findings: RenderTagWarning[] = [];
1006
1020
  for (const target of targets) {
1007
1021
  let result: Malloy.Result;
1008
1022
  try {
@@ -1024,8 +1038,16 @@ export class Model {
1024
1038
  .map((e) => e.message)
1025
1039
  .join("; ")}`,
1026
1040
  );
1041
+ for (const e of errors) {
1042
+ findings.push({
1043
+ target: target.label,
1044
+ message: e.message,
1045
+ severity: "error",
1046
+ });
1047
+ }
1027
1048
  }
1028
1049
  }
1050
+ return findings;
1029
1051
  }
1030
1052
 
1031
1053
  public async getModel(): Promise<ApiCompiledModel> {
@@ -40,6 +40,7 @@ type ApiDatabase = components["schemas"]["Database"];
40
40
  type ApiModel = components["schemas"]["Model"];
41
41
  type ApiNotebook = components["schemas"]["Notebook"];
42
42
  export type ApiPackage = components["schemas"]["Package"];
43
+ type ApiPackageWarning = NonNullable<ApiPackage["warnings"]>[number];
43
44
  type ApiColumn = components["schemas"]["Column"];
44
45
  type ApiTableDescription = components["schemas"]["TableDescription"];
45
46
  // A thunk lets callers pass a live reference to the *current* environment
@@ -77,6 +78,12 @@ export class Package {
77
78
  // no persist source. Surfaced read-only on getPackageMetadata() so a caller
78
79
  // can derive build instructions without a separate plan round-trip.
79
80
  private buildPlan: BuildPlan | null = null;
81
+ // Non-fatal render-tag findings aggregated across the package's models (each
82
+ // tagged with its model path), surfaced read-only on
83
+ // getPackageMetadata().warnings. Refreshed on load and reload. A bad render
84
+ // tag does not fail the load (see Model.validateRenderTags); this is the
85
+ // response-level signal that a tag is misconfigured.
86
+ private renderTagWarnings: ApiPackageWarning[] = [];
80
87
  private static meter = publisherMeter();
81
88
  private static packageLoadHistogram = this.meter.createHistogram(
82
89
  "malloy_package_load_duration",
@@ -350,6 +357,7 @@ export class Package {
350
357
  // placeholders instead; that branch goes through a different
351
358
  // hydration path.)
352
359
  const models = new Map<string, Model>();
360
+ const renderTagWarnings: ApiPackageWarning[] = [];
353
361
  for (const sm of outcome.models) {
354
362
  if (sm.compilationError) {
355
363
  const err = Model.deserializeCompilationError(sm.compilationError);
@@ -371,7 +379,10 @@ export class Package {
371
379
  // Validate renderer tags on the main thread (the renderer is too heavy
372
380
  // to load inside the pure-CPU package-load worker). A misconfigured tag
373
381
  // is logged as a warning naming the target; it does not fail the load.
374
- await model.validateRenderTags();
382
+ // The findings also ride the package response as non-fatal `warnings`.
383
+ for (const w of await model.validateRenderTags()) {
384
+ renderTagWarnings.push({ model: sm.modelPath, ...w });
385
+ }
375
386
  // Reject unquoted `#@ persist name=` annotations the same way: an
376
387
  // unquoted name is dropped from the build plan, so the source would
377
388
  // publish but never materialize. Scan the raw `.malloy` source (the
@@ -406,6 +417,7 @@ export class Package {
406
417
  models,
407
418
  malloyConfig,
408
419
  );
420
+ pkg.renderTagWarnings = renderTagWarnings;
409
421
 
410
422
  // Compute the persist build plan off the live (unbound) models, before the
411
423
  // caller binds any configured manifest, so the surfaced plan reflects the
@@ -484,6 +496,9 @@ export class Package {
484
496
  if (warnings.length > 0) {
485
497
  metadata.exploresWarnings = warnings;
486
498
  }
499
+ if (this.renderTagWarnings.length > 0) {
500
+ metadata.warnings = [...this.renderTagWarnings];
501
+ }
487
502
  return metadata;
488
503
  }
489
504
 
@@ -704,6 +719,7 @@ export class Package {
704
719
  }
705
720
 
706
721
  const nextModels = new Map<string, Model>();
722
+ const renderTagWarnings: ApiPackageWarning[] = [];
707
723
  for (const sm of outcome.models) {
708
724
  if (sm.compilationError) {
709
725
  const err = Model.deserializeCompilationError(sm.compilationError);
@@ -735,7 +751,9 @@ export class Package {
735
751
  // unexpected internal failure is recorded as this model's
736
752
  // compilationError rather than aborting the whole reload.
737
753
  try {
738
- await model.validateRenderTags();
754
+ for (const w of await model.validateRenderTags()) {
755
+ renderTagWarnings.push({ model: sm.modelPath, ...w });
756
+ }
739
757
  nextModels.set(sm.modelPath, model);
740
758
  } catch (renderErr) {
741
759
  const err =
@@ -760,6 +778,7 @@ export class Package {
760
778
  }
761
779
  }
762
780
  this.models = nextModels;
781
+ this.renderTagWarnings = renderTagWarnings;
763
782
  // A reload re-reads publisher.json in the worker; pick up any change to
764
783
  // the explore set and query-boundary mode so listModels()/the gate
765
784
  // reflect edited explores without a full Package.create.
@@ -316,6 +316,17 @@ source: gated is duckdb.sql("select 1 as id")`,
316
316
  m.includes("nums -> card"),
317
317
  ),
318
318
  ).toBe(true);
319
+ // The same finding rides the package response (non-fatal) so the
320
+ // control plane / UI can surface it without scraping logs.
321
+ const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
322
+ expect(
323
+ responseWarnings.some(
324
+ (w) =>
325
+ w.model === "bad_render.malloy" &&
326
+ w.target === "nums -> card" &&
327
+ w.severity === "error",
328
+ ),
329
+ ).toBe(true);
319
330
  } finally {
320
331
  warnSpy.mockRestore();
321
332
  await duckdb.close();
@@ -337,6 +348,8 @@ source: gated is duckdb.sql("select 1 as id")`,
337
348
  try {
338
349
  const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
339
350
  expect(pkg.getModelPaths()).toEqual(["good_render.malloy"]);
351
+ // A clean package omits the warnings field entirely.
352
+ expect(pkg.getPackageMetadata().warnings).toBeUndefined();
340
353
  } finally {
341
354
  await duckdb.close();
342
355
  }
@@ -449,6 +462,14 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
449
462
  expect(
450
463
  warnings.some((m) => m.includes("Invalid renderer configuration")),
451
464
  ).toBe(true);
465
+ // The notebook finding also rides the package response (not just logs).
466
+ const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
467
+ expect(
468
+ responseWarnings.some(
469
+ (w) =>
470
+ w.model === "bad_render.malloynb" && w.severity === "error",
471
+ ),
472
+ ).toBe(true);
452
473
  } finally {
453
474
  warnSpy.mockRestore();
454
475
  await duckdb.close();
@@ -499,12 +520,94 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
499
520
  expect(
500
521
  warnings.some((m) => m.includes("Invalid renderer configuration")),
501
522
  ).toBe(true);
523
+ // Reload refreshes the response-level warnings too.
524
+ const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
525
+ expect(responseWarnings.some((w) => w.model === "m.malloy")).toBe(
526
+ true,
527
+ );
502
528
  } finally {
503
529
  warnSpy.mockRestore();
504
530
  await duckdb.close();
505
531
  }
506
532
  });
507
533
 
534
+ it("clears render-tag warnings on reload when a bad tag is fixed (bad -> clean)", async () => {
535
+ writeManifest();
536
+ // Start BAD so the package loads with a warning.
537
+ fs.writeFileSync(
538
+ path.join(tempDir, "m.malloy"),
539
+ `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
540
+ measure: total is a.sum()
541
+ # big_value { sparkline=trend }
542
+ view: card is {
543
+ aggregate: total
544
+ nest:
545
+ # line_chart { size=spark }
546
+ trend is { group_by: a; aggregate: total }
547
+ }
548
+ }`,
549
+ );
550
+
551
+ const { malloyConfig, duckdb } = await makeMalloyConfig();
552
+ try {
553
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
554
+ expect(
555
+ (pkg.getPackageMetadata().warnings ?? []).some(
556
+ (w) => w.model === "m.malloy",
557
+ ),
558
+ ).toBe(true);
559
+
560
+ // Fix the tag, then reload: renderTagWarnings is rebuilt from scratch,
561
+ // so the stale finding must disappear (field omitted when empty).
562
+ fs.writeFileSync(
563
+ path.join(tempDir, "m.malloy"),
564
+ `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
565
+ measure: total is a.sum()
566
+ # bar_chart
567
+ view: chart is { group_by: a; aggregate: total }
568
+ }`,
569
+ );
570
+ await pkg.reloadAllModels({});
571
+
572
+ expect(pkg.getPackageMetadata().warnings).toBeUndefined();
573
+ } finally {
574
+ await duckdb.close();
575
+ }
576
+ });
577
+
578
+ it("aggregates render-tag warnings from multiple models, each tagged with its own path", async () => {
579
+ writeManifest();
580
+ const badModel = (view: string) =>
581
+ `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
582
+ measure: total is a.sum()
583
+ # big_value { sparkline=trend }
584
+ view: ${view} is {
585
+ aggregate: total
586
+ nest:
587
+ # line_chart { size=spark }
588
+ trend is { group_by: a; aggregate: total }
589
+ }
590
+ }`;
591
+ fs.writeFileSync(path.join(tempDir, "a.malloy"), badModel("card_a"));
592
+ fs.writeFileSync(path.join(tempDir, "b.malloy"), badModel("card_b"));
593
+
594
+ const { malloyConfig, duckdb } = await makeMalloyConfig();
595
+ try {
596
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
597
+ const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
598
+ const models = new Set(responseWarnings.map((w) => w.model));
599
+ expect(models.has("a.malloy")).toBe(true);
600
+ expect(models.has("b.malloy")).toBe(true);
601
+ expect(models.size).toBe(2);
602
+ // The message field is carried through, not just model/target/severity.
603
+ expect(
604
+ responseWarnings.every((w) => (w.message ?? "").length > 0),
605
+ ).toBe(true);
606
+ } finally {
607
+ await duckdb.close();
608
+ }
609
+ });
610
+
508
611
  // NB: kept last in this describe — swapping the singleton for a
509
612
  // pre-shutdown pool also tears down the shared `pool` (the swap
510
613
  // implementation shuts down the outgoing singleton). Subsequent