@askdb/studio 0.2.0-beta.34 → 0.2.0-beta.36

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/server.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { extname, relative, join, resolve, dirname, sep } from "node:path";
5
5
  import { spawn } from "node:child_process";
@@ -11,14 +11,24 @@ import { anthropicProvider } from "@askdb/ai-anthropic";
11
11
  import { azureProvider } from "@askdb/ai-azure";
12
12
  import { googleProvider } from "@askdb/ai-google";
13
13
  import { openaiProvider } from "@askdb/ai-openai";
14
- import { ask, isBuiltInDialectId, loadSchema, suggestEnrichment, tenantScopeSchema, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
14
+ import { ask, formatSensitiveReference, isBuiltInDialectId, loadSchema, schemaHasSensitiveIdentifiers, SqlValidationError, validateSensitiveReferences, suggestEnrichment, tenantScopeSchema, normalizeTenantPolicy, parseTenantPolicyMarkdown, SchemaParseError, writeTenantPolicyMarkdown, tenantPolicyFrontmatterSchema, } from "@askdb/core";
15
15
  import { buildSchemaIndex, chunkContentHash, chunkSchema, createAiSdkEmbedder, createFileStore, createMemoryStore, createPgvectorStore, createRetriever, loadChunkerSourcesFromDir, } from "@askdb/rag";
16
16
  import { buildDefaultTableBody, buildFrontmatter, buildSuggestionContext, buildSuggestionTarget, buildTableDraft, loadWorkspace, replaceH2Section, replaceTableDescription, saveConcepts, saveTable, } from "@askdb/enrich";
17
- import { EXECUTE_DRIVER_REGISTRY, isDriverInstalled } from "./execute-registry.js";
17
+ import { EXECUTE_DRIVER_REGISTRY, executeDialectFor, isDriverInstalled, isStudioExecuteProvider, validateExecuteSql, } from "./execute-registry.js";
18
+ import { lockfilePackageManager, packageManagerAddArgs, packageManagerSpawnSpec, } from "./package-manager.js";
18
19
  import { resolveStudioIntrospectionPlan, runStudioIntrospection } from "./introspection.js";
19
20
  import { probeSetupState, writeSetupConfig, SetupError } from "./setup.js";
21
+ import { checkApiRequest, checkHost, createStudioSessionToken, injectSessionToken, } from "./request-guard.js";
20
22
  const ai = createAiRegistry([openaiProvider, azureProvider, googleProvider, anthropicProvider]);
21
- const CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
23
+ const DEFAULT_CLIENT_DIR = fileURLToPath(new URL("./client/", import.meta.url));
24
+ let clientDirForTests;
25
+ /** @internal Tests only — serve client assets from another directory. */
26
+ export function setStudioClientDirForTests(dir) {
27
+ clientDirForTests = dir;
28
+ }
29
+ function clientDir() {
30
+ return clientDirForTests ?? DEFAULT_CLIENT_DIR;
31
+ }
22
32
  const STUDIO_RAG_MOCK_DIMENSIONS = 64;
23
33
  const STUDIO_RAG_MOCK_EMBEDDER_ID = `studio:mock-lexical-${STUDIO_RAG_MOCK_DIMENSIONS}`;
24
34
  const DEFAULT_EMBEDDING_MODEL = "text-embedding-3-small";
@@ -37,11 +47,24 @@ export function createStudioServer(options) {
37
47
  workspace: setupReason ? null : loadWorkspace(schemaDir),
38
48
  setupReason,
39
49
  };
40
- return createServer(async (req, res) => {
50
+ const sessionToken = createStudioSessionToken();
51
+ const server = createServer(async (req, res) => {
41
52
  try {
53
+ // Host allowlist on every request (static assets included) — defeats DNS rebinding.
54
+ const hostFailure = checkHost(req, options.host);
55
+ if (hostFailure) {
56
+ return writeJson(res, hostFailure.status, { error: { message: hostFailure.message } });
57
+ }
42
58
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
59
+ // Origin, session token, and content-type checks on every API call.
60
+ if (url.pathname.startsWith("/api/")) {
61
+ const apiFailure = checkApiRequest(req, sessionToken);
62
+ if (apiFailure) {
63
+ return writeJson(res, apiFailure.status, { error: { message: apiFailure.message } });
64
+ }
65
+ }
43
66
  if (req.method === "GET" && url.pathname === "/") {
44
- return serveClientFile(res, "index.html");
67
+ return serveIndexHtml(res, sessionToken);
45
68
  }
46
69
  if (req.method === "GET" && url.pathname.startsWith("/assets/")) {
47
70
  return serveClientFile(res, decodeURIComponent(url.pathname.slice(1)));
@@ -165,7 +188,7 @@ export function createStudioServer(options) {
165
188
  return writeJson(res, 200, result);
166
189
  }
167
190
  if (req.method === "GET" && !url.pathname.startsWith("/api/")) {
168
- return serveClientFile(res, "index.html");
191
+ return serveIndexHtml(res, sessionToken);
169
192
  }
170
193
  writeJson(res, 404, { error: { message: "Not found" } });
171
194
  }
@@ -176,6 +199,7 @@ export function createStudioServer(options) {
176
199
  });
177
200
  }
178
201
  });
202
+ return Object.assign(server, { sessionToken });
179
203
  }
180
204
  export function serializeWorkspace(workspace) {
181
205
  const rt = getAskDbRuntimeConfig();
@@ -212,6 +236,7 @@ export function serializeWorkspace(workspace) {
212
236
  missingColumnIds: workspace.warnings
213
237
  .filter((warning) => warning.kind === "missing_column_md" && warning.tableId === table.physical.id)
214
238
  .map((warning) => warning.columnId),
239
+ escalatedByOtherFiles: table.escalatedByOtherFiles ?? [],
215
240
  };
216
241
  }),
217
242
  concepts: workspace.concepts?.frontmatter.concepts ?? [],
@@ -398,8 +423,21 @@ function saveConceptsDraft(state, concepts) {
398
423
  state.workspace = loadWorkspace(state.schemaDir);
399
424
  }
400
425
  function saveTenantPolicy(state, frontmatter, body) {
426
+ const workspace = requireWorkspace(state);
401
427
  const filePath = join(state.schemaDir, "tenant-policy.md");
402
428
  const md = writeTenantPolicyMarkdown(frontmatter, body);
429
+ // Run core's own load-time validation before writing: a policy that `loadSchema` would
430
+ // reject (e.g. two root labels deriving one placeholder) must not reach disk, or every
431
+ // later request fails until the file is fixed by hand.
432
+ try {
433
+ const physical = workspace.physical;
434
+ normalizeTenantPolicy(parseTenantPolicyMarkdown(md, filePath), new Set(physical.tables.map((table) => table.id)), new Set(physical.tables.flatMap((table) => table.columns.map((column) => column.id))));
435
+ }
436
+ catch (error) {
437
+ if (error instanceof SchemaParseError)
438
+ throw new StudioHttpError(400, error.message);
439
+ throw error;
440
+ }
403
441
  writeFileSync(filePath, md, "utf8");
404
442
  state.workspace = loadWorkspace(state.schemaDir);
405
443
  }
@@ -419,7 +457,9 @@ function saveDraft(state, tableId, draft) {
419
457
  const table = workspace.tables.find((candidate) => candidate.physical.id === tableId);
420
458
  if (!table)
421
459
  throw new StudioHttpError(404, `No such table: ${tableId}`);
422
- const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft);
460
+ // Passing the file's current front-matter keeps entries for other tables' columns
461
+ // (e.g. a misplaced `sensitive: true`), which the draft does not carry.
462
+ const frontmatter = buildFrontmatter(table.physical, workspace.physical.schemaId, draft, table.parsed?.frontmatter);
423
463
  let body = table.parsed
424
464
  ? replaceTableDescription(table.parsed.body, draft.description)
425
465
  : buildDefaultTableBody(table.physical.name, draft.description);
@@ -1052,8 +1092,9 @@ function stableTokenHash(token) {
1052
1092
  // ---------------------------------------------------------------------------
1053
1093
  const HISTORY_MAX_STORED = 200;
1054
1094
  const HISTORY_MAX_RETURNED = 50;
1095
+ const HISTORY_FILE_NAME = "playground-history.json";
1055
1096
  function playgroundHistoryPath(schemaDir) {
1056
- return join(schemaDir, "playground-history.json");
1097
+ return join(schemaDir, HISTORY_FILE_NAME);
1057
1098
  }
1058
1099
  function readPlaygroundHistory(schemaDir) {
1059
1100
  const raw = readOptionalJson(playgroundHistoryPath(schemaDir));
@@ -1072,8 +1113,59 @@ function appendPlaygroundHistory(schemaDir, entry) {
1072
1113
  timestamp: new Date().toISOString(),
1073
1114
  };
1074
1115
  const updated = [newEntry, ...existing].slice(0, HISTORY_MAX_STORED);
1116
+ ensureSchemaDirGitignore(schemaDir);
1075
1117
  writeFileSync(histPath, JSON.stringify(updated, null, 2), "utf8");
1076
1118
  }
1119
+ /**
1120
+ * Keep Playground history (questions + generated SQL, local-only) out of git.
1121
+ * The schema artifact directory is meant to be committed, so Studio makes sure
1122
+ * its `.gitignore` lists `playground-history.json` before writing the file.
1123
+ *
1124
+ * - No `.gitignore` yet: write one that also ignores `.env` files — the same
1125
+ * rules `askdb init` writes (plan 043) — unless the schema dir is the project
1126
+ * root, where ignoring `.env` is the project's call, not Studio's.
1127
+ * - Existing `.gitignore` without the entry: append the entry; never rewrite.
1128
+ * - Failure is non-fatal: history still saves.
1129
+ */
1130
+ function ensureSchemaDirGitignore(schemaDir) {
1131
+ const gitignorePath = join(schemaDir, ".gitignore");
1132
+ try {
1133
+ if (existsSync(gitignorePath)) {
1134
+ const content = readFileSync(gitignorePath, "utf8");
1135
+ const lines = content.split(/\r?\n/).map((line) => line.trim());
1136
+ if (lines.includes(HISTORY_FILE_NAME) || lines.includes(`/${HISTORY_FILE_NAME}`))
1137
+ return;
1138
+ const separator = content === "" || content.endsWith("\n") ? "" : "\n";
1139
+ appendFileSync(gitignorePath, `${separator}# AskDB Studio Playground history (local only)\n${HISTORY_FILE_NAME}\n`, "utf8");
1140
+ return;
1141
+ }
1142
+ const lines = [
1143
+ "# Written by AskDB Studio.",
1144
+ "#",
1145
+ "# The schema artifact in this directory is meant to be committed: schema.json",
1146
+ "# plus any enrichment you author in Studio. Playground history (your questions",
1147
+ "# and generated SQL) is local only.",
1148
+ HISTORY_FILE_NAME,
1149
+ ];
1150
+ if (!isProjectRootDir(schemaDir)) {
1151
+ lines.push("", "# Credentials: keep API keys and connection strings out of version control", "# even when they live here.", ".env", ".env.*", "!.env.example");
1152
+ }
1153
+ writeFileSync(gitignorePath, `${lines.join("\n")}\n`, "utf8");
1154
+ }
1155
+ catch {
1156
+ // Non-fatal: the history write matters more than this defensive file.
1157
+ }
1158
+ }
1159
+ function isProjectRootDir(dir) {
1160
+ if (existsSync(join(dir, "package.json")))
1161
+ return true;
1162
+ try {
1163
+ return readdirSync(dir).some((name) => name.startsWith("askdb.config."));
1164
+ }
1165
+ catch {
1166
+ return false;
1167
+ }
1168
+ }
1077
1169
  function deletePlaygroundHistoryEntry(schemaDir, id) {
1078
1170
  const histPath = playgroundHistoryPath(schemaDir);
1079
1171
  const raw = readOptionalJson(histPath);
@@ -1087,36 +1179,130 @@ function deletePlaygroundHistoryEntry(schemaDir, id) {
1087
1179
  writeFileSync(histPath, JSON.stringify(filtered, null, 2), "utf8");
1088
1180
  return { ok: true };
1089
1181
  }
1182
+ const HISTORY_LIMITS = {
1183
+ question: 4_000,
1184
+ sql: 100_000,
1185
+ explain: 100_000,
1186
+ /** Serialized JSON size for `tenantScope` / `tenantParams`. */
1187
+ json: 20_000,
1188
+ error: 2_000,
1189
+ ragChunkIds: 200,
1190
+ ragChunkId: 512,
1191
+ };
1192
+ /**
1193
+ * Validate a history entry and copy **only** the known fields — the file is
1194
+ * written inside the schema artifact directory, so nothing unexpected (or
1195
+ * unbounded) from the request body may be persisted.
1196
+ */
1090
1197
  function parsePlaygroundHistoryEntry(body) {
1091
1198
  if (!isRecord(body)) {
1092
1199
  throw new StudioHttpError(400, "Request body must be a JSON object.");
1093
1200
  }
1094
- if (typeof body.question !== "string" || body.question.trim() === "") {
1201
+ const question = requireBoundedString(body.question, "question", HISTORY_LIMITS.question);
1202
+ if (question.trim() === "") {
1095
1203
  throw new StudioHttpError(400, "`question` is required.");
1096
1204
  }
1097
1205
  if (body.mode !== "full" && body.mode !== "rag") {
1098
1206
  throw new StudioHttpError(400, "`mode` must be `full` or `rag`.");
1099
1207
  }
1100
- if (typeof body.sqlMode !== "string") {
1101
- throw new StudioHttpError(400, "`sqlMode` is required.");
1208
+ if (body.sqlMode !== "sql-only" && body.sqlMode !== "sql-params") {
1209
+ throw new StudioHttpError(400, "`sqlMode` must be `sql-only` or `sql-params`.");
1102
1210
  }
1103
- if (typeof body.sql !== "string") {
1104
- throw new StudioHttpError(400, "`sql` is required.");
1211
+ const entry = {
1212
+ question,
1213
+ mode: body.mode,
1214
+ sqlMode: body.sqlMode,
1215
+ sql: requireBoundedString(body.sql, "sql", HISTORY_LIMITS.sql),
1216
+ };
1217
+ if (body.explain !== undefined && body.explain !== null) {
1218
+ entry.explain = requireBoundedString(body.explain, "explain", HISTORY_LIMITS.explain);
1105
1219
  }
1106
- return body;
1220
+ if (body.tenantScope !== undefined && body.tenantScope !== null) {
1221
+ entry.tenantScope = requireBoundedJsonObject(body.tenantScope, "tenantScope");
1222
+ }
1223
+ if (body.tenantParams !== undefined && body.tenantParams !== null) {
1224
+ entry.tenantParams = requireBoundedJsonObject(body.tenantParams, "tenantParams");
1225
+ }
1226
+ if (body.executionResult !== undefined && body.executionResult !== null) {
1227
+ const r = body.executionResult;
1228
+ if (!isRecord(r) ||
1229
+ !isNonNegativeFinite(r.rowCount) ||
1230
+ !isNonNegativeFinite(r.durationMs) ||
1231
+ typeof r.truncated !== "boolean") {
1232
+ throw new StudioHttpError(400, "`executionResult` must be { rowCount: number, durationMs: number, truncated: boolean, error?: string }.");
1233
+ }
1234
+ entry.executionResult = {
1235
+ rowCount: r.rowCount,
1236
+ durationMs: r.durationMs,
1237
+ truncated: r.truncated,
1238
+ ...(r.error !== undefined
1239
+ ? { error: requireBoundedString(r.error, "executionResult.error", HISTORY_LIMITS.error) }
1240
+ : {}),
1241
+ };
1242
+ }
1243
+ if (body.ragChunkIds !== undefined && body.ragChunkIds !== null) {
1244
+ if (!Array.isArray(body.ragChunkIds) ||
1245
+ body.ragChunkIds.length > HISTORY_LIMITS.ragChunkIds ||
1246
+ !body.ragChunkIds.every((id) => typeof id === "string" && id.length <= HISTORY_LIMITS.ragChunkId)) {
1247
+ throw new StudioHttpError(400, `\`ragChunkIds\` must be an array of at most ${HISTORY_LIMITS.ragChunkIds} strings.`);
1248
+ }
1249
+ entry.ragChunkIds = [...body.ragChunkIds];
1250
+ }
1251
+ return entry;
1252
+ }
1253
+ function requireBoundedString(value, field, maxLength) {
1254
+ if (typeof value !== "string") {
1255
+ throw new StudioHttpError(400, `\`${field}\` must be a string.`);
1256
+ }
1257
+ if (value.length > maxLength) {
1258
+ throw new StudioHttpError(400, `\`${field}\` is too long (max ${maxLength} characters).`);
1259
+ }
1260
+ return value;
1261
+ }
1262
+ function requireBoundedJsonObject(value, field) {
1263
+ if (!isRecord(value)) {
1264
+ throw new StudioHttpError(400, `\`${field}\` must be a JSON object.`);
1265
+ }
1266
+ if (JSON.stringify(value).length > HISTORY_LIMITS.json) {
1267
+ throw new StudioHttpError(400, `\`${field}\` is too large (max ${HISTORY_LIMITS.json} characters as JSON).`);
1268
+ }
1269
+ return value;
1270
+ }
1271
+ function isNonNegativeFinite(value) {
1272
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
1107
1273
  }
1108
1274
  // ---------------------------------------------------------------------------
1109
1275
  // Execute endpoint helpers
1110
1276
  // ---------------------------------------------------------------------------
1277
+ const EXECUTE_DISABLED_MESSAGE = "Studio execute is off. To run generated SQL from the Playground, set `studio.execute.enabled: true` " +
1278
+ "and an execute connection (`studio.execute.databaseUrl`, or `studio.execute.file` for SQLite) in " +
1279
+ "askdb.config.ts, ideally for a read-only database role, then restart Studio.";
1280
+ function executeNotConfiguredMessage(exec) {
1281
+ const field = exec.provider === "sqlite" ? "studio.execute.file" : "studio.execute.databaseUrl";
1282
+ const base = `No execute connection configured. Set \`${field}\` in askdb.config.ts (ideally a read-only database role).`;
1283
+ return exec.introspectionConnectionAvailable
1284
+ ? `${base} Studio no longer reuses the introspection connection by default; set \`studio.execute.useIntrospectionConnection: true\` to opt in.`
1285
+ : base;
1286
+ }
1111
1287
  async function getExecuteStatus(schemaDir) {
1112
1288
  const rt = getAskDbRuntimeConfig();
1113
- const { provider, databaseUrl, file } = rt.studio.execute;
1289
+ const exec = rt.studio.execute;
1290
+ const { provider, databaseUrl, file } = exec;
1114
1291
  const def = EXECUTE_DRIVER_REGISTRY[provider];
1115
1292
  const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1116
1293
  const installed = isDriverInstalled(def.packageName, projectRoot);
1117
1294
  const connectionKind = provider === "sqlite" ? "file" : "url";
1118
1295
  const configured = connectionKind === "file" ? Boolean(file) : Boolean(databaseUrl);
1296
+ const disabledReason = !exec.enabled
1297
+ ? EXECUTE_DISABLED_MESSAGE
1298
+ : !configured
1299
+ ? executeNotConfiguredMessage(exec)
1300
+ : null;
1119
1301
  return {
1302
+ enabled: exec.enabled,
1303
+ disabledReason,
1304
+ timeoutMs: exec.timeoutMs,
1305
+ maxRows: exec.maxRows,
1120
1306
  provider,
1121
1307
  label: def.label,
1122
1308
  configured,
@@ -1124,50 +1310,44 @@ async function getExecuteStatus(schemaDir) {
1124
1310
  packageName: def.packageName,
1125
1311
  installed,
1126
1312
  installCommand: def.installCommand,
1127
- canInstallFromStudio: true,
1313
+ canInstallFromStudio: exec.enabled,
1128
1314
  manualInstallReason: null,
1129
1315
  };
1130
1316
  }
1131
1317
  async function installExecuteDriver(body, schemaDir) {
1132
1318
  const rt = getAskDbRuntimeConfig();
1133
- const configuredProvider = rt.studio.execute.provider;
1134
- // Accept the configured provider or an explicit allowlisted provider.
1135
- let provider = configuredProvider;
1136
- if (isRecord(body) && typeof body.provider === "string") {
1137
- const requested = body.provider;
1138
- if (!(requested in EXECUTE_DRIVER_REGISTRY)) {
1139
- return {
1140
- ok: false,
1141
- provider: configuredProvider,
1142
- packageName: EXECUTE_DRIVER_REGISTRY[configuredProvider].packageName,
1143
- command: [],
1144
- error: `Unknown provider: ${body.provider}`,
1145
- installed: false,
1146
- };
1319
+ if (!rt.studio.execute.enabled) {
1320
+ throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
1321
+ }
1322
+ // Accept the configured provider or an explicit allowlisted provider. `Object.hasOwn`
1323
+ // (via isStudioExecuteProvider) rejects inherited keys such as "constructor".
1324
+ let provider = rt.studio.execute.provider;
1325
+ if (isRecord(body) && body.provider !== undefined) {
1326
+ if (!isStudioExecuteProvider(body.provider)) {
1327
+ throw new StudioHttpError(400, `Unknown provider: ${JSON.stringify(body.provider)}. Expected one of: ${Object.keys(EXECUTE_DRIVER_REGISTRY).join(", ")}.`);
1147
1328
  }
1148
- provider = requested;
1329
+ provider = body.provider;
1149
1330
  }
1150
1331
  const def = EXECUTE_DRIVER_REGISTRY[provider];
1151
- const { packageManager, command, args, manualReason } = detectPackageManager(schemaDir, def.packageName);
1152
- if (manualReason) {
1332
+ const detection = detectPackageManager(schemaDir, def.packageName);
1333
+ if (detection.manualReason !== null) {
1153
1334
  return {
1154
1335
  ok: false,
1155
1336
  provider,
1156
1337
  packageName: def.packageName,
1157
1338
  command: [],
1158
- error: manualReason,
1339
+ error: detection.manualReason,
1159
1340
  installed: false,
1160
1341
  };
1161
1342
  }
1162
- // Run the install.
1163
- const { stdout, stderr, code } = await spawnCommand(command, args, schemaDir);
1164
- const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1343
+ const { packageManager, projectRoot, spawnSpec } = detection;
1344
+ const { stdout, stderr, code } = await spawnCommand(spawnSpec, projectRoot);
1165
1345
  const installed = isDriverInstalled(def.packageName, projectRoot);
1166
1346
  return {
1167
1347
  ok: code === 0,
1168
1348
  provider,
1169
1349
  packageName: def.packageName,
1170
- command: [command, ...args],
1350
+ command: [spawnSpec.command, ...spawnSpec.args],
1171
1351
  stdout,
1172
1352
  stderr,
1173
1353
  error: code !== 0 ? `${packageManager} exited with code ${code}` : undefined,
@@ -1178,50 +1358,17 @@ function detectPackageManager(cwd, packageName) {
1178
1358
  // Walk up from schemaDir to find the project root's lockfile.
1179
1359
  const projectRoot = findProjectRoot(cwd);
1180
1360
  if (!projectRoot) {
1181
- return {
1182
- packageManager: "",
1183
- command: "",
1184
- args: [],
1185
- manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.`,
1186
- };
1361
+ return { manualReason: `Could not locate a package.json above ${cwd}. Run the install manually.` };
1187
1362
  }
1188
- if (existsSync(join(projectRoot, "pnpm-lock.yaml"))) {
1189
- return {
1190
- packageManager: "pnpm",
1191
- command: "pnpm",
1192
- args: ["add", packageName],
1193
- manualReason: null,
1194
- };
1195
- }
1196
- if (existsSync(join(projectRoot, "package-lock.json"))) {
1197
- return {
1198
- packageManager: "npm",
1199
- command: "npm",
1200
- args: ["install", packageName],
1201
- manualReason: null,
1202
- };
1203
- }
1204
- if (existsSync(join(projectRoot, "yarn.lock"))) {
1205
- return {
1206
- packageManager: "yarn",
1207
- command: "yarn",
1208
- args: ["add", packageName],
1209
- manualReason: null,
1210
- };
1211
- }
1212
- if (existsSync(join(projectRoot, "bun.lockb")) || existsSync(join(projectRoot, "bun.lock"))) {
1213
- return {
1214
- packageManager: "bun",
1215
- command: "bun",
1216
- args: ["add", packageName],
1217
- manualReason: null,
1218
- };
1363
+ const pm = lockfilePackageManager(projectRoot);
1364
+ if (!pm) {
1365
+ return { manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}` };
1219
1366
  }
1220
1367
  return {
1221
- packageManager: "",
1222
- command: "",
1223
- args: [],
1224
- manualReason: `No recognized lockfile found in ${projectRoot}. Run: pnpm add ${packageName}`,
1368
+ manualReason: null,
1369
+ packageManager: pm,
1370
+ projectRoot,
1371
+ spawnSpec: packageManagerSpawnSpec(pm, packageManagerAddArgs(pm, [packageName])),
1225
1372
  };
1226
1373
  }
1227
1374
  function findProjectRoot(startDir) {
@@ -1236,9 +1383,9 @@ function findProjectRoot(startDir) {
1236
1383
  }
1237
1384
  return null;
1238
1385
  }
1239
- function spawnCommand(command, args, cwd) {
1386
+ function spawnCommand(spec, cwd) {
1240
1387
  return new Promise((resolve) => {
1241
- const proc = spawn(command, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
1388
+ const proc = spawn(spec.command, spec.args, { cwd, shell: spec.shell, stdio: ["ignore", "pipe", "pipe"] });
1242
1389
  const stdoutChunks = [];
1243
1390
  const stderrChunks = [];
1244
1391
  proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
@@ -1257,15 +1404,69 @@ function spawnCommand(command, args, cwd) {
1257
1404
  }
1258
1405
  async function executeQuery(body, schemaDir) {
1259
1406
  const rt = getAskDbRuntimeConfig();
1260
- const { provider, databaseUrl, file } = rt.studio.execute;
1407
+ const exec = rt.studio.execute;
1408
+ if (!exec.enabled) {
1409
+ throw new StudioHttpError(403, EXECUTE_DISABLED_MESSAGE);
1410
+ }
1261
1411
  if (!isRecord(body) || typeof body.sql !== "string" || body.sql.trim() === "") {
1262
- return { ok: false, error: "`sql` is required." };
1412
+ throw new StudioHttpError(400, "`sql` is required.");
1413
+ }
1414
+ if (body.params !== undefined && !Array.isArray(body.params)) {
1415
+ throw new StudioHttpError(400, "`params` must be an array.");
1416
+ }
1417
+ const params = body.params ?? [];
1418
+ // Same read-only SELECT guardrail `ask()` applies, for the execute engine's
1419
+ // dialect — rejects multi-statement input, comments, DDL/DML keywords, …
1420
+ // before anything reaches a driver.
1421
+ let sql;
1422
+ try {
1423
+ sql = validateExecuteSql(exec.provider, body.sql, rt.nlToSql.dialect);
1424
+ }
1425
+ catch (error) {
1426
+ if (error instanceof SqlValidationError) {
1427
+ throw new StudioHttpError(400, `SQL rejected by the read-only guardrail (${error.rule}): ${error.message}`);
1428
+ }
1429
+ throw error;
1263
1430
  }
1264
- const sql = body.sql;
1265
- const params = Array.isArray(body.params) ? body.params : [];
1431
+ const warnings = sensitiveExecuteWarnings(sql, schemaDir, executeDialectFor(exec.provider, rt.nlToSql.dialect));
1266
1432
  const projectRoot = findProjectRoot(schemaDir) ?? schemaDir;
1267
- const def = EXECUTE_DRIVER_REGISTRY[provider];
1268
- return def.execute({ connectionString: databaseUrl, file, sql, params, projectRoot });
1433
+ const def = EXECUTE_DRIVER_REGISTRY[exec.provider];
1434
+ const result = await def.execute({
1435
+ connectionString: exec.databaseUrl,
1436
+ file: exec.file,
1437
+ sql,
1438
+ params,
1439
+ projectRoot,
1440
+ timeoutMs: exec.timeoutMs,
1441
+ maxRows: exec.maxRows,
1442
+ });
1443
+ if (!result.ok && /^No (connection URL|SQLite file path) configured/.test(result.error)) {
1444
+ return { ok: false, error: executeNotConfiguredMessage(exec) };
1445
+ }
1446
+ return result.ok && warnings.length > 0 ? { ...result, warnings } : result;
1447
+ }
1448
+ /**
1449
+ * Warn (never block) when the SQL references identifiers marked `sensitive` —
1450
+ * the same check `ask()` runs in its default `"warn"` mode. Studio has no
1451
+ * strict-mode setting; hosts that need enforcement call
1452
+ * `validateSensitiveReferences(sql, schema, { mode: "strict" })` themselves.
1453
+ */
1454
+ function sensitiveExecuteWarnings(sql, schemaDir, dialect) {
1455
+ let schema;
1456
+ try {
1457
+ schema = loadSchema(schemaDir);
1458
+ }
1459
+ catch {
1460
+ return [];
1461
+ }
1462
+ if (!schemaHasSensitiveIdentifiers(schema))
1463
+ return [];
1464
+ const result = validateSensitiveReferences(sql, schema, { mode: "warn", dialect });
1465
+ if (result.references.length === 0)
1466
+ return [];
1467
+ return [
1468
+ `This query reads identifiers marked sensitive: ${result.references.map(formatSensitiveReference).join(", ")}.`,
1469
+ ];
1269
1470
  }
1270
1471
  function isLoopbackRequest(req) {
1271
1472
  const socket = req.socket;
@@ -1468,11 +1669,36 @@ function sumNullable(values) {
1468
1669
  function readFiniteNumber(value) {
1469
1670
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
1470
1671
  }
1672
+ /** Largest JSON request body Studio accepts. */
1673
+ export const MAX_JSON_BODY_BYTES = 1024 * 1024;
1674
+ function bodyTooLarge() {
1675
+ return new StudioHttpError(413, `Request body is too large (limit ${MAX_JSON_BODY_BYTES} bytes).`);
1676
+ }
1471
1677
  async function readJson(req) {
1678
+ const declared = Number(req.headers["content-length"]);
1679
+ if (Number.isFinite(declared) && declared > MAX_JSON_BODY_BYTES) {
1680
+ // Node discards the unread body after the 413 response is sent.
1681
+ throw bodyTooLarge();
1682
+ }
1472
1683
  const chunks = [];
1684
+ let size = 0;
1685
+ let overflow = false;
1473
1686
  for await (const chunk of req) {
1474
- chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
1687
+ // Keep draining (without buffering) once over the limit, so the client
1688
+ // can read the 413 instead of hitting a reset connection.
1689
+ if (overflow)
1690
+ continue;
1691
+ const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
1692
+ size += buf.length;
1693
+ if (size > MAX_JSON_BODY_BYTES) {
1694
+ overflow = true;
1695
+ chunks.length = 0;
1696
+ continue;
1697
+ }
1698
+ chunks.push(buf);
1475
1699
  }
1700
+ if (overflow)
1701
+ throw bodyTooLarge();
1476
1702
  if (chunks.length === 0)
1477
1703
  return {};
1478
1704
  try {
@@ -1492,25 +1718,49 @@ function writeText(res, status, contentType, body) {
1492
1718
  });
1493
1719
  res.end(body);
1494
1720
  }
1495
- function serveClientFile(res, relativePath) {
1496
- const filePath = resolve(CLIENT_DIR, relativePath);
1497
- const fileRelativeToClientDir = relative(CLIENT_DIR, filePath);
1721
+ function resolveClientFile(res, relativePath) {
1722
+ const root = clientDir();
1723
+ const filePath = resolve(root, relativePath);
1724
+ const fileRelativeToClientDir = relative(root, filePath);
1498
1725
  if (fileRelativeToClientDir.startsWith("..") || fileRelativeToClientDir === "") {
1499
- return writeJson(res, 404, { error: { message: "Not found" } });
1726
+ writeJson(res, 404, { error: { message: "Not found" } });
1727
+ return null;
1500
1728
  }
1501
1729
  if (!existsSync(filePath)) {
1502
- return writeJson(res, 500, {
1730
+ writeJson(res, 500, {
1503
1731
  error: {
1504
1732
  message: "Studio client assets are missing. Run `pnpm --filter @askdb/studio build` before starting Studio.",
1505
1733
  },
1506
1734
  });
1735
+ return null;
1507
1736
  }
1737
+ return filePath;
1738
+ }
1739
+ function serveClientFile(res, relativePath) {
1740
+ const filePath = resolveClientFile(res, relativePath);
1741
+ if (!filePath)
1742
+ return;
1508
1743
  res.writeHead(200, {
1509
1744
  "content-type": contentTypeFor(filePath),
1510
1745
  "cache-control": "no-store",
1511
1746
  });
1512
1747
  res.end(readFileSync(filePath));
1513
1748
  }
1749
+ /** Serve the SPA shell with this launch's session token injected. */
1750
+ function serveIndexHtml(res, sessionToken) {
1751
+ const filePath = resolveClientFile(res, "index.html");
1752
+ if (!filePath)
1753
+ return;
1754
+ res.writeHead(200, {
1755
+ "content-type": "text/html; charset=utf-8",
1756
+ "cache-control": "no-store",
1757
+ // The page carries the session token and drives SQL execution — never let
1758
+ // another site frame it (clickjacking).
1759
+ "x-frame-options": "DENY",
1760
+ "content-security-policy": "frame-ancestors 'none'",
1761
+ });
1762
+ res.end(injectSessionToken(readFileSync(filePath, "utf8"), sessionToken));
1763
+ }
1514
1764
  function contentTypeFor(path) {
1515
1765
  switch (extname(path)) {
1516
1766
  case ".html":