@teleporthq/teleport-plugin-next-workflows 0.43.32 → 0.43.34

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 (90) hide show
  1. package/__tests__/client-config-redaction.test.ts +192 -0
  2. package/__tests__/data-api-boolean-checkbox-coercion.test.ts +143 -0
  3. package/__tests__/data-api-safe-query.test.ts +3 -1
  4. package/__tests__/data-update-item-runtime.test.ts +116 -0
  5. package/__tests__/raw-query-param-binding.test.ts +216 -0
  6. package/__tests__/runtime-template-token-resolution.test.ts +38 -0
  7. package/dist/cjs/data-api-route-generator.d.ts.map +1 -1
  8. package/dist/cjs/data-api-route-generator.js +92 -6
  9. package/dist/cjs/data-api-route-generator.js.map +1 -1
  10. package/dist/cjs/executor-generator.d.ts.map +1 -1
  11. package/dist/cjs/executor-generator.js +31 -0
  12. package/dist/cjs/executor-generator.js.map +1 -1
  13. package/dist/cjs/nodes/data/data-delete-item.d.ts.map +1 -1
  14. package/dist/cjs/nodes/data/data-delete-item.js +15 -4
  15. package/dist/cjs/nodes/data/data-delete-item.js.map +1 -1
  16. package/dist/cjs/nodes/data/data-raw-query.d.ts.map +1 -1
  17. package/dist/cjs/nodes/data/data-raw-query.js +7 -1
  18. package/dist/cjs/nodes/data/data-raw-query.js.map +1 -1
  19. package/dist/cjs/nodes/data/data-select.d.ts.map +1 -1
  20. package/dist/cjs/nodes/data/data-select.js +51 -3
  21. package/dist/cjs/nodes/data/data-select.js.map +1 -1
  22. package/dist/cjs/nodes/data/data-update-item.d.ts.map +1 -1
  23. package/dist/cjs/nodes/data/data-update-item.js +58 -5
  24. package/dist/cjs/nodes/data/data-update-item.js.map +1 -1
  25. package/dist/cjs/nodes/general/general-extract-form-data.d.ts.map +1 -1
  26. package/dist/cjs/nodes/general/general-extract-form-data.js +37 -1
  27. package/dist/cjs/nodes/general/general-extract-form-data.js.map +1 -1
  28. package/dist/cjs/segment-splitter.d.ts +26 -2
  29. package/dist/cjs/segment-splitter.d.ts.map +1 -1
  30. package/dist/cjs/segment-splitter.js +45 -1
  31. package/dist/cjs/segment-splitter.js.map +1 -1
  32. package/dist/cjs/sql-query-validator.d.ts +40 -0
  33. package/dist/cjs/sql-query-validator.d.ts.map +1 -1
  34. package/dist/cjs/sql-query-validator.js +49 -1
  35. package/dist/cjs/sql-query-validator.js.map +1 -1
  36. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  37. package/dist/cjs/workflow-component-plugin.d.ts.map +1 -1
  38. package/dist/cjs/workflow-component-plugin.js +22 -8
  39. package/dist/cjs/workflow-component-plugin.js.map +1 -1
  40. package/dist/cjs/workflow-project-plugin.d.ts.map +1 -1
  41. package/dist/cjs/workflow-project-plugin.js +8 -2
  42. package/dist/cjs/workflow-project-plugin.js.map +1 -1
  43. package/dist/esm/data-api-route-generator.d.ts.map +1 -1
  44. package/dist/esm/data-api-route-generator.js +92 -6
  45. package/dist/esm/data-api-route-generator.js.map +1 -1
  46. package/dist/esm/executor-generator.d.ts.map +1 -1
  47. package/dist/esm/executor-generator.js +31 -0
  48. package/dist/esm/executor-generator.js.map +1 -1
  49. package/dist/esm/nodes/data/data-delete-item.d.ts.map +1 -1
  50. package/dist/esm/nodes/data/data-delete-item.js +15 -4
  51. package/dist/esm/nodes/data/data-delete-item.js.map +1 -1
  52. package/dist/esm/nodes/data/data-raw-query.d.ts.map +1 -1
  53. package/dist/esm/nodes/data/data-raw-query.js +7 -1
  54. package/dist/esm/nodes/data/data-raw-query.js.map +1 -1
  55. package/dist/esm/nodes/data/data-select.d.ts.map +1 -1
  56. package/dist/esm/nodes/data/data-select.js +51 -3
  57. package/dist/esm/nodes/data/data-select.js.map +1 -1
  58. package/dist/esm/nodes/data/data-update-item.d.ts.map +1 -1
  59. package/dist/esm/nodes/data/data-update-item.js +58 -5
  60. package/dist/esm/nodes/data/data-update-item.js.map +1 -1
  61. package/dist/esm/nodes/general/general-extract-form-data.d.ts.map +1 -1
  62. package/dist/esm/nodes/general/general-extract-form-data.js +37 -1
  63. package/dist/esm/nodes/general/general-extract-form-data.js.map +1 -1
  64. package/dist/esm/segment-splitter.d.ts +26 -2
  65. package/dist/esm/segment-splitter.d.ts.map +1 -1
  66. package/dist/esm/segment-splitter.js +42 -0
  67. package/dist/esm/segment-splitter.js.map +1 -1
  68. package/dist/esm/sql-query-validator.d.ts +40 -0
  69. package/dist/esm/sql-query-validator.d.ts.map +1 -1
  70. package/dist/esm/sql-query-validator.js +47 -0
  71. package/dist/esm/sql-query-validator.js.map +1 -1
  72. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  73. package/dist/esm/workflow-component-plugin.d.ts.map +1 -1
  74. package/dist/esm/workflow-component-plugin.js +23 -9
  75. package/dist/esm/workflow-component-plugin.js.map +1 -1
  76. package/dist/esm/workflow-project-plugin.d.ts.map +1 -1
  77. package/dist/esm/workflow-project-plugin.js +9 -3
  78. package/dist/esm/workflow-project-plugin.js.map +1 -1
  79. package/package.json +4 -4
  80. package/src/data-api-route-generator.ts +92 -6
  81. package/src/executor-generator.ts +31 -0
  82. package/src/nodes/data/data-delete-item.ts +15 -4
  83. package/src/nodes/data/data-raw-query.ts +7 -1
  84. package/src/nodes/data/data-select.ts +48 -3
  85. package/src/nodes/data/data-update-item.ts +62 -5
  86. package/src/nodes/general/general-extract-form-data.ts +37 -1
  87. package/src/segment-splitter.ts +49 -0
  88. package/src/sql-query-validator.ts +64 -0
  89. package/src/workflow-component-plugin.ts +23 -3
  90. package/src/workflow-project-plugin.ts +18 -3
@@ -176,9 +176,26 @@ async function safeQuery(client, sql, params, mode) {
176
176
  }
177
177
  }
178
178
 
179
- function buildWhereClause(filters, queryParams, startIndex) {
179
+ function isSkippableFilterValue(value) {
180
+ if (value === undefined || value === null) return true;
181
+ if (typeof value === 'string' && value.trim() === '') return true;
182
+ if (Array.isArray(value) && value.length === 0) return true;
183
+ return false;
184
+ }
185
+
186
+ function isAllSentinelFilterValue(value, filter) {
187
+ if (typeof value !== 'string') return false;
188
+ var normalized = value.trim().toLowerCase();
189
+ if (filter && typeof filter.treatAsAll === 'string' && filter.treatAsAll.trim().toLowerCase() === normalized) {
190
+ return true;
191
+ }
192
+ return normalized === 'all' || normalized === 'any' || normalized === 'everything' || normalized === '__all__';
193
+ }
194
+
195
+ function buildWhereClause(filters, queryParams, startIndex, options) {
180
196
  var conditions = [];
181
197
  var paramIndex = startIndex;
198
+ var skipOptionalEmpty = !!(options && options.skipOptionalEmpty);
182
199
 
183
200
  if (!filters || !Array.isArray(filters) || filters.length === 0) {
184
201
  return { clause: '', paramIndex: paramIndex };
@@ -194,6 +211,7 @@ function buildWhereClause(filters, queryParams, startIndex) {
194
211
  var operand = f.operand || f.operator || '=';
195
212
 
196
213
  if (value === undefined) continue;
214
+ if (skipOptionalEmpty && (isSkippableFilterValue(value) || isAllSentinelFilterValue(value, f))) continue;
197
215
 
198
216
  if (Array.isArray(value)) {
199
217
  if (value.length === 0) continue;
@@ -211,6 +229,37 @@ function buildWhereClause(filters, queryParams, startIndex) {
211
229
  conditions.push(field + ' IS NOT NULL');
212
230
  }
213
231
  } else {
232
+ var normalizedOperand = String(operand || '=').toLowerCase();
233
+ if (normalizedOperand === 'contains' || normalizedOperand === 'not_contains' || normalizedOperand === 'not-contains') {
234
+ if (isSkippableFilterValue(value)) {
235
+ conditions.push('FALSE');
236
+ continue;
237
+ }
238
+ conditions.push(field + (normalizedOperand === 'contains' ? ' ILIKE ' : ' NOT ILIKE ') + '$' + paramIndex);
239
+ queryParams.push('%' + String(value) + '%');
240
+ paramIndex++;
241
+ continue;
242
+ }
243
+ if (normalizedOperand === 'startswith' || normalizedOperand === 'starts_with' || normalizedOperand === 'starts-with') {
244
+ if (isSkippableFilterValue(value)) {
245
+ conditions.push('FALSE');
246
+ continue;
247
+ }
248
+ conditions.push(field + ' ILIKE $' + paramIndex);
249
+ queryParams.push(String(value) + '%');
250
+ paramIndex++;
251
+ continue;
252
+ }
253
+ if (normalizedOperand === 'endswith' || normalizedOperand === 'ends_with' || normalizedOperand === 'ends-with') {
254
+ if (isSkippableFilterValue(value)) {
255
+ conditions.push('FALSE');
256
+ continue;
257
+ }
258
+ conditions.push(field + ' ILIKE $' + paramIndex);
259
+ queryParams.push('%' + String(value));
260
+ paramIndex++;
261
+ continue;
262
+ }
214
263
  var validOps = ['=', '!=', '>', '<', '>=', '<=', 'LIKE', 'ILIKE'];
215
264
  var sqlOp = validOps.indexOf(operand) !== -1 ? operand : '=';
216
265
  conditions.push(field + ' ' + sqlOp + ' $' + paramIndex);
@@ -253,7 +302,7 @@ async function handleSelect(client, body) {
253
302
  var cols = selectedColumns.length > 0 ? selectedColumns.join(', ') : '*';
254
303
  var sql = 'SELECT ' + cols + ' FROM ' + tableName;
255
304
 
256
- var where = buildWhereClause(filters, queryParams, 1);
305
+ var where = buildWhereClause(filters, queryParams, 1, { skipOptionalEmpty: true });
257
306
  sql += where.clause;
258
307
 
259
308
  if (sorts.length > 0) {
@@ -278,7 +327,11 @@ async function handleSelect(client, body) {
278
327
  // Validate the raw user-provided query for forbidden operations
279
328
  assertQuerySafe(rawQueryUserPart);
280
329
  sql = rawQueryUserPart;
281
- queryParams = [];
330
+ // SECURITY: the raw override fully replaces the assembled SELECT, so bind the
331
+ // security net's $N params for it, never inline text. rawQueryUserPartParams
332
+ // carries the workflow values (already resolved by the executor) that back the
333
+ // $1, $2, … placeholders the net emitted into rawQueryUserPart.
334
+ queryParams = Array.isArray(body.rawQueryUserPartParams) ? body.rawQueryUserPartParams : [];
282
335
  }
283
336
 
284
337
  // Final validation of the assembled query
@@ -289,7 +342,7 @@ async function handleSelect(client, body) {
289
342
 
290
343
  var countSql = 'SELECT COUNT(*) FROM ' + tableName;
291
344
  var countParams = [];
292
- var countWhere = buildWhereClause(filters, countParams, 1);
345
+ var countWhere = buildWhereClause(filters, countParams, 1, { skipOptionalEmpty: true });
293
346
  countSql += countWhere.clause;
294
347
  var countResult = await safeQuery(client, countSql, countParams, 'count');
295
348
  var count = parseInt(countResult.rows[0].count, 10);
@@ -306,7 +359,7 @@ async function handleCount(client, body) {
306
359
 
307
360
  var queryParams = [];
308
361
  var sql = 'SELECT COUNT(*) FROM ' + tableName;
309
- var where = buildWhereClause(filters, queryParams, 1);
362
+ var where = buildWhereClause(filters, queryParams, 1, { skipOptionalEmpty: true });
310
363
  sql += where.clause;
311
364
 
312
365
  var result = await safeQuery(client, sql, queryParams, 'count');
@@ -340,8 +393,31 @@ function splitCommaOrPgArrayString(val) {
340
393
  return s.split(',').map(function(x) { return x.trim(); }).filter(Boolean);
341
394
  }
342
395
 
396
+ // HTML checkbox convention: a checked box submits a truthy string value
397
+ // ('on' by default, or the input's own value attribute); an UNCHECKED box
398
+ // is omitted from FormData entirely, which general-extract-form-data (the
399
+ // client-side node that builds the workflow payload) represents as '' /
400
+ // undefined - never false. Without this branch that '' fell through to
401
+ // the generic empty-string-to-null guard below and was written as SQL
402
+ // NULL instead of false: a silent "not featured" -> NULL data corruption
403
+ // in the common case (breaks any WHERE is_featured = true/false filter,
404
+ // since NULL fails three-valued-logic equality), and a hard 23502
405
+ // not-null-violation whenever the boolean column itself is NOT NULL - the
406
+ // exact "update failed" error reported for an unchecked "feature this item"
407
+ // checkbox. "Not submitted" always means unchecked/false for an HTML
408
+ // checkbox, so this mapping is unambiguous and safe for every boolean
409
+ // column, not just one page's flag.
410
+ var BOOLEAN_TRUE_STRINGS = { 'on': 1, 'true': 1, '1': 1 };
411
+
343
412
  function coerceValueForPgColumn(col, val, colTypes) {
344
413
  var dt = colTypes[col];
414
+ if (dt === 'boolean') {
415
+ if (typeof val === 'boolean') return val;
416
+ if (val === undefined || val === null) return false;
417
+ if (typeof val === 'number') return val !== 0;
418
+ if (typeof val === 'string') return !!BOOLEAN_TRUE_STRINGS[val.trim().toLowerCase()];
419
+ return false;
420
+ }
345
421
  if (dt !== 'ARRAY') return val;
346
422
  if (val == null) return val;
347
423
  if (Array.isArray(val)) return val;
@@ -670,6 +746,16 @@ async function handleRawQuery(client, body, req) {
670
746
  return { rows: [], error: 'No query provided' };
671
747
  }
672
748
 
749
+ // SECURITY: bind the workflow's context/state values as POSITIONAL parameters
750
+ // ($1, $2, …) instead of interpolating them into the SQL text. The generation
751
+ // net rewrites every {{state.X}}/{{Current User.id}}/… value interpolation into
752
+ // a $N placeholder and moves the {{…}} token into this sibling params array; the
753
+ // executor resolves those tokens to concrete values before POSTing, so here they
754
+ // arrive as plain bound values. A bound value can never terminate a string literal
755
+ // or alter query structure — SQL injection is impossible for the value class.
756
+ // Legacy raw queries carry no params array and run unparameterized (backward-compat).
757
+ var params = Array.isArray(body.params) ? body.params : [];
758
+
673
759
  // Validate the raw query for forbidden operations before execution
674
760
  assertQuerySafe(query);
675
761
 
@@ -677,7 +763,7 @@ async function handleRawQuery(client, body, req) {
677
763
  await ensurePgVector(client);
678
764
  }
679
765
 
680
- var result = await safeQuery(client, query, [], 'raw-query');
766
+ var result = await safeQuery(client, query, params, 'raw-query');
681
767
  var rows = Array.isArray(result.rows) ? result.rows : [];
682
768
 
683
769
  // Auto-fire the low-stock alert when the workflow runs the post-
@@ -294,6 +294,37 @@ function finalizeResolvedConfig(nodeType, config) {
294
294
  }
295
295
  }
296
296
  walk(config, false);
297
+
298
+ // BACKSTOP: a data-node FILTER value that STILL holds a literal {{…}} token
299
+ // after resolution is an unresolvable template — a JS expression, an embedded
300
+ // token, or an unknown root the runtime never substitutes (run d9a24741:
301
+ // "{{state.filterDateEnd || '2099-12-31'}} 23:59:59" and
302
+ // "{{Current Page Entity.guest_id}}"). Left alone it reaches SQL as literal
303
+ // text and breaks the query. For a READ (data-select / data-count) DROP the
304
+ // clause — the optional-filter idiom degrades to "no filter" (more rows, never
305
+ // a crash). For a WRITE the filter scopes the mutation, so an unscoped/partial
306
+ // filter is unsafe → surface a validation error (same posture as the
307
+ // route-param sentinel above).
308
+ if (isDataNode && config && Array.isArray(config.filters)) {
309
+ var isReadNode = nodeType === 'data-select' || nodeType === 'data-count';
310
+ var keptFilters = [];
311
+ for (var fi = 0; fi < config.filters.length; fi++) {
312
+ var filt = config.filters[fi];
313
+ var fval = filt && typeof filt === 'object'
314
+ ? (filt.value !== undefined ? filt.value : filt.destination)
315
+ : undefined;
316
+ if (typeof fval === 'string' && /\\{\\{[\\s\\S]*?\\}\\}/.test(fval)) {
317
+ if (!isReadNode && !error) {
318
+ var fcol = (filt && (filt.column || filt.field || filt.source)) || 'filter';
319
+ error = 'Filter on "' + fcol + '" carries an unresolved template ({{…}}) and cannot safely scope this write';
320
+ }
321
+ continue; // drop the unresolvable clause
322
+ }
323
+ keptFilters.push(filt);
324
+ }
325
+ config.filters = keptFilters;
326
+ }
327
+
297
328
  return error;
298
329
  }
299
330
 
@@ -29,7 +29,13 @@ async function data_delete_item(config: any, context: any) {
29
29
  '" needs a page route param not available here; nothing deleted (workflow continues)'
30
30
  )
31
31
  }
32
- return { deletedId: null, success: true, deletedCount: 0, __skippedUnavailableFilter: true }
32
+ return {
33
+ deletedId: null,
34
+ success: true,
35
+ deletedCount: 0,
36
+ affected: 0,
37
+ __skippedUnavailableFilter: true,
38
+ }
33
39
  }
34
40
  }
35
41
 
@@ -52,16 +58,21 @@ async function data_delete_item(config: any, context: any) {
52
58
  deletedId: null,
53
59
  success: false,
54
60
  deletedCount: 0,
61
+ affected: 0,
55
62
  error: data.error || 'Delete item failed',
56
63
  }
57
64
  }
65
+ // `affected` mirrors `deletedCount` so a success gate authored against either
66
+ // synonym resolves (the AI schema historically advertised `affected`).
67
+ const __deleted = data.deletedCount || 0
58
68
  return {
59
69
  deletedId: data.deletedId || null,
60
- success: (data.deletedCount || 0) > 0,
61
- deletedCount: data.deletedCount || 0,
70
+ success: __deleted > 0,
71
+ deletedCount: __deleted,
72
+ affected: __deleted,
62
73
  }
63
74
  } catch (err: unknown) {
64
- return { deletedId: null, success: false, error: (err as Error).message }
75
+ return { deletedId: null, success: false, affected: 0, error: (err as Error).message }
65
76
  }
66
77
  }
67
78
  export const dataDeleteItem: NodeHandlerGenerator = {
@@ -3,13 +3,19 @@ import { NodeHandlerGenerator, handlerToString } from '../types'
3
3
  async function data_raw_query(config: any, context: any) {
4
4
  const dataSourceId = config.dataSourceId
5
5
  const query = config.query
6
+ // SECURITY: values that back the query's $N placeholders ride here as bound
7
+ // params, never interpolated into the SQL text. The generation net stored each
8
+ // as a {{…}} token; resolveConfig has already substituted them to concrete
9
+ // values by the time this handler runs. Empty/absent when the query is a static
10
+ // literal (backward-compat: the API route then runs it unparameterized).
11
+ const params = Array.isArray(config.params) ? config.params : []
6
12
  const baseUrl = (context && context.__baseUrl) || ''
7
13
 
8
14
  try {
9
15
  const response = await fetch(baseUrl + '/api/data/' + dataSourceId + '/raw-query', {
10
16
  method: 'POST',
11
17
  headers: { 'Content-Type': 'application/json' },
12
- body: JSON.stringify({ query }),
18
+ body: JSON.stringify({ query, params }),
13
19
  })
14
20
 
15
21
  const data = await response.json()
@@ -9,8 +9,50 @@ async function data_select(config: any, context: any) {
9
9
  const limit = config.limit
10
10
  const skip = config.skip
11
11
  const rawQueryUserPart = config.rawQueryUserPart
12
+ // SECURITY: bound values that back the raw override's $N placeholders. The
13
+ // generation net emitted them alongside rawQueryUserPart; resolveConfig has
14
+ // already substituted the {{…}} tokens to concrete values here.
15
+ const rawQueryUserPartParams = Array.isArray(config.rawQueryUserPartParams)
16
+ ? config.rawQueryUserPartParams
17
+ : []
12
18
  const baseUrl = (context && context.__baseUrl) || ''
13
19
 
20
+ function isEmptyFilterValue(value: any) {
21
+ if (value === undefined || value === null) return true
22
+ if (typeof value === 'string' && value.trim() === '') return true
23
+ if (Array.isArray(value) && value.length === 0) return true
24
+ return false
25
+ }
26
+
27
+ function isAllSentinel(value: any, filter: any) {
28
+ if (typeof value !== 'string') return false
29
+ const normalized = value.trim().toLowerCase()
30
+ const configured = filter && filter.treatAsAll
31
+ if (typeof configured === 'string' && configured.trim().toLowerCase() === normalized) {
32
+ return true
33
+ }
34
+ return (
35
+ normalized === 'all' ||
36
+ normalized === 'any' ||
37
+ normalized === 'everything' ||
38
+ normalized === '__all__'
39
+ )
40
+ }
41
+
42
+ function shouldSkipOptionalFilter(filter: any) {
43
+ if (!filter) return true
44
+ const value = filter.destination !== undefined ? filter.destination : filter.value
45
+ if (isEmptyFilterValue(value)) return true
46
+ if (isAllSentinel(value, filter)) return true
47
+ if (Array.isArray(filter.skipIfValue) && filter.skipIfValue.indexOf(value) !== -1) return true
48
+ if (filter.skipIfEmpty === true && isEmptyFilterValue(value)) return true
49
+ return false
50
+ }
51
+
52
+ const effectiveFilters = filters.filter(function (filter: any) {
53
+ return !shouldSkipOptionalFilter(filter)
54
+ })
55
+
14
56
  // A filter whose value is the unresolved route-param sentinel (see
15
57
  // resolveTemplateTokenString in runtime-utils) — e.g. a select scoped to
16
58
  // {{Current Page Entity.id}} that runs on a page with no such route param
@@ -19,8 +61,8 @@ async function data_select(config: any, context: any) {
19
61
  // WHOLE workflow (a single unresolvable filter must never kill the note-create
20
62
  // submit). Never fall through to an unscoped query. Observable via the warn +
21
63
  // the __skippedUnavailableFilter marker.
22
- for (let __fi = 0; __fi < filters.length; __fi++) {
23
- const __f: any = filters[__fi]
64
+ for (let __fi = 0; __fi < effectiveFilters.length; __fi++) {
65
+ const __f: any = effectiveFilters[__fi]
24
66
  if (
25
67
  __f &&
26
68
  (__f.value === '__TQ_UNRESOLVED_ROUTE_PARAM__' ||
@@ -39,7 +81,7 @@ async function data_select(config: any, context: any) {
39
81
  }
40
82
 
41
83
  try {
42
- const payload: any = { tableName, filters, sorts, selectedColumns }
84
+ const payload: any = { tableName, filters: effectiveFilters, sorts, selectedColumns }
43
85
  if (limit !== undefined && limit !== null) {
44
86
  payload.limit = limit
45
87
  }
@@ -48,6 +90,9 @@ async function data_select(config: any, context: any) {
48
90
  }
49
91
  if (rawQueryUserPart) {
50
92
  payload.rawQueryUserPart = rawQueryUserPart
93
+ if (rawQueryUserPartParams.length > 0) {
94
+ payload.rawQueryUserPartParams = rawQueryUserPartParams
95
+ }
51
96
  }
52
97
 
53
98
  const response = await fetch(baseUrl + '/api/data/' + dataSourceId + '/select', {
@@ -30,7 +30,13 @@ async function data_update_item(config: any, context: any) {
30
30
  '" needs a page route param not available here; no rows updated (workflow continues)'
31
31
  )
32
32
  }
33
- return { id: null, updatedCount: 0, success: true, __skippedUnavailableFilter: true }
33
+ return {
34
+ id: null,
35
+ updatedCount: 0,
36
+ affected: 0,
37
+ success: true,
38
+ __skippedUnavailableFilter: true,
39
+ }
34
40
  }
35
41
  }
36
42
  if (Array.isArray(columnMappings)) {
@@ -49,6 +55,43 @@ async function data_update_item(config: any, context: any) {
49
55
  }
50
56
  }
51
57
 
58
+ // A columnMapping whose resolved value is `undefined` means its binding never
59
+ // produced a value: an unread page-entity column, or (most commonly) an empty
60
+ // form field that general-extract-form-data omits by default. Sending it would
61
+ // write NULL and break a NOT NULL column — run d9a24741 blanked
62
+ // guests.full_name from an un-prefilled guest_name input. OMIT such mappings so
63
+ // the column keeps its stored value. (An intentional null — e.g. a route-param
64
+ // sentinel degraded above — is preserved: only `undefined` is dropped.)
65
+ let effectiveColumnMappings: any = columnMappings
66
+ if (Array.isArray(columnMappings)) {
67
+ effectiveColumnMappings = columnMappings.filter(function (__m: any) {
68
+ return !(__m && typeof __m === 'object' && __m.value === undefined)
69
+ })
70
+ } else if (columnMappings && typeof columnMappings === 'object') {
71
+ effectiveColumnMappings = {}
72
+ const __ck = Object.keys(columnMappings)
73
+ for (let __ci = 0; __ci < __ck.length; __ci++) {
74
+ if ((columnMappings as any)[__ck[__ci]] !== undefined) {
75
+ ;(effectiveColumnMappings as any)[__ck[__ci]] = (columnMappings as any)[__ck[__ci]]
76
+ }
77
+ }
78
+ }
79
+ const __hasWrites = Array.isArray(effectiveColumnMappings)
80
+ ? effectiveColumnMappings.length > 0
81
+ : !!effectiveColumnMappings && Object.keys(effectiveColumnMappings).length > 0
82
+ if (!__hasWrites) {
83
+ // Nothing resolvable to write — never send an empty UPDATE (the DB would
84
+ // reject it / touch nothing). Report a clean zero-row no-op so a downstream
85
+ // updatedCount/affected gate takes its "no change" branch.
86
+ return {
87
+ id: null,
88
+ updatedCount: 0,
89
+ affected: 0,
90
+ success: true,
91
+ __skippedEmptyUpdate: true,
92
+ }
93
+ }
94
+
52
95
  // Same anonymous-user hint as data-create-item — a workflow that
53
96
  // updates a row written by a guest must be able to re-stamp its
54
97
  // ownership column with the same anon UUID rather than NULL.
@@ -72,7 +115,12 @@ async function data_update_item(config: any, context: any) {
72
115
  }
73
116
 
74
117
  try {
75
- const reqBody: any = { tableName, filters, columnMappings, returnUpdated: true }
118
+ const reqBody: any = {
119
+ tableName,
120
+ filters,
121
+ columnMappings: effectiveColumnMappings,
122
+ returnUpdated: true,
123
+ }
76
124
  if (__anonymousUserId) {
77
125
  reqBody.__anonymousUserId = __anonymousUserId
78
126
  }
@@ -90,11 +138,20 @@ async function data_update_item(config: any, context: any) {
90
138
  const data = await response.json()
91
139
 
92
140
  if (!response.ok) {
93
- return { id: null, updatedCount: 0, error: data.error || 'Update item failed' }
141
+ return { id: null, updatedCount: 0, affected: 0, error: data.error || 'Update item failed' }
142
+ }
143
+ // `affected` mirrors `updatedCount` — a success gate authored against either
144
+ // synonym (the AI schema historically advertised `affected`) must resolve.
145
+ // Placed after the row spread so it can't be shadowed by a table column.
146
+ const __updated = data.updatedCount || 0
147
+ return {
148
+ id: data.id || null,
149
+ ...(data.item || {}),
150
+ updatedCount: __updated,
151
+ affected: __updated,
94
152
  }
95
- return { id: data.id || null, ...(data.item || {}), updatedCount: data.updatedCount || 0 }
96
153
  } catch (err: unknown) {
97
- return { id: null, updatedCount: 0, error: (err as Error).message }
154
+ return { id: null, updatedCount: 0, affected: 0, error: (err as Error).message }
98
155
  }
99
156
  }
100
157
  export const dataUpdateItem: NodeHandlerGenerator = {
@@ -97,11 +97,47 @@ async function general_extract_form_data(config: any, context: Record<string, un
97
97
  }
98
98
 
99
99
  const fieldKeys = Object.keys(fields)
100
- const result: Record<string, any> = { fields }
100
+ const result: Record<string, any> = { fields, formData: fields }
101
101
  for (let fi = 0; fi < fieldKeys.length; fi++) {
102
102
  result[fieldKeys[fi]] = fields[fieldKeys[fi]]
103
103
  }
104
104
 
105
+ // Workflows generated/imported from HTML often refer to a form control by
106
+ // its DOM id (`class-session-select`) while FormData exposes submitted
107
+ // values by name (`class_session_id`). Preserve the canonical name keys
108
+ // above, and add non-overwriting aliases for id/data-form-field-id so both
109
+ // conventions resolve to the same submitted value.
110
+ try {
111
+ const addAlias = (alias: string | null | undefined, name: string | undefined) => {
112
+ if (!alias || !name) return
113
+ if (fields[name] === undefined) return
114
+ if (result[alias] === undefined) {
115
+ result[alias] = fields[name]
116
+ }
117
+ }
118
+ const controls = (formEl as HTMLFormElement).querySelectorAll(
119
+ 'input[name], select[name], textarea[name], button[name]'
120
+ )
121
+ for (let ci = 0; ci < controls.length; ci++) {
122
+ const control = controls[ci] as HTMLInputElement
123
+ addAlias(control.id, control.name)
124
+ addAlias(control.getAttribute('data-form-field-id'), control.name)
125
+ }
126
+ const outsideControls = document.querySelectorAll(
127
+ 'input[name], select[name], textarea[name], button[name]'
128
+ )
129
+ for (let oi = 0; oi < outsideControls.length; oi++) {
130
+ const control = outsideControls[oi] as HTMLInputElement
131
+ if (formEl.contains(control)) continue
132
+ const ownerForm = control.closest('form')
133
+ if (ownerForm && ownerForm !== formEl) continue
134
+ addAlias(control.id, control.name)
135
+ addAlias(control.getAttribute('data-form-field-id'), control.name)
136
+ }
137
+ } catch (_aliasErr) {
138
+ /* aliases are best-effort; canonical name-based fields remain intact */
139
+ }
140
+
105
141
  // Diagnostic: surface any required inputs whose value is empty at extraction
106
142
  // time so downstream custom-JS validators have something concrete to report
107
143
  // instead of a generic "please fill in all required fields" message.
@@ -279,6 +279,55 @@ const enforceLoopBodyIntegrity = (
279
279
  return { reorderedOrder: working, forcedEnvByNodeId }
280
280
  }
281
281
 
282
+ /**
283
+ * Resolve a node's intrinsic execution env using the exact same rules the
284
+ * segment walk applies, without any surrounding-segment context. Used by the
285
+ * client-config redactor to classify nodes that never land in a segment (e.g.
286
+ * error-handler branch nodes). Unknown/universal nodes resolve to 'client',
287
+ * the conservative default (their config is kept).
288
+ */
289
+ export const resolveNodeExecutionEnv = (node: UIDLWorkflowNode): WorkflowExecutionEnv =>
290
+ resolveExecutionEnv(node, null)
291
+
292
+ // The ONLY field of a server node's config the CLIENT executor ever reads is
293
+ // the AI-streaming flag (findStreamingAINodes / callStreamingServerSegment in
294
+ // executor-generator). Everything else — the raw SQL query, filters, table
295
+ // name, bound params, column mappings, data source id, … — is consumed
296
+ // exclusively inside the server API route (see api-route-generator's
297
+ // SEGMENT_CONFIG, which keeps the full config server-side). None of it may
298
+ // reach the browser bundle.
299
+ export const CLIENT_SAFE_SERVER_CONFIG_KEYS: ReadonlyArray<string> = ['streaming']
300
+
301
+ /**
302
+ * Strip a server node's config down to the client-safe whitelist before it is
303
+ * serialized into any bundle that ships to the browser (the page-level
304
+ * `__wfConfig_*` constants and the shared custom-nodes file).
305
+ *
306
+ * Safety: a server segment executes entirely inside its own API route, and the
307
+ * generated client node-handler file omits every server node type (see
308
+ * NextWorkflowProjectPlugin.generateNodeHandlerFile), so the browser has no
309
+ * handler to execute a server node with — it only needs a server node's
310
+ * { id, type, stepNumber, edges } for branch-skip bookkeeping plus
311
+ * `config.streaming` for streaming detection. Dropping the rest can therefore
312
+ * only remove data the client never acts on. Client and universal node configs
313
+ * are returned untouched.
314
+ */
315
+ export const redactServerNodeConfig = (
316
+ config: Record<string, unknown>,
317
+ env: WorkflowExecutionEnv
318
+ ): Record<string, unknown> => {
319
+ if (env !== 'server' || !config || typeof config !== 'object') {
320
+ return config
321
+ }
322
+ const safe: Record<string, unknown> = {}
323
+ for (const key of CLIENT_SAFE_SERVER_CONFIG_KEYS) {
324
+ if (config[key] !== undefined) {
325
+ safe[key] = config[key]
326
+ }
327
+ }
328
+ return safe
329
+ }
330
+
282
331
  export const getServerSegments = (segments: WorkflowSegment[]): WorkflowSegment[] => {
283
332
  return segments.filter((s) => s.env === 'server')
284
333
  }
@@ -693,6 +693,70 @@ export const validateSQLQuery = (query: string): SQLValidationResult => {
693
693
  }
694
694
  }
695
695
 
696
+ // ---------------------------------------------------------------------------
697
+ // Raw-query parameterization safety
698
+ // ---------------------------------------------------------------------------
699
+
700
+ /**
701
+ * Matches a single `{{ … }}` workflow-context template token anywhere in a
702
+ * string (non-greedy to the first `}}`). A token surviving in the SQL TEXT is
703
+ * the SQL-injection signal — the workflow executor substitutes it TEXTUALLY
704
+ * into the query. Parameterized binds use `$1` / `$2` placeholders instead.
705
+ */
706
+ export const RAW_QUERY_TEMPLATE_TOKEN_RE = /\{\{[\s\S]+?\}\}/
707
+
708
+ export interface RawQuerySafetyWarning {
709
+ /** Stable id, aligned with the design-time security rule catalogue. */
710
+ ruleId: 'sql-unparameterized-context'
711
+ message: string
712
+ }
713
+
714
+ export interface RawQuerySafetyResult {
715
+ /** True when no `{{ … }}` token remains in the SQL text (safe / parameterized). */
716
+ isSafe: boolean
717
+ /** Informational security warnings — empty for a correctly-parameterized query. */
718
+ warnings: RawQuerySafetyWarning[]
719
+ }
720
+
721
+ /**
722
+ * Assess whether a raw-SQL string is safely PARAMETERIZED.
723
+ *
724
+ * SAFE (zero warnings): the SQL text carries only positional `$N` placeholders
725
+ * and every workflow-context/state value lives in the sibling `params`
726
+ * (`rawQueryUserPartParams`) array, bound at runtime via
727
+ * `client.query(sql, params)`. A bound value can never terminate a string
728
+ * literal or alter query structure, so injection is impossible for that class.
729
+ * A `{{ … }}` token appearing in the `params` array is the CORRECT, safe home
730
+ * for it and is intentionally NOT inspected here — only the SQL text is scanned.
731
+ *
732
+ * UNSAFE (one warning): a `{{ … }}` template token still appears in the SQL
733
+ * TEXT. The executor substitutes it textually, so an attacker-controlled value
734
+ * (e.g. a search term containing `'`) can break out and inject SQL. Post-net
735
+ * this should never happen — the generation-time net rewrites every value
736
+ * interpolation into `$N` and fails loud on anything it cannot bind — but the
737
+ * check stays as a runtime/publish-time backstop.
738
+ *
739
+ * @param query - The raw SQL text (e.g. `data-raw-query.query` /
740
+ * `data-select.rawQueryUserPart`).
741
+ */
742
+ export const analyzeRawQueryParameterization = (query: unknown): RawQuerySafetyResult => {
743
+ if (typeof query !== 'string' || !RAW_QUERY_TEMPLATE_TOKEN_RE.test(query)) {
744
+ return { isSafe: true, warnings: [] }
745
+ }
746
+ return {
747
+ isSafe: false,
748
+ warnings: [
749
+ {
750
+ ruleId: 'sql-unparameterized-context',
751
+ message:
752
+ 'Raw SQL interpolates a workflow context value with `{{...}}` directly into the ' +
753
+ 'query text. If that value is influenced by external input it could be used for ' +
754
+ 'SQL injection. Bind the value via the query parameter list (e.g. `$1`) instead.',
755
+ },
756
+ ],
757
+ }
758
+ }
759
+
696
760
  // ---------------------------------------------------------------------------
697
761
  // Context value risk assessment
698
762
  // ---------------------------------------------------------------------------