@malloy-publisher/server 0.0.216 → 0.0.218

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 (39) hide show
  1. package/dist/app/api-doc.yaml +116 -0
  2. package/dist/app/assets/{EnvironmentPage-CuO6sty5.js → EnvironmentPage-DxOiCxcd.js} +1 -1
  3. package/dist/app/assets/{HomePage-u3vxROIF.js → HomePage-i8qAtD6x.js} +1 -1
  4. package/dist/app/assets/{MainPage-Bt2sYLbr.js → MainPage-B58d5pmX.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-B1rj61zs.js → MaterializationsPage-6OEVM543.js} +1 -1
  6. package/dist/app/assets/{ModelPage-BpvONvSR.js → ModelPage-8d5l7YkU.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DHNcwboW.js → PackagePage-BVmoVsPQ.js} +1 -1
  8. package/dist/app/assets/{RouteError-D1vhLJvr.js → RouteError-DFX521_t.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-CZmud-yI.js → WorkbookPage-CzucDJ-T.js} +1 -1
  10. package/dist/app/assets/{core-XtBSnW2U.es-DE88sVsZ.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
  11. package/dist/app/assets/{index-BlnQtDZj.js → index--xJ1pzL-.js} +73 -73
  12. package/dist/app/assets/{index-CYf2akGH.js → index-Bxkza-hz.js} +1 -1
  13. package/dist/app/assets/{index-BQdOB6m9.js → index-DxRKma36.js} +1 -1
  14. package/dist/app/assets/{index.umd-BdQ0R4hx.js → index.umd-o-yUIEtS.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  17. package/dist/server.mjs +19050 -75
  18. package/dist/sshcrypto-8m50vnmb.node +0 -0
  19. package/package.json +1 -1
  20. package/src/dto/connection.dto.ts +43 -0
  21. package/src/server.ts +55 -1
  22. package/src/service/build_plan.spec.ts +21 -0
  23. package/src/service/build_plan.ts +14 -0
  24. package/src/service/connection.ts +104 -2
  25. package/src/service/connection_config.spec.ts +116 -0
  26. package/src/service/connection_config.ts +48 -0
  27. package/src/service/model.ts +25 -3
  28. package/src/service/package.ts +21 -2
  29. package/src/service/package_worker_path.spec.ts +103 -0
  30. package/src/service/proxy.spec.ts +367 -0
  31. package/src/service/proxy.ts +233 -0
  32. package/tests/fixtures/html-pages-test/public/barehtml.html +4 -0
  33. package/tests/fixtures/html-pages-test/public/bodymeta.html +8 -0
  34. package/tests/fixtures/html-pages-test/public/fitcomment.html +11 -0
  35. package/tests/fixtures/html-pages-test/public/nohead.html +8 -0
  36. package/tests/fixtures/html-pages-test/public/notfit.html +13 -0
  37. package/tests/fixtures/html-pages-test/public/slides.html +12 -0
  38. package/tests/fixtures/html-pages-test/public/unterminated.html +10 -0
  39. package/tests/integration/html_pages/html_pages.integration.spec.ts +63 -1
Binary file
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.216",
4
+ "version": "0.0.218",
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
  }
package/src/server.ts CHANGED
@@ -491,7 +491,32 @@ type PageItem = {
491
491
  packageName: string;
492
492
  path: string;
493
493
  title: string;
494
+ fit?: "viewport";
494
495
  };
496
+
497
+ // The spots in an HTML head where a "<meta ...>" literal would NOT be a live
498
+ // tag: HTML comments (terminated or unterminated) and raw-text/RCDATA elements
499
+ // (script/style/title/textarea). One alternation so a single .replace covers
500
+ // them all (and so the fixpoint loop below applies one self-referential
501
+ // replace, which is the complete-sanitization shape CodeQL recognizes).
502
+ const NON_TAG_TEXT_PATTERN =
503
+ /<!--[\s\S]*?-->|<!--[\s\S]*$|<(script|style|title|textarea)\b[\s\S]*?<\/\1\s*>/gi;
504
+
505
+ // Remove those matches until the string stops changing. A single pass is
506
+ // incomplete because removing one match can splice the surrounding text into a
507
+ // new one (CWE-116), so re-apply the same pattern to its own output until a
508
+ // fixpoint. Each pass only deletes, so the string strictly shrinks and the loop
509
+ // terminates, bounded by the input length (callers pass at most the first 4KB).
510
+ function stripNonTagText(input: string): string {
511
+ let current = input;
512
+ let previous: string;
513
+ do {
514
+ previous = current;
515
+ current = current.replace(NON_TAG_TEXT_PATTERN, "");
516
+ } while (current !== previous);
517
+ return current;
518
+ }
519
+
495
520
  async function listPackagePages(
496
521
  environmentName: string,
497
522
  packageName: string,
@@ -539,8 +564,9 @@ async function listPackagePages(
539
564
  (entry.name.endsWith(".html") || entry.name.endsWith(".htm"))
540
565
  ) {
541
566
  const rel = path.relative(publicRoot, full).replace(/\\/g, "/");
542
- // Cheap title extraction: read first 4KB and grep for <title>.
567
+ // Cheap metadata extraction: read first 4KB and grep the <head>.
543
568
  let title = rel;
569
+ let fit: "viewport" | undefined;
544
570
  try {
545
571
  const fh = await fs.open(full, "r");
546
572
  try {
@@ -549,6 +575,33 @@ async function listPackagePages(
549
575
  const head = buf.slice(0, bytesRead).toString("utf8");
550
576
  const m = head.match(/<title[^>]*>([^<]+)<\/title>/i);
551
577
  if (m) title = m[1].trim();
578
+ // Full-screen apps (e.g. slide decks) opt into a viewport-fill
579
+ // embed with <meta name="publisher:fit" content="viewport">.
580
+ // FIRST strip the spots where the literal string is NOT a live
581
+ // tag (comments, script/style/title/textarea), THEN look at the
582
+ // <head> region only (up to </head> or <body>). Order matters:
583
+ // stripping before locating the boundary keeps a literal
584
+ // "<body>"/"</head>" inside a comment or <script> from
585
+ // truncating the scan and hiding a real tag. What's left and
586
+ // matches is a genuine <meta> the browser would honor too, so a
587
+ // documented/commented example or a string in a code block
588
+ // can't opt the page in. Match by name (attribute order/quoting
589
+ // vary), then confirm content="viewport"; the [\s"'] before
590
+ // `name` keeps `data-name="publisher:fit"` out. Like the title,
591
+ // the tag must sit within the first 4KB.
592
+ const cleaned = stripNonTagText(head);
593
+ const headEnd = cleaned.search(/<\/head\s*>|<body[\s>]/i);
594
+ const headTags =
595
+ headEnd === -1 ? cleaned : cleaned.slice(0, headEnd);
596
+ const fitMeta = headTags.match(
597
+ /<meta\b[^>]*[\s"']name\s*=\s*["']publisher:fit["'][^>]*>/i,
598
+ );
599
+ if (
600
+ fitMeta &&
601
+ /\bcontent\s*=\s*["']\s*viewport\s*["']/i.test(fitMeta[0])
602
+ ) {
603
+ fit = "viewport";
604
+ }
552
605
  } finally {
553
606
  await fh.close();
554
607
  }
@@ -560,6 +613,7 @@ async function listPackagePages(
560
613
  packageName,
561
614
  path: rel,
562
615
  title,
616
+ fit,
563
617
  });
564
618
  }
565
619
  }
@@ -229,6 +229,27 @@ describe("deriveBuildPlan", () => {
229
229
 
230
230
  expect(Object.keys(plan.sources)).toEqual(["a@m"]);
231
231
  });
232
+
233
+ it("carries the per-source package-relative modelPath", () => {
234
+ const a = fakeSource({ name: "a", buildId: "bid-a" });
235
+ const b = fakeSource({ name: "b", buildId: "bid-b" });
236
+ const plan = deriveBuildPlan(
237
+ [
238
+ {
239
+ connectionName: "duckdb",
240
+ nodes: [[{ sourceID: "a@m", dependsOn: [] }]],
241
+ },
242
+ ] as unknown as Parameters<typeof deriveBuildPlan>[0],
243
+ { "a@m": a, "b@m": b },
244
+ { duckdb: "dig" },
245
+ undefined,
246
+ { "a@m": "rollup.malloy" },
247
+ );
248
+
249
+ // Mapped source gets its model path; an unmapped source stays undefined.
250
+ expect(plan.sources["a@m"].modelPath).toBe("rollup.malloy");
251
+ expect(plan.sources["b@m"].modelPath).toBeUndefined();
252
+ });
232
253
  });
233
254
 
234
255
  describe("compilePackageBuildPlan", () => {
@@ -42,6 +42,13 @@ export interface CompiledBuildPlan {
42
42
  sources: Record<string, PersistSource>;
43
43
  connectionDigests: Record<string, string>;
44
44
  connections: Map<string, MalloyConnection>;
45
+ /**
46
+ * sourceID -> the package-relative path of the `.malloy` model that declares
47
+ * the source (the only place that mapping is known, since a sourceID's
48
+ * embedded modelURL is an absolute `file://` path with no package boundary).
49
+ * Optional so existing fixtures/callers that don't track it still typecheck.
50
+ */
51
+ sourceModelPaths?: Record<string, string>;
45
52
  }
46
53
 
47
54
  /** Output columns of a persist source, degrading to [] if unavailable. */
@@ -192,6 +199,7 @@ export async function compilePackageBuildPlan(
192
199
  ): Promise<CompiledBuildPlan> {
193
200
  const allGraphs: MalloyBuildGraph[] = [];
194
201
  const allSources: Record<string, PersistSource> = {};
202
+ const sourceModelPaths: Record<string, string> = {};
195
203
 
196
204
  for (const modelPath of pkg.getModelPaths()) {
197
205
  // Only `.malloy` models declare persist sources. Skip `.malloynb`
@@ -225,6 +233,7 @@ export async function compilePackageBuildPlan(
225
233
  allGraphs.push(...buildPlan.graphs);
226
234
  for (const [sourceID, source] of Object.entries(buildPlan.sources)) {
227
235
  allSources[sourceID] = source;
236
+ sourceModelPaths[sourceID] = modelPath;
228
237
  }
229
238
  }
230
239
 
@@ -256,6 +265,7 @@ export async function compilePackageBuildPlan(
256
265
  sources: allSources,
257
266
  connectionDigests,
258
267
  connections,
268
+ sourceModelPaths,
259
269
  };
260
270
  }
261
271
 
@@ -265,6 +275,7 @@ export function deriveBuildPlan(
265
275
  sources: Record<string, PersistSource>,
266
276
  connectionDigests: Record<string, string>,
267
277
  sourceNames?: string[],
278
+ sourceModelPaths?: Record<string, string>,
268
279
  ): BuildPlan {
269
280
  const include = sourceNames ? new Set(sourceNames) : null;
270
281
 
@@ -290,6 +301,7 @@ export function deriveBuildPlan(
290
301
  sql: source.getSQL(),
291
302
  columns: deriveColumns(source),
292
303
  annotationFields: deriveAnnotationFields(source),
304
+ modelPath: sourceModelPaths?.[sourceID],
293
305
  };
294
306
  }
295
307
 
@@ -313,5 +325,7 @@ export async function computePackageBuildPlan(
313
325
  compiled.graphs,
314
326
  compiled.sources,
315
327
  compiled.connectionDigests,
328
+ undefined,
329
+ compiled.sourceModelPaths,
316
330
  );
317
331
  }
@@ -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,30 @@ function buildSnowflakePrivateKeyConnection(
921
925
  });
922
926
  }
923
927
 
928
+ function buildProxiedPostgresConnection(
929
+ metadata: EnvironmentConnectionMetadata,
930
+ endpoint: ProxyEndpoint,
931
+ ): PooledPostgresConnection {
932
+ const name = metadata.apiConnection.name!;
933
+ const pg = metadata.apiConnection.postgresConnection;
934
+ if (!pg) {
935
+ throw new Error(
936
+ `Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
937
+ );
938
+ }
939
+ return new PooledPostgresConnection({
940
+ name,
941
+ host: endpoint.host,
942
+ port: endpoint.port,
943
+ username: pg.userName,
944
+ password: pg.password,
945
+ databaseName: pg.databaseName,
946
+ // Pool sizing mirrors buildSnowflakePrivateKeyConnection.
947
+ poolMin: 1,
948
+ poolMax: 20,
949
+ });
950
+ }
951
+
924
952
  function buildDuckLakeConnection(
925
953
  metadata: EnvironmentConnectionMetadata,
926
954
  entry: CoreConnectionEntry,
@@ -987,6 +1015,11 @@ export function buildEnvironmentMalloyConfig(
987
1015
  const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
988
1016
  const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
989
1017
  const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
1018
+ const proxyConnectionCache = new Map<
1019
+ string,
1020
+ Promise<PooledPostgresConnection>
1021
+ >();
1022
+ const proxyEndpoints = new Map<string, ProxyEndpoint>();
990
1023
  const attachPromises = new WeakMap<Connection, Promise<void>>();
991
1024
 
992
1025
  const malloyConfig = new MalloyConfig(assembled.pojo, {
@@ -1044,6 +1077,64 @@ export function buildEnvironmentMalloyConfig(
1044
1077
  return connection;
1045
1078
  }
1046
1079
 
1080
+ if (metadata?.proxy) {
1081
+ let connectionPromise = proxyConnectionCache.get(name!);
1082
+ if (!connectionPromise) {
1083
+ const connType = metadata.apiConnection.type;
1084
+ if (connType !== "postgres") {
1085
+ throw new Error(
1086
+ `SSH proxy is not yet supported for connection type '${connType}'. ` +
1087
+ `Only 'postgres' is implemented.`,
1088
+ );
1089
+ }
1090
+ // validateConnectionShape already enforces explicit host/port
1091
+ // at config load (the connectionString form can't be tunneled);
1092
+ // this is a defense-in-depth guard for any path that reaches
1093
+ // lookup without that validation.
1094
+ const pgConfig = metadata.apiConnection.postgresConnection;
1095
+ if (!pgConfig?.host || !pgConfig?.port) {
1096
+ throw new Error(
1097
+ `SSH proxy for connection '${name}' requires explicit host and ` +
1098
+ `port on the postgres connection; the connectionString form ` +
1099
+ `is not supported with a proxy.`,
1100
+ );
1101
+ }
1102
+ connectionPromise = (async () => {
1103
+ const endpoint = await openProxy(metadata.proxy!, {
1104
+ host: pgConfig.host!,
1105
+ port: pgConfig.port!,
1106
+ });
1107
+ try {
1108
+ const connection = buildProxiedPostgresConnection(
1109
+ metadata,
1110
+ endpoint,
1111
+ );
1112
+ // Register only after the build succeeds: a throw between
1113
+ // openProxy and here would otherwise leave an orphaned
1114
+ // tunnel in proxyEndpoints that a retry can't reach (the
1115
+ // outer .catch evicts the cache but not the endpoint).
1116
+ proxyEndpoints.set(name!, endpoint);
1117
+ return connection;
1118
+ } catch (err) {
1119
+ await endpoint.close().catch(() => {});
1120
+ throw err;
1121
+ }
1122
+ })();
1123
+ // Drop a rejected build from the cache so a later lookup can
1124
+ // retry, rather than replaying a transient SSH failure (bastion
1125
+ // restart, network blip) until the environment is rebuilt.
1126
+ connectionPromise.catch(() => {
1127
+ if (
1128
+ proxyConnectionCache.get(name!) === connectionPromise
1129
+ ) {
1130
+ proxyConnectionCache.delete(name!);
1131
+ }
1132
+ });
1133
+ proxyConnectionCache.set(name!, connectionPromise);
1134
+ }
1135
+ return connectionPromise;
1136
+ }
1137
+
1047
1138
  if (metadata?.hasSnowflakePrivateKey) {
1048
1139
  let connectionPromise = snowflakeJwtCache.get(name!);
1049
1140
  if (!connectionPromise) {
@@ -1086,6 +1177,7 @@ export function buildEnvironmentMalloyConfig(
1086
1177
  ...duckLakeCache.values(),
1087
1178
  ...snowflakeJwtCache.values(),
1088
1179
  ...azureDuckDBCache.values(),
1180
+ ...proxyConnectionCache.values(),
1089
1181
  ];
1090
1182
  const closeResults = await Promise.allSettled([
1091
1183
  malloyConfig.shutdown("close"),
@@ -1094,11 +1186,21 @@ export function buildEnvironmentMalloyConfig(
1094
1186
  await connection.close();
1095
1187
  }),
1096
1188
  ]);
1189
+ // Close proxy tunnels only AFTER the build promises above have settled:
1190
+ // openProxy may still be in flight during a teardown race, and the
1191
+ // endpoint is registered inside that promise — capturing the map
1192
+ // earlier would miss it and leak the tunnel. Closing after the DB
1193
+ // connections also keeps the tunnel alive while they drain.
1194
+ const endpointResults = await Promise.allSettled(
1195
+ [...proxyEndpoints.values()].map((ep) => ep.close()),
1196
+ );
1097
1197
  duckLakeCache.clear();
1098
1198
  snowflakeJwtCache.clear();
1099
1199
  azureDuckDBCache.clear();
1200
+ proxyConnectionCache.clear();
1201
+ proxyEndpoints.clear();
1100
1202
 
1101
- const failures = closeResults.filter(
1203
+ const failures = [...closeResults, ...endpointResults].filter(
1102
1204
  (result): result is PromiseRejectedResult =>
1103
1205
  result.status === "rejected",
1104
1206
  );
@@ -349,3 +349,119 @@ 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
+ });
@@ -22,6 +22,7 @@ export type EnvironmentConnectionMetadata = {
22
22
  isDuckLake: boolean;
23
23
  databasePath?: string;
24
24
  workingDirectory: string;
25
+ proxy?: ApiConnection["proxy"];
25
26
  };
26
27
 
27
28
  export type AssembledEnvironmentConnections = {
@@ -258,6 +259,52 @@ function buildDuckdbEntry(
258
259
  }
259
260
 
260
261
  function validateConnectionShape(connection: ApiConnection): void {
262
+ if (connection.proxy) {
263
+ // A connection proxy makes THIS server open an outbound SSH tunnel to a
264
+ // tenant-configured bastion. It's a normal connection capability,
265
+ // authorized by whoever configures the connection — deliberately NOT gated
266
+ // by an env flag, and kept separate from the `publisher` HTTP multi-hop
267
+ // type's PUBLISHER_ALLOW_PROXY_CONNECTIONS gate below (that flag is about
268
+ // publisher-to-publisher proxying, a distinct operator decision). Host-key
269
+ // pinning is fail-closed at connect time (see openProxy).
270
+ if (connection.proxy.type !== "ssh") {
271
+ throw new Error(
272
+ `Connection '${connection.name}' has an unsupported proxy type '${connection.proxy.type}'. Only 'ssh' is supported.`,
273
+ );
274
+ }
275
+ if (connection.type !== "postgres") {
276
+ throw new Error(
277
+ `Connection proxy is not supported for type '${connection.type}' (only 'postgres' today).`,
278
+ );
279
+ }
280
+ if (!connection.proxy.ssh) {
281
+ throw new Error(
282
+ `Connection proxy on '${connection.name}' has type 'ssh' but no 'ssh' config object.`,
283
+ );
284
+ }
285
+ // The tunnel forwards to an explicit host:port; the connectionString form
286
+ // can't be rewritten to the local endpoint. Reject it outright when a
287
+ // proxy is set — normal postgres gives connectionString precedence over
288
+ // host/port, so a config carrying BOTH would silently tunnel to
289
+ // host/port and ignore the connectionString, connecting to a different
290
+ // database than the operator configured. Require discrete host/port.
291
+ if (connection.postgresConnection?.connectionString) {
292
+ throw new Error(
293
+ `Connection proxy on '${connection.name}' does not support the connectionString form; ` +
294
+ `provide discrete host and port instead (the tunnel forwards to an explicit endpoint).`,
295
+ );
296
+ }
297
+ if (
298
+ !connection.postgresConnection?.host ||
299
+ !connection.postgresConnection?.port
300
+ ) {
301
+ throw new Error(
302
+ `Connection proxy on '${connection.name}' requires explicit host and port on the ` +
303
+ `postgres connection; the connectionString form is not supported with a proxy.`,
304
+ );
305
+ }
306
+ }
307
+
261
308
  switch (connection.type) {
262
309
  case "postgres":
263
310
  case "mysql":
@@ -445,6 +492,7 @@ export function assembleEnvironmentConnections(
445
492
  isDuckLake,
446
493
  databasePath,
447
494
  workingDirectory: environmentPath,
495
+ proxy: connection.proxy,
448
496
  });
449
497
 
450
498
  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> {