@teleporthq/teleport-plugin-next-data-source 0.44.15 → 0.45.0
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__/_helpers/exposure.ts +29 -0
- package/__tests__/_helpers/first-page-runtime.ts +12 -2
- package/__tests__/blog-post-contents.test.ts +3 -1
- package/__tests__/blog-post-page-enrichment.test.ts +2 -1
- package/__tests__/blog-post-seo-fields.test.ts +124 -0
- package/__tests__/browser-row-policy.test.ts +65 -5
- package/__tests__/data-source-cache-wrapping.test.ts +48 -7
- package/__tests__/external-read-routes-guard.test.ts +991 -0
- package/__tests__/null-filter-where.test.ts +19 -2
- package/__tests__/range-control.test.ts +29 -0
- package/__tests__/search-relevance.test.ts +42 -6
- package/__tests__/table-read-route-protected-tables.test.ts +54 -4
- package/dist/cjs/count-fetchers.d.ts.map +1 -1
- package/dist/cjs/count-fetchers.js +1 -1
- package/dist/cjs/count-fetchers.js.map +1 -1
- package/dist/cjs/data-source-fetchers.d.ts +4 -0
- package/dist/cjs/data-source-fetchers.d.ts.map +1 -1
- package/dist/cjs/data-source-fetchers.js +97 -20
- package/dist/cjs/data-source-fetchers.js.map +1 -1
- package/dist/cjs/fetchers/postgresql.d.ts +3 -1
- package/dist/cjs/fetchers/postgresql.d.ts.map +1 -1
- package/dist/cjs/fetchers/postgresql.js +11 -3
- package/dist/cjs/fetchers/postgresql.js.map +1 -1
- package/dist/cjs/fetchers/raw-query.d.ts +20 -1
- package/dist/cjs/fetchers/raw-query.d.ts.map +1 -1
- package/dist/cjs/fetchers/raw-query.js +37 -7
- package/dist/cjs/fetchers/raw-query.js.map +1 -1
- package/dist/cjs/fetchers/redshift.d.ts.map +1 -1
- package/dist/cjs/fetchers/redshift.js +2 -1
- package/dist/cjs/fetchers/redshift.js.map +1 -1
- package/dist/cjs/fetchers/teleport.d.ts.map +1 -1
- package/dist/cjs/fetchers/teleport.js +5 -3
- package/dist/cjs/fetchers/teleport.js.map +1 -1
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/cjs/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js +126 -0
- package/dist/cjs/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/cjs/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/cjs/fetchers/utils/table-access-guard.js +123 -15
- package/dist/cjs/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/cjs/pagination-controls.d.ts +4 -1
- package/dist/cjs/pagination-controls.d.ts.map +1 -1
- package/dist/cjs/pagination-controls.js +4 -1
- package/dist/cjs/pagination-controls.js.map +1 -1
- package/dist/cjs/pagination-plugin.js +1 -0
- package/dist/cjs/pagination-plugin.js.map +1 -1
- package/dist/cjs/range-control.d.ts +10 -5
- package/dist/cjs/range-control.d.ts.map +1 -1
- package/dist/cjs/range-control.js +23 -2
- package/dist/cjs/range-control.js.map +1 -1
- package/dist/cjs/transformations/blog-post.d.ts +5 -0
- package/dist/cjs/transformations/blog-post.d.ts.map +1 -1
- package/dist/cjs/transformations/blog-post.js +6 -2
- package/dist/cjs/transformations/blog-post.js.map +1 -1
- package/dist/cjs/transformations/index.d.ts +18 -2
- package/dist/cjs/transformations/index.d.ts.map +1 -1
- package/dist/cjs/transformations/index.js +26 -1
- package/dist/cjs/transformations/index.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/esm/count-fetchers.d.ts.map +1 -1
- package/dist/esm/count-fetchers.js +1 -1
- package/dist/esm/count-fetchers.js.map +1 -1
- package/dist/esm/data-source-fetchers.d.ts +4 -0
- package/dist/esm/data-source-fetchers.d.ts.map +1 -1
- package/dist/esm/data-source-fetchers.js +97 -20
- package/dist/esm/data-source-fetchers.js.map +1 -1
- package/dist/esm/fetchers/postgresql.d.ts +3 -1
- package/dist/esm/fetchers/postgresql.d.ts.map +1 -1
- package/dist/esm/fetchers/postgresql.js +12 -4
- package/dist/esm/fetchers/postgresql.js.map +1 -1
- package/dist/esm/fetchers/raw-query.d.ts +20 -1
- package/dist/esm/fetchers/raw-query.d.ts.map +1 -1
- package/dist/esm/fetchers/raw-query.js +38 -8
- package/dist/esm/fetchers/raw-query.js.map +1 -1
- package/dist/esm/fetchers/redshift.d.ts.map +1 -1
- package/dist/esm/fetchers/redshift.js +2 -1
- package/dist/esm/fetchers/redshift.js.map +1 -1
- package/dist/esm/fetchers/teleport.d.ts.map +1 -1
- package/dist/esm/fetchers/teleport.js +6 -4
- package/dist/esm/fetchers/teleport.js.map +1 -1
- package/dist/esm/fetchers/utils/external-read-guard.d.ts +53 -0
- package/dist/esm/fetchers/utils/external-read-guard.d.ts.map +1 -0
- package/dist/esm/fetchers/utils/external-read-guard.js +118 -0
- package/dist/esm/fetchers/utils/external-read-guard.js.map +1 -0
- package/dist/esm/fetchers/utils/table-access-guard.d.ts +76 -12
- package/dist/esm/fetchers/utils/table-access-guard.d.ts.map +1 -1
- package/dist/esm/fetchers/utils/table-access-guard.js +119 -15
- package/dist/esm/fetchers/utils/table-access-guard.js.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/pagination-controls.d.ts +4 -1
- package/dist/esm/pagination-controls.d.ts.map +1 -1
- package/dist/esm/pagination-controls.js +4 -1
- package/dist/esm/pagination-controls.js.map +1 -1
- package/dist/esm/pagination-plugin.js +1 -0
- package/dist/esm/pagination-plugin.js.map +1 -1
- package/dist/esm/range-control.d.ts +10 -5
- package/dist/esm/range-control.d.ts.map +1 -1
- package/dist/esm/range-control.js +23 -2
- package/dist/esm/range-control.js.map +1 -1
- package/dist/esm/transformations/blog-post.d.ts +5 -0
- package/dist/esm/transformations/blog-post.d.ts.map +1 -1
- package/dist/esm/transformations/blog-post.js +6 -2
- package/dist/esm/transformations/blog-post.js.map +1 -1
- package/dist/esm/transformations/index.d.ts +18 -2
- package/dist/esm/transformations/index.d.ts.map +1 -1
- package/dist/esm/transformations/index.js +27 -2
- package/dist/esm/transformations/index.js.map +1 -1
- package/dist/esm/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/count-fetchers.ts +10 -2
- package/src/data-source-fetchers.ts +144 -23
- package/src/fetchers/postgresql.ts +38 -9
- package/src/fetchers/raw-query.ts +125 -13
- package/src/fetchers/redshift.ts +3 -0
- package/src/fetchers/teleport.ts +29 -8
- package/src/fetchers/utils/external-read-guard.ts +289 -0
- package/src/fetchers/utils/table-access-guard.ts +469 -20
- package/src/index.ts +1 -1
- package/src/pagination-controls.ts +4 -1
- package/src/pagination-plugin.ts +1 -0
- package/src/range-control.ts +40 -8
- package/src/transformations/blog-post.ts +107 -4
- 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.
|
|
3
|
+
"version": "0.45.0",
|
|
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.
|
|
30
|
-
"@teleporthq/teleport-shared": "^0.
|
|
31
|
-
"@teleporthq/teleport-types": "^0.
|
|
29
|
+
"@teleporthq/teleport-plugin-common": "^0.45.0",
|
|
30
|
+
"@teleporthq/teleport-shared": "^0.45.0",
|
|
31
|
+
"@teleporthq/teleport-types": "^0.45.0"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "72396d1815ef1f0c184f27126dbfd0fd9c6dc0f8"
|
|
34
34
|
}
|
package/src/count-fetchers.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
323
|
-
ttl:
|
|
324
|
-
sMaxAge:
|
|
325
|
-
swr:
|
|
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(
|
|
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:
|
|
334
|
-
ttl:
|
|
335
|
-
sMaxAge:
|
|
336
|
-
swr:
|
|
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
|
-
|
|
371
|
-
|
|
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 =
|
|
379
|
-
const countExport =
|
|
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 = !
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/fetchers/redshift.ts
CHANGED
|
@@ -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)},
|