@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.
- package/__tests__/array-overlap-sql.test.ts +53 -0
- package/__tests__/blog-post-page-enrichment.test.ts +214 -0
- package/__tests__/browser-row-policy.test.ts +503 -0
- package/__tests__/data-source-cache-wrapping.test.ts +18 -2
- package/__tests__/ecommerce-product-options.test.ts +206 -0
- package/__tests__/ecommerce-product-pages.test.ts +82 -0
- package/__tests__/ecommerce-product-pre-options.golden.json +505 -0
- package/__tests__/ecommerce-product-storefront-contract.test.ts +293 -0
- package/__tests__/ecommerce-product-subscriptions.test.ts +121 -0
- package/__tests__/fetchers.test.ts +2 -2
- package/__tests__/items-path.test.ts +150 -0
- package/__tests__/product-options-storefront.fixture.json +2510 -0
- package/__tests__/product-page-url-fixtures.json +25 -0
- package/__tests__/product-price-sort.ts +4 -2
- package/__tests__/raw-rows-mode.test.ts +111 -0
- package/__tests__/rest-api-response-cache.test.ts +434 -0
- package/__tests__/table-read-route-protected-tables.test.ts +493 -0
- package/dist/cjs/array-overlap-sql.d.ts +21 -0
- package/dist/cjs/array-overlap-sql.d.ts.map +1 -0
- package/dist/cjs/array-overlap-sql.js +25 -0
- package/dist/cjs/array-overlap-sql.js.map +1 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +10 -4
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/javascript.d.ts.map +1 -1
- package/dist/cjs/fetchers/javascript.js +1 -1
- package/dist/cjs/fetchers/javascript.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +8 -4
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +8 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +42 -4
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/rest-api.d.ts.map +1 -1
- package/dist/cjs/fetchers/rest-api.js +7 -2
- package/dist/cjs/fetchers/rest-api.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +8 -5
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js +66 -0
- package/dist/cjs/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js +12 -0
- package/dist/cjs/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js +64 -0
- package/dist/cjs/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js +34 -0
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/cjs/items-path.d.ts +10 -0
- package/dist/cjs/items-path.d.ts.map +1 -0
- package/dist/cjs/items-path.js +86 -0
- package/dist/cjs/items-path.js.map +1 -0
- package/dist/cjs/localized-columns.d.ts.map +1 -1
- package/dist/cjs/localized-columns.js +13 -0
- package/dist/cjs/localized-columns.js.map +1 -1
- package/dist/cjs/pagination-plugin.d.ts.map +1 -1
- package/dist/cjs/pagination-plugin.js +17 -5
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/cjs/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js +25 -0
- package/dist/cjs/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/cjs/transformations/blog-comments.d.ts +30 -0
- package/dist/cjs/transformations/blog-comments.d.ts.map +1 -0
- package/dist/cjs/transformations/blog-comments.js +34 -0
- package/dist/cjs/transformations/blog-comments.js.map +1 -0
- package/dist/cjs/transformations/blog-post.d.ts +2 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +4 -1
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts +9 -1
- package/dist/cjs/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/cjs/transformations/ecommerce-product.js +28 -1
- package/dist/cjs/transformations/ecommerce-product.js.map +1 -1
- package/dist/cjs/transformations/heading-anchors.d.ts +25 -0
- package/dist/cjs/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/cjs/transformations/heading-anchors.js +29 -0
- package/dist/cjs/transformations/heading-anchors.js.map +1 -0
- package/dist/cjs/transformations/index.d.ts +47 -4
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +56 -18
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/transformations/shared-utils.d.ts.map +1 -1
- package/dist/cjs/transformations/shared-utils.js +1 -1
- package/dist/cjs/transformations/shared-utils.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/utils.d.ts.map +1 -1
- package/dist/cjs/utils.js +1 -1
- package/dist/cjs/utils.js.map +1 -1
- package/dist/esm/array-overlap-sql.d.ts +21 -0
- package/dist/esm/array-overlap-sql.d.ts.map +1 -0
- package/dist/esm/array-overlap-sql.js +21 -0
- package/dist/esm/array-overlap-sql.js.map +1 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +11 -5
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/javascript.d.ts.map +1 -1
- package/dist/esm/fetchers/javascript.js +1 -1
- package/dist/esm/fetchers/javascript.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +1 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +8 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +8 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +42 -4
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/rest-api.d.ts.map +1 -1
- package/dist/esm/fetchers/rest-api.js +7 -2
- package/dist/esm/fetchers/rest-api.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +8 -5
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts +43 -0
- package/dist/esm/fetchers/utils/browser-row-policy.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js +60 -0
- package/dist/esm/fetchers/utils/browser-row-policy.js.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js +8 -0
- package/dist/esm/fetchers/utils/request-sql-guards.js.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts +40 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js +59 -0
- package/dist/esm/fetchers/utils/rest-api-response-cache.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +23 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.js +29 -0
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -0
- package/dist/esm/items-path.d.ts +10 -0
- package/dist/esm/items-path.d.ts.map +1 -0
- package/dist/esm/items-path.js +58 -0
- package/dist/esm/items-path.js.map +1 -0
- package/dist/esm/localized-columns.d.ts.map +1 -1
- package/dist/esm/localized-columns.js +13 -0
- package/dist/esm/localized-columns.js.map +1 -1
- package/dist/esm/pagination-plugin.d.ts.map +1 -1
- package/dist/esm/pagination-plugin.js +17 -5
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/transformations/blog-adjacent-posts.d.ts +16 -0
- package/dist/esm/transformations/blog-adjacent-posts.d.ts.map +1 -0
- package/dist/esm/transformations/blog-adjacent-posts.js +21 -0
- package/dist/esm/transformations/blog-adjacent-posts.js.map +1 -0
- package/dist/esm/transformations/blog-comments.d.ts +30 -0
- package/dist/esm/transformations/blog-comments.d.ts.map +1 -0
- package/dist/esm/transformations/blog-comments.js +30 -0
- package/dist/esm/transformations/blog-comments.js.map +1 -0
- package/dist/esm/transformations/blog-post.d.ts +2 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +4 -1
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/ecommerce-product.d.ts +9 -1
- package/dist/esm/transformations/ecommerce-product.d.ts.map +1 -1
- package/dist/esm/transformations/ecommerce-product.js +29 -2
- package/dist/esm/transformations/ecommerce-product.js.map +1 -1
- package/dist/esm/transformations/heading-anchors.d.ts +25 -0
- package/dist/esm/transformations/heading-anchors.d.ts.map +1 -0
- package/dist/esm/transformations/heading-anchors.js +25 -0
- package/dist/esm/transformations/heading-anchors.js.map +1 -0
- package/dist/esm/transformations/index.d.ts +47 -4
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +56 -18
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/transformations/shared-utils.d.ts.map +1 -1
- package/dist/esm/transformations/shared-utils.js +1 -1
- package/dist/esm/transformations/shared-utils.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/utils.d.ts.map +1 -1
- package/dist/esm/utils.js +1 -1
- package/dist/esm/utils.js.map +1 -1
- package/package.json +5 -5
- package/src/array-overlap-sql.ts +41 -0
- package/src/data-source-fetchers.ts +41 -5
- package/src/fetchers/javascript.ts +9 -1
- package/src/fetchers/postgresql.ts +37 -10
- package/src/fetchers/raw-query.ts +60 -5
- package/src/fetchers/rest-api.ts +27 -5
- package/src/fetchers/teleport.ts +37 -13
- package/src/fetchers/utils/browser-row-policy.ts +212 -0
- package/src/fetchers/utils/request-sql-guards.ts +25 -0
- package/src/fetchers/utils/rest-api-response-cache.ts +188 -0
- package/src/fetchers/utils/table-access-guard.ts +39 -0
- package/src/items-path.ts +74 -0
- package/src/localized-columns.ts +13 -0
- package/src/pagination-plugin.ts +29 -7
- package/src/transformations/blog-adjacent-posts.ts +74 -0
- package/src/transformations/blog-comments.ts +132 -0
- package/src/transformations/blog-post.ts +38 -5
- package/src/transformations/ecommerce-product.ts +568 -5
- package/src/transformations/heading-anchors.ts +85 -0
- package/src/transformations/index.ts +165 -22
- package/src/transformations/shared-utils.ts +6 -0
- package/src/utils.ts +26 -4
package/src/fetchers/teleport.ts
CHANGED
|
@@ -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
|
-
//
|
|
198
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|