@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
@@ -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
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Unit tests for the SSH proxy layer.
3
+ *
4
+ * These tests are hermetic: they stand up an in-process ssh2 server that
5
+ * forwards to a plain TCP echo server, call openProxy(), connect a client
6
+ * to the returned local endpoint, and assert that bytes round-trip through
7
+ * the tunnel.
8
+ *
9
+ * No external network access is required.
10
+ */
11
+
12
+ import net from "net";
13
+ import { generateKeyPairSync } from "crypto";
14
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
15
+ import {
16
+ Server as SshServer,
17
+ utils as sshUtils,
18
+ type Connection as SshServerConnection,
19
+ } from "ssh2";
20
+ import { openProxy } from "./proxy";
21
+
22
+ // ── Key material generated once for the test suite ────────────────────────────
23
+
24
+ // ssh2's server requires PEM private keys it can parse. These are ephemeral,
25
+ // test-only keys; RSA-2048 (not 1024) so static analysis doesn't flag them.
26
+ const hostKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
27
+ const hostPrivatePem = hostKeys.privateKey
28
+ .export({ type: "pkcs1", format: "pem" })
29
+ .toString();
30
+
31
+ const clientKeys = generateKeyPairSync("rsa", { modulusLength: 2048 });
32
+ const clientPrivatePem = clientKeys.privateKey
33
+ .export({ type: "pkcs1", format: "pem" })
34
+ .toString();
35
+ // Helpers ─────────────────────────────────────────────────────────────────────
36
+
37
+ function startEchoServer(): Promise<{
38
+ port: number;
39
+ close: () => Promise<void>;
40
+ }> {
41
+ return new Promise((resolve, reject) => {
42
+ // Track accepted sockets so close() can force them down. server.close()
43
+ // alone only stops accepting and then waits for open connections to end;
44
+ // a lingering forwarded socket would otherwise hang teardown (and the
45
+ // afterEach hook) until the test timeout, especially under --serial CI.
46
+ const sockets = new Set<net.Socket>();
47
+ const server = net.createServer((socket) => {
48
+ sockets.add(socket);
49
+ socket.on("close", () => sockets.delete(socket));
50
+ socket.pipe(socket);
51
+ });
52
+ server.on("error", reject);
53
+ server.listen(0, "127.0.0.1", () => {
54
+ const addr = server.address() as net.AddressInfo;
55
+ resolve({
56
+ port: addr.port,
57
+ close: () =>
58
+ new Promise((res, rej) => {
59
+ for (const s of sockets) s.destroy();
60
+ server.close((err) => (err ? rej(err) : res()));
61
+ }),
62
+ });
63
+ });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Start a minimal in-process SSH server that accepts the test client key
69
+ * and fulfils tcpip-forward (forwardOut) requests.
70
+ */
71
+ function startSshServer(opts: {
72
+ rejectAuth?: boolean;
73
+ rejectForward?: boolean;
74
+ }): Promise<{
75
+ port: number;
76
+ hostKeyBase64: string;
77
+ close: () => Promise<void>;
78
+ }> {
79
+ return new Promise((resolve, reject) => {
80
+ // Track live client connections and forwarded destination sockets so
81
+ // close() can force them down; otherwise a lingering forwarded socket
82
+ // keeps sshd.close() (and the afterEach hook) hanging until the test
83
+ // timeout, which is what made the tunnel tests flake under --serial CI.
84
+ const clients = new Set<SshServerConnection>();
85
+ const destSockets = new Set<net.Socket>();
86
+ const sshd = new SshServer(
87
+ { hostKeys: [hostPrivatePem] },
88
+ (client: SshServerConnection) => {
89
+ clients.add(client);
90
+ client.on("close", () => clients.delete(client));
91
+ client.on("authentication", (ctx) => {
92
+ if (opts.rejectAuth) {
93
+ ctx.reject();
94
+ return;
95
+ }
96
+ // Accept publickey auth for our test client key.
97
+ if (ctx.method === "publickey" && !opts.rejectAuth) {
98
+ ctx.accept();
99
+ } else {
100
+ ctx.reject();
101
+ }
102
+ });
103
+
104
+ client.on("ready", () => {
105
+ client.on("tcpip", (accept, _reject, info) => {
106
+ if (opts.rejectForward) {
107
+ _reject();
108
+ return;
109
+ }
110
+ const channel = accept();
111
+ // Connect to the real destination and pipe.
112
+ const dest = net.createConnection(
113
+ { host: info.destIP, port: info.destPort },
114
+ () => {
115
+ channel.pipe(dest).pipe(channel);
116
+ channel.on("error", () => dest.destroy());
117
+ dest.on("error", () => channel.destroy());
118
+ },
119
+ );
120
+ destSockets.add(dest);
121
+ dest.on("close", () => destSockets.delete(dest));
122
+ dest.on("error", () => channel.destroy());
123
+ });
124
+ });
125
+
126
+ client.on("error", () => {
127
+ /* swallow per-client errors in tests */
128
+ });
129
+ },
130
+ );
131
+
132
+ sshd.on("error", reject);
133
+ sshd.listen(0, "127.0.0.1", () => {
134
+ const addr = sshd.address() as net.AddressInfo;
135
+
136
+ // Capture the server's host key fingerprint (base64 of the raw DER) by
137
+ // parsing the pem via the ssh2 util so the format matches what
138
+ // hostVerifier receives.
139
+ const parsed = sshUtils.parseKey(hostPrivatePem);
140
+ const hostKeyBase64 = (parsed as { getPublicSSH(): Buffer })
141
+ .getPublicSSH()
142
+ .toString("base64");
143
+
144
+ resolve({
145
+ port: addr.port,
146
+ hostKeyBase64,
147
+ close: () =>
148
+ new Promise((res) => {
149
+ for (const s of destSockets) s.destroy();
150
+ for (const c of clients) c.end();
151
+ sshd.close(() => res());
152
+ }),
153
+ });
154
+ });
155
+ });
156
+ }
157
+
158
+ function connectAndSend(port: number, message: string): Promise<string> {
159
+ return new Promise((resolve, reject) => {
160
+ const socket = net.createConnection({ host: "127.0.0.1", port }, () => {
161
+ socket.write(message);
162
+ });
163
+ const chunks: Buffer[] = [];
164
+ socket.on("data", (chunk) => {
165
+ chunks.push(chunk);
166
+ if (Buffer.concat(chunks).toString().includes(message)) {
167
+ socket.destroy();
168
+ resolve(Buffer.concat(chunks).toString());
169
+ }
170
+ });
171
+ socket.on("error", reject);
172
+ });
173
+ }
174
+
175
+ // Best-effort, time-bounded teardown. Destroying every ssh2/socket handle is
176
+ // reliable locally but a lingering graceful close can still stall on some CI
177
+ // platforms (Bun + ssh2 on Linux/Windows), which would hang the afterEach hook
178
+ // until the 100s test timeout. The assertions have already run by teardown, so
179
+ // cap each close and move on rather than block.
180
+ function closeQuietly(close: () => Promise<void>, ms = 3000): Promise<void> {
181
+ return Promise.race([
182
+ close().catch(() => {}),
183
+ new Promise<void>((resolve) => {
184
+ const timer = setTimeout(resolve, ms);
185
+ (timer as unknown as { unref?: () => void }).unref?.();
186
+ }),
187
+ ]);
188
+ }
189
+
190
+ // ── Tests ─────────────────────────────────────────────────────────────────────
191
+
192
+ describe("openProxy — SSH tunnel", () => {
193
+ let echoServer: { port: number; close: () => Promise<void> };
194
+ let sshServer: {
195
+ port: number;
196
+ hostKeyBase64: string;
197
+ close: () => Promise<void>;
198
+ };
199
+
200
+ beforeEach(async () => {
201
+ echoServer = await startEchoServer();
202
+ sshServer = await startSshServer({});
203
+ });
204
+
205
+ afterEach(async () => {
206
+ await closeQuietly(() => echoServer.close());
207
+ await closeQuietly(() => sshServer.close());
208
+ });
209
+
210
+ it("round-trips bytes through the tunnel", async () => {
211
+ const ep = await openProxy(
212
+ {
213
+ type: "ssh",
214
+ ssh: {
215
+ host: "127.0.0.1",
216
+ port: sshServer.port,
217
+ username: "testuser",
218
+ privateKey: clientPrivatePem,
219
+ hostKey: sshServer.hostKeyBase64,
220
+ },
221
+ },
222
+ { host: "127.0.0.1", port: echoServer.port },
223
+ );
224
+
225
+ try {
226
+ const reply = await connectAndSend(ep.port, "hello-proxy");
227
+ expect(reply).toContain("hello-proxy");
228
+ } finally {
229
+ await closeQuietly(() => ep.close());
230
+ }
231
+ });
232
+
233
+ it("accepts a hostKey given as a full known_hosts line", async () => {
234
+ // Users typically paste `ssh-keyscan` output, e.g.
235
+ // `bastion.example.com ssh-rsa AAAA…` — the verifier must extract the blob.
236
+ const knownHostsLine = `bastion.example.com ssh-rsa ${sshServer.hostKeyBase64}`;
237
+ const ep = await openProxy(
238
+ {
239
+ type: "ssh",
240
+ ssh: {
241
+ host: "127.0.0.1",
242
+ port: sshServer.port,
243
+ username: "testuser",
244
+ privateKey: clientPrivatePem,
245
+ hostKey: knownHostsLine,
246
+ },
247
+ },
248
+ { host: "127.0.0.1", port: echoServer.port },
249
+ );
250
+
251
+ try {
252
+ const reply = await connectAndSend(ep.port, "known-hosts-line");
253
+ expect(reply).toContain("known-hosts-line");
254
+ } finally {
255
+ await closeQuietly(() => ep.close());
256
+ }
257
+ });
258
+
259
+ it("accepts a hashed (|1|) known_hosts line — the hash only obscures the hostname", async () => {
260
+ // `ssh-keyscan -H` output hashes the hostname (`|1|salt|hash`) but leaves
261
+ // the key blob in the clear; the verifier extracts the blob token and never
262
+ // matches hostnames, so hashed lines work the same as unhashed ones.
263
+ const hashedLine = `|1|dNQdBg9dg8u7Vw8vq3B0uZ3example=|kZ2exampleHashValue0000000= ssh-rsa ${sshServer.hostKeyBase64}`;
264
+ const ep = await openProxy(
265
+ {
266
+ type: "ssh",
267
+ ssh: {
268
+ host: "127.0.0.1",
269
+ port: sshServer.port,
270
+ username: "testuser",
271
+ privateKey: clientPrivatePem,
272
+ hostKey: hashedLine,
273
+ },
274
+ },
275
+ { host: "127.0.0.1", port: echoServer.port },
276
+ );
277
+
278
+ try {
279
+ const reply = await connectAndSend(ep.port, "hashed-line");
280
+ expect(reply).toContain("hashed-line");
281
+ } finally {
282
+ await closeQuietly(() => ep.close());
283
+ }
284
+ });
285
+
286
+ it("rejects when hostKey does not match", async () => {
287
+ const wrongKey = Buffer.alloc(32, 0xff).toString("base64");
288
+
289
+ await expect(
290
+ openProxy(
291
+ {
292
+ type: "ssh",
293
+ ssh: {
294
+ host: "127.0.0.1",
295
+ port: sshServer.port,
296
+ username: "testuser",
297
+ privateKey: clientPrivatePem,
298
+ hostKey: wrongKey,
299
+ },
300
+ },
301
+ { host: "127.0.0.1", port: echoServer.port },
302
+ ),
303
+ ).rejects.toThrow(/host-key mismatch/i);
304
+ });
305
+
306
+ it("rejects when hostKey is absent and opt-out env is not set", async () => {
307
+ const orig = process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
308
+ delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
309
+
310
+ try {
311
+ await expect(
312
+ openProxy(
313
+ {
314
+ type: "ssh",
315
+ ssh: {
316
+ host: "127.0.0.1",
317
+ port: sshServer.port,
318
+ username: "testuser",
319
+ privateKey: clientPrivatePem,
320
+ },
321
+ },
322
+ { host: "127.0.0.1", port: echoServer.port },
323
+ ),
324
+ ).rejects.toThrow(/no hostKey provided/i);
325
+ } finally {
326
+ if (orig !== undefined) {
327
+ process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = orig;
328
+ }
329
+ }
330
+ });
331
+
332
+ it("connects when hostKey is absent and opt-out env is set", async () => {
333
+ process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY = "true";
334
+ try {
335
+ const ep = await openProxy(
336
+ {
337
+ type: "ssh",
338
+ ssh: {
339
+ host: "127.0.0.1",
340
+ port: sshServer.port,
341
+ username: "testuser",
342
+ privateKey: clientPrivatePem,
343
+ },
344
+ },
345
+ { host: "127.0.0.1", port: echoServer.port },
346
+ );
347
+ try {
348
+ const reply = await connectAndSend(ep.port, "allow-unknown");
349
+ expect(reply).toContain("allow-unknown");
350
+ } finally {
351
+ await closeQuietly(() => ep.close());
352
+ }
353
+ } finally {
354
+ delete process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY;
355
+ }
356
+ });
357
+
358
+ it("rejects for unsupported proxy type", async () => {
359
+ await expect(
360
+ openProxy(
361
+ // @ts-expect-error — intentionally passing an unsupported type
362
+ { type: "unsupported" },
363
+ { host: "127.0.0.1", port: echoServer.port },
364
+ ),
365
+ ).rejects.toThrow(/not supported/i);
366
+ });
367
+ });