@teleporthq/teleport-plugin-next-data-source 0.44.14 → 0.44.17

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 (125) hide show
  1. package/__tests__/_helpers/exposure.ts +29 -0
  2. package/__tests__/_helpers/first-page-runtime.ts +12 -2
  3. package/__tests__/blog-post-contents.test.ts +3 -1
  4. package/__tests__/blog-post-page-enrichment.test.ts +2 -1
  5. package/__tests__/blog-post-seo-fields.test.ts +124 -0
  6. package/__tests__/browser-row-policy.test.ts +65 -5
  7. package/__tests__/data-source-cache-wrapping.test.ts +48 -7
  8. package/__tests__/external-read-routes-guard.test.ts +991 -0
  9. package/__tests__/null-filter-where.test.ts +19 -2
  10. package/__tests__/range-control.test.ts +29 -0
  11. package/__tests__/search-relevance.test.ts +42 -6
  12. package/__tests__/table-read-route-protected-tables.test.ts +54 -4
  13. package/dist/cjs/count-fetchers.d.ts.map +1 -1
  14. package/dist/cjs/count-fetchers.js +1 -1
  15. package/dist/cjs/count-fetchers.js.map +1 -1
  16. package/dist/cjs/data-source-fetchers.d.ts +4 -0
  17. package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
  18. package/dist/cjs/data-source-fetchers.js +97 -20
  19. package/dist/cjs/data-source-fetchers.js.map +1 -1
  20. package/dist/cjs/fetchers/postgresql.d.ts +3 -1
  21. package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
  22. package/dist/cjs/fetchers/postgresql.js +11 -3
  23. package/dist/cjs/fetchers/postgresql.js.map +1 -1
  24. package/dist/cjs/fetchers/raw-query.d.ts +20 -1
  25. package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
  26. package/dist/cjs/fetchers/raw-query.js +37 -7
  27. package/dist/cjs/fetchers/raw-query.js.map +1 -1
  28. package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
  29. package/dist/cjs/fetchers/redshift.js +2 -1
  30. package/dist/cjs/fetchers/redshift.js.map +1 -1
  31. package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
  32. package/dist/cjs/fetchers/teleport.js +5 -3
  33. package/dist/cjs/fetchers/teleport.js.map +1 -1
  34. package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
  35. package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
  36. package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
  37. package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
  38. package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
  39. package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
  40. package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
  41. package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
  42. package/dist/cjs/index.js +1 -1
  43. package/dist/cjs/pagination-controls.d.ts +4 -1
  44. package/dist/cjs/pagination-controls.d.ts.map +1 -1
  45. package/dist/cjs/pagination-controls.js +4 -1
  46. package/dist/cjs/pagination-controls.js.map +1 -1
  47. package/dist/cjs/pagination-plugin.js +1 -0
  48. package/dist/cjs/pagination-plugin.js.map +1 -1
  49. package/dist/cjs/range-control.d.ts +10 -5
  50. package/dist/cjs/range-control.d.ts.map +1 -1
  51. package/dist/cjs/range-control.js +23 -2
  52. package/dist/cjs/range-control.js.map +1 -1
  53. package/dist/cjs/transformations/blog-post.d.ts +5 -0
  54. package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
  55. package/dist/cjs/transformations/blog-post.js +6 -2
  56. package/dist/cjs/transformations/blog-post.js.map +1 -1
  57. package/dist/cjs/transformations/index.d.ts +18 -2
  58. package/dist/cjs/transformations/index.d.ts.map +1 -1
  59. package/dist/cjs/transformations/index.js +26 -1
  60. package/dist/cjs/transformations/index.js.map +1 -1
  61. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  62. package/dist/esm/count-fetchers.d.ts.map +1 -1
  63. package/dist/esm/count-fetchers.js +1 -1
  64. package/dist/esm/count-fetchers.js.map +1 -1
  65. package/dist/esm/data-source-fetchers.d.ts +4 -0
  66. package/dist/esm/data-source-fetchers.d.ts.map +1 -1
  67. package/dist/esm/data-source-fetchers.js +97 -20
  68. package/dist/esm/data-source-fetchers.js.map +1 -1
  69. package/dist/esm/fetchers/postgresql.d.ts +3 -1
  70. package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
  71. package/dist/esm/fetchers/postgresql.js +12 -4
  72. package/dist/esm/fetchers/postgresql.js.map +1 -1
  73. package/dist/esm/fetchers/raw-query.d.ts +20 -1
  74. package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
  75. package/dist/esm/fetchers/raw-query.js +38 -8
  76. package/dist/esm/fetchers/raw-query.js.map +1 -1
  77. package/dist/esm/fetchers/redshift.d.ts.map +1 -1
  78. package/dist/esm/fetchers/redshift.js +2 -1
  79. package/dist/esm/fetchers/redshift.js.map +1 -1
  80. package/dist/esm/fetchers/teleport.d.ts.map +1 -1
  81. package/dist/esm/fetchers/teleport.js +6 -4
  82. package/dist/esm/fetchers/teleport.js.map +1 -1
  83. package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
  84. package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
  85. package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
  86. package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
  87. package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
  88. package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
  89. package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
  90. package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
  91. package/dist/esm/index.js +1 -1
  92. package/dist/esm/pagination-controls.d.ts +4 -1
  93. package/dist/esm/pagination-controls.d.ts.map +1 -1
  94. package/dist/esm/pagination-controls.js +4 -1
  95. package/dist/esm/pagination-controls.js.map +1 -1
  96. package/dist/esm/pagination-plugin.js +1 -0
  97. package/dist/esm/pagination-plugin.js.map +1 -1
  98. package/dist/esm/range-control.d.ts +10 -5
  99. package/dist/esm/range-control.d.ts.map +1 -1
  100. package/dist/esm/range-control.js +23 -2
  101. package/dist/esm/range-control.js.map +1 -1
  102. package/dist/esm/transformations/blog-post.d.ts +5 -0
  103. package/dist/esm/transformations/blog-post.d.ts.map +1 -1
  104. package/dist/esm/transformations/blog-post.js +6 -2
  105. package/dist/esm/transformations/blog-post.js.map +1 -1
  106. package/dist/esm/transformations/index.d.ts +18 -2
  107. package/dist/esm/transformations/index.d.ts.map +1 -1
  108. package/dist/esm/transformations/index.js +27 -2
  109. package/dist/esm/transformations/index.js.map +1 -1
  110. package/dist/esm/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +5 -5
  112. package/src/count-fetchers.ts +10 -2
  113. package/src/data-source-fetchers.ts +144 -23
  114. package/src/fetchers/postgresql.ts +38 -9
  115. package/src/fetchers/raw-query.ts +125 -13
  116. package/src/fetchers/redshift.ts +3 -0
  117. package/src/fetchers/teleport.ts +29 -8
  118. package/src/fetchers/utils/external-read-guard.ts +289 -0
  119. package/src/fetchers/utils/table-access-guard.ts +469 -20
  120. package/src/index.ts +1 -1
  121. package/src/pagination-controls.ts +4 -1
  122. package/src/pagination-plugin.ts +1 -0
  123. package/src/range-control.ts +40 -8
  124. package/src/transformations/blog-post.ts +107 -4
  125. package/src/transformations/index.ts +44 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teleporthq/teleport-plugin-next-data-source",
3
- "version": "0.44.14",
3
+ "version": "0.44.17",
4
4
  "description": "A plugin for managing data sources for Next.js projects.",
5
5
  "author": "teleportHQ",
6
6
  "license": "MIT",
@@ -26,9 +26,9 @@
26
26
  "dependencies": {
27
27
  "@babel/parser": "^7.26.10",
28
28
  "@babel/types": "^7.5.5",
29
- "@teleporthq/teleport-plugin-common": "^0.44.11",
30
- "@teleporthq/teleport-shared": "^0.44.11",
31
- "@teleporthq/teleport-types": "^0.44.11"
29
+ "@teleporthq/teleport-plugin-common": "^0.44.16",
30
+ "@teleporthq/teleport-shared": "^0.44.16",
31
+ "@teleporthq/teleport-types": "^0.44.16"
32
32
  },
33
- "gitHead": "f3ba28c4d52283672ea6e04671756bf34c181955"
33
+ "gitHead": "cc3f4e64c87e23d1ca5650026a0753aeb6272691"
34
34
  }
@@ -62,7 +62,15 @@ async function getCount(req, res) {
62
62
  // handler gets the wrapper object rather than the records, which is not an
63
63
  // array — so the count below would report 0 for a list that clearly has rows.
64
64
  const { query, queryColumns, filters, collectionPath } = req.query
65
- const fakeReq = { query: { query, queryColumns, filters, collectionPath }, method: 'GET' }
65
+ // The count route already let this request through (see
66
+ // external-read-guard.ts); counting re-reads every matching row as the
67
+ // route's own server-side call, never capped to a browser's window.
68
+ const fakeReq = {
69
+ query: { query, queryColumns, filters, collectionPath },
70
+ method: 'GET',
71
+ headers: req.headers || {},
72
+ __tqServerCall: true,
73
+ }
66
74
  let result = null
67
75
  let statusCode = 200
68
76
 
@@ -98,7 +106,7 @@ async function getCount(req, res) {
98
106
  console.error('Error getting count:', error)
99
107
  return res.status(500).json({
100
108
  success: false,
101
- error: error.message || 'Failed to get count',
109
+ error: 'Failed to get count',
102
110
  timestamp: Date.now()
103
111
  })
104
112
  }
@@ -34,9 +34,14 @@ import {
34
34
  import { validateDatabaseConfig } from './validation'
35
35
  import { generateCountFetcher } from './count-fetchers'
36
36
  import type { EntityTransformOptions } from './transformations'
37
- import { DataCache, TableAccess } from '@teleporthq/teleport-shared'
37
+ import { DataCache, DataExposure, TableAccess } from '@teleporthq/teleport-shared'
38
38
  import type { DataSourceServerCacheOptions } from './cache/types'
39
39
  import { isViewDependentTable } from './fetchers/utils/browser-row-policy'
40
+ import {
41
+ guardExternalDriverCount,
42
+ guardExternalDriverModule,
43
+ } from './fetchers/utils/external-read-guard'
44
+ import { schemaOf } from './fetchers/utils/table-access-guard'
40
45
 
41
46
  /** `public."users"` → `users`: the table a spelling names. */
42
47
  const bareTableName = (tableName: string): string =>
@@ -92,10 +97,61 @@ export const getDataSourceDependencies = (
92
97
  return dependencyMap[type]
93
98
  }
94
99
 
100
+ /**
101
+ * Whether the driver's read route carries the table-access guard inline (the
102
+ * TeleportHQ and Postgres-family fetchers); every other driver is wrapped by
103
+ * `guardExternalDriverModule`.
104
+ */
105
+ const hasInlineReadGuard = (dataSource: UIDLDataSource): boolean =>
106
+ dataSource.type === 'teleport' ||
107
+ dataSource.type === 'postgresql' ||
108
+ dataSource.type === 'cockroachdb' ||
109
+ (dataSource.type === 'supabase' && isSupabaseConnectionStringFallback(dataSource.config))
110
+
111
+ /**
112
+ * The read route of `tableName` over `dataSource`: its driver's fetcher, behind
113
+ * the read guard (see `fetchers/utils/table-access-guard.ts`).
114
+ */
95
115
  export function generateDataSourceFetcher(
96
116
  dataSource: UIDLDataSource,
97
117
  tableName: string,
98
118
  transformOptions: EntityTransformOptions = {}
119
+ ): string {
120
+ const routeExposure = routeExposureOf(dataSource, tableName, transformOptions)
121
+ const driverCode = generateDriverFetcher(dataSource, tableName, {
122
+ ...transformOptions,
123
+ routeExposure,
124
+ })
125
+ if (hasInlineReadGuard(dataSource)) {
126
+ return driverCode
127
+ }
128
+ return guardExternalDriverModule(driverCode, {
129
+ tableName,
130
+ dataSourceType: dataSource.type,
131
+ exposure: routeExposure,
132
+ trustedReaderRoles: transformOptions.trustedReaderRoles || [],
133
+ schema: schemaOf(dataSource.config, tableName),
134
+ reader: transformOptions,
135
+ })
136
+ }
137
+
138
+ /** The exposure of the route of `tableName` over `dataSource` (by source id and table). */
139
+ const routeExposureOf = (
140
+ dataSource: UIDLDataSource,
141
+ tableName: string,
142
+ transformOptions: EntityTransformOptions
143
+ ) =>
144
+ transformOptions.routeExposure ||
145
+ DataExposure.readExposureFor(transformOptions.dataExposure, {
146
+ tableName,
147
+ dataSourceId: dataSource && dataSource.id,
148
+ dataSourceType: dataSource && dataSource.type,
149
+ })
150
+
151
+ function generateDriverFetcher(
152
+ dataSource: UIDLDataSource,
153
+ tableName: string,
154
+ transformOptions: EntityTransformOptions = {}
99
155
  ): string {
100
156
  if (!dataSource || typeof dataSource !== 'object') {
101
157
  throw new Error('Invalid data source: data source must be a valid object')
@@ -119,7 +175,14 @@ export function generateDataSourceFetcher(
119
175
  if (!validation.isValid) {
120
176
  throw new Error(`PostgreSQL/CockroachDB config validation failed: ${validation.error}`)
121
177
  }
122
- return generatePostgreSQLFetcher(config, tableName, transformOptions.trustedReaderRoles)
178
+ return generatePostgreSQLFetcher(
179
+ config,
180
+ tableName,
181
+ transformOptions.trustedReaderRoles,
182
+ transformOptions.dataExposure,
183
+ transformOptions.routeExposure,
184
+ transformOptions
185
+ )
123
186
  }
124
187
 
125
188
  case 'mysql':
@@ -196,7 +259,14 @@ export function generateDataSourceFetcher(
196
259
  `Supabase (PostgreSQL fallback) config validation failed: ${pgValidation.error}`
197
260
  )
198
261
  }
199
- return generatePostgreSQLFetcher(config, tableName, transformOptions.trustedReaderRoles)
262
+ return generatePostgreSQLFetcher(
263
+ config,
264
+ tableName,
265
+ transformOptions.trustedReaderRoles,
266
+ transformOptions.dataExposure,
267
+ transformOptions.routeExposure,
268
+ transformOptions
269
+ )
200
270
  }
201
271
 
202
272
  const validation = validateSupabaseConfig(config)
@@ -275,6 +345,13 @@ export function generateDataSourceFetcherWithCore(
275
345
  transformOptions: EntityTransformOptions = {},
276
346
  cacheOptions?: DataSourceServerCacheOptions
277
347
  ): string {
348
+ // A row-owned page's read answers each session its own rows: nothing about
349
+ // it may be cached for another (see DataReadExposure.ownerRules).
350
+ const ownedExposure = routeExposureOf(dataSource, tableName, transformOptions)
351
+ const routeCacheOptions =
352
+ ownedExposure.public || ownedExposure.anySession || !(ownedExposure.ownerRules || []).length
353
+ ? cacheOptions
354
+ : undefined
278
355
  const apiHandler = generateDataSourceFetcher(dataSource, tableName, transformOptions)
279
356
 
280
357
  // Extract the handler function body from the API route
@@ -292,8 +369,12 @@ export function generateDataSourceFetcherWithCore(
292
369
  const imports = handlerCode.match(importRegex) || []
293
370
  const handlerWithoutImports = handlerCode.replace(importRegex, '').trim()
294
371
 
295
- // Generate count fetcher code and extract its imports
296
- const countFetcherCode = generateCountFetcher(dataSource, tableName)
372
+ // Generate count fetcher code and extract its imports. An external driver's
373
+ // count runs behind the same guard as its rows.
374
+ const driverCountCode = generateCountFetcher(dataSource, tableName)
375
+ const countFetcherCode = hasInlineReadGuard(dataSource)
376
+ ? driverCountCode
377
+ : guardExternalDriverCount(driverCountCode)
297
378
  const countImports = countFetcherCode.match(importRegex) || []
298
379
  const countFetcherWithoutImports = countFetcherCode.replace(importRegex, '').trim()
299
380
 
@@ -309,31 +390,38 @@ export function generateDataSourceFetcherWithCore(
309
390
  // The import depth differs by destination: this module is written to
310
391
  // `utils/data-sources/` normally, but the `isApiRoute` variant is inlined
311
392
  // straight into `pages/api/`.
312
- const cachePreamble = cacheOptions
393
+ const cachePreamble = routeCacheOptions
313
394
  ? `import { tqWithCache } from '${isApiRoute ? '../../' : '../'}tq-cache/server'`
314
395
  : ''
315
396
  // A module carrying the browser row policy (the Postgres-family routes) keys
316
397
  // its cache by who is reading; see `__tqCacheByView` below.
317
398
  const splitsCacheByView = handlerCode.indexOf('function __brpSharesPublicView(') !== -1
318
-
319
- const cacheWiring = cacheOptions
399
+ // Only a table every visitor may read can sit in a shared (CDN) cache, and a
400
+ // table only some sessions may read is checked BEFORE the cache: a cached
401
+ // answer never reaches a visitor the route itself would refuse.
402
+ const routeExposure = routeExposureOf(dataSource, tableName, transformOptions)
403
+ const routeIsPublic = routeExposure.public
404
+ const sharedCacheSeconds = (seconds: number | undefined): number =>
405
+ routeIsPublic ? seconds || 0 : 0
406
+
407
+ const cacheWiring = routeCacheOptions
320
408
  ? `
321
409
  const __tqDataCache = ${JSON.stringify({
322
- scope: cacheOptions.scope,
323
- ttl: cacheOptions.ttlSeconds,
324
- sMaxAge: cacheOptions.sMaxAge || 0,
325
- swr: cacheOptions.staleWhileRevalidate || 0,
410
+ scope: routeCacheOptions.scope,
411
+ ttl: routeCacheOptions.ttlSeconds,
412
+ sMaxAge: sharedCacheSeconds(routeCacheOptions.sMaxAge),
413
+ swr: sharedCacheSeconds(routeCacheOptions.staleWhileRevalidate),
326
414
  })}
327
415
  const __tqCountCache = ${JSON.stringify({
328
- scope: DataCache.countScopeFor(cacheOptions.scope),
416
+ scope: DataCache.countScopeFor(routeCacheOptions.scope),
329
417
  // Its OWN keyspace, but the DATA scope's version row. A write bumps one
330
418
  // row; if the count kept a separate version nothing would ever bump it,
331
419
  // and the total would stay stale after every create and delete — the
332
420
  // grid showing one more row than the pager counts.
333
- versionScope: cacheOptions.scope,
334
- ttl: cacheOptions.ttlSeconds,
335
- sMaxAge: cacheOptions.sMaxAge || 0,
336
- swr: cacheOptions.staleWhileRevalidate || 0,
421
+ versionScope: routeCacheOptions.scope,
422
+ ttl: routeCacheOptions.ttlSeconds,
423
+ sMaxAge: sharedCacheSeconds(routeCacheOptions.sMaxAge),
424
+ swr: sharedCacheSeconds(routeCacheOptions.staleWhileRevalidate),
337
425
  })}
338
426
 
339
427
  ${
@@ -361,22 +449,55 @@ function __tqCacheByView(fn, opts) {
361
449
  sMaxAge: 0,
362
450
  swr: 0,
363
451
  }))
452
+ // The app's own server code reads whole tables; a browser's read is capped
453
+ // (__taReadWindow). Neither is ever served the other's answer.
454
+ const serverView = tqWithCache(fn, Object.assign({}, opts, {
455
+ scope: opts.scope + ':server',
456
+ versionScope: opts.versionScope || opts.scope,
457
+ sMaxAge: 0,
458
+ swr: 0,
459
+ }))
364
460
  return async function (req, res) {
461
+ if (__taIsInternalRequest(req)) return serverView(req, res)
365
462
  if (__tqProfileTable && __taIsVisitorView(req)) return fn(req, res)
366
463
  return (await __brpSharesPublicView(req, __tqViewDependent)) ? publicView(req, res) : trustedView(req, res)
367
464
  }
368
465
  }
369
-
370
- const cachedHandler = __tqCacheByView(handler, __tqDataCache)
371
- const cachedGetCount = __tqCacheByView(getCount, __tqCountCache)`
466
+ ${
467
+ routeIsPublic
468
+ ? ''
469
+ : `
470
+ // Readable only by some sessions (see __TA_ROUTE_EXPOSURE): the session is
471
+ // checked before any cached answer is handed out.
472
+ function __tqExposureFirst(fn) {
473
+ return async function (req, res) {
474
+ const __denied = await __taGuardExposure(req)
475
+ if (__denied) {
476
+ return res.status(__denied.status).json({ success: false, error: __denied.message, timestamp: Date.now() })
477
+ }
478
+ return fn(req, res)
479
+ }
480
+ }
481
+ `
482
+ }
483
+ const cachedHandler = ${
484
+ routeIsPublic
485
+ ? '__tqCacheByView(handler, __tqDataCache)'
486
+ : '__tqExposureFirst(__tqCacheByView(handler, __tqDataCache))'
487
+ }
488
+ const cachedGetCount = ${
489
+ routeIsPublic
490
+ ? '__tqCacheByView(getCount, __tqCountCache)'
491
+ : '__tqExposureFirst(__tqCacheByView(getCount, __tqCountCache))'
492
+ }`
372
493
  : `const cachedHandler = tqWithCache(handler, __tqDataCache)
373
494
  const cachedGetCount = tqWithCache(getCount, __tqCountCache)`
374
495
  }
375
496
  `
376
497
  : ''
377
498
 
378
- const handlerExport = cacheOptions ? 'cachedHandler' : 'handler'
379
- const countExport = cacheOptions ? 'cachedGetCount' : 'getCount'
499
+ const handlerExport = routeCacheOptions ? 'cachedHandler' : 'handler'
500
+ const countExport = routeCacheOptions ? 'cachedGetCount' : 'getCount'
380
501
 
381
502
  // For API routes, export just the handler function
382
503
  // For utils files, export the full object with all functions
@@ -384,7 +505,7 @@ const cachedGetCount = tqWithCache(getCount, __tqCountCache)`
384
505
  // The uncached branch is spelled out verbatim rather than interpolated, so an
385
506
  // uncached project keeps generating byte-identical output — `handler as
386
507
  // handler` would be harmless at runtime and still a diff in every snapshot.
387
- const exports = !cacheOptions
508
+ const exports = !routeCacheOptions
388
509
  ? isApiRoute
389
510
  ? 'export default handler'
390
511
  : `export { fetchData, fetchCount, handler, getCount }
@@ -9,7 +9,14 @@ import { generateSortFallbackFieldHelper, generateSortFieldSqlHelper } from '../
9
9
  import { SORT_NULLS_LAST_SQL } from '../list-sort-order'
10
10
  import { generateRowOrderTiebreakHelper, rowIdentityColumn } from '../row-order'
11
11
  import { generateProductFilterClauseHelper } from '../product-filter-fields'
12
- import { generateReadGuardCall, generateTableAccessPreamble } from './utils/table-access-guard'
12
+ import type { DataReadExposure, ProjectDataExposure } from '@teleporthq/teleport-types'
13
+ import { DataExposure } from '@teleporthq/teleport-shared'
14
+ import {
15
+ generateReadGuardCall,
16
+ generateTableAccessPreamble,
17
+ SessionReaderPlacement,
18
+ sqlRouteFieldPolicy,
19
+ } from './utils/table-access-guard'
13
20
  import { generateSearchSqlHelpersCode, PG_SEARCH_SQL } from '../search-relevance'
14
21
  import { generateRequestSqlGuardsCode } from './utils/request-sql-guards'
15
22
  import { generateArrayOverlapSqlCode } from '../array-overlap-sql'
@@ -31,7 +38,13 @@ interface PostgreSQLConfig {
31
38
  export const generatePostgreSQLFetcher = (
32
39
  config: Record<string, unknown>,
33
40
  tableName: string,
34
- trustedReaderRoles: ReadonlyArray<string> = []
41
+ trustedReaderRoles: ReadonlyArray<string> = [],
42
+ // Who may read the table from a browser — see DataExposure.readExposureFor.
43
+ dataExposure?: ProjectDataExposure,
44
+ // The route's own exposure, already resolved for its data source (preferred).
45
+ routeExposure?: DataReadExposure,
46
+ // Where the route sits and whether its session reader checks revocation.
47
+ reader: SessionReaderPlacement = {}
35
48
  ): string => {
36
49
  const pgConfig = config as PostgreSQLConfig
37
50
  const schema = pgConfig.options?.schema
@@ -62,10 +75,20 @@ export const generatePostgreSQLFetcher = (
62
75
 
63
76
  return `${PG_CLIENT_IMPORT}
64
77
 
65
- ${generateTableAccessPreamble(trustedReaderRoles)}
78
+ ${generateTableAccessPreamble(
79
+ trustedReaderRoles,
80
+ routeExposure || DataExposure.readExposureFor(dataExposure, { tableName }),
81
+ sqlRouteFieldPolicy(config, tableName),
82
+ tableName,
83
+ reader
84
+ )}
66
85
 
86
+ // No query waits on the database longer than this (enforced by the client:
87
+ // a server-side statement_timeout does not survive a pooler).
67
88
  const getClient = () => {
68
- return new Client(${clientConfig})
89
+ return new Client(Object.assign(${clientConfig}, { query_timeout: ${
90
+ DataExposure.READ_QUERY_TIMEOUT_MS
91
+ } }))
69
92
  }
70
93
 
71
94
  ${generateSafeJSONParseCode()}
@@ -136,7 +159,9 @@ const processFilters = (filters, conditions, queryParams, paramIndex) => {
136
159
  return null
137
160
  }
138
161
 
139
- const validOps = ['=', '!=', '>', '<', '>=', '<=']
162
+ // 'like' reads a JSON-list cell for one member (a post's former slugs); the
163
+ // value is a bound parameter, so a pattern never reaches the SQL text.
164
+ const validOps = ['=', '!=', '>', '<', '>=', '<=', 'like']
140
165
  const sqlOperator = validOps.includes(operand) ? operand : '='
141
166
  const clause = \`\${field} \${sqlOperator} $\${paramIndex}\`
142
167
  queryParams.push(value)
@@ -201,7 +226,8 @@ ${generateReadGuardCall(tableName, null)}
201
226
  : [${JSON.stringify(tableName)}]
202
227
 
203
228
  const schemaResult = await client.query(schemaQuery, schemaParams)
204
- columns = schemaResult.rows.map(row => row.column_name)
229
+ // A credential column is never searched: a search is an oracle for it.
230
+ columns = schemaResult.rows.map(row => row.column_name).filter((column) => !__taIsCredentialColumn(column))
205
231
  } catch (schemaError) {
206
232
  console.warn('Failed to fetch column names from information_schema:', schemaError.message)
207
233
  // Continue without search if we can't get columns
@@ -267,8 +293,10 @@ ${generateReadGuardCall(tableName, null)}
267
293
  }
268
294
  const usedRewrittenSort = orderBySql !== plainOrderBySql
269
295
 
270
- const limitValue = requestRowCount(limit || perPage)
271
- const offsetValue = offset !== undefined ? parseInt(offset) : (page && perPage ? (parseInt(page) - 1) * parseInt(perPage) : undefined)
296
+ // Whole numbers only, and a browser's read capped - see __taReadWindow.
297
+ const __window = __taReadWindow(req)
298
+ const limitValue = __window.limit || __window.perPage
299
+ const offsetValue = __window.offset !== undefined ? __window.offset : (__window.page && __window.perPage ? (__window.page - 1) * __window.perPage : undefined)
272
300
 
273
301
  let sqlTail = ''
274
302
  if (limitValue !== undefined) {
@@ -381,7 +409,8 @@ ${generateReadGuardCall(tableName, null)}
381
409
  }
382
410
 
383
411
  const schemaResult = await client.query(schemaQuery, schemaParams)
384
- columns = schemaResult.rows.map(row => row.column_name)
412
+ // A credential column is never searched: a search is an oracle for it.
413
+ columns = schemaResult.rows.map(row => row.column_name).filter((column) => !__taIsCredentialColumn(column))
385
414
  } catch (schemaError) {
386
415
  console.warn('Failed to fetch column names from information_schema:', schemaError.message)
387
416
  // Continue without search if we can't get columns
@@ -1,7 +1,36 @@
1
- import { SessionCookieResolver } from '@teleporthq/teleport-shared'
1
+ import { DataExposure, SessionCookieResolver, TableAccess } from '@teleporthq/teleport-shared'
2
2
  import { replaceSecretReference } from '../utils'
3
3
  import { PG_CLIENT_IMPORT } from './pg-client-import'
4
4
 
5
+ /**
6
+ * What a raw-query state route is behind: the protection of the page it
7
+ * serves (`PageAccess.resolvePageAccessPolicy`; null for a page every visitor
8
+ * may open, and for an app-wide global state), and the roles that may read the
9
+ * protected tables (`TableAccess.resolveTrustedReaderRoles`).
10
+ */
11
+ export interface RawQueryRouteAccess {
12
+ pageAccess?: {
13
+ requiresAuth: boolean
14
+ allowedRoles: string[]
15
+ requiresSubscription?: boolean
16
+ subscriptionProductIds?: string[]
17
+ } | null
18
+ trustedReaderRoles?: string[]
19
+ /** The route's path back to the project root; absent, three folders (`pages/api/page-state/`, `pages/api/global-state/`). */
20
+ projectRoot?: string
21
+ /** The project has authentication: the session reader checks revocation (absent: it does). */
22
+ withRevocation?: boolean
23
+ }
24
+
25
+ /** The longest value a request may bind into the statement (a route param). */
26
+ const MAX_REQUEST_PARAM_LENGTH = 256
27
+
28
+ /** The largest row count the baked statement asks for itself (`LIMIT n` at its end), or 0. */
29
+ const statementLimit = (query: string): number => {
30
+ const match = /\blimit\s+(\d+)\s*(?:offset\s+\d+\s*)?;?\s*$/i.exec(query)
31
+ return match ? parseInt(match[1], 10) : 0
32
+ }
33
+
5
34
  interface PostgreSQLConfig {
6
35
  host?: string
7
36
  port?: number
@@ -45,14 +74,23 @@ const currentUserField = (param: string): string => {
45
74
  export const generateRawQueryFetcher = (
46
75
  config: Record<string, unknown>,
47
76
  query: string,
48
- paramFields: string[]
77
+ paramFields: string[],
78
+ access: RawQueryRouteAccess = {}
49
79
  ): string => {
50
80
  const pgConfig = config as PostgreSQLConfig
51
81
  const userParams = paramFields.filter(isCurrentUserParam)
52
82
  const requestParams = paramFields.filter((param) => !isCurrentUserParam(param))
83
+ const pageAccess = access.pageAccess || null
84
+ const rowCap = Math.max(DataExposure.BROWSER_READ_MAX_LIMIT, statementLimit(query))
53
85
 
54
86
  const paramDestructure = [
55
- requestParams.length > 0 ? `const { ${requestParams.join(', ')} } = req.query` : '',
87
+ requestParams.length > 0
88
+ ? `const { ${requestParams
89
+ .map((param) => `${param}: __raw_${param}`)
90
+ .join(', ')} } = req.query\n ${requestParams
91
+ .map((param) => `const ${param} = requestParam(__raw_${param})`)
92
+ .join('\n ')}`
93
+ : '',
56
94
  userParams.length > 0 ? 'const __sessionUser = await sessionUser(req)' : '',
57
95
  ...userParams.map(
58
96
  (param) =>
@@ -64,9 +102,21 @@ export const generateRawQueryFetcher = (
64
102
  .filter(Boolean)
65
103
  .join('\n ')
66
104
 
67
- const sessionHelpers =
68
- userParams.length > 0
69
- ? `${SessionCookieResolver.generateCommonJsSessionTokenResolverCode()}
105
+ // Every route carries the session resolver and the table-access helpers: the
106
+ // page's protection, the protected tables and the credential columns are
107
+ // checked here as on every other read route.
108
+ // The route lives in pages/api/page-state/ or pages/api/global-state/: three folders below the root.
109
+ const projectRoot = access.projectRoot || '../../..'
110
+ const sessionHelpers = `${SessionCookieResolver.generateCommonJsSessionTokenResolverCode(
111
+ projectRoot,
112
+ {
113
+ withRevocation: access.withRevocation !== false,
114
+ }
115
+ )}
116
+ ${TableAccess.generateTableAccessHelperCode({
117
+ trustedReaderRoles: access.trustedReaderRoles || [],
118
+ projectRoot,
119
+ })}
70
120
  // The signed-in user this request's session names, or null.
71
121
  async function sessionUser(req) {
72
122
  try {
@@ -83,8 +133,60 @@ function sessionUserField(user, field) {
83
133
  var value = field === 'id' && user.id == null ? user.sub : user[field]
84
134
  return value == null || value === '' ? undefined : value
85
135
  }
136
+
137
+ // A value the request binds into the statement (a details page's route param):
138
+ // one short string, never a list or an object.
139
+ function requestParam(value) {
140
+ if (Array.isArray(value)) value = value[0]
141
+ if (value === undefined || value === null) return undefined
142
+ var text = String(value)
143
+ return text.length > 0 && text.length <= ${MAX_REQUEST_PARAM_LENGTH} ? text : undefined
144
+ }
145
+
146
+ // Who may run this route's statement: the visitors the page it serves admits
147
+ // (PAGE_ACCESS, null for every visitor), and — when the statement reads a
148
+ // protected table — only the app's own server code, a trusted role, or a
149
+ // session the statement itself is scoped to (a {{Current User.*}} value bound
150
+ // from that session above). null when the request may proceed.
151
+ var PAGE_ACCESS = ${JSON.stringify(
152
+ pageAccess
153
+ ? {
154
+ allowedRoles: pageAccess.allowedRoles || [],
155
+ requiresSubscription: pageAccess.requiresSubscription === true,
156
+ subscriptionProductIds: pageAccess.subscriptionProductIds || [],
157
+ }
158
+ : null
159
+ )}
160
+ ${pageAccess && pageAccess.requiresSubscription ? DataExposure.generateEntitlementCheckCode() : ''}
161
+ var SESSION_SCOPED = ${JSON.stringify(userParams.length > 0)}
162
+ var ROW_CAP = ${rowCap}
163
+ // No query waits on the database longer than this (enforced by the client:
164
+ // a server-side statement_timeout does not survive a pooler).
165
+ var QUERY_TIMEOUT_MS = ${DataExposure.READ_QUERY_TIMEOUT_MS}
166
+ async function guardStatement(req, query) {
167
+ // A retired table is reached by no caller, this app's own server code included.
168
+ if (__taRetiredTableNamed(null, query)) return { status: 403, message: 'Forbidden' }
169
+ if (__taIsInternalRequest(req)) return null
170
+ var token = await sessionUser(req)
171
+ var role = token ? __taRoleOf(token) : null
172
+ if (PAGE_ACCESS) {
173
+ if (!token) return { status: 401, message: 'Unauthenticated' }
174
+ if (PAGE_ACCESS.allowedRoles.length > 0 && (!role || PAGE_ACCESS.allowedRoles.indexOf(role) === -1)) {
175
+ return { status: 403, message: 'Forbidden' }
176
+ }
177
+ // A subscriber-only page: its states too need the entitlement.
178
+ if (PAGE_ACCESS.requiresSubscription && !(await __dxEntitled(token, PAGE_ACCESS.subscriptionProductIds))) {
179
+ return { status: 403, message: 'Forbidden' }
180
+ }
181
+ }
182
+ if (__taProtectedTablesRead(null, query).length === 0) return null
183
+ if (role && __TA_TRUSTED_READER_ROLES.indexOf(role) !== -1) return null
184
+ // Scoped to the session's own value: without a session the statement never
185
+ // runs (no value to bind), and the request is answered with no rows.
186
+ if (SESSION_SCOPED) return null
187
+ return token ? { status: 403, message: 'Forbidden' } : { status: 401, message: 'Unauthenticated' }
188
+ }
86
189
  `
87
- : ''
88
190
 
89
191
  const paramValidation =
90
192
  paramFields.length > 0
@@ -119,9 +221,10 @@ const getClient = () => {
119
221
  if (connStr) {
120
222
  const sslEnv = process.env.TELEPORT_DB_SSL
121
223
  const sslOpt = sslEnv === 'false' ? false : sslEnv === 'true' ? { rejectUnauthorized: false } : undefined
122
- return new Client(Object.assign({ connectionString: connStr }, sslOpt !== undefined ? { ssl: sslOpt } : {}))
224
+ return new Client(Object.assign({ connectionString: connStr, query_timeout: QUERY_TIMEOUT_MS }, sslOpt !== undefined ? { ssl: sslOpt } : {}))
123
225
  }
124
226
  return new Client({
227
+ query_timeout: QUERY_TIMEOUT_MS,
125
228
  host: process.env.TELEPORT_DB_HOST || ${JSON.stringify(pgConfig.host ?? null)},
126
229
  port: parseInt(process.env.TELEPORT_DB_PORT || '${pgConfig.port || 5432}', 10),
127
230
  user: process.env.TELEPORT_DB_USER || ${JSON.stringify(
@@ -149,11 +252,14 @@ const validateQuery = (query) => {
149
252
 
150
253
  export default async function handler(req, res) {
151
254
  try {
255
+ const query = ${JSON.stringify(query)}
256
+ const __denied = await guardStatement(req, query)
257
+ if (__denied) {
258
+ return res.status(__denied.status).json({ success: false, error: __denied.message, timestamp: Date.now() })
259
+ }
152
260
  ${paramDestructure}
153
261
  ${paramValidation}
154
262
 
155
- const query = ${JSON.stringify(query)}
156
-
157
263
  if (!validateQuery(query)) {
158
264
  return res.status(400).json({
159
265
  success: false,
@@ -167,21 +273,27 @@ export default async function handler(req, res) {
167
273
 
168
274
  try {
169
275
  const result = await client.query(query, ${paramArray})
276
+ const rows = Array.isArray(result && result.rows) ? result.rows : []
170
277
 
171
278
  return res.status(200).json({
172
279
  success: true,
173
- data: result.rows,
280
+ // No credential column leaves in a row, and a browser gets at most
281
+ // ROW_CAP rows (the statement's own LIMIT when it asks for more).
282
+ data: __taWithoutCredentials(__taIsInternalRequest(req) ? rows : rows.slice(0, ROW_CAP), null),
174
283
  timestamp: Date.now()
175
284
  })
176
285
  } finally {
177
286
  await client.end()
178
287
  }
179
288
  } catch (error) {
180
- // A database error names tables, columns and values: server log only.
181
- console.error('Raw query fetch error:', error)
289
+ // A database error names tables, columns and values: server log only,
290
+ // under the reference the client gets.
291
+ const id = Math.floor(Math.random() * 0xffffffff).toString(16)
292
+ console.error('Raw query fetch error (' + id + '):', error)
182
293
  return res.status(500).json({
183
294
  success: false,
184
295
  error: 'Failed to fetch data',
296
+ id: id,
185
297
  timestamp: Date.now()
186
298
  })
187
299
  }
@@ -5,6 +5,7 @@ import {
5
5
  generateFilterTreeHelpersCode,
6
6
  generateSearchEscapeHelpersCode,
7
7
  } from '../utils'
8
+ import { DataExposure } from '@teleporthq/teleport-shared'
8
9
  import { generateSearchSqlHelpersCode, REDSHIFT_SEARCH_SQL } from '../search-relevance'
9
10
 
10
11
  interface RedshiftConfig {
@@ -34,8 +35,10 @@ export const generateRedshiftFetcher = (
34
35
 
35
36
  return `import { Client } from 'pg'
36
37
 
38
+ // No query waits on the database longer than this (enforced by the client).
37
39
  const getClient = () => {
38
40
  return new Client({
41
+ query_timeout: ${DataExposure.READ_QUERY_TIMEOUT_MS},
39
42
  host: ${JSON.stringify(host)},
40
43
  port: ${port || 5439},
41
44
  user: ${JSON.stringify(user)},