@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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Generic TCP proxy layer for publisher database connections.
3
+ *
4
+ * Currently supports SSH bastion tunnels (type "ssh"). Future connection
5
+ * types (mysql, trino, …) only need a new `openProxy` branch — the caller
6
+ * interface is unchanged.
7
+ *
8
+ * A connection proxy is a normal connection capability (authorized by whoever
9
+ * configures the connection); it is intentionally NOT behind an env-flag gate,
10
+ * and is kept separate from the `publisher` HTTP multi-hop type's
11
+ * PUBLISHER_ALLOW_PROXY_CONNECTIONS gate. The trust boundary is host-key pinning
12
+ * (below), which is fail-closed.
13
+ *
14
+ * # Host-key policy
15
+ *
16
+ * Publisher is fail-closed by default: if `ssh.hostKey` is absent the tunnel
17
+ * is refused at connect time to prevent MITM. Set the environment variable
18
+ *
19
+ * PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY=true
20
+ *
21
+ * to disable this check for development/testing. Never set this in
22
+ * production.
23
+ */
24
+
25
+ import net from "net";
26
+ import { Client as SshClient } from "ssh2";
27
+ import type { ConnectConfig, SyncHostVerifier } from "ssh2";
28
+ import { components } from "../api";
29
+
30
+ type ConnectionProxy = components["schemas"]["ConnectionProxy"];
31
+
32
+ export interface ProxyEndpoint {
33
+ host: string;
34
+ port: number;
35
+ close(): Promise<void>;
36
+ }
37
+
38
+ const SSH_CONNECT_TIMEOUT_MS = 15_000;
39
+ const SSH_KEEPALIVE_INTERVAL_MS = 15_000;
40
+
41
+ /**
42
+ * Extract the base64 wire-format key from a stored hostKey, accepting any of:
43
+ * a full known_hosts line (`[markers] host keytype AAAA…`), a `keytype AAAA…`
44
+ * pair, or the bare base64 blob. SSH public-key blobs always base64-encode to a
45
+ * string starting with "AAAA" (the 4-byte length prefix of the key-type name),
46
+ * so we pick that token; otherwise fall back to the last whitespace-delimited
47
+ * token. This matches what ssh2's hostVerifier hands us (`key.toString("base64")`).
48
+ */
49
+ function extractHostKeyBase64(raw: string): string {
50
+ const tokens = raw.trim().split(/\s+/);
51
+ const blob = tokens.find((t) => /^AAAA[A-Za-z0-9+/]+={0,2}$/.test(t));
52
+ return blob ?? tokens[tokens.length - 1] ?? "";
53
+ }
54
+
55
+ /**
56
+ * Open a proxy tunnel described by `proxy` that ultimately delivers traffic to
57
+ * `target`. Returns a local `127.0.0.1:port` endpoint the DB driver should
58
+ * connect to in place of the real host/port.
59
+ */
60
+ export async function openProxy(
61
+ proxy: ConnectionProxy,
62
+ target: { host: string; port: number },
63
+ ): Promise<ProxyEndpoint> {
64
+ if (proxy.type === "ssh") {
65
+ if (!proxy.ssh) {
66
+ throw new Error(
67
+ "ConnectionProxy type is 'ssh' but the 'ssh' config object is missing.",
68
+ );
69
+ }
70
+ return openSshProxy(proxy.ssh, target);
71
+ }
72
+ throw new Error(
73
+ `Proxy type '${proxy.type}' is not supported yet. Only 'ssh' is implemented.`,
74
+ );
75
+ }
76
+
77
+ function openSshProxy(
78
+ ssh: components["schemas"]["SshProxyConfig"],
79
+ target: { host: string; port: number },
80
+ ): Promise<ProxyEndpoint> {
81
+ return new Promise((resolve, reject) => {
82
+ const client = new SshClient();
83
+ let localPort = 0;
84
+ let server: net.Server | undefined;
85
+ let settled = false;
86
+ // Track accepted local sockets so close() can force them down. Relying on
87
+ // server.close() alone waits for in-flight sockets to drain, which can
88
+ // hang teardown indefinitely if a forwarded socket lingers.
89
+ const sockets = new Set<net.Socket>();
90
+
91
+ function fail(err: Error): void {
92
+ if (settled) return;
93
+ settled = true;
94
+ server?.close();
95
+ client.end();
96
+ reject(err);
97
+ }
98
+
99
+ const connectConfig: ConnectConfig = {
100
+ host: ssh.host,
101
+ port: ssh.port ?? 22,
102
+ username: ssh.username,
103
+ privateKey: ssh.privateKey,
104
+ ...(ssh.privateKeyPass ? { passphrase: ssh.privateKeyPass } : {}),
105
+ readyTimeout: SSH_CONNECT_TIMEOUT_MS,
106
+ // Send SSH-level keepalives so an idle tunnel isn't silently reaped by
107
+ // Cloud NAT / bastion ClientAlive / stateful-firewall idle timeouts —
108
+ // which would otherwise only surface as a confusing pg error on the
109
+ // next query. 3 missed (~45s) before ssh2 considers the link dead.
110
+ keepaliveInterval: SSH_KEEPALIVE_INTERVAL_MS,
111
+ keepaliveCountMax: 3,
112
+ hostVerifier: ((key: Buffer): boolean => {
113
+ // `key` is the raw Buffer of the host's public key.
114
+ const presented = key.toString("base64");
115
+ if (ssh.hostKey) {
116
+ const expected = extractHostKeyBase64(ssh.hostKey);
117
+ if (presented !== expected) {
118
+ fail(
119
+ new Error(
120
+ `SSH host-key mismatch for ${ssh.host}: expected ${expected.slice(0, 24)}… but got ${presented.slice(0, 24)}….`,
121
+ ),
122
+ );
123
+ return false;
124
+ }
125
+ return true;
126
+ }
127
+ // No hostKey provided.
128
+ if (process.env.PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY === "true") {
129
+ return true;
130
+ }
131
+ fail(
132
+ new Error(
133
+ `SSH connection to ${ssh.host} refused: no hostKey provided and ` +
134
+ `PUBLISHER_SSH_ALLOW_UNKNOWN_HOSTKEY is not 'true'. ` +
135
+ `Set hostKey to the bastion's host public key (an OpenSSH ` +
136
+ `known_hosts line or its base64 blob, e.g. from ssh-keyscan).`,
137
+ ),
138
+ );
139
+ return false;
140
+ }) as SyncHostVerifier,
141
+ };
142
+
143
+ client.on("error", (err) => fail(err));
144
+
145
+ client.on("ready", () => {
146
+ server = net.createServer((socket) => {
147
+ sockets.add(socket);
148
+ socket.on("close", () => sockets.delete(socket));
149
+ // Buffer incoming data immediately so we don't lose bytes that
150
+ // arrive before the forwardOut channel is open (relevant in Bun
151
+ // where resume() doesn't re-emit already-buffered data).
152
+ const earlyData: Buffer[] = [];
153
+ socket.on("data", (chunk: Buffer) => earlyData.push(chunk));
154
+
155
+ client.forwardOut(
156
+ "127.0.0.1",
157
+ localPort,
158
+ target.host,
159
+ target.port,
160
+ (err, channel) => {
161
+ if (err) {
162
+ socket.destroy(err);
163
+ return;
164
+ }
165
+ // The local socket may have closed while forwardOut was still
166
+ // opening the channel; if so, tear the channel down instead of
167
+ // attaching it to a dead socket (which would leak the remote
168
+ // connection). The rest of this callback is synchronous, so no
169
+ // close can slip in between this check and the listeners below.
170
+ if (socket.destroyed) {
171
+ channel.destroy();
172
+ return;
173
+ }
174
+ // Flush buffered data, then switch to live forwarding.
175
+ for (const chunk of earlyData) {
176
+ channel.write(chunk);
177
+ }
178
+ earlyData.length = 0;
179
+ socket.removeAllListeners("data");
180
+ // pipe() honors backpressure (pauses the source when the
181
+ // destination's buffer is full) — unlike a raw on('data') =>
182
+ // write() which would buffer unbounded on a slow peer.
183
+ socket.pipe(channel);
184
+ channel.pipe(socket);
185
+ socket.on("error", () => channel.destroy());
186
+ channel.on("error", () => socket.destroy());
187
+ // Propagate teardown both ways so neither side is left
188
+ // half-open when the other closes (e.g. pool eviction or the
189
+ // DB dropping the connection).
190
+ socket.on("close", () => channel.destroy());
191
+ channel.on("close", () => socket.destroy());
192
+ },
193
+ );
194
+ });
195
+
196
+ server.listen(0, "127.0.0.1", () => {
197
+ const addr = server!.address();
198
+ if (!addr || typeof addr === "string") {
199
+ fail(
200
+ new Error(
201
+ "Failed to obtain local listen port for SSH proxy.",
202
+ ),
203
+ );
204
+ return;
205
+ }
206
+ localPort = addr.port;
207
+
208
+ if (settled) return;
209
+ settled = true;
210
+
211
+ resolve({
212
+ host: "127.0.0.1",
213
+ port: localPort,
214
+ close(): Promise<void> {
215
+ return new Promise((res) => {
216
+ // Force-destroy live forwarded sockets first so server.close()
217
+ // doesn't wait on them (which would hang teardown).
218
+ for (const s of sockets) s.destroy();
219
+ server!.close(() => {
220
+ client.end();
221
+ res();
222
+ });
223
+ });
224
+ },
225
+ });
226
+ });
227
+
228
+ server.on("error", (err) => fail(err));
229
+ });
230
+
231
+ client.connect(connectConfig);
232
+ });
233
+ }
@@ -0,0 +1,4 @@
1
+ <!doctype html>
2
+ <title>Bare HTML</title>
3
+ <!-- example only, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
4
+ <h1>No head or body tags; the tag appears only in a comment.</h1>
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html>
3
+ <title>Body Meta</title>
4
+ <body>
5
+ <meta name="publisher:fit" content="viewport" />
6
+ <h1>A real fit meta in &lt;body&gt;; the head-only scan means no opt-in.</h1>
7
+ </body>
8
+ </html>
@@ -0,0 +1,11 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <!-- this deck does <body> styling tricks; the literal must not truncate the scan -->
5
+ <meta name="publisher:fit" content="viewport" />
6
+ <title>Fit After Comment</title>
7
+ </head>
8
+ <body style="height: 100vh; margin: 0; overflow: hidden">
9
+ <section style="height: 100%">A full-screen slide</section>
10
+ </body>
11
+ </html>
@@ -0,0 +1,8 @@
1
+ <!doctype html>
2
+ <html>
3
+ <title>No Head Close Tag</title>
4
+ <body>
5
+ <h1>Omits the optional &lt;/head&gt; end tag</h1>
6
+ <!-- shown in body, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
7
+ </body>
8
+ </html>
@@ -0,0 +1,13 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta data-name="publisher:fit" content="viewport" />
7
+ <title>Tall Dashboard</title>
8
+ </head>
9
+ <body>
10
+ <h1>A normal dashboard that hugs its content</h1>
11
+ <!-- shown in body, must NOT opt in: <meta name="publisher:fit" content="viewport"> -->
12
+ </body>
13
+ </html>
@@ -0,0 +1,12 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="publisher:fit" content="viewport" />
7
+ <title>FY27 Slide Deck</title>
8
+ </head>
9
+ <body style="height: 100vh; margin: 0; overflow: hidden">
10
+ <section style="height: 100%">A full-screen slide</section>
11
+ </body>
12
+ </html>
@@ -0,0 +1,10 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Unterminated Comment</title>
5
+ <!-- never closed, must NOT opt in: <meta name="publisher:fit" content="viewport">
6
+ </head>
7
+ <body>
8
+ <h1>An unterminated comment swallows the tag.</h1>
9
+ </body>
10
+ </html>
@@ -66,6 +66,7 @@ interface PageItem {
66
66
  packageName?: string;
67
67
  path?: string;
68
68
  title?: string;
69
+ fit?: "viewport";
69
70
  }
70
71
 
71
72
  // Creating a symlink that escapes the package needs privileges the Windows CI
@@ -322,7 +323,17 @@ describe("In-package HTML data apps (E2E)", () => {
322
323
  const paths = pages.map((p) => p.path).sort();
323
324
  // Only HTML files under public/ are listed; the toEqual pins the exact
324
325
  // set, so non-public files (manifest, models, data) can't appear.
325
- expect(paths).toEqual(["index.html", "sub/page2.html"]);
326
+ expect(paths).toEqual([
327
+ "barehtml.html",
328
+ "bodymeta.html",
329
+ "fitcomment.html",
330
+ "index.html",
331
+ "nohead.html",
332
+ "notfit.html",
333
+ "slides.html",
334
+ "sub/page2.html",
335
+ "unterminated.html",
336
+ ]);
326
337
 
327
338
  const index = pages.find((p) => p.path === "index.html");
328
339
  expect(index?.title).toBe("Carrier Dashboard");
@@ -332,6 +343,57 @@ describe("In-package HTML data apps (E2E)", () => {
332
343
  );
333
344
  });
334
345
 
346
+ it("surfaces fit=viewport only for pages that opt in via <meta name=publisher:fit>", async () => {
347
+ const res = await fetch(apiUrl("/pages"));
348
+ expect(res.status).toBe(200);
349
+ const pages = (await res.json()) as PageItem[];
350
+
351
+ // slides.html opts in with <meta name="publisher:fit" content="viewport">
352
+ // (alongside a standard charset + viewport meta) → fill the embedded viewer.
353
+ expect(pages.find((p) => p.path === "slides.html")?.fit).toBe("viewport");
354
+
355
+ // fitcomment.html has a real <meta publisher:fit> AFTER a comment that
356
+ // contains the literal "<body>"; stripping comments before locating the
357
+ // </head>/<body> boundary must keep that literal from truncating the scan
358
+ // and hiding the real tag (it must still opt in).
359
+ expect(pages.find((p) => p.path === "fitcomment.html")?.fit).toBe(
360
+ "viewport",
361
+ );
362
+
363
+ // notfit.html carries the STANDARD <meta name="viewport"> (device-width),
364
+ // a decoy <meta data-name="publisher:fit"> in <head>, and the real tag as
365
+ // text in a <body> comment. None is a genuine <head> name=publisher:fit,
366
+ // so it must NOT be mistaken for an opt-in.
367
+ expect(pages.find((p) => p.path === "notfit.html")?.fit).toBeUndefined();
368
+
369
+ // A page with no relevant meta keeps content-height auto-sizing (the
370
+ // field is omitted, never `null`/`"content"`).
371
+ expect(pages.find((p) => p.path === "index.html")?.fit).toBeUndefined();
372
+
373
+ // nohead.html omits the optional </head> end tag and shows the tag in a
374
+ // <body> comment; the scan stops at <body>, so it must NOT opt in.
375
+ expect(pages.find((p) => p.path === "nohead.html")?.fit).toBeUndefined();
376
+
377
+ // barehtml.html omits BOTH </head> and <body> and shows the tag only in
378
+ // an HTML comment; comment-stripping must keep it from opting in.
379
+ expect(
380
+ pages.find((p) => p.path === "barehtml.html")?.fit,
381
+ ).toBeUndefined();
382
+
383
+ // bodymeta.html has a REAL (uncommented) <meta publisher:fit> but in
384
+ // <body> and omits </head>, so the head-only scan must stop at <body>
385
+ // and not opt in (locks the <body> boundary).
386
+ expect(
387
+ pages.find((p) => p.path === "bodymeta.html")?.fit,
388
+ ).toBeUndefined();
389
+
390
+ // unterminated.html wraps the tag in a comment with no closing -->; the
391
+ // unterminated-comment strip must still keep it from opting in.
392
+ expect(
393
+ pages.find((p) => p.path === "unterminated.html")?.fit,
394
+ ).toBeUndefined();
395
+ });
396
+
335
397
  it("400s a malformed environment/package name on /pages", async () => {
336
398
  // getEnvironment runs assertSafePackageName, so a name outside
337
399
  // IdentifierPattern is a 400 (now documented on list-pages in api-doc).