@askdb/studio 0.2.0-beta.35 → 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/README.md +12 -24
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -0
- package/dist/cli.js.map +1 -1
- package/dist/client/assets/index-TAdxvl0x.js +101 -0
- package/dist/client/index.html +1 -1
- package/dist/execute-registry.d.ts +53 -0
- package/dist/execute-registry.d.ts.map +1 -1
- package/dist/execute-registry.js +232 -41
- package/dist/execute-registry.js.map +1 -1
- package/dist/introspection.d.ts.map +1 -1
- package/dist/introspection.js +3 -0
- package/dist/introspection.js.map +1 -1
- package/dist/package-manager.d.ts +31 -0
- package/dist/package-manager.d.ts.map +1 -0
- package/dist/package-manager.js +54 -0
- package/dist/package-manager.js.map +1 -0
- package/dist/request-guard.d.ts +44 -0
- package/dist/request-guard.d.ts.map +1 -0
- package/dist/request-guard.js +170 -0
- package/dist/request-guard.js.map +1 -0
- package/dist/server.d.ts +13 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +344 -94
- package/dist/server.js.map +1 -1
- package/dist/setup.d.ts +1 -1
- package/dist/setup.d.ts.map +1 -1
- package/dist/setup.js +46 -58
- package/dist/setup.js.map +1 -1
- package/dist/shared/api.d.ts +19 -0
- package/dist/shared/api.d.ts.map +1 -1
- package/package.json +46 -38
- package/dist/client/assets/index-BvbATe8r.js +0 -101
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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 (
|
|
1101
|
-
throw new StudioHttpError(400, "`sqlMode`
|
|
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
|
-
|
|
1104
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
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 =
|
|
1329
|
+
provider = body.provider;
|
|
1149
1330
|
}
|
|
1150
1331
|
const def = EXECUTE_DRIVER_REGISTRY[provider];
|
|
1151
|
-
const
|
|
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
|
-
|
|
1163
|
-
const { stdout, stderr, code } = await spawnCommand(
|
|
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
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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(
|
|
1386
|
+
function spawnCommand(spec, cwd) {
|
|
1240
1387
|
return new Promise((resolve) => {
|
|
1241
|
-
const proc = spawn(command, args, { cwd, shell:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1496
|
-
const
|
|
1497
|
-
const
|
|
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
|
-
|
|
1726
|
+
writeJson(res, 404, { error: { message: "Not found" } });
|
|
1727
|
+
return null;
|
|
1500
1728
|
}
|
|
1501
1729
|
if (!existsSync(filePath)) {
|
|
1502
|
-
|
|
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":
|