planche 0.5.3 → 0.7.0

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 (3) hide show
  1. package/README.md +47 -0
  2. package/dist/index.js +940 -658
  3. package/package.json +3 -2
package/dist/index.js CHANGED
@@ -6,421 +6,9 @@ import { createRequire } from "module";
6
6
  import { spawn } from "child_process";
7
7
  import { parseArgs } from "util";
8
8
 
9
- // src/server.ts
10
- import express from "express";
11
- import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
12
-
13
- // ../shared/contract.ts
14
- var PROTOCOL_VERSION = 2;
15
-
16
- // src/ssh-tunnel.ts
17
- import { createServer } from "net";
18
- import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
19
- import { createHash, createHmac, timingSafeEqual } from "crypto";
20
- import { homedir as homedir2 } from "os";
21
- import { basename as basename2, join as join2 } from "path";
22
- import { Client } from "ssh2";
23
-
24
- // src/ssh-config.ts
25
- import { readFile, readdir } from "fs/promises";
26
- import { homedir } from "os";
27
- import { dirname, isAbsolute, join, basename } from "path";
28
- var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
29
- var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join(homedir(), ".ssh", "config");
30
- var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
31
- (f) => join(homedir(), ".ssh", f)
32
- );
33
- function expandHome(path) {
34
- return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
35
- }
36
- function globToRegExp(pattern) {
37
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
38
- return new RegExp(`^${escaped}$`, "i");
39
- }
40
- function hostPatternMatches(patterns, host) {
41
- let matched = false;
42
- for (const raw of patterns) {
43
- const negated = raw.startsWith("!");
44
- const pattern = negated ? raw.slice(1) : raw;
45
- if (!globToRegExp(pattern).test(host)) continue;
46
- if (negated) return false;
47
- matched = true;
48
- }
49
- return matched;
50
- }
51
- function tokenize(line) {
52
- const tokens = [];
53
- const re = /"([^"]*)"|(\S+)/g;
54
- let m;
55
- while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
56
- return tokens;
57
- }
58
- function parseLine(line) {
59
- const trimmed = line.trim();
60
- if (!trimmed || trimmed.startsWith("#")) return void 0;
61
- const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
62
- const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
63
- return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
64
- }
65
- async function expandGlob(path) {
66
- const base = basename(path);
67
- if (!/[*?]/.test(base)) return [path];
68
- const dir = dirname(path);
69
- try {
70
- const re = globToRegExp(base);
71
- return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join(dir, name));
72
- } catch {
73
- return [];
74
- }
75
- }
76
- async function loadLines(file, seen) {
77
- if (seen.has(file)) return [];
78
- seen.add(file);
79
- try {
80
- return (await readFile(file, "utf8")).split(/\r?\n/);
81
- } catch {
82
- return [];
83
- }
84
- }
85
- async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
86
- const result = { identityFiles: [] };
87
- if (!file) return result;
88
- const seen = /* @__PURE__ */ new Set();
89
- async function walk(path, active) {
90
- let blockActive = active;
91
- for (const line of await loadLines(path, seen)) {
92
- const d = parseLine(line);
93
- if (!d) continue;
94
- switch (d.keyword) {
95
- case "host":
96
- blockActive = hostPatternMatches(d.args, host);
97
- continue;
98
- case "match":
99
- blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
100
- continue;
101
- case "include":
102
- for (const inc of d.args) {
103
- const target = expandHome(inc);
104
- const abs = isAbsolute(target) ? target : join(homedir(), ".ssh", target);
105
- for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
106
- }
107
- continue;
108
- }
109
- if (!blockActive) continue;
110
- const value = d.args[0];
111
- if (value === void 0) continue;
112
- switch (d.keyword) {
113
- case "hostname":
114
- result.hostName ??= value.replace(/%h/g, host);
115
- break;
116
- case "port":
117
- result.port ??= Number(value) || void 0;
118
- break;
119
- case "user":
120
- result.user ??= value;
121
- break;
122
- case "identityfile":
123
- for (const f of d.args) {
124
- const expanded = expandHome(f.replace(/%d/g, homedir()).replace(/%h/g, host));
125
- if (expanded.split(/[\\/]/).includes("..")) continue;
126
- result.identityFiles.push(expanded);
127
- }
128
- break;
129
- case "identityagent":
130
- result.identityAgent ??= value === "none" ? void 0 : expandHome(value.replace(/%d/g, homedir()));
131
- break;
132
- case "proxyjump":
133
- result.proxyJump ??= value;
134
- break;
135
- }
136
- }
137
- }
138
- await walk(file, true);
139
- return result;
140
- }
141
-
142
- // src/ssh-tunnel.ts
143
- var TunnelError = class extends Error {
144
- constructor(message, status) {
145
- super(message);
146
- this.status = status;
147
- this.name = "TunnelError";
148
- }
149
- status;
150
- };
151
- var HostKeyError = class extends TunnelError {
152
- hostKey;
153
- constructor(host, port, type, fingerprint2) {
154
- super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
155
- this.name = "HostKeyError";
156
- this.hostKey = { host, port, type, fingerprint: fingerprint2 };
157
- }
158
- };
159
- var CONNECT_TIMEOUT_MS = 2e4;
160
- var PLANCHE_DIR = join2(homedir2(), ".planche");
161
- var PLANCHE_KNOWN_HOSTS = join2(PLANCHE_DIR, "known_hosts");
162
- var OPENSSH_KNOWN_HOSTS = join2(homedir2(), ".ssh", "known_hosts");
163
- function parseSshConfig(value) {
164
- if (value === void 0 || value === null) return void 0;
165
- if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
166
- const body = value;
167
- const host = typeof body.host === "string" ? body.host.trim() : "";
168
- const user = typeof body.user === "string" ? body.user.trim() : "";
169
- const auth = body.auth;
170
- if (!host) throw new TunnelError("ssh.host is required", 400);
171
- if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
172
- if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
173
- const port = Math.trunc(Number(body.port)) || 22;
174
- if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
175
- if (auth !== "password" && auth !== "key" && auth !== "agent") {
176
- throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
177
- }
178
- const str = (v) => typeof v === "string" && v ? v : void 0;
179
- const config = {
180
- host,
181
- port,
182
- user,
183
- auth,
184
- password: auth === "password" ? str(body.password) ?? "" : void 0,
185
- privateKey: auth === "key" ? str(body.privateKey) : void 0,
186
- passphrase: auth === "key" ? str(body.passphrase) : void 0,
187
- acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
188
- };
189
- return config;
190
- }
191
- function keyType(blob) {
192
- if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
193
- const len = blob.readUInt32BE(0);
194
- if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
195
- const type = blob.subarray(4, 4 + len).toString("ascii");
196
- if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
197
- return type;
198
- }
199
- function knownHostsEntryMatches(field, patterns) {
200
- if (field.startsWith("|1|")) {
201
- const [, , salt, hash] = field.split("|");
202
- if (!salt || !hash) return false;
203
- const key = Buffer.from(salt, "base64");
204
- const want = Buffer.from(hash, "base64");
205
- return patterns.some((p) => {
206
- const got = createHmac("sha1", key).update(p).digest();
207
- return got.length === want.length && timingSafeEqual(got, want);
208
- });
209
- }
210
- const entries = field.toLowerCase().split(",");
211
- return patterns.some((p) => hostPatternMatches(entries, p));
212
- }
213
- function hostPatterns(host, port) {
214
- const h = host.toLowerCase();
215
- return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
216
- }
217
- async function readKnownHosts(file) {
218
- try {
219
- return (await readFile2(file, "utf8")).split(/\r?\n/);
220
- } catch {
221
- return [];
222
- }
223
- }
224
- async function lookupHostKey(host, port, blob) {
225
- const patterns = hostPatterns(host, port);
226
- const type = keyType(blob);
227
- const encoded = blob.toString("base64");
228
- let sawHost = false;
229
- const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
230
- for (const raw of lines) {
231
- const line = raw.trim();
232
- if (!line || line.startsWith("#")) continue;
233
- const fields = line.split(/\s+/);
234
- const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
235
- const [hosts, lineType, lineKey] = fields;
236
- if (!hosts || !lineType || !lineKey) continue;
237
- if (!knownHostsEntryMatches(hosts, patterns)) continue;
238
- if (marker === "@revoked") {
239
- if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
240
- continue;
241
- }
242
- if (marker === "@cert-authority") continue;
243
- sawHost = true;
244
- if (lineType === type && lineKey === encoded) return "match";
245
- }
246
- return sawHost ? "mismatch" : "unknown";
247
- }
248
- async function rememberHostKey(host, port, blob) {
249
- await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
250
- const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
251
- await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
252
- `, { mode: 384 });
253
- }
254
- function fingerprint(blob) {
255
- return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
256
- }
257
- async function resolveConfig(ssh) {
258
- const cfg = await resolveSshHost(ssh.host);
259
- if (cfg.proxyJump) {
260
- throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
261
- }
262
- if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
263
- throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
264
- }
265
- const resolved = {
266
- ...ssh,
267
- host: cfg.hostName ?? ssh.host,
268
- port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
269
- user: ssh.user || cfg.user || ""
270
- };
271
- if (!resolved.user) {
272
- throw new TunnelError(
273
- SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
274
- 400
275
- );
276
- }
277
- if (resolved.auth === "key" && !resolved.privateKey) {
278
- if (SSH_CONFIG_DISABLED) {
279
- throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
280
- }
281
- const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
282
- for (const file of candidates) {
283
- try {
284
- resolved.privateKey = await readFile2(file, "utf8");
285
- console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
286
- break;
287
- } catch {
288
- }
289
- }
290
- if (!resolved.privateKey) {
291
- throw new TunnelError(
292
- `No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename2(f)).join(", ")} in ~/.ssh)`,
293
- 400
294
- );
295
- }
296
- }
297
- return { ...resolved, identityAgent: cfg.identityAgent };
298
- }
299
- function toConnectConfig(ssh) {
300
- const base = {
301
- host: ssh.host,
302
- port: ssh.port,
303
- username: ssh.user,
304
- readyTimeout: CONNECT_TIMEOUT_MS,
305
- keepaliveInterval: 3e4
306
- };
307
- switch (ssh.auth) {
308
- case "password":
309
- return { ...base, password: ssh.password ?? "", tryKeyboard: true };
310
- case "key":
311
- return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
312
- case "agent": {
313
- const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
314
- if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
315
- return { ...base, agent };
316
- }
317
- }
318
- }
319
- function describeSshError(err, ssh) {
320
- const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
321
- if (err instanceof TunnelError) return err;
322
- switch (err.level) {
323
- case "client-authentication":
324
- return new TunnelError(`SSH authentication failed for ${target}`, 401);
325
- case "client-timeout":
326
- return new TunnelError(`SSH connection to ${target} timed out`, 400);
327
- default:
328
- return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
329
- }
330
- }
331
- async function openSshTunnel(requested, target) {
332
- const ssh = await resolveConfig(requested);
333
- const client = new Client();
334
- const connectConfig = toConnectConfig(ssh);
335
- let verifyError;
336
- connectConfig.hostVerifier = (key, verified) => {
337
- lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
338
- const fp = fingerprint(key);
339
- if (result === "mismatch") {
340
- verifyError = new TunnelError(
341
- `Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
342
- 400
343
- );
344
- verified(false);
345
- return;
346
- }
347
- if (result === "unknown") {
348
- if (ssh.acceptHostKey !== fp) {
349
- verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
350
- verified(false);
351
- return;
352
- }
353
- console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
354
- await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
355
- }
356
- verified(true);
357
- }).catch((err) => {
358
- verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
359
- verified(false);
360
- });
361
- };
362
- await new Promise((resolve2, reject) => {
363
- client.once("ready", resolve2);
364
- client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
365
- try {
366
- client.connect(connectConfig);
367
- } catch (err) {
368
- reject(describeSshError(err, ssh));
369
- }
370
- });
371
- client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
372
- const sockets = /* @__PURE__ */ new Set();
373
- let forwardError;
374
- const server = createServer((socket) => {
375
- sockets.add(socket);
376
- socket.once("close", () => sockets.delete(socket));
377
- socket.on("error", () => socket.destroy());
378
- client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
379
- if (err) {
380
- const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
381
- const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
382
- forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
383
- console.error(`[ssh] ${forwardError}`);
384
- socket.destroy();
385
- return;
386
- }
387
- stream.on("error", () => socket.destroy());
388
- socket.pipe(stream).pipe(socket);
389
- stream.once("close", () => socket.destroy());
390
- socket.once("close", () => stream.destroy());
391
- });
392
- });
393
- await new Promise((resolve2, reject) => {
394
- server.once("error", reject);
395
- server.listen(0, "127.0.0.1", () => resolve2());
396
- }).catch((err) => {
397
- client.end();
398
- throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
399
- });
400
- const { port } = server.address();
401
- let closed = false;
402
- const close = () => new Promise((resolve2) => {
403
- if (closed) return resolve2();
404
- closed = true;
405
- for (const s of sockets) s.destroy();
406
- server.close(() => {
407
- client.end();
408
- resolve2();
409
- });
410
- });
411
- client.once("close", () => void close());
412
- return {
413
- host: "127.0.0.1",
414
- port,
415
- get forwardError() {
416
- return forwardError;
417
- },
418
- close
419
- };
420
- }
421
-
422
- // src/tunneling.ts
423
- import { randomUUID } from "crypto";
9
+ // src/gateway.ts
10
+ import { readFileSync } from "fs";
11
+ import { resolve as resolve2 } from "path";
424
12
 
425
13
  // src/drivers/mysql.ts
426
14
  import mysql from "mysql2/promise";
@@ -519,6 +107,9 @@ var MySqlDriver = class {
519
107
  isResultQuery: false
520
108
  };
521
109
  }
110
+ async setReadOnly() {
111
+ await this.connection.query("SET SESSION TRANSACTION READ ONLY");
112
+ }
522
113
  async close() {
523
114
  await this.connection.end();
524
115
  }
@@ -712,6 +303,9 @@ var PostgresDriver = class {
712
303
  isResultQuery: false
713
304
  };
714
305
  }
306
+ async setReadOnly() {
307
+ await this.client.query("SET default_transaction_read_only = on");
308
+ }
715
309
  async close() {
716
310
  await this.client.end();
717
311
  }
@@ -752,8 +346,8 @@ var postgresDriverFactory = {
752
346
  // src/drivers/sqlite.ts
753
347
  import Database from "better-sqlite3";
754
348
  import { existsSync, realpathSync } from "fs";
755
- import { homedir as homedir3 } from "os";
756
- import { basename as basename3, dirname as dirname2, join as join3, relative, resolve, sep } from "path";
349
+ import { homedir } from "os";
350
+ import { basename, dirname, join, relative, resolve, sep } from "path";
757
351
  function normalizeDeclaredType(declared) {
758
352
  if (!declared) return null;
759
353
  const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
@@ -766,24 +360,24 @@ function inferTypeFromValue(value) {
766
360
  if (Buffer.isBuffer(value)) return "blob";
767
361
  return "text";
768
362
  }
769
- var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome2(process.env.PLANCHE_SQLITE_DIR)) : null;
770
- var DEFAULT_ROOT = homedir3();
363
+ var SQLITE_DIR = process.env.PLANCHE_SQLITE_DIR ? resolve(expandHome(process.env.PLANCHE_SQLITE_DIR)) : null;
364
+ var DEFAULT_ROOT = homedir();
771
365
  var DENIED_UNDER_HOME = process.platform === "darwin" ? ["Library"] : process.platform === "win32" ? ["AppData"] : [];
772
366
  var ALLOW_ATTACH = process.env.PLANCHE_SQLITE_ATTACH === "1";
773
367
  var DB_EXTENSIONS = /\.(db|db3|s3db|sl3|sqlite|sqlite3)$/i;
774
- function expandHome2(path) {
775
- return path === "~" || path.startsWith("~/") ? resolve(homedir3(), path.slice(2)) : path;
368
+ function expandHome(path) {
369
+ return path === "~" || path.startsWith("~/") ? resolve(homedir(), path.slice(2)) : path;
776
370
  }
777
371
  function realish(file) {
778
372
  let probe = file;
779
373
  const rest = [];
780
374
  for (; ; ) {
781
375
  try {
782
- return rest.length ? join3(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
376
+ return rest.length ? join(realpathSync.native(probe), ...rest) : realpathSync.native(probe);
783
377
  } catch {
784
- const parent = dirname2(probe);
378
+ const parent = dirname(probe);
785
379
  if (parent === probe) return file;
786
- rest.unshift(basename3(probe));
380
+ rest.unshift(basename(probe));
787
381
  probe = parent;
788
382
  }
789
383
  }
@@ -794,7 +388,7 @@ function isInside(path, root) {
794
388
  function resolveFile(file, forCreate = false) {
795
389
  if (file === ":memory:") return file;
796
390
  if (file.startsWith("file:")) throw new Error("SQLite URI filenames are not supported by the agent; give a plain path");
797
- const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome2(file)));
391
+ const resolved = realish(resolve(SQLITE_DIR ?? process.cwd(), expandHome(file)));
798
392
  if (SQLITE_DIR) {
799
393
  if (!isInside(resolved, realish(SQLITE_DIR))) throw new Error("SQLite file is outside the directory allowed by PLANCHE_SQLITE_DIR");
800
394
  } else {
@@ -925,6 +519,9 @@ var SqliteDriver = class {
925
519
  });
926
520
  return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
927
521
  }
522
+ async setReadOnly() {
523
+ this.db.pragma("query_only = 1");
524
+ }
928
525
  async close() {
929
526
  if (this.db.inTransaction) {
930
527
  try {
@@ -1151,280 +748,686 @@ var OracleDriver = class {
1151
748
  this.label = `${config.user}@${config.host}:${config.port}/${config.serviceName}`;
1152
749
  this.currentSchema = config.user.toUpperCase();
1153
750
  }
1154
- conn;
1155
- type = "oracle";
751
+ conn;
752
+ type = "oracle";
753
+ label;
754
+ currentSchema;
755
+ manualTx = false;
756
+ /** Column names per `OWNER.TABLE`, for result-source resolution. */
757
+ columns = /* @__PURE__ */ new Map();
758
+ async init() {
759
+ for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
760
+ }
761
+ async tableColumns(schema, table) {
762
+ const key = `${schema}.${table}`;
763
+ const cached = this.columns.get(key);
764
+ if (cached !== void 0) return cached;
765
+ let out = null;
766
+ try {
767
+ const res = await this.conn.execute(
768
+ "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
769
+ { o: schema, t: table },
770
+ { outFormat: oracledb.OUT_FORMAT_ARRAY }
771
+ );
772
+ out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
773
+ } catch {
774
+ out = null;
775
+ }
776
+ if (this.columns.size > 200) this.columns.clear();
777
+ this.columns.set(key, out);
778
+ return out;
779
+ }
780
+ async query(sql2, database) {
781
+ if (database && database !== this.currentSchema) {
782
+ await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
783
+ this.currentSchema = database;
784
+ }
785
+ const text = normalizeStatement(sql2);
786
+ if (START_TX.test(text)) this.manualTx = true;
787
+ const res = await this.conn.execute(text, [], {
788
+ outFormat: oracledb.OUT_FORMAT_ARRAY,
789
+ fetchTypeHandler,
790
+ autoCommit: !this.manualTx
791
+ });
792
+ if (SET_SCHEMA.test(text)) this.currentSchema = null;
793
+ if (END_TX.test(text)) this.manualTx = false;
794
+ if (res.metaData && res.metaData.length > 0) {
795
+ const source = singleSourceTable(text);
796
+ const schema = source?.schema ?? this.currentSchema ?? "";
797
+ const known = source && schema ? await this.tableColumns(schema, source.table) : null;
798
+ const fields = res.metaData.map((col) => {
799
+ const fromTable = known?.has(col.name) ?? false;
800
+ return {
801
+ name: col.name,
802
+ orgName: col.name,
803
+ type: (col.dbTypeName ?? "").toLowerCase(),
804
+ table: fromTable ? source.table : "",
805
+ orgTable: fromTable ? source.table : "",
806
+ db: fromTable ? schema : "",
807
+ maxLength: col.byteSize ?? 0
808
+ };
809
+ });
810
+ const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
811
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
812
+ }
813
+ return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
814
+ }
815
+ async close() {
816
+ await this.conn.close();
817
+ }
818
+ };
819
+ function connectionAttributes(config) {
820
+ return {
821
+ user: config.user,
822
+ password: config.password,
823
+ connectString: `${config.host}:${config.port}/${config.serviceName}`
824
+ };
825
+ }
826
+ var oracleDriverFactory = {
827
+ async open(config) {
828
+ const conn = await oracledb.getConnection(connectionAttributes(config));
829
+ const driver = new OracleDriver(conn, config);
830
+ await driver.init();
831
+ return driver;
832
+ },
833
+ async test(config) {
834
+ const conn = await oracledb.getConnection(connectionAttributes(config));
835
+ await conn.close();
836
+ },
837
+ parseConfig(body) {
838
+ const host = typeof body.host === "string" ? body.host.trim() : "";
839
+ const user = typeof body.user === "string" ? body.user : "";
840
+ if (!host || !user) {
841
+ throw new Error("host and user are required");
842
+ }
843
+ if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
844
+ const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
845
+ if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
846
+ return {
847
+ driver: "oracle",
848
+ host,
849
+ port: Number(body.port) || 1521,
850
+ user,
851
+ password: typeof body.password === "string" ? body.password : "",
852
+ serviceName
853
+ };
854
+ }
855
+ };
856
+
857
+ // src/drivers/d1.ts
858
+ var API = "https://api.cloudflare.com/client/v4";
859
+ var D1Error = class extends Error {
860
+ constructor(message, status) {
861
+ super(message);
862
+ this.status = status;
863
+ }
864
+ status;
865
+ };
866
+ var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
867
+ var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
868
+ var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
869
+ function singleSourceTable2(sql2) {
870
+ const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
871
+ if (!m) return null;
872
+ const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
873
+ if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
874
+ const raw = m[1];
875
+ const first = raw[0];
876
+ if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
877
+ if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
878
+ if (first === "[") return raw.slice(1, -1);
879
+ return raw;
880
+ }
881
+ function normalizeDeclaredType2(declared) {
882
+ if (typeof declared !== "string") return null;
883
+ const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
884
+ return base || null;
885
+ }
886
+ function inferTypeFromValue2(value) {
887
+ if (value === null || value === void 0) return "null";
888
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
889
+ if (Array.isArray(value)) return "blob";
890
+ return "text";
891
+ }
892
+ function firstNonNull2(records, index) {
893
+ for (const row of records) {
894
+ const value = row[index];
895
+ if (value !== null && value !== void 0) return value;
896
+ }
897
+ return null;
898
+ }
899
+ var D1Driver = class {
900
+ type = "d1";
1156
901
  label;
1157
- currentSchema;
1158
- manualTx = false;
1159
- /** Column names per `OWNER.TABLE`, for result-source resolution. */
902
+ url;
903
+ headers;
904
+ /** Declared column types per table, for result-source resolution. */
1160
905
  columns = /* @__PURE__ */ new Map();
1161
- async init() {
1162
- for (const stmt of SESSION_FORMATS) await this.conn.execute(stmt);
906
+ constructor(config) {
907
+ this.label = `d1:${config.databaseId}`;
908
+ this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
909
+ this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
1163
910
  }
1164
- async tableColumns(schema, table) {
1165
- const key = `${schema}.${table}`;
1166
- const cached = this.columns.get(key);
911
+ async raw(sql2) {
912
+ let res;
913
+ try {
914
+ res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
915
+ } catch (err) {
916
+ throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
917
+ }
918
+ let body = null;
919
+ try {
920
+ body = await res.json();
921
+ } catch {
922
+ }
923
+ if (!res.ok || !body?.success) {
924
+ const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
925
+ throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
926
+ }
927
+ const result = body.result?.[0];
928
+ if (!result) throw new D1Error("Cloudflare API returned no result");
929
+ return result;
930
+ }
931
+ async tableColumns(table) {
932
+ const cached = this.columns.get(table);
1167
933
  if (cached !== void 0) return cached;
1168
934
  let out = null;
1169
935
  try {
1170
- const res = await this.conn.execute(
1171
- "SELECT column_name FROM all_tab_columns WHERE owner = :o AND table_name = :t",
1172
- { o: schema, t: table },
1173
- { outFormat: oracledb.OUT_FORMAT_ARRAY }
1174
- );
1175
- out = res.rows && res.rows.length > 0 ? new Set(res.rows.map((r) => r[0])) : null;
936
+ const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
937
+ const cols = info.results?.columns ?? [];
938
+ const name = cols.indexOf("name");
939
+ const type = cols.indexOf("type");
940
+ const rows = info.results?.rows ?? [];
941
+ if (rows.length > 0 && name >= 0) {
942
+ out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
943
+ }
1176
944
  } catch {
1177
945
  out = null;
1178
946
  }
1179
947
  if (this.columns.size > 200) this.columns.clear();
1180
- this.columns.set(key, out);
948
+ this.columns.set(table, out);
1181
949
  return out;
1182
950
  }
1183
- async query(sql2, database) {
1184
- if (database && database !== this.currentSchema) {
1185
- await this.conn.execute(`ALTER SESSION SET CURRENT_SCHEMA = ${quoteIdent2(database)}`);
1186
- this.currentSchema = database;
951
+ async query(sql2) {
952
+ if (DATABASE_LIST.test(sql2)) {
953
+ return {
954
+ fields: [
955
+ { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
956
+ { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
957
+ { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
958
+ ],
959
+ records: [[0, "main", ""]],
960
+ affectedRows: 0,
961
+ insertId: 0,
962
+ isResultQuery: true
963
+ };
1187
964
  }
1188
- const text = normalizeStatement(sql2);
1189
- if (START_TX.test(text)) this.manualTx = true;
1190
- const res = await this.conn.execute(text, [], {
1191
- outFormat: oracledb.OUT_FORMAT_ARRAY,
1192
- fetchTypeHandler,
1193
- autoCommit: !this.manualTx
1194
- });
1195
- if (SET_SCHEMA.test(text)) this.currentSchema = null;
1196
- if (END_TX.test(text)) this.manualTx = false;
1197
- if (res.metaData && res.metaData.length > 0) {
1198
- const source = singleSourceTable(text);
1199
- const schema = source?.schema ?? this.currentSchema ?? "";
1200
- const known = source && schema ? await this.tableColumns(schema, source.table) : null;
1201
- const fields = res.metaData.map((col) => {
1202
- const fromTable = known?.has(col.name) ?? false;
1203
- return {
1204
- name: col.name,
1205
- orgName: col.name,
1206
- type: (col.dbTypeName ?? "").toLowerCase(),
1207
- table: fromTable ? source.table : "",
1208
- orgTable: fromTable ? source.table : "",
1209
- db: fromTable ? schema : "",
1210
- maxLength: col.byteSize ?? 0
1211
- };
1212
- });
1213
- const records = (res.rows ?? []).map((row) => row.map(normalizeValue2));
1214
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
965
+ const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
966
+ if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
967
+ throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
1215
968
  }
1216
- return { fields: [], records: [], affectedRows: res.rowsAffected ?? 0, insertId: 0, isResultQuery: false };
969
+ if (this.isDdl(text)) this.columns.clear();
970
+ const result = await this.raw(text);
971
+ const columns = result.results?.columns ?? [];
972
+ const records = result.results?.rows ?? [];
973
+ if (columns.length === 0 && !RESULT_KIND.test(text)) {
974
+ return {
975
+ fields: [],
976
+ records: [],
977
+ affectedRows: result.meta?.changes ?? 0,
978
+ insertId: result.meta?.last_row_id ?? 0,
979
+ isResultQuery: false
980
+ };
981
+ }
982
+ const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
983
+ const known = source ? await this.tableColumns(source) : null;
984
+ const fields = columns.map((name, index) => {
985
+ const fromTable = known?.has(name) ?? false;
986
+ const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
987
+ return {
988
+ name,
989
+ orgName: name,
990
+ type,
991
+ table: fromTable && source ? source : "",
992
+ orgTable: fromTable && source ? source : "",
993
+ db: fromTable ? "main" : "",
994
+ maxLength: 0
995
+ };
996
+ });
997
+ return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
998
+ }
999
+ isDdl(sql2) {
1000
+ return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
1217
1001
  }
1218
1002
  async close() {
1219
- await this.conn.close();
1003
+ this.columns.clear();
1220
1004
  }
1221
1005
  };
1222
- function connectionAttributes(config) {
1223
- return {
1224
- user: config.user,
1225
- password: config.password,
1226
- connectString: `${config.host}:${config.port}/${config.serviceName}`
1227
- };
1228
- }
1229
- var oracleDriverFactory = {
1006
+ var d1DriverFactory = {
1230
1007
  async open(config) {
1231
- const conn = await oracledb.getConnection(connectionAttributes(config));
1232
- const driver = new OracleDriver(conn, config);
1233
- await driver.init();
1008
+ const driver = new D1Driver(config);
1009
+ await driver.raw("SELECT 1");
1234
1010
  return driver;
1235
1011
  },
1236
1012
  async test(config) {
1237
- const conn = await oracledb.getConnection(connectionAttributes(config));
1238
- await conn.close();
1013
+ await new D1Driver(config).raw("SELECT 1");
1239
1014
  },
1240
1015
  parseConfig(body) {
1241
- const host = typeof body.host === "string" ? body.host.trim() : "";
1242
- const user = typeof body.user === "string" ? body.user : "";
1243
- if (!host || !user) {
1244
- throw new Error("host and user are required");
1016
+ const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1017
+ const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1018
+ const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1019
+ if (!accountId || !databaseId || !apiToken) {
1020
+ throw new Error("accountId, databaseId and apiToken are required");
1021
+ }
1022
+ return { driver: "d1", accountId, databaseId, apiToken };
1023
+ }
1024
+ };
1025
+
1026
+ // src/ssh-tunnel.ts
1027
+ import { createServer } from "net";
1028
+ import { mkdir, readFile as readFile2, appendFile } from "fs/promises";
1029
+ import { createHash, createHmac, timingSafeEqual } from "crypto";
1030
+ import { homedir as homedir3 } from "os";
1031
+ import { basename as basename3, join as join3 } from "path";
1032
+ import { Client } from "ssh2";
1033
+
1034
+ // src/ssh-config.ts
1035
+ import { readFile, readdir } from "fs/promises";
1036
+ import { homedir as homedir2 } from "os";
1037
+ import { dirname as dirname2, isAbsolute, join as join2, basename as basename2 } from "path";
1038
+ var SSH_CONFIG_DISABLED = process.env.PLANCHE_SSH_CONFIG === "none";
1039
+ var DEFAULT_SSH_CONFIG = SSH_CONFIG_DISABLED ? "" : process.env.PLANCHE_SSH_CONFIG || join2(homedir2(), ".ssh", "config");
1040
+ var DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_ecdsa", "id_rsa", "id_ed25519_sk", "id_ecdsa_sk"].map(
1041
+ (f) => join2(homedir2(), ".ssh", f)
1042
+ );
1043
+ function expandHome2(path) {
1044
+ return path === "~" || path.startsWith("~/") ? join2(homedir2(), path.slice(1)) : path;
1045
+ }
1046
+ function globToRegExp(pattern) {
1047
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
1048
+ return new RegExp(`^${escaped}$`, "i");
1049
+ }
1050
+ function hostPatternMatches(patterns, host) {
1051
+ let matched = false;
1052
+ for (const raw of patterns) {
1053
+ const negated = raw.startsWith("!");
1054
+ const pattern = negated ? raw.slice(1) : raw;
1055
+ if (!globToRegExp(pattern).test(host)) continue;
1056
+ if (negated) return false;
1057
+ matched = true;
1058
+ }
1059
+ return matched;
1060
+ }
1061
+ function tokenize(line) {
1062
+ const tokens = [];
1063
+ const re = /"([^"]*)"|(\S+)/g;
1064
+ let m;
1065
+ while (m = re.exec(line)) tokens.push(m[1] ?? m[2]);
1066
+ return tokens;
1067
+ }
1068
+ function parseLine(line) {
1069
+ const trimmed = line.trim();
1070
+ if (!trimmed || trimmed.startsWith("#")) return void 0;
1071
+ const eq = trimmed.match(/^([A-Za-z]+)\s*=\s*(.*)$/);
1072
+ const [keyword, rest] = eq ? [eq[1], eq[2]] : [trimmed.split(/\s+/, 1)[0], trimmed.slice(trimmed.split(/\s+/, 1)[0].length)];
1073
+ return { keyword: keyword.toLowerCase(), args: tokenize(rest) };
1074
+ }
1075
+ async function expandGlob(path) {
1076
+ const base = basename2(path);
1077
+ if (!/[*?]/.test(base)) return [path];
1078
+ const dir = dirname2(path);
1079
+ try {
1080
+ const re = globToRegExp(base);
1081
+ return (await readdir(dir)).filter((name) => re.test(name)).sort().map((name) => join2(dir, name));
1082
+ } catch {
1083
+ return [];
1084
+ }
1085
+ }
1086
+ async function loadLines(file, seen) {
1087
+ if (seen.has(file)) return [];
1088
+ seen.add(file);
1089
+ try {
1090
+ return (await readFile(file, "utf8")).split(/\r?\n/);
1091
+ } catch {
1092
+ return [];
1093
+ }
1094
+ }
1095
+ async function resolveSshHost(host, file = DEFAULT_SSH_CONFIG) {
1096
+ const result = { identityFiles: [] };
1097
+ if (!file) return result;
1098
+ const seen = /* @__PURE__ */ new Set();
1099
+ async function walk(path, active) {
1100
+ let blockActive = active;
1101
+ for (const line of await loadLines(path, seen)) {
1102
+ const d = parseLine(line);
1103
+ if (!d) continue;
1104
+ switch (d.keyword) {
1105
+ case "host":
1106
+ blockActive = hostPatternMatches(d.args, host);
1107
+ continue;
1108
+ case "match":
1109
+ blockActive = d.args.length === 1 && d.args[0].toLowerCase() === "all";
1110
+ continue;
1111
+ case "include":
1112
+ for (const inc of d.args) {
1113
+ const target = expandHome2(inc);
1114
+ const abs = isAbsolute(target) ? target : join2(homedir2(), ".ssh", target);
1115
+ for (const expanded of await expandGlob(abs)) await walk(expanded, blockActive);
1116
+ }
1117
+ continue;
1118
+ }
1119
+ if (!blockActive) continue;
1120
+ const value = d.args[0];
1121
+ if (value === void 0) continue;
1122
+ switch (d.keyword) {
1123
+ case "hostname":
1124
+ result.hostName ??= value.replace(/%h/g, host);
1125
+ break;
1126
+ case "port":
1127
+ result.port ??= Number(value) || void 0;
1128
+ break;
1129
+ case "user":
1130
+ result.user ??= value;
1131
+ break;
1132
+ case "identityfile":
1133
+ for (const f of d.args) {
1134
+ const expanded = expandHome2(f.replace(/%d/g, homedir2()).replace(/%h/g, host));
1135
+ if (expanded.split(/[\\/]/).includes("..")) continue;
1136
+ result.identityFiles.push(expanded);
1137
+ }
1138
+ break;
1139
+ case "identityagent":
1140
+ result.identityAgent ??= value === "none" ? void 0 : expandHome2(value.replace(/%d/g, homedir2()));
1141
+ break;
1142
+ case "proxyjump":
1143
+ result.proxyJump ??= value;
1144
+ break;
1145
+ }
1245
1146
  }
1246
- if (!/^[A-Za-z0-9._[\]-]{1,253}$/.test(host)) throw new Error("host is not a valid host name");
1247
- const serviceName = typeof body.serviceName === "string" && body.serviceName ? body.serviceName.trim() : "FREEPDB1";
1248
- if (!/^[A-Za-z0-9._$#-]{1,128}$/.test(serviceName)) throw new Error("serviceName may only contain letters, digits, . _ $ # -");
1249
- return {
1250
- driver: "oracle",
1251
- host,
1252
- port: Number(body.port) || 1521,
1253
- user,
1254
- password: typeof body.password === "string" ? body.password : "",
1255
- serviceName
1256
- };
1257
1147
  }
1258
- };
1148
+ await walk(file, true);
1149
+ return result;
1150
+ }
1259
1151
 
1260
- // src/drivers/d1.ts
1261
- var API = "https://api.cloudflare.com/client/v4";
1262
- var D1Error = class extends Error {
1152
+ // src/ssh-tunnel.ts
1153
+ var TunnelError = class extends Error {
1263
1154
  constructor(message, status) {
1264
1155
  super(message);
1265
1156
  this.status = status;
1157
+ this.name = "TunnelError";
1266
1158
  }
1267
1159
  status;
1268
1160
  };
1269
- var SCHEMA_PRAGMA = /^\s*PRAGMA\s+(?:"main"|main|'main'|\[main\])\s*\.\s*/i;
1270
- var DATABASE_LIST = /^\s*PRAGMA\s+database_list\s*;?\s*$/i;
1271
- var RESULT_KIND = /^\s*(SELECT|WITH|PRAGMA|EXPLAIN|VALUES)\b/i;
1272
- function singleSourceTable2(sql2) {
1273
- const m = /\bFROM\s+(?:"?main"?\s*\.\s*)?("[^"]+"|`[^`]+`|\[[^\]]+\]|[\w$]+)([\s\S]*)$/i.exec(sql2);
1274
- if (!m) return null;
1275
- const rest = m[2].replace(/\b(WHERE|ORDER\s+BY|GROUP\s+BY|HAVING|LIMIT|OFFSET|UNION|EXCEPT|INTERSECT|WINDOW)\b[\s\S]*$/i, "");
1276
- if (/,|\bJOIN\b|\(/i.test(rest) || rest.trim().split(/\s+/).filter(Boolean).length > 2) return null;
1277
- const raw = m[1];
1278
- const first = raw[0];
1279
- if (first === '"') return raw.slice(1, -1).replaceAll('""', '"');
1280
- if (first === "`") return raw.slice(1, -1).replaceAll("``", "`");
1281
- if (first === "[") return raw.slice(1, -1);
1282
- return raw;
1283
- }
1284
- function normalizeDeclaredType2(declared) {
1285
- if (typeof declared !== "string") return null;
1286
- const base = declared.trim().toLowerCase().replace(/\(.*$/, "").trim();
1287
- return base || null;
1161
+ var HostKeyError = class extends TunnelError {
1162
+ hostKey;
1163
+ constructor(host, port, type, fingerprint2) {
1164
+ super(`The SSH host key of ${host}:${port} is not known yet (${type} ${fingerprint2}). Verify the fingerprint and accept it to continue.`, 409);
1165
+ this.name = "HostKeyError";
1166
+ this.hostKey = { host, port, type, fingerprint: fingerprint2 };
1167
+ }
1168
+ };
1169
+ var CONNECT_TIMEOUT_MS = 2e4;
1170
+ var PLANCHE_DIR = join3(homedir3(), ".planche");
1171
+ var PLANCHE_KNOWN_HOSTS = join3(PLANCHE_DIR, "known_hosts");
1172
+ var OPENSSH_KNOWN_HOSTS = join3(homedir3(), ".ssh", "known_hosts");
1173
+ function parseSshConfig(value) {
1174
+ if (value === void 0 || value === null) return void 0;
1175
+ if (typeof value !== "object") throw new TunnelError("ssh must be an object", 400);
1176
+ const body = value;
1177
+ const host = typeof body.host === "string" ? body.host.trim() : "";
1178
+ const user = typeof body.user === "string" ? body.user.trim() : "";
1179
+ const auth = body.auth;
1180
+ if (!host) throw new TunnelError("ssh.host is required", 400);
1181
+ if (host.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(host) || /^[.-]+$/.test(host)) throw new TunnelError("ssh.host is not a valid host name", 400);
1182
+ if (user.length > 64 || /[\s\x00-\x1f]/.test(user)) throw new TunnelError("ssh.user is not valid", 400);
1183
+ const port = Math.trunc(Number(body.port)) || 22;
1184
+ if (port < 1 || port > 65535) throw new TunnelError("ssh.port must be between 1 and 65535", 400);
1185
+ if (auth !== "password" && auth !== "key" && auth !== "agent") {
1186
+ throw new TunnelError("ssh.auth must be one of password, key, agent", 400);
1187
+ }
1188
+ const str = (v) => typeof v === "string" && v ? v : void 0;
1189
+ const config = {
1190
+ host,
1191
+ port,
1192
+ user,
1193
+ auth,
1194
+ password: auth === "password" ? str(body.password) ?? "" : void 0,
1195
+ privateKey: auth === "key" ? str(body.privateKey) : void 0,
1196
+ passphrase: auth === "key" ? str(body.passphrase) : void 0,
1197
+ acceptHostKey: typeof body.acceptHostKey === "string" && /^SHA256:[A-Za-z0-9+/]{43}$/.test(body.acceptHostKey) ? body.acceptHostKey : void 0
1198
+ };
1199
+ return config;
1288
1200
  }
1289
- function inferTypeFromValue2(value) {
1290
- if (value === null || value === void 0) return "null";
1291
- if (typeof value === "number") return Number.isInteger(value) ? "integer" : "real";
1292
- if (Array.isArray(value)) return "blob";
1293
- return "text";
1201
+ function keyType(blob) {
1202
+ if (blob.length < 4) throw new TunnelError("Malformed host key", 400);
1203
+ const len = blob.readUInt32BE(0);
1204
+ if (len <= 0 || len > 64 || blob.length < 4 + len) throw new TunnelError("Malformed host key", 400);
1205
+ const type = blob.subarray(4, 4 + len).toString("ascii");
1206
+ if (!/^[A-Za-z0-9@.-]+$/.test(type)) throw new TunnelError("Malformed host key", 400);
1207
+ return type;
1294
1208
  }
1295
- function firstNonNull2(records, index) {
1296
- for (const row of records) {
1297
- const value = row[index];
1298
- if (value !== null && value !== void 0) return value;
1209
+ function knownHostsEntryMatches(field, patterns) {
1210
+ if (field.startsWith("|1|")) {
1211
+ const [, , salt, hash] = field.split("|");
1212
+ if (!salt || !hash) return false;
1213
+ const key = Buffer.from(salt, "base64");
1214
+ const want = Buffer.from(hash, "base64");
1215
+ return patterns.some((p) => {
1216
+ const got = createHmac("sha1", key).update(p).digest();
1217
+ return got.length === want.length && timingSafeEqual(got, want);
1218
+ });
1299
1219
  }
1300
- return null;
1220
+ const entries = field.toLowerCase().split(",");
1221
+ return patterns.some((p) => hostPatternMatches(entries, p));
1301
1222
  }
1302
- var D1Driver = class {
1303
- type = "d1";
1304
- label;
1305
- url;
1306
- headers;
1307
- /** Declared column types per table, for result-source resolution. */
1308
- columns = /* @__PURE__ */ new Map();
1309
- constructor(config) {
1310
- this.label = `d1:${config.databaseId}`;
1311
- this.url = `${API}/accounts/${encodeURIComponent(config.accountId)}/d1/database/${encodeURIComponent(config.databaseId)}/raw`;
1312
- this.headers = { Authorization: `Bearer ${config.apiToken}`, "Content-Type": "application/json" };
1223
+ function hostPatterns(host, port) {
1224
+ const h = host.toLowerCase();
1225
+ return port === 22 ? [h, `[${h}]:22`] : [`[${h}]:${port}`];
1226
+ }
1227
+ async function readKnownHosts(file) {
1228
+ try {
1229
+ return (await readFile2(file, "utf8")).split(/\r?\n/);
1230
+ } catch {
1231
+ return [];
1313
1232
  }
1314
- async raw(sql2) {
1315
- let res;
1316
- try {
1317
- res = await fetch(this.url, { method: "POST", headers: this.headers, body: JSON.stringify({ sql: sql2 }), signal: AbortSignal.timeout(6e4) });
1318
- } catch (err) {
1319
- throw new D1Error(`Cannot reach the Cloudflare API: ${err instanceof Error ? err.message : String(err)}`, 400);
1320
- }
1321
- let body = null;
1322
- try {
1323
- body = await res.json();
1324
- } catch {
1325
- }
1326
- if (!res.ok || !body?.success) {
1327
- const message = (body?.errors?.map((e) => String(e.message)).join("; ") || `Cloudflare API responded ${res.status}`).slice(0, 500);
1328
- throw new D1Error(message, res.status === 401 || res.status === 403 ? 401 : res.ok ? void 0 : 400);
1233
+ }
1234
+ async function lookupHostKey(host, port, blob) {
1235
+ const patterns = hostPatterns(host, port);
1236
+ const type = keyType(blob);
1237
+ const encoded = blob.toString("base64");
1238
+ let sawHost = false;
1239
+ const lines = [...await readKnownHosts(OPENSSH_KNOWN_HOSTS), ...await readKnownHosts(PLANCHE_KNOWN_HOSTS)];
1240
+ for (const raw of lines) {
1241
+ const line = raw.trim();
1242
+ if (!line || line.startsWith("#")) continue;
1243
+ const fields = line.split(/\s+/);
1244
+ const marker = fields[0].startsWith("@") ? fields.shift() : void 0;
1245
+ const [hosts, lineType, lineKey] = fields;
1246
+ if (!hosts || !lineType || !lineKey) continue;
1247
+ if (!knownHostsEntryMatches(hosts, patterns)) continue;
1248
+ if (marker === "@revoked") {
1249
+ if (lineType === type && lineKey === encoded) throw new TunnelError(`The host key of ${host}:${port} is marked @revoked in known_hosts`, 400);
1250
+ continue;
1329
1251
  }
1330
- const result = body.result?.[0];
1331
- if (!result) throw new D1Error("Cloudflare API returned no result");
1332
- return result;
1252
+ if (marker === "@cert-authority") continue;
1253
+ sawHost = true;
1254
+ if (lineType === type && lineKey === encoded) return "match";
1333
1255
  }
1334
- async tableColumns(table) {
1335
- const cached = this.columns.get(table);
1336
- if (cached !== void 0) return cached;
1337
- let out = null;
1338
- try {
1339
- const info = await this.raw(`PRAGMA table_info("${table.replaceAll('"', '""')}")`);
1340
- const cols = info.results?.columns ?? [];
1341
- const name = cols.indexOf("name");
1342
- const type = cols.indexOf("type");
1343
- const rows = info.results?.rows ?? [];
1344
- if (rows.length > 0 && name >= 0) {
1345
- out = new Map(rows.map((r) => [String(r[name]), normalizeDeclaredType2(type >= 0 ? r[type] : null)]));
1346
- }
1347
- } catch {
1348
- out = null;
1349
- }
1350
- if (this.columns.size > 200) this.columns.clear();
1351
- this.columns.set(table, out);
1352
- return out;
1256
+ return sawHost ? "mismatch" : "unknown";
1257
+ }
1258
+ async function rememberHostKey(host, port, blob) {
1259
+ await mkdir(PLANCHE_DIR, { recursive: true, mode: 448 });
1260
+ const name = port === 22 ? host.toLowerCase() : `[${host.toLowerCase()}]:${port}`;
1261
+ await appendFile(PLANCHE_KNOWN_HOSTS, `${name} ${keyType(blob)} ${blob.toString("base64")}
1262
+ `, { mode: 384 });
1263
+ }
1264
+ function fingerprint(blob) {
1265
+ return "SHA256:" + createHash("sha256").update(blob).digest("base64").replace(/=+$/, "");
1266
+ }
1267
+ async function resolveConfig(ssh) {
1268
+ const cfg = await resolveSshHost(ssh.host);
1269
+ if (cfg.proxyJump) {
1270
+ throw new TunnelError(`ProxyJump (${cfg.proxyJump}) configured for ${ssh.host} in ~/.ssh/config is not supported`, 400);
1353
1271
  }
1354
- async query(sql2) {
1355
- if (DATABASE_LIST.test(sql2)) {
1356
- return {
1357
- fields: [
1358
- { name: "seq", orgName: "seq", type: "integer", table: "", orgTable: "", db: "", maxLength: 0 },
1359
- { name: "name", orgName: "name", type: "text", table: "", orgTable: "", db: "", maxLength: 0 },
1360
- { name: "file", orgName: "file", type: "text", table: "", orgTable: "", db: "", maxLength: 0 }
1361
- ],
1362
- records: [[0, "main", ""]],
1363
- affectedRows: 0,
1364
- insertId: 0,
1365
- isResultQuery: true
1366
- };
1272
+ if (cfg.hostName && (cfg.hostName.length > 253 || !/^[A-Za-z0-9._:[\]-]+$/.test(cfg.hostName))) {
1273
+ throw new TunnelError(`HostName for ${ssh.host} in ~/.ssh/config is not a valid host name`, 400);
1274
+ }
1275
+ const resolved = {
1276
+ ...ssh,
1277
+ host: cfg.hostName ?? ssh.host,
1278
+ port: ssh.port === 22 && cfg.port ? cfg.port : ssh.port,
1279
+ user: ssh.user || cfg.user || ""
1280
+ };
1281
+ if (!resolved.user) {
1282
+ throw new TunnelError(
1283
+ SSH_CONFIG_DISABLED ? "ssh.user is required" : `ssh.user is required (no User for ${ssh.host} in ~/.ssh/config)`,
1284
+ 400
1285
+ );
1286
+ }
1287
+ if (resolved.auth === "key" && !resolved.privateKey) {
1288
+ if (SSH_CONFIG_DISABLED) {
1289
+ throw new TunnelError("ssh.privateKey is required (the agent runs with PLANCHE_SSH_CONFIG=none)", 400);
1367
1290
  }
1368
- const text = sql2.replace(SCHEMA_PRAGMA, "PRAGMA ");
1369
- if (/^\s*(BEGIN|COMMIT|END|ROLLBACK|SAVEPOINT|RELEASE)\b/i.test(text)) {
1370
- throw new D1Error("Cloudflare D1 does not support transaction statements over the API; every request auto-commits.");
1291
+ const candidates = cfg.identityFiles.length ? cfg.identityFiles : DEFAULT_IDENTITY_FILES;
1292
+ for (const file of candidates) {
1293
+ try {
1294
+ resolved.privateKey = await readFile2(file, "utf8");
1295
+ console.log(`[ssh] Using identity file ${file} for ${ssh.host}`);
1296
+ break;
1297
+ } catch {
1298
+ }
1371
1299
  }
1372
- if (this.isDdl(text)) this.columns.clear();
1373
- const result = await this.raw(text);
1374
- const columns = result.results?.columns ?? [];
1375
- const records = result.results?.rows ?? [];
1376
- if (columns.length === 0 && !RESULT_KIND.test(text)) {
1377
- return {
1378
- fields: [],
1379
- records: [],
1380
- affectedRows: result.meta?.changes ?? 0,
1381
- insertId: result.meta?.last_row_id ?? 0,
1382
- isResultQuery: false
1383
- };
1300
+ if (!resolved.privateKey) {
1301
+ throw new TunnelError(
1302
+ `No private key given and none readable for ${ssh.host} (tried ${candidates.map((f) => basename3(f)).join(", ")} in ~/.ssh)`,
1303
+ 400
1304
+ );
1384
1305
  }
1385
- const source = RESULT_KIND.test(text) ? singleSourceTable2(text) : null;
1386
- const known = source ? await this.tableColumns(source) : null;
1387
- const fields = columns.map((name, index) => {
1388
- const fromTable = known?.has(name) ?? false;
1389
- const type = (fromTable ? known?.get(name) : null) ?? inferTypeFromValue2(firstNonNull2(records, index));
1390
- return {
1391
- name,
1392
- orgName: name,
1393
- type,
1394
- table: fromTable && source ? source : "",
1395
- orgTable: fromTable && source ? source : "",
1396
- db: fromTable ? "main" : "",
1397
- maxLength: 0
1398
- };
1399
- });
1400
- return { fields, records, affectedRows: 0, insertId: 0, isResultQuery: true };
1401
1306
  }
1402
- isDdl(sql2) {
1403
- return /^\s*(CREATE|ALTER|DROP)\b/i.test(sql2);
1307
+ return { ...resolved, identityAgent: cfg.identityAgent };
1308
+ }
1309
+ function toConnectConfig(ssh) {
1310
+ const base = {
1311
+ host: ssh.host,
1312
+ port: ssh.port,
1313
+ username: ssh.user,
1314
+ readyTimeout: CONNECT_TIMEOUT_MS,
1315
+ keepaliveInterval: 3e4
1316
+ };
1317
+ switch (ssh.auth) {
1318
+ case "password":
1319
+ return { ...base, password: ssh.password ?? "", tryKeyboard: true };
1320
+ case "key":
1321
+ return { ...base, privateKey: ssh.privateKey, passphrase: ssh.passphrase };
1322
+ case "agent": {
1323
+ const agent = process.env.SSH_AUTH_SOCK || ssh.identityAgent || (process.platform === "win32" ? "pageant" : void 0);
1324
+ if (!agent) throw new TunnelError("No ssh-agent found (SSH_AUTH_SOCK is not set)", 400);
1325
+ return { ...base, agent };
1326
+ }
1404
1327
  }
1405
- async close() {
1406
- this.columns.clear();
1328
+ }
1329
+ function describeSshError(err, ssh) {
1330
+ const target = `${ssh.user}@${ssh.host}:${ssh.port}`;
1331
+ if (err instanceof TunnelError) return err;
1332
+ switch (err.level) {
1333
+ case "client-authentication":
1334
+ return new TunnelError(`SSH authentication failed for ${target}`, 401);
1335
+ case "client-timeout":
1336
+ return new TunnelError(`SSH connection to ${target} timed out`, 400);
1337
+ default:
1338
+ return new TunnelError(`SSH connection to ${target} failed: ${err.message}`, 400);
1407
1339
  }
1408
- };
1409
- var d1DriverFactory = {
1410
- async open(config) {
1411
- const driver = new D1Driver(config);
1412
- await driver.raw("SELECT 1");
1413
- return driver;
1414
- },
1415
- async test(config) {
1416
- await new D1Driver(config).raw("SELECT 1");
1417
- },
1418
- parseConfig(body) {
1419
- const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
1420
- const databaseId = typeof body.databaseId === "string" ? body.databaseId.trim() : "";
1421
- const apiToken = typeof body.apiToken === "string" ? body.apiToken.trim() : "";
1422
- if (!accountId || !databaseId || !apiToken) {
1423
- throw new Error("accountId, databaseId and apiToken are required");
1340
+ }
1341
+ async function openSshTunnel(requested, target) {
1342
+ const ssh = await resolveConfig(requested);
1343
+ const client = new Client();
1344
+ const connectConfig = toConnectConfig(ssh);
1345
+ let verifyError;
1346
+ connectConfig.hostVerifier = (key, verified) => {
1347
+ lookupHostKey(ssh.host, ssh.port, key).then(async (result) => {
1348
+ const fp = fingerprint(key);
1349
+ if (result === "mismatch") {
1350
+ verifyError = new TunnelError(
1351
+ `Host key for ${ssh.host}:${ssh.port} has changed (${fp}). Remove the old entry from ~/.ssh/known_hosts or ~/.planche/known_hosts if this is expected.`,
1352
+ 400
1353
+ );
1354
+ verified(false);
1355
+ return;
1356
+ }
1357
+ if (result === "unknown") {
1358
+ if (ssh.acceptHostKey !== fp) {
1359
+ verifyError = new HostKeyError(ssh.host, ssh.port, keyType(key), fp);
1360
+ verified(false);
1361
+ return;
1362
+ }
1363
+ console.log(`[ssh] Host key for ${ssh.host}:${ssh.port} (${fp}) accepted by the user, saved to ~/.planche/known_hosts`);
1364
+ await rememberHostKey(ssh.host, ssh.port, key).catch((e) => console.warn(`[ssh] Could not save host key: ${e.message}`));
1365
+ }
1366
+ verified(true);
1367
+ }).catch((err) => {
1368
+ verifyError = err instanceof TunnelError ? err : new TunnelError(`Host key verification failed: ${err instanceof Error ? err.message : String(err)}`, 400);
1369
+ verified(false);
1370
+ });
1371
+ };
1372
+ await new Promise((resolve4, reject) => {
1373
+ client.once("ready", resolve4);
1374
+ client.once("error", (err) => reject(verifyError ?? describeSshError(err, ssh)));
1375
+ try {
1376
+ client.connect(connectConfig);
1377
+ } catch (err) {
1378
+ reject(describeSshError(err, ssh));
1424
1379
  }
1425
- return { driver: "d1", accountId, databaseId, apiToken };
1426
- }
1427
- };
1380
+ });
1381
+ client.on("error", (err) => console.error(`[ssh] ${ssh.host}:${ssh.port}: ${err.message}`));
1382
+ const sockets = /* @__PURE__ */ new Set();
1383
+ let forwardError;
1384
+ const server = createServer((socket) => {
1385
+ sockets.add(socket);
1386
+ socket.once("close", () => sockets.delete(socket));
1387
+ socket.on("error", () => socket.destroy());
1388
+ client.forwardOut(socket.localAddress ?? "127.0.0.1", socket.localPort ?? 0, target.host, target.port, (err, stream) => {
1389
+ if (err) {
1390
+ const local = /^(localhost|127\.0\.0\.1|::1)$/i.test(target.host);
1391
+ const hint = local ? `"${target.host}" is resolved on the jump host itself; use the database's real address (e.g. the RDS endpoint) as the database host` : "is the database reachable from the jump host, and does it allow TCP forwarding?";
1392
+ forwardError = `SSH port forwarding to ${target.host}:${target.port} failed: ${err.message} (${hint})`;
1393
+ console.error(`[ssh] ${forwardError}`);
1394
+ socket.destroy();
1395
+ return;
1396
+ }
1397
+ stream.on("error", () => socket.destroy());
1398
+ socket.pipe(stream).pipe(socket);
1399
+ stream.once("close", () => socket.destroy());
1400
+ socket.once("close", () => stream.destroy());
1401
+ });
1402
+ });
1403
+ await new Promise((resolve4, reject) => {
1404
+ server.once("error", reject);
1405
+ server.listen(0, "127.0.0.1", () => resolve4());
1406
+ }).catch((err) => {
1407
+ client.end();
1408
+ throw new TunnelError(`Could not open local tunnel port: ${err.message}`, 400);
1409
+ });
1410
+ const { port } = server.address();
1411
+ let closed = false;
1412
+ const close = () => new Promise((resolve4) => {
1413
+ if (closed) return resolve4();
1414
+ closed = true;
1415
+ for (const s of sockets) s.destroy();
1416
+ server.close(() => {
1417
+ client.end();
1418
+ resolve4();
1419
+ });
1420
+ });
1421
+ client.once("close", () => void close());
1422
+ return {
1423
+ host: "127.0.0.1",
1424
+ port,
1425
+ get forwardError() {
1426
+ return forwardError;
1427
+ },
1428
+ close
1429
+ };
1430
+ }
1428
1431
 
1429
1432
  // src/drivers/index.ts
1430
1433
  var factories = {
@@ -1463,6 +1466,7 @@ function tunneled(driver, tunnel, label) {
1463
1466
  type: driver.type,
1464
1467
  label,
1465
1468
  query: (sql2, database) => driver.query(sql2, database),
1469
+ setReadOnly: driver.setReadOnly ? () => driver.setReadOnly() : void 0,
1466
1470
  async close() {
1467
1471
  try {
1468
1472
  await driver.close();
@@ -1522,6 +1526,58 @@ function testDirect(config) {
1522
1526
  }
1523
1527
  }
1524
1528
 
1529
+ // src/gateway.ts
1530
+ function loadGatewayConfig(file) {
1531
+ const path = resolve2(file);
1532
+ let raw;
1533
+ try {
1534
+ raw = JSON.parse(readFileSync(path, "utf8"));
1535
+ } catch (err) {
1536
+ throw new Error(`Cannot read gateway config ${path}: ${err instanceof Error ? err.message : String(err)}`);
1537
+ }
1538
+ const data = raw;
1539
+ if (!data || typeof data !== "object" || !Array.isArray(data.profiles) || data.profiles.length === 0) {
1540
+ throw new Error(`Gateway config ${path} needs a non-empty "profiles" array`);
1541
+ }
1542
+ const names = /* @__PURE__ */ new Set();
1543
+ const profiles = data.profiles.map((entry, i) => {
1544
+ const e = entry ?? {};
1545
+ const name = typeof e.name === "string" ? e.name.trim() : "";
1546
+ if (!name) throw new Error(`Gateway profile #${i + 1} has no "name"`);
1547
+ if (names.has(name)) throw new Error(`Gateway profile name is not unique: ${name}`);
1548
+ names.add(name);
1549
+ const { name: _n, readOnly, ...driver } = e;
1550
+ void _n;
1551
+ let config;
1552
+ try {
1553
+ config = parseDriverConfig(driver);
1554
+ } catch (err) {
1555
+ throw new Error(`Gateway profile "${name}": ${err instanceof Error ? err.message : String(err)}`);
1556
+ }
1557
+ return { name, readOnly: readOnly !== false, config };
1558
+ });
1559
+ return { name: typeof data.name === "string" && data.name.trim() ? data.name.trim() : "Planche gateway", profiles };
1560
+ }
1561
+ function describeProfile(entry) {
1562
+ const c = entry.config;
1563
+ const database = "database" in c && typeof c.database === "string" ? c.database : c.driver === "oracle" ? c.serviceName : void 0;
1564
+ return { name: entry.name, driver: c.driver, readOnly: entry.readOnly, ...database ? { database } : {} };
1565
+ }
1566
+
1567
+ // src/index.ts
1568
+ import { existsSync as existsSync3 } from "fs";
1569
+ import { dirname as dirname3, join as join5, resolve as resolve3 } from "path";
1570
+
1571
+ // src/server.ts
1572
+ import express from "express";
1573
+ import { createHash as createHash2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1574
+
1575
+ // ../shared/contract.ts
1576
+ var PROTOCOL_VERSION = 2;
1577
+
1578
+ // src/tunneling.ts
1579
+ import { randomUUID } from "crypto";
1580
+
1525
1581
  // src/drivers/connection-lost.ts
1526
1582
  var ConnectionLostError = class extends Error {
1527
1583
  status = 404;
@@ -1585,12 +1641,105 @@ function isConnectionLost(err) {
1585
1641
  return e.originalError !== void 0 && e.originalError !== err && isConnectionLost(e.originalError);
1586
1642
  }
1587
1643
 
1644
+ // src/read-only.ts
1645
+ var ReadOnlyViolation = class extends Error {
1646
+ status = 403;
1647
+ constructor(message) {
1648
+ super(message);
1649
+ this.name = "ReadOnlyViolation";
1650
+ }
1651
+ };
1652
+ var READ_KEYWORDS = /* @__PURE__ */ new Set(["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "VALUES", "TABLE", "PRAGMA"]);
1653
+ function splitStatements(sql2) {
1654
+ const out = [];
1655
+ let cur = "";
1656
+ let i = 0;
1657
+ const n = sql2.length;
1658
+ while (i < n) {
1659
+ const ch = sql2[i];
1660
+ const next = sql2[i + 1];
1661
+ if (ch === "-" && next === "-") {
1662
+ while (i < n && sql2[i] !== "\n") i++;
1663
+ cur += " ";
1664
+ continue;
1665
+ }
1666
+ if (ch === "#") {
1667
+ while (i < n && sql2[i] !== "\n") i++;
1668
+ cur += " ";
1669
+ continue;
1670
+ }
1671
+ if (ch === "/" && next === "*") {
1672
+ i += 2;
1673
+ while (i < n && !(sql2[i] === "*" && sql2[i + 1] === "/")) i++;
1674
+ i += 2;
1675
+ cur += " ";
1676
+ continue;
1677
+ }
1678
+ if (ch === "'" || ch === '"' || ch === "`" || ch === "[") {
1679
+ const close = ch === "[" ? "]" : ch;
1680
+ i++;
1681
+ while (i < n) {
1682
+ if (sql2[i] === "\\" && close === "'") {
1683
+ i += 2;
1684
+ continue;
1685
+ }
1686
+ if (sql2[i] === close) {
1687
+ if (sql2[i + 1] === close && close !== "]") {
1688
+ i += 2;
1689
+ continue;
1690
+ }
1691
+ break;
1692
+ }
1693
+ i++;
1694
+ }
1695
+ i++;
1696
+ cur += ch === "[" ? " x " : " 'x' ";
1697
+ continue;
1698
+ }
1699
+ if (ch === "$" && next === "$") {
1700
+ const end = sql2.indexOf("$$", i + 2);
1701
+ i = end === -1 ? n : end + 2;
1702
+ cur += " 'x' ";
1703
+ continue;
1704
+ }
1705
+ if (ch === ";") {
1706
+ out.push(cur);
1707
+ cur = "";
1708
+ i++;
1709
+ continue;
1710
+ }
1711
+ cur += ch;
1712
+ i++;
1713
+ }
1714
+ out.push(cur);
1715
+ return out.map((s) => s.trim()).filter((s) => s.length > 0);
1716
+ }
1717
+ function assertReadOnlyStatement(sql2) {
1718
+ const statements = splitStatements(sql2);
1719
+ if (statements.length === 0) return;
1720
+ if (statements.length > 1) throw new ReadOnlyViolation("Read-only profile: one statement per request");
1721
+ const stmt = statements[0];
1722
+ const first = /^[A-Za-z]+/.exec(stmt)?.[0].toUpperCase() ?? "";
1723
+ if (!READ_KEYWORDS.has(first)) throw new ReadOnlyViolation(`Read-only profile: ${first || "this statement"} is not allowed`);
1724
+ if (first === "WITH") {
1725
+ const words = stmt.toUpperCase().match(/\b(INSERT|UPDATE|DELETE|MERGE|REPLACE)\b/);
1726
+ if (words) throw new ReadOnlyViolation(`Read-only profile: ${words[1]} inside WITH is not allowed`);
1727
+ }
1728
+ if (first === "PRAGMA" && /[=(]/.test(stmt)) throw new ReadOnlyViolation("Read-only profile: PRAGMA may only read");
1729
+ if (/\bINTO\s+(OUTFILE|DUMPFILE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: INTO OUTFILE is not allowed");
1730
+ if (/\bFOR\s+(UPDATE|SHARE|NO\s+KEY\s+UPDATE|KEY\s+SHARE)\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
1731
+ if (/\bLOCK\s+IN\s+SHARE\s+MODE\b/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: locking reads are not allowed");
1732
+ if (first === "SELECT" && /\bINTO\b/i.test(stmt) && !/\bINTO\s+@/i.test(stmt)) throw new ReadOnlyViolation("Read-only profile: SELECT INTO is not allowed");
1733
+ }
1734
+
1588
1735
  // src/tunneling.ts
1589
1736
  var IDLE_TIMEOUT_MS = Number(process.env.PLANCHE_IDLE_TIMEOUT_MS) || 30 * 60 * 1e3;
1590
1737
  var MAX_CONNECTIONS = Number(process.env.PLANCHE_MAX_CONNECTIONS) || 64;
1591
1738
  var ConnectionManager = class {
1592
1739
  connections = /* @__PURE__ */ new Map();
1593
1740
  lastUsed = /* @__PURE__ */ new Map();
1741
+ /** Connections opened for a read-only gateway profile: writes are refused before reaching the driver. */
1742
+ readOnly = /* @__PURE__ */ new Set();
1594
1743
  constructor() {
1595
1744
  const timer = setInterval(() => void this.closeIdle(), 6e4);
1596
1745
  timer.unref();
@@ -1608,14 +1757,23 @@ var ConnectionManager = class {
1608
1757
  /**
1609
1758
  * Open a new connection and return a unique connection ID.
1610
1759
  */
1611
- async connect(config) {
1760
+ async connect(config, options = {}) {
1612
1761
  if (this.connections.size >= MAX_CONNECTIONS) {
1613
1762
  throw new Error(`Too many open connections (${MAX_CONNECTIONS}); disconnect some first`);
1614
1763
  }
1615
1764
  const driver = await openDriver(config);
1765
+ if (options.readOnly && driver.setReadOnly) {
1766
+ try {
1767
+ await driver.setReadOnly();
1768
+ } catch (err) {
1769
+ await driver.close().catch(() => void 0);
1770
+ throw err;
1771
+ }
1772
+ }
1616
1773
  const connectionId = randomUUID();
1617
1774
  this.connections.set(connectionId, driver);
1618
1775
  this.lastUsed.set(connectionId, Date.now());
1776
+ if (options.readOnly) this.readOnly.add(connectionId);
1619
1777
  console.log(`[ConnectionManager] New ${driver.type} connection: ${connectionId} -> ${driver.label}`);
1620
1778
  return connectionId;
1621
1779
  }
@@ -1632,6 +1790,7 @@ var ConnectionManager = class {
1632
1790
  throw new Error(`Connection not found: ${connectionId}`);
1633
1791
  }
1634
1792
  this.lastUsed.set(connectionId, Date.now());
1793
+ if (this.readOnly.has(connectionId)) assertReadOnlyStatement(sql2);
1635
1794
  try {
1636
1795
  return await driver.query(sql2, database);
1637
1796
  } catch (err) {
@@ -1639,6 +1798,7 @@ var ConnectionManager = class {
1639
1798
  console.log(`[ConnectionManager] Connection lost, evicting: ${connectionId}`);
1640
1799
  this.connections.delete(connectionId);
1641
1800
  this.lastUsed.delete(connectionId);
1801
+ this.readOnly.delete(connectionId);
1642
1802
  await driver.close().catch(() => void 0);
1643
1803
  throw new ConnectionLostError(`Connection lost: ${err instanceof Error ? err.message : String(err)}`);
1644
1804
  }
@@ -1654,6 +1814,7 @@ var ConnectionManager = class {
1654
1814
  await driver.close();
1655
1815
  this.connections.delete(connectionId);
1656
1816
  this.lastUsed.delete(connectionId);
1817
+ this.readOnly.delete(connectionId);
1657
1818
  console.log(`[ConnectionManager] Disconnected: ${connectionId}`);
1658
1819
  }
1659
1820
  /**
@@ -1685,10 +1846,13 @@ var ConnectionManager = class {
1685
1846
  await Promise.all(promises);
1686
1847
  this.connections.clear();
1687
1848
  this.lastUsed.clear();
1849
+ this.readOnly.clear();
1688
1850
  }
1689
1851
  };
1690
1852
 
1691
1853
  // src/server.ts
1854
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1855
+ import { join as join4 } from "path";
1692
1856
  function errorStatus(err) {
1693
1857
  const status = err?.status;
1694
1858
  return typeof status === "number" && status >= 400 && status < 600 ? status : 400;
@@ -1810,6 +1974,7 @@ function createAgent(options) {
1810
1974
  next();
1811
1975
  });
1812
1976
  app.use(accessControl(allowedOrigins));
1977
+ if (options.appDir) serveApp(app, options.appDir);
1813
1978
  app.get("/health", (req, res) => {
1814
1979
  const body = {
1815
1980
  success: true,
@@ -1819,6 +1984,7 @@ function createAgent(options) {
1819
1984
  activeConnections: manager.activeConnections,
1820
1985
  uptime: process.uptime()
1821
1986
  };
1987
+ if (options.gateway) body.gateway = { name: options.gateway.name, profiles: options.gateway.profiles.length };
1822
1988
  if (req.headers.authorization !== void 0) {
1823
1989
  if (throttle.locked(peerOf(req))) {
1824
1990
  fail(res, 429, "Too many failed authentication attempts; try again shortly");
@@ -1855,10 +2021,28 @@ function createAgent(options) {
1855
2021
  next();
1856
2022
  });
1857
2023
  app.use(express.json({ limit: options.bodyLimit }));
2024
+ app.get("/profiles", (_req, res) => {
2025
+ if (!options.gateway) {
2026
+ fail(res, 404, "Not a gateway");
2027
+ return;
2028
+ }
2029
+ const body = { success: true, gateway: options.gateway.name, profiles: options.gateway.profiles.map(describeProfile) };
2030
+ res.json(body);
2031
+ });
2032
+ const resolveConnect = (body) => {
2033
+ const b = body ?? {};
2034
+ if (options.gateway) {
2035
+ if (typeof b.profile !== "string") throw Object.assign(new Error("Gateway mode: choose a profile (POST { profile })"), { status: 403 });
2036
+ const entry = options.gateway.profiles.find((p) => p.name === b.profile);
2037
+ if (!entry) throw Object.assign(new Error(`Unknown gateway profile: ${b.profile}`), { status: 404 });
2038
+ return { config: entry.config, readOnly: entry.readOnly };
2039
+ }
2040
+ return { config: parseDriverConfig(b), readOnly: false };
2041
+ };
1858
2042
  app.post("/connect", async (req, res) => {
1859
2043
  try {
1860
- const config = parseDriverConfig(req.body ?? {});
1861
- const connectionId = await manager.connect(config);
2044
+ const { config, readOnly } = resolveConnect(req.body);
2045
+ const connectionId = await manager.connect(config, { readOnly });
1862
2046
  res.json({ success: true, connectionId, message: "Connection Successful" });
1863
2047
  } catch (err) {
1864
2048
  const message = errorMessage(err);
@@ -1920,7 +2104,7 @@ function createAgent(options) {
1920
2104
  });
1921
2105
  app.post("/test-connection", async (req, res) => {
1922
2106
  try {
1923
- await manager.testConnection(parseDriverConfig(req.body ?? {}));
2107
+ await manager.testConnection(resolveConnect(req.body).config);
1924
2108
  res.json({ success: true, message: "Connection test successful" });
1925
2109
  } catch (err) {
1926
2110
  const message = errorMessage(err);
@@ -1938,6 +2122,45 @@ function createAgent(options) {
1938
2122
  });
1939
2123
  return { app, manager };
1940
2124
  }
2125
+ function serveApp(app, appDir) {
2126
+ const csp = [
2127
+ "default-src 'self'",
2128
+ "script-src 'self' 'wasm-unsafe-eval'",
2129
+ "connect-src 'self'",
2130
+ "img-src 'self' data: blob:",
2131
+ "style-src 'self' 'unsafe-inline'",
2132
+ "font-src 'self' data:",
2133
+ "worker-src 'self' blob:",
2134
+ "child-src 'self' blob:",
2135
+ "object-src 'none'",
2136
+ "base-uri 'none'",
2137
+ "form-action 'none'",
2138
+ "frame-ancestors 'none'"
2139
+ ].join("; ");
2140
+ const indexFile = join4(appDir, "index.html");
2141
+ const indexHtml = existsSync2(indexFile) ? readFileSync2(indexFile, "utf8").replace("<head>", '<head>\n <meta name="planche-agent" content="same-origin" />') : null;
2142
+ const headers = (res) => {
2143
+ res.setHeader("Content-Security-Policy", csp);
2144
+ res.setHeader("X-Frame-Options", "DENY");
2145
+ res.setHeader("Referrer-Policy", "no-referrer");
2146
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
2147
+ };
2148
+ app.get(["/", "/index.html"], (_req, res) => {
2149
+ if (!indexHtml) {
2150
+ fail(res, 404, "App not bundled");
2151
+ return;
2152
+ }
2153
+ headers(res);
2154
+ res.type("html").send(indexHtml);
2155
+ });
2156
+ app.use(express.static(appDir, {
2157
+ index: false,
2158
+ setHeaders: (res, path) => {
2159
+ if (path.endsWith(".html")) headers(res);
2160
+ if (/\/assets\//.test(path)) res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
2161
+ }
2162
+ }));
2163
+ }
1941
2164
 
1942
2165
  // src/index.ts
1943
2166
  var { version: VERSION } = createRequire(import.meta.url)("../package.json");
@@ -1948,6 +2171,38 @@ var DEV_ORIGINS = [
1948
2171
  "http://localhost:4173",
1949
2172
  "http://127.0.0.1:4173"
1950
2173
  ];
2174
+ function resolveAppDir(explicit) {
2175
+ if (explicit) {
2176
+ const dir = resolve3(explicit);
2177
+ if (!existsSync3(join5(dir, "index.html"))) {
2178
+ console.error(`--app-dir has no index.html: ${dir}`);
2179
+ process.exit(2);
2180
+ }
2181
+ return dir;
2182
+ }
2183
+ try {
2184
+ const require2 = createRequire(import.meta.url);
2185
+ const pkgPath = require2.resolve("planche-app/package.json");
2186
+ const dir = join5(dirname3(pkgPath), "dist");
2187
+ if (existsSync3(join5(dir, "index.html"))) {
2188
+ const appVersion = require2(pkgPath).version;
2189
+ if (appVersion && appVersion !== VERSION) {
2190
+ console.warn(`WARNING: planche-app ${appVersion} does not match this agent (${VERSION}). Install matching versions if the app reports the agent as outdated.`);
2191
+ }
2192
+ return dir;
2193
+ }
2194
+ } catch {
2195
+ }
2196
+ console.error("Serving the web app needs the planche-app package (it holds the built UI).");
2197
+ console.error("");
2198
+ console.error(" npm i -g planche planche-app");
2199
+ console.error("or, without installing:");
2200
+ console.error(" npx --package planche --package planche-app planche --gateway <file>");
2201
+ console.error("");
2202
+ console.error("Already have a build? Point at it with --app-dir <dir>.");
2203
+ console.error("Only need the agent? Drop --gateway/--serve-app and use the app on planche.dev.");
2204
+ process.exit(2);
2205
+ }
1951
2206
  function usage() {
1952
2207
  return `planche ${VERSION} \u2014 local agent for the Planche SQL client
1953
2208
 
@@ -1962,6 +2217,13 @@ Options:
1962
2217
  --origin <url> Extra browser origin allowed to use the agent (repeatable,
1963
2218
  env PLANCHE_ALLOWED_ORIGINS as a comma-separated list)
1964
2219
  --allow-remote Required with a non-loopback --addr (see README: no TLS)
2220
+ --gateway <file> Gateway mode: serve named read-only profiles from a JSON
2221
+ config instead of accepting credentials (env PLANCHE_GATEWAY)
2222
+ --public-url <u> URL users open when the agent serves the app itself
2223
+ (behind your TLS proxy; env PLANCHE_PUBLIC_URL)
2224
+ --no-serve-app Do not serve the web app at / (served by default in
2225
+ gateway mode; needs the planche-app package)
2226
+ --app-dir <dir> Serve this build of the web app instead of planche-app
1965
2227
  --no-open Do not open the browser
1966
2228
  --verbose Log every request
1967
2229
  -v, --version Print the version
@@ -2011,6 +2273,10 @@ function main() {
2011
2273
  verbose: { type: "boolean" },
2012
2274
  "allow-remote": { type: "boolean" },
2013
2275
  "print-token": { type: "boolean" },
2276
+ gateway: { type: "string" },
2277
+ "public-url": { type: "string" },
2278
+ "serve-app": { type: "boolean" },
2279
+ "app-dir": { type: "string" },
2014
2280
  version: { type: "boolean", short: "v" },
2015
2281
  help: { type: "boolean", short: "h" }
2016
2282
  },
@@ -2035,7 +2301,20 @@ function main() {
2035
2301
  const addr = flags.addr ?? process.env.PLANCHE_ADDR ?? "127.0.0.1";
2036
2302
  const tokenFromEnv = !flags.token && !!process.env.PLANCHE_AUTH_TOKEN;
2037
2303
  const authToken = flags.token ?? process.env.PLANCHE_AUTH_TOKEN ?? randomUUID2();
2038
- const appUrl = (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
2304
+ const gatewayFile = flags.gateway ?? process.env.PLANCHE_GATEWAY;
2305
+ let gateway;
2306
+ if (gatewayFile) {
2307
+ try {
2308
+ gateway = loadGatewayConfig(gatewayFile);
2309
+ } catch (err) {
2310
+ console.error(err instanceof Error ? err.message : String(err));
2311
+ process.exit(2);
2312
+ }
2313
+ }
2314
+ const serveApp2 = flags["serve-app"] ?? Boolean(gateway);
2315
+ const appDir = serveApp2 ? resolveAppDir(flags["app-dir"] ?? process.env.PLANCHE_APP_DIR) : void 0;
2316
+ const publicUrl = (flags["public-url"] ?? process.env.PLANCHE_PUBLIC_URL ?? "").replace(/\/+$/, "");
2317
+ const appUrl = (serveApp2 ? publicUrl || null : null) ?? (flags.app ?? process.env.PLANCHE_APP_URL ?? DEFAULT_APP_URL).replace(/\/+$/, "");
2039
2318
  const shouldOpen = flags.open ?? true;
2040
2319
  const bodyLimit = process.env.PLANCHE_BODY_LIMIT || "50mb";
2041
2320
  const appOrigin = originOf(appUrl);
@@ -2068,6 +2347,7 @@ function main() {
2068
2347
  }
2069
2348
  const devApp = LOOPBACK_ADDRS.has(new URL(appUrl).hostname);
2070
2349
  const allowedOrigins = [appOrigin, ...devApp ? DEV_ORIGINS : [], ...extraOrigins];
2350
+ if (serveApp2) allowedOrigins.push(`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`);
2071
2351
  const pairingCode = { code: randomUUID2(), expiresAt: Date.now() + 10 * 60 * 1e3 };
2072
2352
  const listenAddr = addr.replace(/^\[(.*)\]$/, "$1");
2073
2353
  const agentUrl = `http://${listenAddr.includes(":") ? `[${listenAddr}]` : listenAddr}:${port}`;
@@ -2079,9 +2359,11 @@ function main() {
2079
2359
  logRequests: flags.verbose ?? false,
2080
2360
  // Browsers omit a default port from `Host`, so the bare names are allowed too.
2081
2361
  allowedHosts: loopback ? ["127.0.0.1", "localhost", "[::1]", `127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] : void 0,
2082
- pairingCode
2362
+ pairingCode,
2363
+ gateway,
2364
+ appDir
2083
2365
  });
2084
- const pairUrl = `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
2366
+ const pairUrl = serveApp2 ? `${publicUrl || agentUrl}/#pair=${encodeURIComponent(pairingCode.code)}` : `${appUrl}/#agent=${encodeURIComponent(agentUrl)}&pair=${encodeURIComponent(pairingCode.code)}`;
2085
2367
  const server = app.listen(port, listenAddr, () => {
2086
2368
  const line = "-".repeat(72);
2087
2369
  console.log(line);