@smallwebco/tinypivot-server 1.0.57
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/LICENSE.md +78 -0
- package/README.md +362 -0
- package/dist/index.cjs +635 -0
- package/dist/index.d.cts +180 -0
- package/dist/index.d.ts +180 -0
- package/dist/index.js +594 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// src/validation.ts
|
|
2
|
+
var FORBIDDEN_KEYWORDS = [
|
|
3
|
+
"INSERT",
|
|
4
|
+
"UPDATE",
|
|
5
|
+
"DELETE",
|
|
6
|
+
"DROP",
|
|
7
|
+
"ALTER",
|
|
8
|
+
"TRUNCATE",
|
|
9
|
+
"CREATE",
|
|
10
|
+
"GRANT",
|
|
11
|
+
"REVOKE",
|
|
12
|
+
"EXEC",
|
|
13
|
+
"EXECUTE",
|
|
14
|
+
"MERGE",
|
|
15
|
+
"UPSERT",
|
|
16
|
+
"REPLACE",
|
|
17
|
+
"CALL",
|
|
18
|
+
"SET",
|
|
19
|
+
"LOCK",
|
|
20
|
+
"UNLOCK"
|
|
21
|
+
];
|
|
22
|
+
var DANGEROUS_PATTERNS = [
|
|
23
|
+
/INTO\s+(?:OUTFILE|DUMPFILE)/i,
|
|
24
|
+
// File operations
|
|
25
|
+
/LOAD\s+DATA/i,
|
|
26
|
+
// File loading
|
|
27
|
+
/;\s*(?:INSERT|UPDATE|DELETE|DROP)/i
|
|
28
|
+
// Statement injection
|
|
29
|
+
];
|
|
30
|
+
function validateSQL(sql, allowedTables) {
|
|
31
|
+
const trimmedSQL = sql.trim();
|
|
32
|
+
const upperSQL = trimmedSQL.toUpperCase();
|
|
33
|
+
if (!upperSQL.startsWith("SELECT") && !upperSQL.startsWith("WITH")) {
|
|
34
|
+
return {
|
|
35
|
+
valid: false,
|
|
36
|
+
error: "Only SELECT queries (including CTEs with WITH) are allowed"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (upperSQL.startsWith("WITH")) {
|
|
40
|
+
if (!upperSQL.includes("SELECT")) {
|
|
41
|
+
return {
|
|
42
|
+
valid: false,
|
|
43
|
+
error: "CTE queries must include a final SELECT statement"
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
for (const keyword of FORBIDDEN_KEYWORDS) {
|
|
48
|
+
const regex = new RegExp(`\\b${keyword}\\b`, "i");
|
|
49
|
+
if (regex.test(trimmedSQL)) {
|
|
50
|
+
return {
|
|
51
|
+
valid: false,
|
|
52
|
+
error: `Query contains forbidden keyword: ${keyword}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
57
|
+
if (pattern.test(trimmedSQL)) {
|
|
58
|
+
return {
|
|
59
|
+
valid: false,
|
|
60
|
+
error: "Query contains dangerous pattern"
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const statements = trimmedSQL.split(";").filter((s) => s.trim().length > 0);
|
|
65
|
+
if (statements.length > 1) {
|
|
66
|
+
return {
|
|
67
|
+
valid: false,
|
|
68
|
+
error: "Multiple statements are not allowed"
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (/--/.test(trimmedSQL) || /\/\*/.test(trimmedSQL)) {
|
|
72
|
+
return {
|
|
73
|
+
valid: false,
|
|
74
|
+
error: "SQL comments are not allowed"
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const extractedTables = extractTableNames(trimmedSQL);
|
|
78
|
+
const normalizedAllowed = allowedTables.map((t) => t.toLowerCase());
|
|
79
|
+
for (const table of extractedTables) {
|
|
80
|
+
if (!normalizedAllowed.includes(table.toLowerCase())) {
|
|
81
|
+
return {
|
|
82
|
+
valid: false,
|
|
83
|
+
error: `Table "${table}" is not in the allowed list`
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { valid: true };
|
|
88
|
+
}
|
|
89
|
+
function extractTableNames(sql) {
|
|
90
|
+
const tables = [];
|
|
91
|
+
const fromMatch = sql.match(/\bFROM\s+([a-z_]\w*(?:\s*,\s*[a-z_]\w*)*)/i);
|
|
92
|
+
if (fromMatch) {
|
|
93
|
+
const fromTables = fromMatch[1].split(",").map((t) => t.trim());
|
|
94
|
+
tables.push(...fromTables);
|
|
95
|
+
}
|
|
96
|
+
const joinRegex = /\b(?:LEFT|RIGHT|INNER|OUTER|CROSS|FULL)?\s*JOIN\s+([a-z_]\w*)/gi;
|
|
97
|
+
let match;
|
|
98
|
+
while ((match = joinRegex.exec(sql)) !== null) {
|
|
99
|
+
tables.push(match[1]);
|
|
100
|
+
}
|
|
101
|
+
return [...new Set(tables.map((t) => t.replace(/["`]/g, "")))];
|
|
102
|
+
}
|
|
103
|
+
function sanitizeTableName(name) {
|
|
104
|
+
return name.replace(/\W/g, "");
|
|
105
|
+
}
|
|
106
|
+
function ensureLimit(sql, maxRows) {
|
|
107
|
+
if (/\bLIMIT\s+\d+/i.test(sql)) {
|
|
108
|
+
const limitMatch = sql.match(/\bLIMIT\s+(\d+)/i);
|
|
109
|
+
if (limitMatch) {
|
|
110
|
+
const existingLimit = Number.parseInt(limitMatch[1], 10);
|
|
111
|
+
if (existingLimit > maxRows) {
|
|
112
|
+
return sql.replace(/\bLIMIT\s+\d+/i, `LIMIT ${maxRows}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return sql;
|
|
116
|
+
}
|
|
117
|
+
const trimmed = sql.replace(/;\s*$/, "").trim();
|
|
118
|
+
return `${trimmed} LIMIT ${maxRows}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/unified-handler.ts
|
|
122
|
+
function detectProvider(apiKey) {
|
|
123
|
+
if (apiKey.startsWith("sk-ant-")) {
|
|
124
|
+
return "anthropic";
|
|
125
|
+
}
|
|
126
|
+
if (apiKey.startsWith("sk-or-")) {
|
|
127
|
+
return "openrouter";
|
|
128
|
+
}
|
|
129
|
+
return "openai";
|
|
130
|
+
}
|
|
131
|
+
function getProviderConfig(provider) {
|
|
132
|
+
switch (provider) {
|
|
133
|
+
case "anthropic":
|
|
134
|
+
return {
|
|
135
|
+
provider: "anthropic",
|
|
136
|
+
endpoint: "https://api.anthropic.com/v1/messages",
|
|
137
|
+
defaultModel: "claude-3-haiku-20240307",
|
|
138
|
+
buildHeaders: (apiKey) => ({
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
"x-api-key": apiKey,
|
|
141
|
+
"anthropic-version": "2023-06-01"
|
|
142
|
+
}),
|
|
143
|
+
extractContent: (data) => {
|
|
144
|
+
const d = data;
|
|
145
|
+
return d.content?.[0]?.text || "";
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
case "openrouter":
|
|
149
|
+
return {
|
|
150
|
+
provider: "openrouter",
|
|
151
|
+
endpoint: "https://openrouter.ai/api/v1/chat/completions",
|
|
152
|
+
defaultModel: "anthropic/claude-3-haiku",
|
|
153
|
+
buildHeaders: (apiKey) => ({
|
|
154
|
+
"Content-Type": "application/json",
|
|
155
|
+
"Authorization": `Bearer ${apiKey}`,
|
|
156
|
+
"HTTP-Referer": "https://tiny-pivot.com",
|
|
157
|
+
"X-Title": "TinyPivot AI Data Analyst"
|
|
158
|
+
}),
|
|
159
|
+
extractContent: (data) => {
|
|
160
|
+
const d = data;
|
|
161
|
+
return d.choices?.[0]?.message?.content || "";
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
case "openai":
|
|
165
|
+
default:
|
|
166
|
+
return {
|
|
167
|
+
provider: "openai",
|
|
168
|
+
endpoint: "https://api.openai.com/v1/chat/completions",
|
|
169
|
+
defaultModel: "gpt-4o-mini",
|
|
170
|
+
buildHeaders: (apiKey) => ({
|
|
171
|
+
"Content-Type": "application/json",
|
|
172
|
+
"Authorization": `Bearer ${apiKey}`
|
|
173
|
+
}),
|
|
174
|
+
extractContent: (data) => {
|
|
175
|
+
const d = data;
|
|
176
|
+
return d.choices?.[0]?.message?.content || "";
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
function normalizeModelForOpenRouter(model) {
|
|
182
|
+
if (model.includes("/")) {
|
|
183
|
+
return model;
|
|
184
|
+
}
|
|
185
|
+
if (model.startsWith("claude")) {
|
|
186
|
+
return `anthropic/${model}`;
|
|
187
|
+
}
|
|
188
|
+
if (model.startsWith("gpt-") || model.startsWith("o1") || model.startsWith("o3")) {
|
|
189
|
+
return `openai/${model}`;
|
|
190
|
+
}
|
|
191
|
+
if (model.startsWith("gemini")) {
|
|
192
|
+
return `google/${model}`;
|
|
193
|
+
}
|
|
194
|
+
if (model.startsWith("llama") || model.startsWith("codellama")) {
|
|
195
|
+
return `meta-llama/${model}`;
|
|
196
|
+
}
|
|
197
|
+
if (model.startsWith("mistral") || model.startsWith("mixtral") || model.startsWith("codestral")) {
|
|
198
|
+
return `mistralai/${model}`;
|
|
199
|
+
}
|
|
200
|
+
if (model.startsWith("deepseek")) {
|
|
201
|
+
return `deepseek/${model}`;
|
|
202
|
+
}
|
|
203
|
+
if (model.startsWith("qwen")) {
|
|
204
|
+
return `qwen/${model}`;
|
|
205
|
+
}
|
|
206
|
+
return model;
|
|
207
|
+
}
|
|
208
|
+
var PG_TYPE_MAP = {
|
|
209
|
+
"character varying": "string",
|
|
210
|
+
"varchar": "string",
|
|
211
|
+
"character": "string",
|
|
212
|
+
"char": "string",
|
|
213
|
+
"text": "string",
|
|
214
|
+
"uuid": "string",
|
|
215
|
+
"name": "string",
|
|
216
|
+
"citext": "string",
|
|
217
|
+
"integer": "number",
|
|
218
|
+
"int": "number",
|
|
219
|
+
"int2": "number",
|
|
220
|
+
"int4": "number",
|
|
221
|
+
"int8": "number",
|
|
222
|
+
"smallint": "number",
|
|
223
|
+
"bigint": "number",
|
|
224
|
+
"decimal": "number",
|
|
225
|
+
"numeric": "number",
|
|
226
|
+
"real": "number",
|
|
227
|
+
"double precision": "number",
|
|
228
|
+
"float4": "number",
|
|
229
|
+
"float8": "number",
|
|
230
|
+
"money": "number",
|
|
231
|
+
"serial": "number",
|
|
232
|
+
"bigserial": "number",
|
|
233
|
+
"boolean": "boolean",
|
|
234
|
+
"bool": "boolean",
|
|
235
|
+
"date": "date",
|
|
236
|
+
"timestamp": "date",
|
|
237
|
+
"timestamp without time zone": "date",
|
|
238
|
+
"timestamp with time zone": "date",
|
|
239
|
+
"timestamptz": "date",
|
|
240
|
+
"time": "date",
|
|
241
|
+
"time without time zone": "date",
|
|
242
|
+
"time with time zone": "date",
|
|
243
|
+
"timetz": "date",
|
|
244
|
+
"interval": "date"
|
|
245
|
+
};
|
|
246
|
+
function matchesPattern(tableName, pattern) {
|
|
247
|
+
if (typeof pattern === "string") {
|
|
248
|
+
return tableName.toLowerCase() === pattern.toLowerCase();
|
|
249
|
+
}
|
|
250
|
+
return pattern.test(tableName);
|
|
251
|
+
}
|
|
252
|
+
function filterTables(tables, options) {
|
|
253
|
+
let filtered = tables;
|
|
254
|
+
if (options.include && options.include.length > 0) {
|
|
255
|
+
filtered = filtered.filter(
|
|
256
|
+
(table) => options.include.some((pattern) => matchesPattern(table, pattern))
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
if (options.exclude && options.exclude.length > 0) {
|
|
260
|
+
filtered = filtered.filter(
|
|
261
|
+
(table) => !options.exclude.some((pattern) => matchesPattern(table, pattern))
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
return filtered;
|
|
265
|
+
}
|
|
266
|
+
function createTinyPivotHandler(options = {}) {
|
|
267
|
+
const {
|
|
268
|
+
connectionString = process.env.DATABASE_URL,
|
|
269
|
+
apiKey = process.env.AI_API_KEY,
|
|
270
|
+
tables: tableOptions = {},
|
|
271
|
+
maxRows,
|
|
272
|
+
timeout,
|
|
273
|
+
maxTokens = 2048,
|
|
274
|
+
onError
|
|
275
|
+
} = options;
|
|
276
|
+
const modelOverride = options.model || process.env.AI_MODEL;
|
|
277
|
+
const schemas = tableOptions.schemas || ["public"];
|
|
278
|
+
const descriptions = tableOptions.descriptions || {};
|
|
279
|
+
return async (req) => {
|
|
280
|
+
try {
|
|
281
|
+
const body = await req.json();
|
|
282
|
+
const { action } = body;
|
|
283
|
+
if (!action) {
|
|
284
|
+
return createErrorResponse("Missing action parameter", 400);
|
|
285
|
+
}
|
|
286
|
+
switch (action) {
|
|
287
|
+
case "list-tables":
|
|
288
|
+
return handleListTables(connectionString, schemas, tableOptions, descriptions, onError);
|
|
289
|
+
case "get-schema":
|
|
290
|
+
return handleGetSchema(body.tables || [], connectionString, schemas, tableOptions, onError);
|
|
291
|
+
case "query":
|
|
292
|
+
return handleQuery(
|
|
293
|
+
body.sql,
|
|
294
|
+
body.table,
|
|
295
|
+
connectionString,
|
|
296
|
+
schemas,
|
|
297
|
+
tableOptions,
|
|
298
|
+
maxRows,
|
|
299
|
+
timeout,
|
|
300
|
+
onError
|
|
301
|
+
);
|
|
302
|
+
case "chat":
|
|
303
|
+
return handleChat(body.messages || [], apiKey, modelOverride, maxTokens, onError);
|
|
304
|
+
default:
|
|
305
|
+
return createErrorResponse(`Unknown action: ${action}`, 400);
|
|
306
|
+
}
|
|
307
|
+
} catch (error) {
|
|
308
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
309
|
+
onError?.(err);
|
|
310
|
+
return createErrorResponse(err.message, 500);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
async function handleListTables(connectionString, schemas, tableOptions, descriptions, onError) {
|
|
315
|
+
let Pool;
|
|
316
|
+
try {
|
|
317
|
+
const pg = await import("pg");
|
|
318
|
+
Pool = pg.Pool;
|
|
319
|
+
} catch {
|
|
320
|
+
return createErrorResponse(
|
|
321
|
+
"PostgreSQL driver (pg) is not installed. Install it with: pnpm add pg",
|
|
322
|
+
500
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
if (!connectionString) {
|
|
326
|
+
return createErrorResponse(
|
|
327
|
+
"Database connection not configured. Set DATABASE_URL environment variable.",
|
|
328
|
+
500
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
const pool = new Pool({ connectionString });
|
|
332
|
+
try {
|
|
333
|
+
const schemaPlaceholders = schemas.map((_, i) => `$${i + 1}`).join(", ");
|
|
334
|
+
const result = await pool.query(
|
|
335
|
+
`
|
|
336
|
+
SELECT table_name, table_schema
|
|
337
|
+
FROM information_schema.tables
|
|
338
|
+
WHERE table_schema IN (${schemaPlaceholders})
|
|
339
|
+
AND table_type = 'BASE TABLE'
|
|
340
|
+
ORDER BY table_schema, table_name
|
|
341
|
+
`,
|
|
342
|
+
schemas
|
|
343
|
+
);
|
|
344
|
+
let tableNames = result.rows.map((row) => row.table_name);
|
|
345
|
+
tableNames = filterTables(tableNames, tableOptions);
|
|
346
|
+
const response = {
|
|
347
|
+
tables: tableNames.map((name) => ({
|
|
348
|
+
name,
|
|
349
|
+
description: descriptions[name]
|
|
350
|
+
}))
|
|
351
|
+
};
|
|
352
|
+
return new Response(JSON.stringify(response), {
|
|
353
|
+
status: 200,
|
|
354
|
+
headers: { "Content-Type": "application/json" }
|
|
355
|
+
});
|
|
356
|
+
} catch (error) {
|
|
357
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
358
|
+
onError?.(err);
|
|
359
|
+
return createErrorResponse(`Failed to list tables: ${err.message}`, 500);
|
|
360
|
+
} finally {
|
|
361
|
+
await pool.end();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function handleGetSchema(tables, connectionString, schemas, tableOptions, onError) {
|
|
365
|
+
if (!tables || !Array.isArray(tables) || tables.length === 0) {
|
|
366
|
+
return createErrorResponse("Missing or invalid tables array", 400);
|
|
367
|
+
}
|
|
368
|
+
let Pool;
|
|
369
|
+
try {
|
|
370
|
+
const pg = await import("pg");
|
|
371
|
+
Pool = pg.Pool;
|
|
372
|
+
} catch {
|
|
373
|
+
return createErrorResponse(
|
|
374
|
+
"PostgreSQL driver (pg) is not installed. Install it with: pnpm add pg",
|
|
375
|
+
500
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
if (!connectionString) {
|
|
379
|
+
return createErrorResponse(
|
|
380
|
+
"Database connection not configured. Set DATABASE_URL environment variable.",
|
|
381
|
+
500
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
const pool = new Pool({ connectionString });
|
|
385
|
+
try {
|
|
386
|
+
const schemaPlaceholders = schemas.map((_, i) => `$${i + 1}`).join(", ");
|
|
387
|
+
const tablesResult = await pool.query(
|
|
388
|
+
`
|
|
389
|
+
SELECT table_name
|
|
390
|
+
FROM information_schema.tables
|
|
391
|
+
WHERE table_schema IN (${schemaPlaceholders})
|
|
392
|
+
AND table_type = 'BASE TABLE'
|
|
393
|
+
`,
|
|
394
|
+
schemas
|
|
395
|
+
);
|
|
396
|
+
let allowedTables = tablesResult.rows.map((row) => row.table_name);
|
|
397
|
+
allowedTables = filterTables(allowedTables, tableOptions);
|
|
398
|
+
const filteredTables = tables.filter(
|
|
399
|
+
(t) => allowedTables.map((a) => a.toLowerCase()).includes(t.toLowerCase())
|
|
400
|
+
);
|
|
401
|
+
if (filteredTables.length === 0) {
|
|
402
|
+
return createErrorResponse("None of the requested tables are allowed", 403);
|
|
403
|
+
}
|
|
404
|
+
const tableSchemas = [];
|
|
405
|
+
for (const table of filteredTables) {
|
|
406
|
+
const columnsResult = await pool.query(
|
|
407
|
+
`
|
|
408
|
+
SELECT
|
|
409
|
+
column_name,
|
|
410
|
+
data_type,
|
|
411
|
+
is_nullable
|
|
412
|
+
FROM information_schema.columns
|
|
413
|
+
WHERE table_name = $1
|
|
414
|
+
AND table_schema = ANY($2::text[])
|
|
415
|
+
ORDER BY ordinal_position
|
|
416
|
+
`,
|
|
417
|
+
[table, schemas]
|
|
418
|
+
);
|
|
419
|
+
if (columnsResult.rows.length > 0) {
|
|
420
|
+
const columns = columnsResult.rows.map((row) => ({
|
|
421
|
+
name: row.column_name,
|
|
422
|
+
type: PG_TYPE_MAP[row.data_type.toLowerCase()] || "unknown",
|
|
423
|
+
nullable: row.is_nullable === "YES"
|
|
424
|
+
}));
|
|
425
|
+
tableSchemas.push({ table, columns });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
const response = { schemas: tableSchemas };
|
|
429
|
+
return new Response(JSON.stringify(response), {
|
|
430
|
+
status: 200,
|
|
431
|
+
headers: { "Content-Type": "application/json" }
|
|
432
|
+
});
|
|
433
|
+
} catch (error) {
|
|
434
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
435
|
+
onError?.(err);
|
|
436
|
+
return createErrorResponse(`Failed to get schema: ${err.message}`, 500);
|
|
437
|
+
} finally {
|
|
438
|
+
await pool.end();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
async function handleQuery(sql, table, connectionString, schemas, tableOptions, maxRows, timeout, onError) {
|
|
442
|
+
if (!sql || typeof sql !== "string") {
|
|
443
|
+
return createErrorResponse("Missing or invalid SQL query", 400);
|
|
444
|
+
}
|
|
445
|
+
if (!table || typeof table !== "string") {
|
|
446
|
+
return createErrorResponse("Missing or invalid table name", 400);
|
|
447
|
+
}
|
|
448
|
+
let Pool;
|
|
449
|
+
try {
|
|
450
|
+
const pg = await import("pg");
|
|
451
|
+
Pool = pg.Pool;
|
|
452
|
+
} catch {
|
|
453
|
+
return createErrorResponse(
|
|
454
|
+
"PostgreSQL driver (pg) is not installed. Install it with: pnpm add pg",
|
|
455
|
+
500
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
if (!connectionString) {
|
|
459
|
+
return createErrorResponse(
|
|
460
|
+
"Database connection not configured. Set DATABASE_URL environment variable.",
|
|
461
|
+
500
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const poolConfig = { connectionString };
|
|
465
|
+
if (timeout !== void 0) {
|
|
466
|
+
poolConfig.statement_timeout = timeout;
|
|
467
|
+
}
|
|
468
|
+
const pool = new Pool(poolConfig);
|
|
469
|
+
try {
|
|
470
|
+
const schemaPlaceholders = schemas.map((_, i) => `$${i + 1}`).join(", ");
|
|
471
|
+
const tablesResult = await pool.query(
|
|
472
|
+
`
|
|
473
|
+
SELECT table_name
|
|
474
|
+
FROM information_schema.tables
|
|
475
|
+
WHERE table_schema IN (${schemaPlaceholders})
|
|
476
|
+
AND table_type = 'BASE TABLE'
|
|
477
|
+
`,
|
|
478
|
+
schemas
|
|
479
|
+
);
|
|
480
|
+
let allowedTables = tablesResult.rows.map((row) => row.table_name);
|
|
481
|
+
allowedTables = filterTables(allowedTables, tableOptions);
|
|
482
|
+
if (!allowedTables.map((t) => t.toLowerCase()).includes(table.toLowerCase())) {
|
|
483
|
+
return createErrorResponse(`Table "${table}" is not allowed`, 403);
|
|
484
|
+
}
|
|
485
|
+
const validation = validateSQL(sql, allowedTables);
|
|
486
|
+
if (!validation.valid) {
|
|
487
|
+
return createErrorResponse(validation.error || "Invalid SQL", 400);
|
|
488
|
+
}
|
|
489
|
+
const finalSQL = maxRows !== void 0 ? ensureLimit(sql, maxRows) : sql;
|
|
490
|
+
const startTime = Date.now();
|
|
491
|
+
const result = await pool.query(finalSQL);
|
|
492
|
+
const duration = Date.now() - startTime;
|
|
493
|
+
const truncated = maxRows !== void 0 && result.rows.length >= maxRows;
|
|
494
|
+
const response = {
|
|
495
|
+
success: true,
|
|
496
|
+
data: result.rows,
|
|
497
|
+
rowCount: result.rows.length,
|
|
498
|
+
truncated,
|
|
499
|
+
duration
|
|
500
|
+
};
|
|
501
|
+
return new Response(JSON.stringify(response), {
|
|
502
|
+
status: 200,
|
|
503
|
+
headers: { "Content-Type": "application/json" }
|
|
504
|
+
});
|
|
505
|
+
} catch (error) {
|
|
506
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
507
|
+
onError?.(err);
|
|
508
|
+
return createErrorResponse(sanitizeErrorMessage(err.message), 500);
|
|
509
|
+
} finally {
|
|
510
|
+
await pool.end();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
async function handleChat(messages, apiKey, modelOverride, maxTokens, onError) {
|
|
514
|
+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
|
515
|
+
return createErrorResponse("Missing or invalid messages array", 400);
|
|
516
|
+
}
|
|
517
|
+
if (!apiKey) {
|
|
518
|
+
return createErrorResponse(
|
|
519
|
+
"AI API key not configured. Set AI_API_KEY environment variable.",
|
|
520
|
+
500
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const provider = detectProvider(apiKey);
|
|
524
|
+
const config = getProviderConfig(provider);
|
|
525
|
+
let model = modelOverride || config.defaultModel;
|
|
526
|
+
if (provider === "openrouter") {
|
|
527
|
+
model = normalizeModelForOpenRouter(model);
|
|
528
|
+
}
|
|
529
|
+
try {
|
|
530
|
+
let requestBody;
|
|
531
|
+
if (provider === "anthropic") {
|
|
532
|
+
requestBody = {
|
|
533
|
+
model,
|
|
534
|
+
max_tokens: maxTokens,
|
|
535
|
+
messages: messages.map((m) => ({
|
|
536
|
+
role: m.role === "system" ? "user" : m.role,
|
|
537
|
+
content: m.content
|
|
538
|
+
}))
|
|
539
|
+
};
|
|
540
|
+
} else {
|
|
541
|
+
requestBody = {
|
|
542
|
+
model,
|
|
543
|
+
max_tokens: maxTokens,
|
|
544
|
+
messages
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
const response = await fetch(config.endpoint, {
|
|
548
|
+
method: "POST",
|
|
549
|
+
headers: config.buildHeaders(apiKey),
|
|
550
|
+
body: JSON.stringify(requestBody)
|
|
551
|
+
});
|
|
552
|
+
if (!response.ok) {
|
|
553
|
+
const errorText = await response.text();
|
|
554
|
+
throw new Error(`AI provider error (${response.status}): ${errorText}`);
|
|
555
|
+
}
|
|
556
|
+
const data = await response.json();
|
|
557
|
+
const content = config.extractContent(data);
|
|
558
|
+
const aiResponse = { content };
|
|
559
|
+
return new Response(JSON.stringify(aiResponse), {
|
|
560
|
+
status: 200,
|
|
561
|
+
headers: { "Content-Type": "application/json" }
|
|
562
|
+
});
|
|
563
|
+
} catch (error) {
|
|
564
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
565
|
+
onError?.(err);
|
|
566
|
+
const response = { content: "", error: err.message };
|
|
567
|
+
return new Response(JSON.stringify(response), {
|
|
568
|
+
status: 500,
|
|
569
|
+
headers: { "Content-Type": "application/json" }
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
function sanitizeErrorMessage(message) {
|
|
574
|
+
let sanitized = message.replace(/postgresql?:\/\/\S+/gi, "[DATABASE_URL]");
|
|
575
|
+
sanitized = sanitized.replace(/\/[^\s:]+\.(js|ts|mjs|cjs)/g, "[FILE]");
|
|
576
|
+
sanitized = sanitized.replace(/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/g, "[IP]");
|
|
577
|
+
if (sanitized.length > 200) {
|
|
578
|
+
sanitized = `${sanitized.slice(0, 200)}...`;
|
|
579
|
+
}
|
|
580
|
+
return sanitized;
|
|
581
|
+
}
|
|
582
|
+
function createErrorResponse(error, status) {
|
|
583
|
+
return new Response(JSON.stringify({ error }), {
|
|
584
|
+
status,
|
|
585
|
+
headers: { "Content-Type": "application/json" }
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
export {
|
|
589
|
+
createTinyPivotHandler,
|
|
590
|
+
ensureLimit,
|
|
591
|
+
extractTableNames,
|
|
592
|
+
sanitizeTableName,
|
|
593
|
+
validateSQL
|
|
594
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@smallwebco/tinypivot-server",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.0.57",
|
|
5
|
+
"description": "Optional backend handlers for TinyPivot AI Data Analyst",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://tiny-pivot.com",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/smallwebco/tinypivot.git",
|
|
11
|
+
"directory": "packages/server"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"tinypivot",
|
|
15
|
+
"ai",
|
|
16
|
+
"data-analyst",
|
|
17
|
+
"postgresql",
|
|
18
|
+
"sql"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"require": "./dist/index.cjs"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "dist/index.js",
|
|
28
|
+
"module": "dist/index.js",
|
|
29
|
+
"types": "dist/index.d.ts",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"pg": "^8.0.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependenciesMeta": {
|
|
37
|
+
"pg": {
|
|
38
|
+
"optional": true
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@smallwebco/tinypivot-core": "1.0.57"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/pg": "^8.11.6",
|
|
46
|
+
"tsup": "^8.0.1",
|
|
47
|
+
"typescript": "~5.6.2"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup src/index.ts --format esm,cjs --dts --clean",
|
|
51
|
+
"dev": "tsup src/index.ts --format esm,cjs --dts --watch",
|
|
52
|
+
"type-check": "tsc --noEmit"
|
|
53
|
+
}
|
|
54
|
+
}
|