@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.
- package/dist/app/api-doc.yaml +116 -0
- package/dist/app/assets/{EnvironmentPage-CuO6sty5.js → EnvironmentPage-DxOiCxcd.js} +1 -1
- package/dist/app/assets/{HomePage-u3vxROIF.js → HomePage-i8qAtD6x.js} +1 -1
- package/dist/app/assets/{MainPage-Bt2sYLbr.js → MainPage-B58d5pmX.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-B1rj61zs.js → MaterializationsPage-6OEVM543.js} +1 -1
- package/dist/app/assets/{ModelPage-BpvONvSR.js → ModelPage-8d5l7YkU.js} +1 -1
- package/dist/app/assets/{PackagePage-DHNcwboW.js → PackagePage-BVmoVsPQ.js} +1 -1
- package/dist/app/assets/{RouteError-D1vhLJvr.js → RouteError-DFX521_t.js} +1 -1
- package/dist/app/assets/{WorkbookPage-CZmud-yI.js → WorkbookPage-CzucDJ-T.js} +1 -1
- package/dist/app/assets/{core-XtBSnW2U.es-DE88sVsZ.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
- package/dist/app/assets/{index-BlnQtDZj.js → index--xJ1pzL-.js} +73 -73
- package/dist/app/assets/{index-CYf2akGH.js → index-Bxkza-hz.js} +1 -1
- package/dist/app/assets/{index-BQdOB6m9.js → index-DxRKma36.js} +1 -1
- package/dist/app/assets/{index.umd-BdQ0R4hx.js → index.umd-o-yUIEtS.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/server.mjs +19050 -75
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/server.ts +55 -1
- package/src/service/build_plan.spec.ts +21 -0
- package/src/service/build_plan.ts +14 -0
- package/src/service/connection.ts +104 -2
- package/src/service/connection_config.spec.ts +116 -0
- package/src/service/connection_config.ts +48 -0
- package/src/service/model.ts +25 -3
- package/src/service/package.ts +21 -2
- package/src/service/package_worker_path.spec.ts +103 -0
- package/src/service/proxy.spec.ts +367 -0
- package/src/service/proxy.ts +233 -0
- package/tests/fixtures/html-pages-test/public/barehtml.html +4 -0
- package/tests/fixtures/html-pages-test/public/bodymeta.html +8 -0
- package/tests/fixtures/html-pages-test/public/fitcomment.html +11 -0
- package/tests/fixtures/html-pages-test/public/nohead.html +8 -0
- package/tests/fixtures/html-pages-test/public/notfit.html +13 -0
- package/tests/fixtures/html-pages-test/public/slides.html +12 -0
- package/tests/fixtures/html-pages-test/public/unterminated.html +10 -0
- 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,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,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>
|
|
@@ -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([
|
|
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).
|