@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.
Files changed (74) hide show
  1. package/__tests__/ai-chat-rag-sql-parameterization.test.ts +28 -8
  2. package/__tests__/ai-select-database-data-guard.test.ts +218 -0
  3. package/__tests__/ai-select-database-data-handler.test.ts +237 -0
  4. package/__tests__/client-config-redaction.test.ts +34 -2
  5. package/__tests__/parameterized-stock-chain.test.ts +453 -0
  6. package/__tests__/stock-decrement-audit.test.ts +5 -1
  7. package/__tests__/streaming-chunk-payload.test.ts +91 -0
  8. package/__tests__/streaming-on-end-env-filter.test.ts +2 -2
  9. package/dist/cjs/api-route-generator.d.ts.map +1 -1
  10. package/dist/cjs/api-route-generator.js +2 -3
  11. package/dist/cjs/api-route-generator.js.map +1 -1
  12. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  13. package/dist/cjs/data-api-route-generator.js +4 -0
  14. package/dist/cjs/data-api-route-generator.js.map +1 -1
  15. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts +23 -0
  16. package/dist/cjs/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
  17. package/dist/cjs/ecommerce/parameterized-stock-chain.js +397 -0
  18. package/dist/cjs/ecommerce/parameterized-stock-chain.js.map +1 -0
  19. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts +3 -0
  20. package/dist/cjs/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  21. package/dist/cjs/ecommerce-customhandler-rewriter.js +41 -1
  22. package/dist/cjs/ecommerce-customhandler-rewriter.js.map +1 -1
  23. package/dist/cjs/executor-generator.js +1 -1
  24. package/dist/cjs/nodes/ai/ai-provider-utils.js +1 -1
  25. package/dist/cjs/nodes/ai/ai-select-database-data.d.ts +3 -0
  26. package/dist/cjs/nodes/ai/ai-select-database-data.d.ts.map +1 -0
  27. package/dist/cjs/nodes/ai/ai-select-database-data.js +141 -0
  28. package/dist/cjs/nodes/ai/ai-select-database-data.js.map +1 -0
  29. package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts +18 -0
  30. package/dist/cjs/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
  31. package/dist/cjs/nodes/ai/ai-sql-select-guard.js +388 -0
  32. package/dist/cjs/nodes/ai/ai-sql-select-guard.js.map +1 -0
  33. package/dist/cjs/nodes/index.d.ts.map +1 -1
  34. package/dist/cjs/nodes/index.js +2 -1
  35. package/dist/cjs/nodes/index.js.map +1 -1
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  37. package/dist/esm/api-route-generator.d.ts.map +1 -1
  38. package/dist/esm/api-route-generator.js +2 -3
  39. package/dist/esm/api-route-generator.js.map +1 -1
  40. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  41. package/dist/esm/data-api-route-generator.js +4 -0
  42. package/dist/esm/data-api-route-generator.js.map +1 -1
  43. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts +23 -0
  44. package/dist/esm/ecommerce/parameterized-stock-chain.d.ts.map +1 -0
  45. package/dist/esm/ecommerce/parameterized-stock-chain.js +385 -0
  46. package/dist/esm/ecommerce/parameterized-stock-chain.js.map +1 -0
  47. package/dist/esm/ecommerce-customhandler-rewriter.d.ts +3 -0
  48. package/dist/esm/ecommerce-customhandler-rewriter.d.ts.map +1 -1
  49. package/dist/esm/ecommerce-customhandler-rewriter.js +41 -1
  50. package/dist/esm/ecommerce-customhandler-rewriter.js.map +1 -1
  51. package/dist/esm/executor-generator.js +1 -1
  52. package/dist/esm/nodes/ai/ai-provider-utils.js +1 -1
  53. package/dist/esm/nodes/ai/ai-select-database-data.d.ts +3 -0
  54. package/dist/esm/nodes/ai/ai-select-database-data.d.ts.map +1 -0
  55. package/dist/esm/nodes/ai/ai-select-database-data.js +138 -0
  56. package/dist/esm/nodes/ai/ai-select-database-data.js.map +1 -0
  57. package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts +18 -0
  58. package/dist/esm/nodes/ai/ai-sql-select-guard.d.ts.map +1 -0
  59. package/dist/esm/nodes/ai/ai-sql-select-guard.js +384 -0
  60. package/dist/esm/nodes/ai/ai-sql-select-guard.js.map +1 -0
  61. package/dist/esm/nodes/index.d.ts.map +1 -1
  62. package/dist/esm/nodes/index.js +2 -1
  63. package/dist/esm/nodes/index.js.map +1 -1
  64. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  65. package/package.json +4 -4
  66. package/src/api-route-generator.ts +2 -3
  67. package/src/data-api-route-generator.ts +4 -0
  68. package/src/ecommerce/parameterized-stock-chain.ts +449 -0
  69. package/src/ecommerce-customhandler-rewriter.ts +71 -3
  70. package/src/executor-generator.ts +1 -1
  71. package/src/nodes/ai/ai-provider-utils.ts +1 -1
  72. package/src/nodes/ai/ai-select-database-data.ts +162 -0
  73. package/src/nodes/ai/ai-sql-select-guard.ts +402 -0
  74. 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
- 'SELECT d.id, d.content, d.search_content, d.keywords,',
26
- ' 1 - (d.embedding_json::vector <=> q.query_vector) AS similarity',
27
- 'FROM teleport_ai_chat_documents d',
28
- "CROSS JOIN (SELECT NULLIF({{ queryEmbedding }}::text, '')::vector AS query_vector) q",
29
- 'WHERE d.embedding_json IS NOT NULL',
30
- ' AND q.query_vector IS NOT NULL',
31
- 'ORDER BY d.embedding_json::vector <=> q.query_vector ASC',
32
- 'LIMIT 6',
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: 3,
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: CLIENT_ID },
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', () => {