@teleporthq/teleport-plugin-next-data-source 0.43.67 → 0.43.68

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 (202) hide show
  1. package/__tests__/array-overlap-sql.test.ts +53 -0
  2. package/__tests__/blog-post-page-enrichment.test.ts +214 -0
  3. package/__tests__/browser-row-policy.test.ts +503 -0
  4. package/__tests__/data-source-cache-wrapping.test.ts +18 -2
  5. package/__tests__/ecommerce-product-options.test.ts +206 -0
  6. package/__tests__/ecommerce-product-pages.test.ts +82 -0
  7. package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
  8. package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
  9. package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
  10. package/__tests__/fetchers.test.ts +2 -2
  11. package/__tests__/items-path.test.ts +150 -0
  12. package/__tests__/product-options-storefront.fixture.json +2510 -0
  13. package/__tests__/product-page-url-fixtures.json +25 -0
  14. package/__tests__/product-price-sort.ts +4 -2
  15. package/__tests__/raw-rows-mode.test.ts +111 -0
  16. package/__tests__/rest-api-response-cache.test.ts +434 -0
  17. package/__tests__/table-read-route-protected-tables.test.ts +493 -0
  18. package/dist/cjs/array-overlap-sql.d.ts +21 -0
  19. package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
  20. package/dist/cjs/array-overlap-sql.js +25 -0
  21. package/dist/cjs/array-overlap-sql.js.map +1 -0
  22. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  23. package/dist/cjs/data-source-fetchers.js +10 -4
  24. package/dist/cjs/data-source-fetchers.js.map +1 -1
  25. package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/javascript.js +1 -1
  27. package/dist/cjs/fetchers/javascript.js.map +1 -1
  28. package/dist/cjs/fetchers/postgresql.d.ts +1 -1
  29. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  30. package/dist/cjs/fetchers/postgresql.js +8 -4
  31. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  32. package/dist/cjs/fetchers/raw-query.d.ts +8 -1
  33. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  34. package/dist/cjs/fetchers/raw-query.js +42 -4
  35. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  36. package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
  37. package/dist/cjs/fetchers/rest-api.js +7 -2
  38. package/dist/cjs/fetchers/rest-api.js.map +1 -1
  39. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/teleport.js +8 -5
  41. package/dist/cjs/fetchers/teleport.js.map +1 -1
  42. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
  43. package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  44. package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
  45. package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
  46. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
  47. package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  48. package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
  49. package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
  50. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  51. package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  52. package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
  53. package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
  54. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
  55. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
  56. package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
  57. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
  58. package/dist/cjs/items-path.d.ts +10 -0
  59. package/dist/cjs/items-path.d.ts.map +1 -0
  60. package/dist/cjs/items-path.js +86 -0
  61. package/dist/cjs/items-path.js.map +1 -0
  62. package/dist/cjs/localized-columns.d.ts.map +1 -1
  63. package/dist/cjs/localized-columns.js +13 -0
  64. package/dist/cjs/localized-columns.js.map +1 -1
  65. package/dist/cjs/pagination-plugin.d.ts.map +1 -1
  66. package/dist/cjs/pagination-plugin.js +17 -5
  67. package/dist/cjs/pagination-plugin.js.map +1 -1
  68. package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
  69. package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
  70. package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
  71. package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
  72. package/dist/cjs/transformations/blog-comments.d.ts +30 -0
  73. package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
  74. package/dist/cjs/transformations/blog-comments.js +34 -0
  75. package/dist/cjs/transformations/blog-comments.js.map +1 -0
  76. package/dist/cjs/transformations/blog-post.d.ts +2 -0
  77. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  78. package/dist/cjs/transformations/blog-post.js +4 -1
  79. package/dist/cjs/transformations/blog-post.js.map +1 -1
  80. package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
  81. package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
  82. package/dist/cjs/transformations/ecommerce-product.js +28 -1
  83. package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
  84. package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
  85. package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
  86. package/dist/cjs/transformations/heading-anchors.js +29 -0
  87. package/dist/cjs/transformations/heading-anchors.js.map +1 -0
  88. package/dist/cjs/transformations/index.d.ts +47 -4
  89. package/dist/cjs/transformations/index.d.ts.map +1 -1
  90. package/dist/cjs/transformations/index.js +56 -18
  91. package/dist/cjs/transformations/index.js.map +1 -1
  92. package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
  93. package/dist/cjs/transformations/shared-utils.js +1 -1
  94. package/dist/cjs/transformations/shared-utils.js.map +1 -1
  95. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  96. package/dist/cjs/utils.d.ts.map +1 -1
  97. package/dist/cjs/utils.js +1 -1
  98. package/dist/cjs/utils.js.map +1 -1
  99. package/dist/esm/array-overlap-sql.d.ts +21 -0
  100. package/dist/esm/array-overlap-sql.d.ts.map +1 -0
  101. package/dist/esm/array-overlap-sql.js +21 -0
  102. package/dist/esm/array-overlap-sql.js.map +1 -0
  103. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  104. package/dist/esm/data-source-fetchers.js +11 -5
  105. package/dist/esm/data-source-fetchers.js.map +1 -1
  106. package/dist/esm/fetchers/javascript.d.ts.map +1 -1
  107. package/dist/esm/fetchers/javascript.js +1 -1
  108. package/dist/esm/fetchers/javascript.js.map +1 -1
  109. package/dist/esm/fetchers/postgresql.d.ts +1 -1
  110. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  111. package/dist/esm/fetchers/postgresql.js +8 -4
  112. package/dist/esm/fetchers/postgresql.js.map +1 -1
  113. package/dist/esm/fetchers/raw-query.d.ts +8 -1
  114. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  115. package/dist/esm/fetchers/raw-query.js +42 -4
  116. package/dist/esm/fetchers/raw-query.js.map +1 -1
  117. package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
  118. package/dist/esm/fetchers/rest-api.js +7 -2
  119. package/dist/esm/fetchers/rest-api.js.map +1 -1
  120. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  121. package/dist/esm/fetchers/teleport.js +8 -5
  122. package/dist/esm/fetchers/teleport.js.map +1 -1
  123. package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
  124. package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
  125. package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
  126. package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
  127. package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
  128. package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
  129. package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
  130. package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
  131. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
  132. package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
  133. package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
  134. package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
  135. package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
  136. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
  137. package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
  138. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
  139. package/dist/esm/items-path.d.ts +10 -0
  140. package/dist/esm/items-path.d.ts.map +1 -0
  141. package/dist/esm/items-path.js +58 -0
  142. package/dist/esm/items-path.js.map +1 -0
  143. package/dist/esm/localized-columns.d.ts.map +1 -1
  144. package/dist/esm/localized-columns.js +13 -0
  145. package/dist/esm/localized-columns.js.map +1 -1
  146. package/dist/esm/pagination-plugin.d.ts.map +1 -1
  147. package/dist/esm/pagination-plugin.js +17 -5
  148. package/dist/esm/pagination-plugin.js.map +1 -1
  149. package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
  150. package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
  151. package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
  152. package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
  153. package/dist/esm/transformations/blog-comments.d.ts +30 -0
  154. package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
  155. package/dist/esm/transformations/blog-comments.js +30 -0
  156. package/dist/esm/transformations/blog-comments.js.map +1 -0
  157. package/dist/esm/transformations/blog-post.d.ts +2 -0
  158. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  159. package/dist/esm/transformations/blog-post.js +4 -1
  160. package/dist/esm/transformations/blog-post.js.map +1 -1
  161. package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
  162. package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
  163. package/dist/esm/transformations/ecommerce-product.js +29 -2
  164. package/dist/esm/transformations/ecommerce-product.js.map +1 -1
  165. package/dist/esm/transformations/heading-anchors.d.ts +25 -0
  166. package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
  167. package/dist/esm/transformations/heading-anchors.js +25 -0
  168. package/dist/esm/transformations/heading-anchors.js.map +1 -0
  169. package/dist/esm/transformations/index.d.ts +47 -4
  170. package/dist/esm/transformations/index.d.ts.map +1 -1
  171. package/dist/esm/transformations/index.js +56 -18
  172. package/dist/esm/transformations/index.js.map +1 -1
  173. package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
  174. package/dist/esm/transformations/shared-utils.js +1 -1
  175. package/dist/esm/transformations/shared-utils.js.map +1 -1
  176. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  177. package/dist/esm/utils.d.ts.map +1 -1
  178. package/dist/esm/utils.js +1 -1
  179. package/dist/esm/utils.js.map +1 -1
  180. package/package.json +5 -5
  181. package/src/array-overlap-sql.ts +41 -0
  182. package/src/data-source-fetchers.ts +41 -5
  183. package/src/fetchers/javascript.ts +9 -1
  184. package/src/fetchers/postgresql.ts +37 -10
  185. package/src/fetchers/raw-query.ts +60 -5
  186. package/src/fetchers/rest-api.ts +27 -5
  187. package/src/fetchers/teleport.ts +37 -13
  188. package/src/fetchers/utils/browser-row-policy.ts +212 -0
  189. package/src/fetchers/utils/request-sql-guards.ts +25 -0
  190. package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
  191. package/src/fetchers/utils/table-access-guard.ts +39 -0
  192. package/src/items-path.ts +74 -0
  193. package/src/localized-columns.ts +13 -0
  194. package/src/pagination-plugin.ts +29 -7
  195. package/src/transformations/blog-adjacent-posts.ts +74 -0
  196. package/src/transformations/blog-comments.ts +132 -0
  197. package/src/transformations/blog-post.ts +38 -5
  198. package/src/transformations/ecommerce-product.ts +568 -5
  199. package/src/transformations/heading-anchors.ts +85 -0
  200. package/src/transformations/index.ts +165 -22
  201. package/src/transformations/shared-utils.ts +6 -0
  202. package/src/utils.ts +26 -4
@@ -19,6 +19,9 @@ import {
19
19
  import { generateProductFilterClauseHelper } from '../product-filter-fields'
20
20
  import { collectLocalizedColumns, generateLocalizedColumnsHelper } from '../localized-columns'
21
21
  import { REQUEST_LOCALE_PARAM } from '../request-locale'
22
+ import { generateReadGuardCall, generateTableAccessPreamble } from './utils/table-access-guard'
23
+ import { generateRequestSqlGuardsCode } from './utils/request-sql-guards'
24
+ import { generateArrayOverlapSqlCode } from '../array-overlap-sql'
22
25
 
23
26
  interface TeleportDBConfig {
24
27
  host?: string
@@ -104,6 +107,7 @@ export const generateTeleportFetcher = (
104
107
 
105
108
  return `import { Client } from 'pg'
106
109
 
110
+ ${generateTableAccessPreamble(transformOptions.trustedReaderRoles || [])}
107
111
  function normalizePostgresConnectionString(connectionString) {
108
112
  if (!connectionString || typeof connectionString !== 'string') return connectionString;
109
113
  if (/^postgresql:\\/(?!\\/)/i.test(connectionString)) {
@@ -159,8 +163,10 @@ ${generateSafeJSONParseCode()}
159
163
  ${generateFilterTreeHelpersCode()}
160
164
 
161
165
  ${generateSearchEscapeHelpersCode()}
166
+ ${generateArrayOverlapSqlCode()}
162
167
  ${getTransformationCode(tableName, transformOptions)}
163
168
  ${getTransformWrapperCode(tableName, transformOptions)}
169
+ ${generateRequestSqlGuardsCode()}
164
170
  const processFilters = (filters, conditions, queryParams, paramIndex) => {
165
171
  if (!filters) return paramIndex
166
172
 
@@ -190,14 +196,13 @@ const processFilters = (filters, conditions, queryParams, paramIndex) => {
190
196
  return '$' + paramIndex++
191
197
  })
192
198
  if (localizedClause !== null) return localizedClause
199
+ assertRequestColumn(field, 'filter field')
193
200
 
194
201
  if (Array.isArray(value)) {
195
202
  if (value.length === 0) return null
196
203
  if (operand === 'array_overlap') {
197
- // Row column is a JSON array (e.g. category_ids). Match when it
198
- // shares any element with the destination set. jsonb ?| (function
199
- // form) avoids the '?' placeholder-token ambiguity some drivers hit.
200
- const clause = \`jsonb_exists_any(NULLIF(\${field}, '')::jsonb, $\${paramIndex}::text[])\`
204
+ // The row's list (see array-overlap-sql.ts) shares any value with the set.
205
+ const clause = arrayOverlapSql((localizedColumnList(field) || [field]), '$' + paramIndex)
201
206
  queryParams.push(value.map((entry) => String(entry)))
202
207
  paramIndex++
203
208
  return clause
@@ -215,7 +220,7 @@ const processFilters = (filters, conditions, queryParams, paramIndex) => {
215
220
  // ?categoryFilter=a,b,c) expands to multiple ids; one id stays one.
216
221
  const overlapValues = String(value).split(',').map((entry) => entry.trim()).filter(Boolean)
217
222
  if (overlapValues.length === 0) return null
218
- const clause = \`jsonb_exists_any(NULLIF(\${field}, '')::jsonb, $\${paramIndex}::text[])\`
223
+ const clause = arrayOverlapSql((localizedColumnList(field) || [field]), '$' + paramIndex)
219
224
  queryParams.push(overlapValues)
220
225
  paramIndex++
221
226
  return clause
@@ -295,6 +300,7 @@ function assertRawQuerySafe(rawQuery) {
295
300
  }
296
301
 
297
302
  export default async function handler(req, res) {
303
+ ${generateReadGuardCall(tableName, 'req.query && req.query.rawQuery')}
298
304
  const client = getClient()
299
305
 
300
306
  try {
@@ -317,12 +323,16 @@ export default async function handler(req, res) {
317
323
  const rawSafe = JSON.parse(JSON.stringify(rawPlain, dateReplacer))
318
324
  return res.status(200).json({
319
325
  success: true,
320
- data: rawSafe,
326
+ // Every row here ends up in a browser (a page's props or a client
327
+ // refetch), so no credential column survives, whatever joined it.
328
+ data: __taWithoutCredentials(rawSafe, null),
321
329
  timestamp: Date.now()
322
330
  })
323
331
  }
324
332
 
325
333
  const { query, queryColumns, limit, page, perPage, sortBy, sortOrder, filters, sorts, offset } = req.query
334
+ // A visitor never filters, sorts or searches by a column it is not served.
335
+ if (__rowPolicy) __brpAssertVisibleFields(__rowPolicy, req.query)
326
336
  // The language the page is in - what a translatable sort orders by.
327
337
  const requestLocale = typeof req.query.${REQUEST_LOCALE_PARAM} === 'string' ? req.query.${REQUEST_LOCALE_PARAM} : null
328
338
 
@@ -357,6 +367,7 @@ export default async function handler(req, res) {
357
367
  console.warn('Failed to fetch column names from information_schema:', schemaError.message)
358
368
  }
359
369
  }
370
+ if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
360
371
 
361
372
  if (columns.length > 0) {
362
373
  const pattern = '%' + escapeLikePattern(query) + '%'
@@ -371,6 +382,7 @@ export default async function handler(req, res) {
371
382
  }
372
383
 
373
384
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
385
+ if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
374
386
 
375
387
  let sql = \`SELECT * FROM ${tableName}\`
376
388
 
@@ -388,6 +400,7 @@ export default async function handler(req, res) {
388
400
  const parsedSorts = safeJSONParse(sorts)
389
401
  if (Array.isArray(parsedSorts) && parsedSorts.length > 0) {
390
402
  const valid = parsedSorts.filter((sort) => sort && sort.field)
403
+ valid.forEach((sort) => assertRequestColumn(sort.field, 'sort field'))
391
404
  const orderOf = (sort) => (sort.order || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
392
405
  const orderClauses = valid.map((sort) => \`\${localizedSortFieldSql(sort.field, requestLocale) || sortFieldSql(sort.field)} \${orderOf(sort)}\`)
393
406
  const plainClauses = valid.map((sort) => \`\${sortFallbackField(sort.field)} \${orderOf(sort)}\`)
@@ -405,17 +418,18 @@ export default async function handler(req, res) {
405
418
  }
406
419
  }
407
420
  } else if (sortBy) {
421
+ assertRequestColumn(sortBy, 'sort field')
408
422
  const sortByDirection = (sortOrder || '').toUpperCase().startsWith('DESC') ? 'DESC' : 'ASC'
409
423
  orderBySql = \` ORDER BY \${localizedSortFieldSql(sortBy, requestLocale) || sortBy} \${sortByDirection}\`
410
424
  plainOrderBySql = \` ORDER BY \${sortBy} \${sortByDirection}\`
411
425
  }
412
426
  const usedDiscountAwareSort = orderBySql !== plainOrderBySql
413
427
 
414
- const limitValue = limit || perPage
428
+ const limitValue = requestRowCount(limit || perPage)
415
429
  const offsetValue = offset !== undefined ? parseInt(offset) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : undefined)
416
430
 
417
431
  let sqlTail = ''
418
- if (limitValue) {
432
+ if (limitValue !== undefined) {
419
433
  sqlTail += \` LIMIT \${limitValue}\`
420
434
  }
421
435
 
@@ -469,14 +483,20 @@ export default async function handler(req, res) {
469
483
 
470
484
  return res.status(200).json({
471
485
  success: true,
472
- data: __numberedData,
486
+ data: __taWithoutCredentials(
487
+ __rowPolicy ? __brpStripHidden(__rowPolicy, __numberedData) : __numberedData,
488
+ ${JSON.stringify(tableName)}
489
+ ),
473
490
  timestamp: Date.now()
474
491
  })
475
492
  } catch (error) {
476
493
  console.error('Teleport DB fetch error:', error)
477
- return res.status(500).json({
494
+ // A database error names tables, columns and values: it stays in the server
495
+ // log. Only an error this route raised for the caller (it carries a status)
496
+ // is repeated to it.
497
+ return res.status(error.status || 500).json({
478
498
  success: false,
479
- error: error.message || 'Failed to fetch data',
499
+ error: error.status ? error.message : 'Failed to fetch data',
480
500
  timestamp: Date.now()
481
501
  })
482
502
  } finally {
@@ -501,11 +521,13 @@ export const generateTeleportCountFetcher = (
501
521
 
502
522
  return `
503
523
  async function getCount(req, res) {
524
+ ${generateReadGuardCall(tableName, null)}
504
525
  const client = getClient()
505
526
 
506
527
  try {
507
528
  await client.connect()
508
529
  const { query, queryColumns, filters } = req.query
530
+ if (__rowPolicy) __brpAssertVisibleFields(__rowPolicy, req.query)
509
531
  const conditions = []
510
532
  const queryParams = []
511
533
  let paramIndex = 1
@@ -537,6 +559,7 @@ async function getCount(req, res) {
537
559
  console.warn('Failed to fetch column names from information_schema:', schemaError.message)
538
560
  }
539
561
  }
562
+ if (__rowPolicy) columns = __brpVisibleColumns(__rowPolicy, columns)
540
563
 
541
564
  if (columns.length > 0) {
542
565
  const pattern = '%' + escapeLikePattern(query) + '%'
@@ -553,6 +576,7 @@ async function getCount(req, res) {
553
576
  }
554
577
 
555
578
  paramIndex = processFilters(filters, conditions, queryParams, paramIndex)
579
+ if (__rowPolicy) conditions.push('(' + __rowPolicy.predicate + ')')
556
580
 
557
581
  let countSql = \`SELECT COUNT(*) FROM ${tableName}\`
558
582
  if (conditions.length > 0) {
@@ -569,9 +593,9 @@ async function getCount(req, res) {
569
593
  })
570
594
  } catch (error) {
571
595
  console.error('Error getting count:', error)
572
- return res.status(500).json({
596
+ return res.status(error.status || 500).json({
573
597
  success: false,
574
- error: error.message || 'Failed to get count',
598
+ error: error.status ? error.message : 'Failed to get count',
575
599
  timestamp: Date.now()
576
600
  })
577
601
  } finally {
@@ -0,0 +1,212 @@
1
+ /**
2
+ * What a per-table read route (`/api/teleport-<table>-<source>` and its count
3
+ * twin) serves a BROWSER of the tables whose unpublished rows sit beside the
4
+ * public ones. (The tables it serves a browser not at all — the AI assistant's
5
+ * among them — are `TableAccess.PROTECTED_TABLES`, guarded by `__taGuardRead`.)
6
+ *
7
+ * The route answers whoever asks, with the whole table. For the blog that was
8
+ * every draft, scheduled and archived post — and each author's email address;
9
+ * for the catalogue, every draft and inactive product. The storefront's own
10
+ * bindings ask for the public rows, but the route is a URL anyone can call
11
+ * without them. So a caller that is not the store itself (its server code, or a
12
+ * signed-in administrator — the generated admin edits drafts through these
13
+ * routes) is served only the rows the storefront publishes, never a hidden
14
+ * column, and may neither filter, sort nor search by a hidden column: a filter
15
+ * is an oracle for a column it never returns.
16
+ */
17
+
18
+ export interface BrowserRowPolicy {
19
+ /** SQL every browser read of the table is narrowed with. */
20
+ predicate: string
21
+ /** Keys never served to a browser, wherever they sit in a row (raw and transformed names). */
22
+ hiddenColumns: ReadonlyArray<string>
23
+ }
24
+
25
+ export const BROWSER_ROW_POLICIES: Readonly<Record<string, BrowserRowPolicy>> = {
26
+ // ONLY `published` is public — the blog's own rule (see the GUI's
27
+ // `blog-post-status.ts`): drafts, scheduled and archived posts are not.
28
+ teleport_blog_posts: {
29
+ predicate: "status = 'published'",
30
+ hiddenColumns: ['author_email', 'authorEmail'],
31
+ },
32
+ // The storefront sells `active` products; a draft or inactive one is the
33
+ // merchant's, spelled in any case the admin form stored it in.
34
+ teleport_products: {
35
+ predicate: "LOWER(TRIM(status)) = 'active'",
36
+ hiddenColumns: [],
37
+ },
38
+ }
39
+
40
+ /**
41
+ * ES5 source inlined after the table-access preamble (it uses `__ta*` and
42
+ * `__tqSessionToken`). Declares `__brpAccess(req, tableName, rawQuery)`, which
43
+ * resolves to `{ denied, policy }`: `denied` a `{ status, message }` refusal or
44
+ * null, `policy` the narrowing to apply to this read or null (the caller reads
45
+ * the table whole). Plus the helpers that apply a policy to a request and to
46
+ * its rows.
47
+ */
48
+ export const generateBrowserRowPolicyCode =
49
+ (): string => `// GENERATED — see browser-row-policy.ts in teleport-plugin-next-data-source.
50
+ var __BRP_POLICIES = ${JSON.stringify(BROWSER_ROW_POLICIES)};
51
+
52
+ // Who is asking: the store's own server code and its administrators read every
53
+ // row; anyone else is a visitor.
54
+ async function __brpReader(req) {
55
+ if (__taIsInternalRequest(req)) return { trusted: true };
56
+ var token = null;
57
+ try {
58
+ token = await __tqSessionToken(req);
59
+ } catch (e) {
60
+ token = null;
61
+ }
62
+ var role = token ? __taRoleOf(token) : null;
63
+ return { trusted: !!role && __TA_TRUSTED_READER_ROLES.indexOf(role) !== -1 };
64
+ }
65
+
66
+ function __brpMentionsAny(rawQuery, tables) {
67
+ for (var i = 0; i < tables.length; i++) {
68
+ if (__taSqlMentionsTable(rawQuery, tables[i])) return true;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ // A raw statement cannot be narrowed, so a visitor's statement that names a
74
+ // narrowed table is refused whole. The session is only decoded when the read
75
+ // touches one of these tables.
76
+ async function __brpAccess(req, tableName, rawQuery) {
77
+ var raw = typeof rawQuery === 'string' && rawQuery.length > 0 ? rawQuery : null;
78
+ var bare = __taNormalizeTable(tableName);
79
+ var rawNarrowed = !!raw && __brpMentionsAny(raw, Object.keys(__BRP_POLICIES));
80
+ var policy = raw ? null : (Object.prototype.hasOwnProperty.call(__BRP_POLICIES, bare) ? __BRP_POLICIES[bare] : null);
81
+ if (!rawNarrowed && !policy) return { denied: null, policy: null };
82
+ var reader = await __brpReader(req);
83
+ if (reader.trusted) return { denied: null, policy: null };
84
+ if (rawNarrowed) {
85
+ return { denied: { status: 403, message: 'Forbidden: raw queries over this table are only available to server-side calls' }, policy: null };
86
+ }
87
+ return { denied: null, policy: policy };
88
+ }
89
+
90
+ function __brpColumnName(field) {
91
+ var parts = String(field == null ? '' : field).replace(/"/g, '').trim().split('.');
92
+ return parts[parts.length - 1];
93
+ }
94
+
95
+ function __brpIsHidden(policy, field) {
96
+ var name = __brpColumnName(field).toLowerCase();
97
+ for (var i = 0; i < policy.hiddenColumns.length; i++) {
98
+ if (policy.hiddenColumns[i].toLowerCase() === name) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ // Every field a request filters, sorts or searches by, however deep a filter
104
+ // group nests it. A condition's value is data, not a field.
105
+ function __brpRequestFields(value, out) {
106
+ if (Array.isArray(value)) {
107
+ for (var i = 0; i < value.length; i++) __brpRequestFields(value[i], out);
108
+ } else if (value && typeof value === 'object') {
109
+ if (typeof value.source === 'string') out.push(value.source);
110
+ if (typeof value.field === 'string') out.push(value.field);
111
+ var keys = Object.keys(value);
112
+ for (var k = 0; k < keys.length; k++) {
113
+ if (keys[k] === 'destination' || keys[k] === 'value') continue;
114
+ if (value[keys[k]] && typeof value[keys[k]] === 'object') __brpRequestFields(value[keys[k]], out);
115
+ }
116
+ } else if (typeof value === 'string' && value) {
117
+ out.push(value);
118
+ }
119
+ return out;
120
+ }
121
+
122
+ function __brpParse(value) {
123
+ if (typeof value !== 'string') return value;
124
+ try {
125
+ return JSON.parse(value);
126
+ } catch (e) {
127
+ return value;
128
+ }
129
+ }
130
+
131
+ function __brpAssertVisibleFields(policy, query) {
132
+ var q = query || {};
133
+ var fields = [];
134
+ __brpRequestFields(__brpParse(q.filters), fields);
135
+ __brpRequestFields(__brpParse(q.sorts), fields);
136
+ __brpRequestFields(__brpParse(q.queryColumns), fields);
137
+ if (typeof q.sortBy === 'string') fields.push(q.sortBy);
138
+ for (var i = 0; i < fields.length; i++) {
139
+ if (__brpIsHidden(policy, fields[i])) {
140
+ var err = new Error('Forbidden: ' + __brpColumnName(fields[i]) + ' is not readable');
141
+ err.status = 403;
142
+ throw err;
143
+ }
144
+ }
145
+ }
146
+
147
+ function __brpVisibleColumns(policy, columns) {
148
+ return columns.filter(function(column) { return !__brpIsHidden(policy, column); });
149
+ }
150
+
151
+ // The rows without a hidden key, at any depth: a transformed row nests related
152
+ // rows (a post's related and adjacent posts) built the same way.
153
+ function __brpStripHidden(policy, value) {
154
+ if (Array.isArray(value)) {
155
+ return value.map(function(entry) { return __brpStripHidden(policy, entry); });
156
+ }
157
+ if (!value || typeof value !== 'object') return value;
158
+ var copy = {};
159
+ Object.keys(value).forEach(function(key) {
160
+ if (!__brpIsHidden(policy, key)) copy[key] = __brpStripHidden(policy, value[key]);
161
+ });
162
+ return copy;
163
+ }
164
+
165
+ // Whether this request is answered exactly as an anonymous visitor's would be,
166
+ // so a response cached for one may serve the other. A read whose answer depends
167
+ // on who asks (\`viewDependent\`: a narrowed, protected or admin-only table) or
168
+ // that runs a raw statement differs for the store's own server code and its
169
+ // administrators, and must never share a cache entry with a visitor.
170
+ async function __brpSharesPublicView(req, viewDependent) {
171
+ var raw = !!(req && req.query && typeof req.query.rawQuery === 'string' && req.query.rawQuery.length > 0);
172
+ if (!viewDependent && !raw) return true;
173
+ return !(await __brpReader(req)).trusted;
174
+ }
175
+ `
176
+
177
+ /**
178
+ * Whether a per-table read route's answer depends on who asks — the rows it
179
+ * narrows, the tables it guards — so its response cache must be split by view.
180
+ */
181
+ export const isViewDependentTable = (
182
+ tableName: string,
183
+ protectedTables: ReadonlyArray<string>
184
+ ): boolean => {
185
+ const parts = String(tableName || '')
186
+ .trim()
187
+ .toLowerCase()
188
+ .split('.')
189
+ const bare = parts[parts.length - 1].replace(/"/g, '')
190
+ return (
191
+ Object.prototype.hasOwnProperty.call(BROWSER_ROW_POLICIES, bare) ||
192
+ protectedTables.indexOf(bare) !== -1
193
+ )
194
+ }
195
+
196
+ /**
197
+ * The early return right after the table-access guard, plus the policy
198
+ * (`__rowPolicy`, null or a policy) the handler narrows the rest of its read by.
199
+ */
200
+ export const generateBrowserRowAccessCall = (
201
+ tableName: string,
202
+ rawQueryExpr: string | null
203
+ ): string => {
204
+ return ` const __rowAccess = await __brpAccess(req, ${JSON.stringify(tableName)}, ${
205
+ rawQueryExpr || 'null'
206
+ })
207
+ if (__rowAccess.denied) {
208
+ return res.status(__rowAccess.denied.status).json({ success: false, error: __rowAccess.denied.message, timestamp: Date.now() })
209
+ }
210
+ const __rowPolicy = __rowAccess.policy
211
+ `
212
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * What a per-table read route takes from its query string and splices into
3
+ * SQL: filter columns, sort columns and a row count. Values are bound as
4
+ * parameters; these three are not, so each is checked for the one shape it
5
+ * may have before it reaches the statement.
6
+ */
7
+ export const generateRequestSqlGuardsCode = (): string => `
8
+ // A column name as this route accepts one from a request: plain, optionally
9
+ // quoted or table-qualified. Anything else - an expression, a sub-select - is
10
+ // SQL the caller wrote, and it would run as part of this statement.
11
+ const REQUEST_COLUMN_RE = /^"?[A-Za-z_][A-Za-z0-9_]*"?(?:\\."?[A-Za-z_][A-Za-z0-9_]*"?)?$/
12
+ function assertRequestColumn(field, label) {
13
+ if (typeof field !== 'string' || !REQUEST_COLUMN_RE.test(field)) {
14
+ const error = new Error('Invalid ' + label)
15
+ error.status = 400
16
+ throw error
17
+ }
18
+ }
19
+
20
+ // A row count from a request, as a whole number or nothing.
21
+ function requestRowCount(value) {
22
+ const parsed = parseInt(value, 10)
23
+ return isNaN(parsed) || parsed < 0 ? undefined : parsed
24
+ }
25
+ `
@@ -0,0 +1,188 @@
1
+ import { DataCache } from '@teleporthq/teleport-shared'
2
+
3
+ /**
4
+ * The REST API data source's response cache: `config.cache` as the editor
5
+ * stores it on the data source.
6
+ */
7
+ export interface RESTAPICacheConfig {
8
+ enabled?: boolean
9
+ ttlSeconds?: number
10
+ }
11
+
12
+ /** A year: past that, the window only ever ends with the next deployment. */
13
+ const MAX_TTL_SECONDS = 31_536_000
14
+
15
+ /**
16
+ * Bounds for the one store every REST API data source in a server process
17
+ * shares. Entries are raw JSON text, so the character budget is close to the
18
+ * memory the cache holds.
19
+ */
20
+ const MAX_ENTRIES = 100
21
+ const MAX_CHARS = 33_554_432
22
+
23
+ /**
24
+ * How long a request may wait on an upstream call another request started.
25
+ * An older call is treated as stuck and a new one is made, so one hung socket
26
+ * cannot hold every later visitor.
27
+ */
28
+ const IN_FLIGHT_JOIN_MS = 10_000
29
+
30
+ /**
31
+ * The window, in whole seconds, a REST API data source caches its upstream
32
+ * response for — or `undefined` when caching is off, which keeps the emitted
33
+ * fetcher exactly what it was before this option existed.
34
+ */
35
+ export const resolveRESTAPICacheTTLSeconds = (
36
+ cache: RESTAPICacheConfig | undefined
37
+ ): number | undefined => {
38
+ if (!cache || typeof cache !== 'object' || cache.enabled !== true) {
39
+ return undefined
40
+ }
41
+
42
+ const { ttlSeconds } = cache
43
+ const seconds =
44
+ typeof ttlSeconds === 'number' && Number.isFinite(ttlSeconds) && ttlSeconds > 0
45
+ ? Math.max(1, Math.floor(ttlSeconds))
46
+ : DataCache.DEFAULT_CACHE_TTL_SECONDS
47
+
48
+ return Math.min(seconds, MAX_TTL_SECONDS)
49
+ }
50
+
51
+ /**
52
+ * Runtime helpers of a cached REST API fetcher.
53
+ *
54
+ * The cache sits under the handler, around the upstream `fetch` only: every
55
+ * caller — the API route, `getStaticProps` through `fetchData`, the count
56
+ * route, the global-state route — asks the same URL, and search, filters, sort
57
+ * and paging are applied to the cached payload afterwards. One upstream call
58
+ * per window therefore serves every page, filter and visitor.
59
+ *
60
+ * - The store lives on `globalThis`, so every data-source module in the
61
+ * process (each API route is its own bundle) shares it. On a serverless host
62
+ * that is one store per warm instance; the `s-maxage` header below is what
63
+ * carries the window to the CDN in front of all of them.
64
+ * - Entries are keyed by the whole upstream request (method, URL, headers,
65
+ * body) and hold the response TEXT: every reader parses its own copy, so a
66
+ * handler that sorts in place can never reorder another request's rows.
67
+ * - Freshness is judged by the READER's window, so two data sources calling
68
+ * the same URL with different windows each get what they asked for.
69
+ * - Only a 2xx JSON response is kept. Errors, non-JSON bodies and network
70
+ * failures reach the caller as before and are retried by the next request.
71
+ *
72
+ * Emitted inside a template literal: no backticks or template placeholders in
73
+ * here, not even in comments.
74
+ */
75
+ export const generateRESTAPIResponseCacheCode = (ttlSeconds: number): string => {
76
+ return `const REST_API_CACHE_TTL_MS = ${ttlSeconds * 1000}
77
+ const REST_API_CACHE_MAX_ENTRIES = ${MAX_ENTRIES}
78
+ const REST_API_CACHE_MAX_CHARS = ${MAX_CHARS}
79
+ const REST_API_IN_FLIGHT_JOIN_MS = ${IN_FLIGHT_JOIN_MS}
80
+
81
+ // One store per server process, shared by every REST API data source module.
82
+ if (!globalThis.__tqRestApiCache) {
83
+ globalThis.__tqRestApiCache = { entries: new Map(), inFlight: new Map(), chars: 0 }
84
+ }
85
+ const restApiCache = globalThis.__tqRestApiCache
86
+
87
+ function restApiCacheKey(url, options) {
88
+ return JSON.stringify([options.method, url, options.headers, options.body === undefined ? null : options.body])
89
+ }
90
+
91
+ function forgetRestApiResponse(key) {
92
+ const entry = restApiCache.entries.get(key)
93
+ if (entry) {
94
+ restApiCache.entries.delete(key)
95
+ restApiCache.chars -= entry.bodyText.length
96
+ }
97
+ }
98
+
99
+ function rememberRestApiResponse(key, entry) {
100
+ forgetRestApiResponse(key)
101
+ if (entry.bodyText.length > REST_API_CACHE_MAX_CHARS) {
102
+ return
103
+ }
104
+ restApiCache.entries.set(key, entry)
105
+ restApiCache.chars += entry.bodyText.length
106
+ // A Map iterates in insertion order and every hit re-inserts its entry, so
107
+ // the first key is always the least recently used one.
108
+ while (
109
+ restApiCache.entries.size > REST_API_CACHE_MAX_ENTRIES ||
110
+ restApiCache.chars > REST_API_CACHE_MAX_CHARS
111
+ ) {
112
+ forgetRestApiResponse(restApiCache.entries.keys().next().value)
113
+ }
114
+ }
115
+
116
+ async function requestRestApi(url, options) {
117
+ const response = await fetch(url, options)
118
+ if (!response.ok) {
119
+ return { ok: false, status: response.status, statusText: response.statusText }
120
+ }
121
+ const bodyText = await response.text()
122
+ // Parsed once here so a body that is not JSON fails this request, as
123
+ // response.json() would, instead of being cached and failing every request
124
+ // until the window ends.
125
+ JSON.parse(bodyText)
126
+ return {
127
+ ok: true,
128
+ status: response.status,
129
+ statusText: response.statusText,
130
+ bodyText,
131
+ fetchedAt: Date.now(),
132
+ }
133
+ }
134
+
135
+ function fetchRestApiWithCache(url, options) {
136
+ const key = restApiCacheKey(url, options)
137
+ const now = Date.now()
138
+
139
+ const cached = restApiCache.entries.get(key)
140
+ if (cached && now >= cached.fetchedAt && now - cached.fetchedAt < REST_API_CACHE_TTL_MS) {
141
+ restApiCache.entries.delete(key)
142
+ restApiCache.entries.set(key, cached)
143
+ return Promise.resolve(cached)
144
+ }
145
+
146
+ // Concurrent misses share one upstream call instead of each making their own.
147
+ const pending = restApiCache.inFlight.get(key)
148
+ if (pending && now - pending.startedAt < REST_API_IN_FLIGHT_JOIN_MS) {
149
+ return pending.promise
150
+ }
151
+
152
+ const flight = { startedAt: now, promise: null }
153
+ const release = () => {
154
+ if (restApiCache.inFlight.get(key) === flight) {
155
+ restApiCache.inFlight.delete(key)
156
+ }
157
+ }
158
+ flight.promise = requestRestApi(url, options).then(
159
+ (result) => {
160
+ release()
161
+ if (result.ok) {
162
+ rememberRestApiResponse(key, result)
163
+ }
164
+ return result
165
+ },
166
+ (error) => {
167
+ release()
168
+ throw error
169
+ }
170
+ )
171
+ restApiCache.inFlight.set(key, flight)
172
+ return flight.promise
173
+ }
174
+
175
+ // Lets the CDN answer this exact URL until the upstream response it was built
176
+ // from expires, so neither layer serves it longer than the window. Only a real
177
+ // GET response gets the header: fetchData, fetchCount and tqWithCache call the
178
+ // handler with a bare { status, json } object.
179
+ function applyRestApiCacheHeaders(req, res, fetchedAt) {
180
+ if (!req || req.method !== 'GET' || !res || typeof res.setHeader !== 'function') {
181
+ return
182
+ }
183
+ const remainingSeconds = Math.floor((fetchedAt + REST_API_CACHE_TTL_MS - Date.now()) / 1000)
184
+ if (remainingSeconds > 0) {
185
+ res.setHeader('Cache-Control', 'public, max-age=0, s-maxage=' + remainingSeconds)
186
+ }
187
+ }`
188
+ }
@@ -0,0 +1,39 @@
1
+ import { SessionCookieResolver, TableAccess } from '@teleporthq/teleport-shared'
2
+ import { generateBrowserRowAccessCall, generateBrowserRowPolicyCode } from './browser-row-policy'
3
+
4
+ /**
5
+ * The money-table guard for a generated per-table read route
6
+ * (`/api/teleport-<table>-<source>` and its `-count` twin).
7
+ *
8
+ * Every table a page binds gets such a route, and the route serves the whole
9
+ * table — plus any single SELECT through `?rawQuery=` — to whoever asks. For a
10
+ * catalogue that is the product; for gift cards and voucher codes it is theft.
11
+ * The guard refuses a protected table (see `TableAccess.PROTECTED_TABLES`)
12
+ * unless the caller is a server segment presenting the app secret or a
13
+ * signed-in member of a trusted role — the generated admin, whose list pages
14
+ * are the one browser reader these tables have. It is followed by the
15
+ * browser row policy (see `browser-row-policy.ts`): unpublished rows and
16
+ * private columns of the public tables, and the admin-only tables.
17
+ */
18
+
19
+ /** Declares the session resolver and the `__ta*` guard once per module. */
20
+ export const generateTableAccessPreamble = (trustedReaderRoles: ReadonlyArray<string>): string => {
21
+ return `${SessionCookieResolver.generateCommonJsSessionTokenResolverCode()}
22
+ ${TableAccess.generateTableAccessHelperCode({ trustedReaderRoles })}
23
+ ${generateBrowserRowPolicyCode()}`
24
+ }
25
+
26
+ /**
27
+ * The early return at the top of a handler, before the client connects:
28
+ * `tableName` is the module's table, `rawQueryExpr` the request's raw statement
29
+ * (or `null` where the handler accepts none).
30
+ */
31
+ export const generateReadGuardCall = (tableName: string, rawQueryExpr: string | null): string => {
32
+ return ` const __access = await __taGuardRead(req, ${JSON.stringify(tableName)}, ${
33
+ rawQueryExpr || 'null'
34
+ })
35
+ if (__access) {
36
+ return res.status(__access.status).json({ success: false, error: __access.message, timestamp: Date.now() })
37
+ }
38
+ ${generateBrowserRowAccessCall(tableName, rawQueryExpr)}`
39
+ }