@teleporthq/teleport-plugin-next-workflows 0.43.58 → 0.43.59
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/__tests__/ai-chat-rag-sql-parameterization.test.ts +28 -8
- package/__tests__/ai-select-database-data-guard.test.ts +218 -0
- package/__tests__/ai-select-database-data-handler.test.ts +237 -0
- package/__tests__/client-config-redaction.test.ts +34 -2
- package/__tests__/parameterized-stock-chain.test.ts +453 -0
- package/__tests__/stock-decrement-audit.test.ts +5 -1
- package/__tests__/streaming-chunk-payload.test.ts +91 -0
- package/__tests__/streaming-on-end-env-filter.test.ts +2 -2
- package/dist/cjs/api-route-generator.d.ts.map +1 -1
- package/dist/cjs/api-route-generator.js +2 -3
- package/dist/cjs/api-route-generator.js.map +1 -1
- package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
- package/dist/cjs/data-api-route-generator.js +4 -0
- package/dist/cjs/data-api-route-generator.js.map +1 -1
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +23 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.js +397 -0
- package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +3 -0
- package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/cjs/ecommerce-customhandler-rewriter.js +41 -1
- package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/cjs/executor-generator.js +1 -1
- package/dist/cjs/nodes/ai/ai-provider-utils.js +1 -1
- package/dist/cjs/nodes/ai/ai-select-database-data.d.ts +3 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.js +141 -0
- package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts +18 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js +388 -0
- package/dist/cjs/nodes/ai/ai-sql-select-guard.js.map +1 -0
- package/dist/cjs/nodes/index.d.ts.map +1 -1
- package/dist/cjs/nodes/index.js +2 -1
- package/dist/cjs/nodes/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/api-route-generator.d.ts.map +1 -1
- package/dist/esm/api-route-generator.js +2 -3
- package/dist/esm/api-route-generator.js.map +1 -1
- package/dist/esm/data-api-route-generator.d.ts.map +1 -1
- package/dist/esm/data-api-route-generator.js +4 -0
- package/dist/esm/data-api-route-generator.js.map +1 -1
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +23 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.js +385 -0
- package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts +3 -0
- package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
- package/dist/esm/ecommerce-customhandler-rewriter.js +41 -1
- package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
- package/dist/esm/executor-generator.js +1 -1
- package/dist/esm/nodes/ai/ai-provider-utils.js +1 -1
- package/dist/esm/nodes/ai/ai-select-database-data.d.ts +3 -0
- package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-select-database-data.js +138 -0
- package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts +18 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.js +384 -0
- package/dist/esm/nodes/ai/ai-sql-select-guard.js.map +1 -0
- package/dist/esm/nodes/index.d.ts.map +1 -1
- package/dist/esm/nodes/index.js +2 -1
- package/dist/esm/nodes/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/api-route-generator.ts +2 -3
- package/src/data-api-route-generator.ts +4 -0
- package/src/ecommerce/parameterized-stock-chain.ts +449 -0
- package/src/ecommerce-customhandler-rewriter.ts +71 -3
- package/src/executor-generator.ts +1 -1
- package/src/nodes/ai/ai-provider-utils.ts +1 -1
- package/src/nodes/ai/ai-select-database-data.ts +162 -0
- package/src/nodes/ai/ai-sql-select-guard.ts +402 -0
- package/src/nodes/index.ts +2 -0
|
@@ -22,14 +22,34 @@ import { generateSqlValidatorCode } from '../src/sql-validator'
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
const SEMANTIC_QUERY = [
|
|
25
|
-
'
|
|
26
|
-
'
|
|
27
|
-
'
|
|
28
|
-
|
|
29
|
-
'
|
|
30
|
-
'
|
|
31
|
-
'
|
|
32
|
-
'
|
|
25
|
+
'WITH corpus AS (',
|
|
26
|
+
' SELECT count(*) AS total_docs,',
|
|
27
|
+
' COALESCE(SUM(LENGTH(content)), 0) AS total_chars',
|
|
28
|
+
' FROM teleport_ai_chat_documents',
|
|
29
|
+
')',
|
|
30
|
+
'SELECT ranked.id, ranked.content, ranked.search_content, ranked.keywords, ranked.similarity',
|
|
31
|
+
'FROM (',
|
|
32
|
+
' SELECT d.id, d.content, d.search_content, d.keywords,',
|
|
33
|
+
' CASE',
|
|
34
|
+
' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
|
|
35
|
+
' ELSE 1 - (d.embedding_json::vector <=> q.query_vector)',
|
|
36
|
+
' END AS similarity,',
|
|
37
|
+
' row_number() OVER (',
|
|
38
|
+
' ORDER BY (q.query_vector IS NULL OR d.embedding_json IS NULL) ASC,',
|
|
39
|
+
' CASE',
|
|
40
|
+
' WHEN q.query_vector IS NULL OR d.embedding_json IS NULL THEN NULL',
|
|
41
|
+
' ELSE d.embedding_json::vector <=> q.query_vector',
|
|
42
|
+
' END ASC,',
|
|
43
|
+
' d.id ASC',
|
|
44
|
+
' ) AS rn',
|
|
45
|
+
' FROM teleport_ai_chat_documents d',
|
|
46
|
+
" CROSS JOIN (SELECT NULLIF({{ queryEmbedding }}::text, '')::vector AS query_vector) q",
|
|
47
|
+
') ranked',
|
|
48
|
+
'CROSS JOIN corpus',
|
|
49
|
+
'WHERE (corpus.total_docs <= 30 AND corpus.total_chars <= 24000)',
|
|
50
|
+
' OR (ranked.similarity IS NOT NULL AND ranked.rn <= 6)',
|
|
51
|
+
'ORDER BY ranked.rn ASC',
|
|
52
|
+
'LIMIT 30',
|
|
33
53
|
].join('\n')
|
|
34
54
|
|
|
35
55
|
const LEXICAL_QUERY = [
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { generateAiSqlSelectGuard } from '../src/nodes/ai/ai-sql-select-guard'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The guard is the ONLY thing standing between an AI model's output and the
|
|
5
|
+
* database: the model can be prompt-injected into producing anything, so a
|
|
6
|
+
* miss here is a write/exfiltration primitive, and an over-match breaks the
|
|
7
|
+
* legitimate aggregate queries the node exists for (MIN/MAX/CASE…END).
|
|
8
|
+
* Exercised exactly as the generated app defines it — by evaluating the
|
|
9
|
+
* emitted source.
|
|
10
|
+
*/
|
|
11
|
+
function loadGuard() {
|
|
12
|
+
// eslint-disable-next-line no-new-func
|
|
13
|
+
return new Function(
|
|
14
|
+
`${generateAiSqlSelectGuard()}
|
|
15
|
+
return {
|
|
16
|
+
validate: __aisql_validateSelectQuery,
|
|
17
|
+
enforceLimit: __aisql_enforceLimit,
|
|
18
|
+
buildSystemPrompt: __aisql_buildSystemPrompt,
|
|
19
|
+
};`
|
|
20
|
+
)() as {
|
|
21
|
+
validate: (sql: string, allowedTables: string[]) => { valid: boolean; reason?: string }
|
|
22
|
+
enforceLimit: (sql: string, maxRows: number) => string
|
|
23
|
+
buildSystemPrompt: (tableSchemas: unknown, allowedTables: string[], maxRows: number) => string
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const { validate, enforceLimit, buildSystemPrompt } = loadGuard()
|
|
28
|
+
const ALLOWED = ['teleport_products', 'teleport_blog_posts']
|
|
29
|
+
|
|
30
|
+
describe('accepts the queries the node exists for', () => {
|
|
31
|
+
it('plain SELECT over an allowed table', () => {
|
|
32
|
+
expect(
|
|
33
|
+
validate('SELECT "id", "name", "price" FROM "teleport_products" LIMIT 10', ALLOWED)
|
|
34
|
+
).toEqual({
|
|
35
|
+
valid: true,
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('aggregates and superlatives (MIN/MAX/ORDER BY … LIMIT 1)', () => {
|
|
40
|
+
expect(
|
|
41
|
+
validate(
|
|
42
|
+
'SELECT "name", "price" FROM "teleport_products" ORDER BY "price" DESC LIMIT 1',
|
|
43
|
+
ALLOWED
|
|
44
|
+
).valid
|
|
45
|
+
).toBe(true)
|
|
46
|
+
expect(validate('SELECT MAX("price") FROM "teleport_products"', ALLOWED).valid).toBe(true)
|
|
47
|
+
expect(validate('SELECT COUNT(*) FROM "teleport_blog_posts";', ALLOWED).valid).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('CASE … END (the forms-worker validator regression)', () => {
|
|
51
|
+
expect(
|
|
52
|
+
validate(
|
|
53
|
+
`SELECT "name", CASE WHEN "price" > 100 THEN 'premium' ELSE 'standard' END AS tier FROM "teleport_products"`,
|
|
54
|
+
ALLOWED
|
|
55
|
+
).valid
|
|
56
|
+
).toBe(true)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('WITH … SELECT over allowed tables, CTE referenced by name', () => {
|
|
60
|
+
expect(
|
|
61
|
+
validate(
|
|
62
|
+
'WITH ranked AS (SELECT "name", "price" FROM "teleport_products") SELECT * FROM ranked ORDER BY "price" ASC LIMIT 1',
|
|
63
|
+
ALLOWED
|
|
64
|
+
).valid
|
|
65
|
+
).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('JOIN with an alias between allowed tables', () => {
|
|
69
|
+
expect(
|
|
70
|
+
validate(
|
|
71
|
+
'SELECT p."name" FROM "teleport_products" p JOIN "teleport_blog_posts" b ON b."id" = p."id"',
|
|
72
|
+
ALLOWED
|
|
73
|
+
).valid
|
|
74
|
+
).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('set-returning functions in FROM are not table refs', () => {
|
|
78
|
+
expect(
|
|
79
|
+
validate(
|
|
80
|
+
`SELECT kv.key FROM "teleport_products" p, jsonb_each_text(p."metadata") kv`,
|
|
81
|
+
ALLOWED
|
|
82
|
+
).valid
|
|
83
|
+
).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keywords inside string literals do not false-positive', () => {
|
|
87
|
+
expect(
|
|
88
|
+
validate(`SELECT "name" FROM "teleport_products" WHERE "status" = 'deleted'`, ALLOWED).valid
|
|
89
|
+
).toBe(true)
|
|
90
|
+
expect(
|
|
91
|
+
validate(
|
|
92
|
+
`SELECT "name" FROM "teleport_products" WHERE "description" = 'DROP by the store'`,
|
|
93
|
+
ALLOWED
|
|
94
|
+
).valid
|
|
95
|
+
).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('rejects everything that is not a single read-only SELECT', () => {
|
|
100
|
+
const rejected = (sql: string, tables: string[] = ALLOWED) =>
|
|
101
|
+
validate(sql, tables).valid === false
|
|
102
|
+
|
|
103
|
+
it('writes and DDL', () => {
|
|
104
|
+
expect(rejected(`INSERT INTO "teleport_products" ("name") VALUES ('x')`)).toBe(true)
|
|
105
|
+
expect(rejected(`UPDATE "teleport_products" SET "price" = 0`)).toBe(true)
|
|
106
|
+
expect(rejected(`DELETE FROM "teleport_products"`)).toBe(true)
|
|
107
|
+
expect(rejected(`DROP TABLE "teleport_products"`)).toBe(true)
|
|
108
|
+
expect(rejected(`SELECT * INTO backup FROM "teleport_products"`)).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('stacked statements and comments', () => {
|
|
112
|
+
expect(rejected(`SELECT 1; DELETE FROM "teleport_products"`)).toBe(true)
|
|
113
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" -- hidden`)).toBe(true)
|
|
114
|
+
expect(rejected(`SELECT /* smuggle */ "name" FROM "teleport_products"`)).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('system catalogs and pg_* objects', () => {
|
|
118
|
+
expect(rejected(`SELECT * FROM information_schema.tables`)).toBe(true)
|
|
119
|
+
expect(rejected(`SELECT * FROM pg_catalog.pg_tables`)).toBe(true)
|
|
120
|
+
expect(rejected(`SELECT * FROM pg_stat_activity`)).toBe(true)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('tables outside the allowlist, in FROM, JOIN, and subqueries', () => {
|
|
124
|
+
expect(rejected(`SELECT * FROM "users"`)).toBe(true)
|
|
125
|
+
expect(rejected(`SELECT * FROM "teleport_products" JOIN "users" u ON u."id" = 1`)).toBe(true)
|
|
126
|
+
expect(
|
|
127
|
+
rejected(`SELECT * FROM "teleport_products" WHERE "id" IN (SELECT "id" FROM "users")`)
|
|
128
|
+
).toBe(true)
|
|
129
|
+
expect(rejected(`SELECT * FROM otherschema."teleport_products"`)).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('a CTE cannot launder a non-allowed table', () => {
|
|
133
|
+
expect(rejected(`WITH x AS (SELECT * FROM "users") SELECT * FROM x`)).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('timing/file functions and locks', () => {
|
|
137
|
+
expect(rejected(`SELECT pg_sleep(10)`)).toBe(true)
|
|
138
|
+
expect(rejected(`SELECT current_setting('server_version')`)).toBe(true)
|
|
139
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" FOR SHARE`)).toBe(true)
|
|
140
|
+
// FOR UPDATE falls to the UPDATE keyword rule
|
|
141
|
+
expect(rejected(`SELECT "name" FROM "teleport_products" FOR UPDATE`)).toBe(true)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('transaction control and session state', () => {
|
|
145
|
+
expect(rejected(`BEGIN`)).toBe(true)
|
|
146
|
+
expect(rejected(`SET search_path TO public`)).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('degenerate inputs', () => {
|
|
150
|
+
expect(rejected('')).toBe(true)
|
|
151
|
+
expect(rejected(' ')).toBe(true)
|
|
152
|
+
expect(rejected('SELECT 1'.padEnd(10001, '1'))).toBe(true)
|
|
153
|
+
expect(validate(null as unknown as string, ALLOWED).valid).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('enforceLimit', () => {
|
|
158
|
+
it('appends a LIMIT when the statement has none', () => {
|
|
159
|
+
expect(enforceLimit('SELECT * FROM "teleport_products"', 100)).toBe(
|
|
160
|
+
'SELECT * FROM "teleport_products" LIMIT 100'
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('clamps an oversized trailing LIMIT and keeps a compliant one', () => {
|
|
165
|
+
expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5000', 100)).toBe(
|
|
166
|
+
'SELECT * FROM "teleport_products" LIMIT 100'
|
|
167
|
+
)
|
|
168
|
+
expect(enforceLimit('SELECT * FROM "teleport_products" LIMIT 5', 100)).toBe(
|
|
169
|
+
'SELECT * FROM "teleport_products" LIMIT 5'
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('a subquery LIMIT does not satisfy the outer statement', () => {
|
|
174
|
+
const sql = 'SELECT * FROM (SELECT * FROM "teleport_products" LIMIT 5) t WHERE t."price" > 0'
|
|
175
|
+
expect(enforceLimit(sql, 100)).toBe(sql + ' LIMIT 100')
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('strips a trailing semicolon and re-clamps maxRows itself', () => {
|
|
179
|
+
expect(enforceLimit('SELECT 1;', 999999)).toBe('SELECT 1 LIMIT 1000')
|
|
180
|
+
expect(enforceLimit('SELECT 1', 0)).toBe('SELECT 1 LIMIT 100')
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe('buildSystemPrompt', () => {
|
|
185
|
+
it('renders baked schemas and falls back for undescribed tables', () => {
|
|
186
|
+
const prompt = buildSystemPrompt(
|
|
187
|
+
[
|
|
188
|
+
{
|
|
189
|
+
table: 'teleport_products',
|
|
190
|
+
columns: [
|
|
191
|
+
{ name: 'id', type: 'uuid', nullable: false },
|
|
192
|
+
{ name: 'price', type: 'numeric' },
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
ALLOWED,
|
|
197
|
+
50
|
|
198
|
+
)
|
|
199
|
+
expect(prompt).toContain('TABLE "teleport_products" ("id" uuid NOT NULL, "price" numeric)')
|
|
200
|
+
expect(prompt).toContain('TABLE "teleport_blog_posts" (column names unknown')
|
|
201
|
+
expect(prompt).toContain('LIMIT of at most 50')
|
|
202
|
+
expect(prompt).toContain('needsQuery')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('teaches the SQL habits the store chat needed in practice', () => {
|
|
206
|
+
const prompt = buildSystemPrompt([], ALLOWED, 50)
|
|
207
|
+
// "what price does the 054 product have?" — partial name, possibly living
|
|
208
|
+
// in a language-suffixed column.
|
|
209
|
+
expect(prompt).toContain('ILIKE')
|
|
210
|
+
expect(prompt).toContain('language-suffixed variants')
|
|
211
|
+
// "most expensive product" with several products at the same top price —
|
|
212
|
+
// ORDER BY + LIMIT 1 silently returned only one of them.
|
|
213
|
+
expect(prompt).toContain('MAX()/MIN() subquery')
|
|
214
|
+
expect(prompt).toContain('never ORDER BY with LIMIT 1')
|
|
215
|
+
// Rows must arrive identifiable and linkable.
|
|
216
|
+
expect(prompt).toContain('name/title/slug-like columns')
|
|
217
|
+
})
|
|
218
|
+
})
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { aiSelectDatabaseData } from '../src/nodes/ai/ai-select-database-data'
|
|
2
|
+
import { generateSharedRuntimeUtilsCode } from '../src/executor-generator'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The handler's result is serialized straight to the visitor's browser, so the
|
|
6
|
+
* contract under test is as much about what it must NEVER contain (the
|
|
7
|
+
* generated SQL, schema text, database error messages) as about what it
|
|
8
|
+
* returns. The AI call and fetch are mocked by pre-defining the guarded
|
|
9
|
+
* globals — the emitted `var X = typeof X !== 'undefined' ? X : …` pattern
|
|
10
|
+
* keeps whatever is already in scope.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
interface MockState {
|
|
14
|
+
aiResponses: string[]
|
|
15
|
+
aiCalls: Array<{ systemMessage: string; userMessage: string; jsonMode: boolean }>
|
|
16
|
+
fetchCalls: Array<{ url: string; body: { query: string; params: unknown[] } }>
|
|
17
|
+
fetchResponse: { ok: boolean; json: unknown }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function loadHandler(state: MockState) {
|
|
21
|
+
const mocks = `
|
|
22
|
+
var __ai_callProvider = async function (params) {
|
|
23
|
+
state.aiCalls.push({ systemMessage: params.systemMessage, userMessage: params.userMessage, jsonMode: params.jsonMode });
|
|
24
|
+
var next = state.aiResponses.shift();
|
|
25
|
+
if (next === '__throw__') { throw new Error('provider exploded'); }
|
|
26
|
+
return { content: next };
|
|
27
|
+
};
|
|
28
|
+
`
|
|
29
|
+
const source = aiSelectDatabaseData.generateServerHandler!()
|
|
30
|
+
const fetchMock = async (url: string, init: { body: string }) => {
|
|
31
|
+
state.fetchCalls.push({ url, body: JSON.parse(init.body) })
|
|
32
|
+
return {
|
|
33
|
+
ok: state.fetchResponse.ok,
|
|
34
|
+
json: async () => state.fetchResponse.json,
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// eslint-disable-next-line no-new-func
|
|
38
|
+
return new Function('state', 'fetch', `${mocks}\n${source}\nreturn ai_select_database_data;`)(
|
|
39
|
+
state,
|
|
40
|
+
fetchMock
|
|
41
|
+
) as (config: unknown, context: Record<string, unknown>) => Promise<any>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeState(overrides: Partial<MockState> = {}): MockState {
|
|
45
|
+
return {
|
|
46
|
+
aiResponses: ['{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\""}'],
|
|
47
|
+
aiCalls: [],
|
|
48
|
+
fetchCalls: [],
|
|
49
|
+
fetchResponse: { ok: true, json: { rows: [{ id: '1' }, { id: '2' }] } },
|
|
50
|
+
...overrides,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const BASE_CONFIG = {
|
|
55
|
+
dataSourceId: 'ds1',
|
|
56
|
+
allowedTables: ['teleport_products'],
|
|
57
|
+
prompt: 'What is the cheapest product?',
|
|
58
|
+
model: 'gpt-4o',
|
|
59
|
+
token: 'sk-test',
|
|
60
|
+
tableSchemas: [
|
|
61
|
+
{ table: 'teleport_products', columns: [{ name: 'id', type: 'uuid', nullable: false }] },
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadIsFatalNodeResult(): (result: unknown) => boolean {
|
|
66
|
+
const utilsModule = { exports: {} as { isFatalNodeResult: (result: unknown) => boolean } }
|
|
67
|
+
// eslint-disable-next-line @typescript-eslint/no-implied-eval
|
|
68
|
+
new Function('module', 'exports', 'require', generateSharedRuntimeUtilsCode())(
|
|
69
|
+
utilsModule,
|
|
70
|
+
utilsModule.exports,
|
|
71
|
+
() => ({})
|
|
72
|
+
)
|
|
73
|
+
return utilsModule.exports.isFatalNodeResult
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('happy path', () => {
|
|
77
|
+
it('generates, validates, executes, and returns only { executed, rowCount, rows, truncated }', async () => {
|
|
78
|
+
const state = makeState()
|
|
79
|
+
const handler = loadHandler(state)
|
|
80
|
+
const result = await handler(BASE_CONFIG, {})
|
|
81
|
+
|
|
82
|
+
expect(result).toEqual({
|
|
83
|
+
executed: true,
|
|
84
|
+
rowCount: 2,
|
|
85
|
+
rows: [{ id: '1' }, { id: '2' }],
|
|
86
|
+
// Two rows under a cap of 50: nothing was cut off, so a caller may
|
|
87
|
+
// report this as a complete result.
|
|
88
|
+
truncated: false,
|
|
89
|
+
})
|
|
90
|
+
expect(state.aiCalls).toHaveLength(1)
|
|
91
|
+
expect(state.aiCalls[0].jsonMode).toBe(true)
|
|
92
|
+
expect(state.aiCalls[0].systemMessage).toContain('TABLE "teleport_products"')
|
|
93
|
+
expect(state.fetchCalls).toHaveLength(1)
|
|
94
|
+
expect(state.fetchCalls[0].url).toBe('/api/data/ds1/raw-query')
|
|
95
|
+
// The executed SQL carries the enforced LIMIT
|
|
96
|
+
expect(state.fetchCalls[0].body.query).toBe('SELECT "id" FROM "teleport_products" LIMIT 100')
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('respects the AI deciding no query is needed, without touching the database', async () => {
|
|
100
|
+
const state = makeState({ aiResponses: ['{"needsQuery": false, "query": null}'] })
|
|
101
|
+
const handler = loadHandler(state)
|
|
102
|
+
const result = await handler(BASE_CONFIG, {})
|
|
103
|
+
|
|
104
|
+
expect(result).toEqual({ executed: false, rows: [], rowCount: 0, truncated: false })
|
|
105
|
+
expect(state.fetchCalls).toHaveLength(0)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('retries once with feedback when the first SQL is rejected', async () => {
|
|
109
|
+
const state = makeState({
|
|
110
|
+
aiResponses: [
|
|
111
|
+
'{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}',
|
|
112
|
+
'{"needsQuery": true, "query": "SELECT \\"id\\" FROM \\"teleport_products\\" LIMIT 5"}',
|
|
113
|
+
],
|
|
114
|
+
})
|
|
115
|
+
const handler = loadHandler(state)
|
|
116
|
+
const result = await handler(BASE_CONFIG, {})
|
|
117
|
+
|
|
118
|
+
expect(result.executed).toBe(true)
|
|
119
|
+
expect(state.aiCalls).toHaveLength(2)
|
|
120
|
+
expect(state.aiCalls[1].userMessage).toContain('was rejected')
|
|
121
|
+
})
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
describe('failure contract — nothing sensitive ever leaves the handler', () => {
|
|
125
|
+
const flatten = (value: unknown): string => JSON.stringify(value)
|
|
126
|
+
|
|
127
|
+
it('rejected SQL on both attempts fails closed with a fixed code', async () => {
|
|
128
|
+
const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
|
|
129
|
+
const state = makeState({ aiResponses: [bad, bad] })
|
|
130
|
+
const handler = loadHandler(state)
|
|
131
|
+
const result = await handler(BASE_CONFIG, {})
|
|
132
|
+
|
|
133
|
+
expect(result).toEqual({
|
|
134
|
+
error: true,
|
|
135
|
+
message: 'AI database query failed (sql_rejected)',
|
|
136
|
+
code: 'sql_rejected',
|
|
137
|
+
})
|
|
138
|
+
expect(flatten(result)).not.toContain('DELETE')
|
|
139
|
+
expect(flatten(result)).not.toContain('teleport_products')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('never forwards the data route error body (pg errors quote the SQL)', async () => {
|
|
143
|
+
const state = makeState({
|
|
144
|
+
fetchResponse: {
|
|
145
|
+
ok: false,
|
|
146
|
+
json: { error: 'syntax error at or near "SELECT \\"id\\" FROM teleport_products"' },
|
|
147
|
+
},
|
|
148
|
+
})
|
|
149
|
+
const handler = loadHandler(state)
|
|
150
|
+
const result = await handler(BASE_CONFIG, {})
|
|
151
|
+
|
|
152
|
+
expect(result.code).toBe('query_failed')
|
|
153
|
+
expect(flatten(result)).not.toContain('syntax error')
|
|
154
|
+
expect(flatten(result)).not.toContain('SELECT')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('optional: true converts every failure into skipped data', async () => {
|
|
158
|
+
const state = makeState({ aiResponses: ['__throw__'] })
|
|
159
|
+
const handler = loadHandler(state)
|
|
160
|
+
const result = await handler({ ...BASE_CONFIG, optional: true }, {})
|
|
161
|
+
|
|
162
|
+
expect(result).toEqual({
|
|
163
|
+
executed: false,
|
|
164
|
+
rows: [],
|
|
165
|
+
rowCount: 0,
|
|
166
|
+
truncated: false,
|
|
167
|
+
skipped: true,
|
|
168
|
+
skipReason: 'provider_error',
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('missing configuration fails before any AI call', async () => {
|
|
173
|
+
const state = makeState()
|
|
174
|
+
const handler = loadHandler(state)
|
|
175
|
+
const result = await handler({ ...BASE_CONFIG, allowedTables: [] }, {})
|
|
176
|
+
|
|
177
|
+
expect(result.code).toBe('missing_configuration')
|
|
178
|
+
expect(state.aiCalls).toHaveLength(0)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('the executor treats default failures as fatal and optional ones as data', async () => {
|
|
182
|
+
const isFatalNodeResult = loadIsFatalNodeResult()
|
|
183
|
+
const bad = '{"needsQuery": true, "query": "DELETE FROM \\"teleport_products\\""}'
|
|
184
|
+
|
|
185
|
+
const fatalState = makeState({ aiResponses: [bad, bad] })
|
|
186
|
+
const fatal = await loadHandler(fatalState)(BASE_CONFIG, {})
|
|
187
|
+
const optionalState = makeState({ aiResponses: [bad, bad] })
|
|
188
|
+
const optional = await loadHandler(optionalState)({ ...BASE_CONFIG, optional: true }, {})
|
|
189
|
+
|
|
190
|
+
expect(isFatalNodeResult(fatal)).toBe(true)
|
|
191
|
+
expect(isFatalNodeResult(optional)).toBe(false)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('registration', () => {
|
|
196
|
+
it('is a server node so its config (schemas, allowlist) is redacted from client bundles', () => {
|
|
197
|
+
expect(aiSelectDatabaseData.executionEnv).toBe('server')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('telling a capped result apart from a complete one', () => {
|
|
202
|
+
it('flags a full page of rows as truncated', async () => {
|
|
203
|
+
// The statement is capped, so a result that fills the cap is one page of a
|
|
204
|
+
// larger answer. Reporting its length as a total is what made the chat say
|
|
205
|
+
// "there are 50 products" for a catalogue of 220.
|
|
206
|
+
const rows = Array.from({ length: 50 }, (_, index) => ({ id: String(index) }))
|
|
207
|
+
const state = makeState({ fetchResponse: { ok: true, json: { rows } } })
|
|
208
|
+
const handler = loadHandler(state)
|
|
209
|
+
|
|
210
|
+
const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
|
|
211
|
+
truncated: boolean
|
|
212
|
+
rowCount: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
expect(result.truncated).toBe(true)
|
|
216
|
+
expect(result.rowCount).toBe(50)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not flag an aggregate answer as truncated', async () => {
|
|
220
|
+
const state = makeState({
|
|
221
|
+
aiResponses: [
|
|
222
|
+
'{"needsQuery": true, "query": "SELECT COUNT(*) AS \\"total_count\\" FROM \\"teleport_products\\""}',
|
|
223
|
+
],
|
|
224
|
+
fetchResponse: { ok: true, json: { rows: [{ total_count: 223 }] } },
|
|
225
|
+
})
|
|
226
|
+
const handler = loadHandler(state)
|
|
227
|
+
|
|
228
|
+
const result = (await handler({ ...BASE_CONFIG, maxRows: 50 }, {})) as {
|
|
229
|
+
truncated: boolean
|
|
230
|
+
rows: unknown[]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// One row carrying the real total — the only shape that can state one.
|
|
234
|
+
expect(result.truncated).toBe(false)
|
|
235
|
+
expect(result.rows).toEqual([{ total_count: 223 }])
|
|
236
|
+
})
|
|
237
|
+
})
|
|
@@ -30,9 +30,14 @@ const AI_PROMPT = 'Summarize this SENSITIVE_SERVER_PROMPT'
|
|
|
30
30
|
const AI_MODEL = 'gpt-4o-secret-model'
|
|
31
31
|
const CLIENT_MARKER = 'CLIENT_VISIBLE_VALUE'
|
|
32
32
|
|
|
33
|
+
const AISQL_ALLOWED_TABLE = 'secret_allowed_table'
|
|
34
|
+
const AISQL_SCHEMA_COLUMN = 'sensitive_schema_column'
|
|
35
|
+
const AISQL_PROMPT = 'SENSITIVE_AISQL_PROMPT'
|
|
36
|
+
|
|
33
37
|
const TRIGGER_ID = 'trigger-1'
|
|
34
38
|
const DATA_ID = 'data-1'
|
|
35
39
|
const AI_ID = 'ai-1'
|
|
40
|
+
const AISQL_ID = 'aisql-1'
|
|
36
41
|
const CLIENT_ID = 'client-1'
|
|
37
42
|
|
|
38
43
|
const buildWorkflow = (): any => ({
|
|
@@ -69,19 +74,40 @@ const buildWorkflow = (): any => ({
|
|
|
69
74
|
stepNumber: 2,
|
|
70
75
|
label: 'Summarize',
|
|
71
76
|
},
|
|
77
|
+
{
|
|
78
|
+
id: AISQL_ID,
|
|
79
|
+
type: 'ai-select-database-data',
|
|
80
|
+
config: {
|
|
81
|
+
dataSourceId: DATA_SOURCE_ID,
|
|
82
|
+
allowedTables: [AISQL_ALLOWED_TABLE],
|
|
83
|
+
prompt: AISQL_PROMPT,
|
|
84
|
+
model: AI_MODEL,
|
|
85
|
+
token: 'sk-secret',
|
|
86
|
+
tableSchemas: [
|
|
87
|
+
{
|
|
88
|
+
table: AISQL_ALLOWED_TABLE,
|
|
89
|
+
columns: [{ name: AISQL_SCHEMA_COLUMN, type: 'text' }],
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
executionEnv: 'server',
|
|
94
|
+
stepNumber: 3,
|
|
95
|
+
label: 'AI query',
|
|
96
|
+
},
|
|
72
97
|
{
|
|
73
98
|
id: CLIENT_ID,
|
|
74
99
|
type: 'state-update-local-state',
|
|
75
100
|
config: { property: 'summary', value: CLIENT_MARKER },
|
|
76
101
|
executionEnv: 'client',
|
|
77
|
-
stepNumber:
|
|
102
|
+
stepNumber: 4,
|
|
78
103
|
label: 'Write summary',
|
|
79
104
|
},
|
|
80
105
|
],
|
|
81
106
|
edges: [
|
|
82
107
|
{ id: 'e1', source: TRIGGER_ID, target: DATA_ID },
|
|
83
108
|
{ id: 'e2', source: DATA_ID, target: AI_ID },
|
|
84
|
-
{ id: 'e3', source: AI_ID, target:
|
|
109
|
+
{ id: 'e3', source: AI_ID, target: AISQL_ID },
|
|
110
|
+
{ id: 'e4', source: AISQL_ID, target: CLIENT_ID },
|
|
85
111
|
],
|
|
86
112
|
})
|
|
87
113
|
|
|
@@ -153,6 +179,12 @@ describe('client bundle redacts server node config', () => {
|
|
|
153
179
|
// AI server node's prompt/model are server-only too.
|
|
154
180
|
expect(moduleCode).not.toContain(AI_PROMPT)
|
|
155
181
|
expect(moduleCode).not.toContain(AI_MODEL)
|
|
182
|
+
// ai-select-database-data bakes the table allowlist + schemas into its
|
|
183
|
+
// config; none of it may reach the browser.
|
|
184
|
+
expect(moduleCode).not.toContain(AISQL_ALLOWED_TABLE)
|
|
185
|
+
expect(moduleCode).not.toContain(AISQL_SCHEMA_COLUMN)
|
|
186
|
+
expect(moduleCode).not.toContain('tableSchemas')
|
|
187
|
+
expect(moduleCode).not.toContain(AISQL_PROMPT)
|
|
156
188
|
})
|
|
157
189
|
|
|
158
190
|
it('preserves the AI streaming flag so streaming detection still works', () => {
|