@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.
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
@@ -1,26 +1,462 @@
1
- import { SessionCookieResolver, TableAccess } from '@teleporthq/teleport-shared'
1
+ import type { DataReadExposure } from '@teleporthq/teleport-types'
2
+ import { DataExposure, SessionCookieResolver, TableAccess } from '@teleporthq/teleport-shared'
2
3
  import { generateBrowserRowAccessCall, generateBrowserRowPolicyCode } from './browser-row-policy'
3
4
 
4
5
  /**
5
- * The money-table guard for a generated per-table read route
6
+ * The guard of a generated per-table read route
6
7
  * (`/api/teleport-<table>-<source>` and its `-count` twin).
7
8
  *
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.
9
+ * Every table a page binds gets such a route, and the route is a URL anyone
10
+ * can call. Three nets, in this order:
11
+ * 1. the table's EXPOSURE, decided at export time from the pages that bind it
12
+ * (`DataExposure.resolveDataExposure`): a table a public page shows is read
13
+ * by anyone; one only protected pages bind needs a session one of them
14
+ * admits; one no page binds is the app's own server code's;
15
+ * 2. the protected tables (see `TableAccess.PROTECTED_TABLES`): money and
16
+ * customer records, read only by a server segment presenting the app
17
+ * secret or a signed-in member of a trusted role (the generated admin);
18
+ * 3. the browser row policy (see `browser-row-policy.ts`): unpublished rows
19
+ * and private columns of the public tables.
20
+ * A server-side caller (the app secret, or the module's own in-process
21
+ * `fetchData`) passes all three.
22
+ *
23
+ * Every row count a request names is a whole number, and a browser's read is
24
+ * capped (`__taReadWindow`): a missing count gets the table's default, a large
25
+ * one the table's maximum.
26
+ */
27
+
28
+ /** No route reads more than this many rows past the first, from a browser. */
29
+ export const BROWSER_READ_MAX_OFFSET = 1000000
30
+
31
+ /**
32
+ * What a request may name as a field of the route's table (C1):
33
+ * - `columns`: the columns the export knows the table has (its schema in the
34
+ * data source config, plus every field the project's own bindings of the
35
+ * table name), or null for a source that has none to know (REST,
36
+ * JavaScript, CSV, Google Sheets, Redis: they filter in memory);
37
+ * - `virtualFields` / `virtualPrefixes`: the names the route itself answers
38
+ * with an expression (the products' effective price, rating, variant axes);
39
+ * - `documentStore`: a store that reads a `$`-key as an operator or a dotted
40
+ * key as a path (MongoDB, Firestore, Redis).
41
+ * A table with a schema but no known column answers every named field 400.
42
+ */
43
+ export interface RequestFieldPolicy {
44
+ columns: string[] | null
45
+ virtualFields?: string[]
46
+ virtualPrefixes?: string[]
47
+ documentStore?: boolean
48
+ /** The shape every named field must also have (a driver's identifier or label rule). */
49
+ pattern?: RegExp
50
+ }
51
+
52
+ /** A column of `config.selectedTables[tableName]`: its name, and its type when the config gives one. */
53
+ export interface SchemaColumn {
54
+ name: string
55
+ type?: string
56
+ }
57
+
58
+ /** The columns `config.selectedTables[tableName]` lists, with their types, or null when the config holds no schema for it. */
59
+ export const schemaOf = (config: unknown, tableName: string): SchemaColumn[] | null => {
60
+ const selected = (
61
+ config as {
62
+ selectedTables?: Record<string, { columns?: Array<{ name?: unknown; type?: unknown }> }>
63
+ }
64
+ )?.selectedTables
65
+ if (!selected || typeof selected !== 'object') {
66
+ return null
67
+ }
68
+ const bare = DataExposure.bareTableName(tableName)
69
+ const table =
70
+ selected[tableName] ||
71
+ Object.keys(selected)
72
+ .filter((key) => DataExposure.bareTableName(key) === bare)
73
+ .map((key) => selected[key])[0]
74
+ if (!table || !Array.isArray(table.columns)) {
75
+ return null
76
+ }
77
+ return table.columns
78
+ .filter((column) => column && typeof column.name === 'string' && column.name.length > 0)
79
+ .map((column) => ({
80
+ name: column.name as string,
81
+ ...(typeof column.type === 'string' && { type: column.type }),
82
+ }))
83
+ }
84
+
85
+ /** The columns `config.selectedTables[tableName]` lists, or null when the config holds no schema for it. */
86
+ export const schemaColumnsOf = (config: unknown, tableName: string): string[] | null => {
87
+ const schema = schemaOf(config, tableName)
88
+ return schema ? schema.map((column) => column.name) : null
89
+ }
90
+
91
+ /**
92
+ * The exposure check, the row window and the request-field check, for a
93
+ * module whose table is `exposure`'s. Uses `__taIsInternalRequest`,
94
+ * `__taRoleOf` and `__tqSessionToken` from the preamble.
95
+ */
96
+ export const generateRouteExposureCode = (
97
+ exposure: DataReadExposure | null | undefined,
98
+ fieldPolicy: RequestFieldPolicy = { columns: [] },
99
+ tableName: string = ''
100
+ ): string => {
101
+ const routeExposure = exposure || DataExposure.CLOSED_READ_EXPOSURE
102
+ const subscriberRules = routeExposure.subscriberRules || []
103
+ const ownerRules = routeExposure.ownerRules || []
104
+ const knownColumns =
105
+ fieldPolicy.columns === null
106
+ ? null
107
+ : Array.from(
108
+ new Set(
109
+ [...fieldPolicy.columns, ...(routeExposure.fields || [])].map((column) =>
110
+ String(column).toLowerCase()
111
+ )
112
+ )
113
+ ).sort()
114
+ return `// GENERATED — see generateRouteExposureCode in teleport-plugin-next-data-source.
115
+ // Who may read this route's table from a browser, decided at export time from
116
+ // the pages that bind it: anyone when a page every visitor may open does;
117
+ // otherwise a session one of the binding pages admits — with an entitled
118
+ // subscription, when that page is subscriber-only. The app's own server code
119
+ // always may.
120
+ var __TA_ROUTE_EXPOSURE = ${JSON.stringify({
121
+ public: routeExposure.public === true,
122
+ anySession: routeExposure.anySession === true,
123
+ roles: routeExposure.roles || [],
124
+ subscriberRules,
125
+ ownerRules,
126
+ defaultLimit: routeExposure.defaultLimit,
127
+ maxLimit: routeExposure.maxLimit,
128
+ })};
129
+ var __TA_MAX_OFFSET = ${BROWSER_READ_MAX_OFFSET};
130
+ var __TA_ROUTE_TABLE = ${JSON.stringify(DataExposure.bareTableName(tableName))};
131
+ // The fields a request may name (see RequestFieldPolicy): null columns = a source
132
+ // without a schema, checked for shape only.
133
+ var __TA_FIELD_POLICY = ${JSON.stringify({
134
+ columns: knownColumns,
135
+ virtualFields: (fieldPolicy.virtualFields || []).map((field) => field.toLowerCase()),
136
+ virtualPrefixes: (fieldPolicy.virtualPrefixes || []).map((prefix) => prefix.toLowerCase()),
137
+ documentStore: fieldPolicy.documentStore === true,
138
+ })};
139
+ var __TA_FIELD_PATTERN = ${fieldPolicy.pattern ? fieldPolicy.pattern.toString() : 'null'};
140
+ ${subscriberRules.length > 0 ? DataExposure.generateEntitlementCheckCode() : ''}
141
+ // null when the request may read the table, else { status, message }.
142
+ async function __taGuardExposure(req) {
143
+ if (__TA_ROUTE_EXPOSURE.public === true) return null;
144
+ if (__taIsInternalRequest(req)) return null;
145
+ // A page's own data, read in-process to render it (\`fetchData(params, { as:
146
+ // 'visitor' })\`): the page's access guard already admitted whoever opened it,
147
+ // and the read is served as an anonymous visitor's (__taGuardRead, the row
148
+ // policy). It carries no session to check here — a protected page would
149
+ // otherwise render nothing.
150
+ if (__taIsVisitorView(req)) return null;
151
+ // No page binds the table: no session could read it from a browser.
152
+ if (
153
+ __TA_ROUTE_EXPOSURE.anySession !== true &&
154
+ __TA_ROUTE_EXPOSURE.roles.length === 0 &&
155
+ __TA_ROUTE_EXPOSURE.subscriberRules.length === 0 &&
156
+ __TA_ROUTE_EXPOSURE.ownerRules.length === 0
157
+ ) {
158
+ return { status: 403, message: 'Forbidden' };
159
+ }
160
+ var token = null;
161
+ try {
162
+ token = await __tqSessionToken(req);
163
+ } catch (e) {
164
+ token = null;
165
+ }
166
+ if (!token) return { status: 401, message: 'Unauthenticated' };
167
+ if (__TA_ROUTE_EXPOSURE.anySession === true) return null;
168
+ var role = __taRoleOf(token);
169
+ if (role && __TA_ROUTE_EXPOSURE.roles.indexOf(role) !== -1) return null;
170
+ // A row-owned page: the session reads its own rows only — the condition is
171
+ // added to the read by the server (__taApplyOwnerScope).
172
+ if (__TA_ROUTE_EXPOSURE.ownerRules.length > 0) {
173
+ var ownerId = token.id || token.sub;
174
+ if (!ownerId) return { status: 403, message: 'Forbidden' };
175
+ req.__tqOwnerScope = { userId: String(ownerId) };
176
+ return null;
177
+ }
178
+ // A subscriber-only page that admits the session: the products its rules
179
+ // name (any product when one rule names none).
180
+ var products = null;
181
+ for (var i = 0; i < __TA_ROUTE_EXPOSURE.subscriberRules.length; i++) {
182
+ var rule = __TA_ROUTE_EXPOSURE.subscriberRules[i];
183
+ if (rule.roles.length > 0 && (!role || rule.roles.indexOf(role) === -1)) continue;
184
+ products = rule.productIds.length === 0 || (products && products.length === 0)
185
+ ? []
186
+ : (products || []).concat(rule.productIds);
187
+ }
188
+ if (products && (await __dxEntitled(token, products))) return null;
189
+ return { status: 403, message: 'Forbidden' };
190
+ }
191
+
192
+ function __taRefuseRequest(label) {
193
+ var err = new Error('Invalid ' + label);
194
+ err.status = 400;
195
+ throw err;
196
+ }
197
+
198
+ // A row count from the request: a whole number, or nothing. Anything else
199
+ // (\`1;--\`, \`1 OR 1=1\`, \`-5\`, \`1e9\`) is refused, never parsed into one.
200
+ function __taWholeNumber(value, label) {
201
+ if (Array.isArray(value)) value = value[0];
202
+ if (value === undefined || value === null || value === '') return undefined;
203
+ var text = String(value).trim();
204
+ if (!/^[0-9]{1,15}$/.test(text)) __taRefuseRequest(label);
205
+ return parseInt(text, 10);
206
+ }
207
+
208
+ // The rows a read may ask for. A server-side caller gets what it names; a
209
+ // browser gets the table's default when it names no count, never more than the
210
+ // table's maximum, and starts no further in than __TA_MAX_OFFSET.
211
+ function __taReadWindow(req) {
212
+ var q = (req && req.query) || {};
213
+ var limit = __taWholeNumber(q.limit, 'limit');
214
+ var perPage = __taWholeNumber(q.perPage, 'perPage');
215
+ var page = __taWholeNumber(q.page, 'page');
216
+ var offset = __taWholeNumber(q.offset, 'offset');
217
+ if (!__taIsInternalRequest(req)) {
218
+ var max = __TA_ROUTE_EXPOSURE.maxLimit;
219
+ if (limit !== undefined) limit = Math.min(limit, max);
220
+ if (perPage !== undefined) perPage = Math.min(perPage, max);
221
+ if (!limit && !perPage) limit = __TA_ROUTE_EXPOSURE.defaultLimit;
222
+ if (offset !== undefined) offset = Math.min(offset, __TA_MAX_OFFSET);
223
+ if (page !== undefined) {
224
+ page = Math.max(1, Math.min(page, Math.floor(__TA_MAX_OFFSET / Math.max(perPage || 1, 1)) + 1));
225
+ }
226
+ }
227
+ return { limit: limit, perPage: perPage, page: page, offset: offset };
228
+ }
229
+
230
+ // The parsed value of a JSON request parameter: undefined when absent, and a
231
+ // refusal when it is text that is not JSON (a driver would read it char by char).
232
+ function __taJsonParam(value, label) {
233
+ if (value === undefined || value === null || value === '') return undefined;
234
+ if (Array.isArray(value)) __taRefuseRequest(label);
235
+ if (typeof value !== 'string') return value;
236
+ try {
237
+ return JSON.parse(value);
238
+ } catch (e) {
239
+ __taRefuseRequest(label);
240
+ }
241
+ }
242
+
243
+ // Every field a filter names, in every shape the drivers accept
244
+ // (normalizeFilterTree): a list of conditions and groups, or an object whose
245
+ // KEYS are the fields. \`values\` collects what each field is compared with.
246
+ function __taFilterFields(parsed, out, values) {
247
+ if (parsed === undefined) return out;
248
+ if (Array.isArray(parsed)) {
249
+ for (var i = 0; i < parsed.length; i++) __taFilterNodeFields(parsed[i], out, values);
250
+ return out;
251
+ }
252
+ if (parsed && typeof parsed === 'object') {
253
+ Object.keys(parsed).forEach(function(key) {
254
+ out.push(key);
255
+ values.push(parsed[key]);
256
+ });
257
+ return out;
258
+ }
259
+ __taRefuseRequest('filters');
260
+ }
261
+
262
+ function __taFilterNodeFields(entry, out, values) {
263
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return;
264
+ if (entry.type === 'group') {
265
+ var children = Array.isArray(entry.children) ? entry.children : [];
266
+ for (var i = 0; i < children.length; i++) __taFilterNodeFields(children[i], out, values);
267
+ return;
268
+ }
269
+ if (entry.source !== undefined) {
270
+ out.push(entry.source);
271
+ values.push(entry.destination);
272
+ }
273
+ }
274
+
275
+ // What a document store compares a field with: a plain value, or a list of
276
+ // plain values (an "in" filter). An object would reach the store as an
277
+ // operator (\`{ "$ne": null }\`, \`{ "$regex": … }\`) and widen the read.
278
+ function __taAssertPlainValue(value) {
279
+ if (value === null || value === undefined) return;
280
+ var type = typeof value;
281
+ if (type === 'string' || type === 'number' || type === 'boolean') return;
282
+ if (Array.isArray(value)) {
283
+ for (var i = 0; i < value.length; i++) {
284
+ if (value[i] !== null && typeof value[i] === 'object') __taRefuseRequest('filters');
285
+ }
286
+ return;
287
+ }
288
+ __taRefuseRequest('filters');
289
+ }
290
+
291
+ // The columns a request's projection names (\`select\`, which Supabase hands
292
+ // PostgREST): the bare star, or a comma list of plain column names. An alias
293
+ // (\`name:password\`), a cast or an embedded table (\`users(email)\`) is
294
+ // refused — it reads a column under another name, or a table no page binds.
295
+ function __taSelectFields(select) {
296
+ if (select === undefined || select === null || select === '') return [];
297
+ if (typeof select !== 'string') __taRefuseRequest('select');
298
+ var text = select.trim();
299
+ if (text === '*') return [];
300
+ var parts = text.split(',');
301
+ var out = [];
302
+ for (var i = 0; i < parts.length; i++) {
303
+ var name = parts[i].trim();
304
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) __taRefuseRequest('select');
305
+ out.push(name);
306
+ }
307
+ return out;
308
+ }
309
+
310
+ var __TA_UNSAFE_KEYS = ['__proto__', 'constructor', 'prototype'];
311
+
312
+ // One field a request names: a column the export knows for the table (or a
313
+ // field the route answers itself), of a safe shape, and — for a browser —
314
+ // never a credential column.
315
+ function __taAssertField(field, browser) {
316
+ if (typeof field !== 'string' || field.length === 0 || field.length > 128) __taRefuseRequest('field');
317
+ if (field.charAt(0) === '$' || __TA_UNSAFE_KEYS.indexOf(field) !== -1) __taRefuseRequest('field');
318
+ if (__TA_FIELD_POLICY.documentStore && field.indexOf('.') !== -1) __taRefuseRequest('field');
319
+ if (__TA_FIELD_PATTERN && !__TA_FIELD_PATTERN.test(field)) __taRefuseRequest('field');
320
+ var name = field.replace(/"/g, '').toLowerCase();
321
+ if (browser) {
322
+ var column = name.split('.').pop();
323
+ if (__taIsCredentialColumn(column)) __taRefuseRequest('field');
324
+ }
325
+ if (__TA_FIELD_POLICY.columns === null) return;
326
+ if (__TA_FIELD_POLICY.virtualFields.indexOf(name) !== -1) return;
327
+ for (var i = 0; i < __TA_FIELD_POLICY.virtualPrefixes.length; i++) {
328
+ if (name.indexOf(__TA_FIELD_POLICY.virtualPrefixes[i]) === 0) return;
329
+ }
330
+ var parts = name.split('.');
331
+ if (parts.length > 2) __taRefuseRequest('field');
332
+ if (parts.length === 2 && parts[0] !== __TA_ROUTE_TABLE) __taRefuseRequest('field');
333
+ if (__TA_FIELD_POLICY.columns.indexOf(parts[parts.length - 1]) === -1) __taRefuseRequest('field');
334
+ }
335
+
336
+ // A sort order: ascending or descending, or none.
337
+ function __taAssertOrder(order) {
338
+ if (order === undefined || order === null || order === '') return;
339
+ if (typeof order !== 'string' || !/^(asc|desc)$/i.test(order)) __taRefuseRequest('sort order');
340
+ }
341
+
342
+ // Every field a request filters, sorts or searches by, checked before any
343
+ // driver call: one sort field with asc|desc, no repeated parameter.
344
+ function __taAssertReadRequest(req) {
345
+ var q = (req && req.query) || {};
346
+ var browser = !__taIsInternalRequest(req);
347
+ var values = [];
348
+ var fields = __taFilterFields(__taJsonParam(q.filters, 'filters'), [], values);
349
+ if (__TA_FIELD_POLICY.documentStore) {
350
+ for (var v = 0; v < values.length; v++) __taAssertPlainValue(values[v]);
351
+ }
352
+ fields = fields.concat(__taSelectFields(q.select));
353
+ var sorts = __taJsonParam(q.sorts, 'sorts');
354
+ if (sorts !== undefined) {
355
+ if (!Array.isArray(sorts) || sorts.length > 1) __taRefuseRequest('sorts');
356
+ for (var i = 0; i < sorts.length; i++) {
357
+ if (!sorts[i] || typeof sorts[i] !== 'object') __taRefuseRequest('sorts');
358
+ fields.push(sorts[i].field);
359
+ __taAssertOrder(sorts[i].order);
360
+ }
361
+ }
362
+ if (q.sortBy !== undefined && q.sortBy !== '') {
363
+ if (typeof q.sortBy !== 'string') __taRefuseRequest('sortBy');
364
+ fields.push(q.sortBy);
365
+ }
366
+ if (Array.isArray(q.sortOrder)) __taRefuseRequest('sortOrder');
367
+ __taAssertOrder(q.sortOrder);
368
+ var queryColumns = q.queryColumns;
369
+ if (typeof queryColumns === 'string' && queryColumns.length > 0 && queryColumns.charAt(0) !== '[' && queryColumns.charAt(0) !== '"') {
370
+ queryColumns = JSON.stringify(queryColumns);
371
+ }
372
+ var searchColumns = __taJsonParam(queryColumns, 'queryColumns');
373
+ if (searchColumns !== undefined) {
374
+ var list = Array.isArray(searchColumns) ? searchColumns : [searchColumns];
375
+ for (var c = 0; c < list.length; c++) fields.push(list[c]);
376
+ }
377
+ for (var f = 0; f < fields.length; f++) __taAssertField(fields[f], browser);
378
+ }
379
+
380
+ // A row-owned page's read (see __taGuardExposure): the owner condition joins
381
+ // whatever the request filters by, so no filter can widen it.
382
+ function __taApplyOwnerScope(req) {
383
+ var scope = req && req.__tqOwnerScope;
384
+ if (!scope) return req;
385
+ var q = (req && req.query) || {};
386
+ var parsed = __taJsonParam(q.filters, 'filters');
387
+ var original = Array.isArray(parsed)
388
+ ? parsed
389
+ : parsed && typeof parsed === 'object'
390
+ ? Object.keys(parsed).map(function(key) { return { source: key, destination: parsed[key], operand: '=' }; })
391
+ : [];
392
+ var owners = __TA_ROUTE_EXPOSURE.ownerRules.map(function(rule) {
393
+ return { type: 'condition', source: rule.column, destination: scope.userId, operand: '=' };
394
+ });
395
+ var tree = [{ type: 'group', operator: 'or', children: owners }];
396
+ if (original.length > 0) tree.push({ type: 'group', operator: 'and', children: original });
397
+ req.query = Object.assign({}, q, { filters: JSON.stringify(tree) });
398
+ return req;
399
+ }
400
+
401
+ // An opaque reference for a refused read: the client gets it, the server log
402
+ // gets the detail under the same reference.
403
+ function __taErrorId() {
404
+ try {
405
+ return require('crypto').randomBytes(8).toString('hex');
406
+ } catch (e) {
407
+ return Math.floor(Math.random() * 0xffffffff).toString(16);
408
+ }
409
+ }
410
+ `
411
+ }
412
+
413
+ /**
414
+ * Where a read route sits and what its session reader checks (see
415
+ * `EntityTransformOptions.routeProjectRoot` / `.sessionRevocation`).
17
416
  */
417
+ export interface SessionReaderPlacement {
418
+ /** The route's path back to the project root; absent, two folders. */
419
+ routeProjectRoot?: string
420
+ /** The project has authentication; absent, the reader checks revocation. */
421
+ sessionRevocation?: boolean
422
+ }
18
423
 
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()}`
424
+ /**
425
+ * Declares the session resolver, the `__ta*` guard and the route's exposure
426
+ * once per module.
427
+ */
428
+ export const generateTableAccessPreamble = (
429
+ trustedReaderRoles: ReadonlyArray<string>,
430
+ exposure?: DataReadExposure | null,
431
+ fieldPolicy?: RequestFieldPolicy,
432
+ tableName?: string,
433
+ reader: SessionReaderPlacement = {}
434
+ ): string => {
435
+ // The fetcher lives in utils/data-sources/ or pages/api/ (two folders below
436
+ // the root) unless the caller says otherwise (pages/api/global-state/).
437
+ const projectRoot = reader.routeProjectRoot || '../..'
438
+ return `${SessionCookieResolver.generateCommonJsSessionTokenResolverCode(projectRoot, {
439
+ withRevocation: reader.sessionRevocation !== false,
440
+ })}
441
+ ${TableAccess.generateTableAccessHelperCode({ trustedReaderRoles, projectRoot })}
442
+ ${generateBrowserRowPolicyCode()}
443
+ ${generateRouteExposureCode(exposure, fieldPolicy, tableName || '')}`
444
+ }
445
+
446
+ /** The products table's virtual filter and sort fields (`product-filter-fields.ts`, `product-price-sort.ts`). */
447
+ export const PRODUCT_VIRTUAL_FIELDS = ['rating', 'on_sale', 'price', 'discounts']
448
+ export const PRODUCT_VIRTUAL_PREFIXES = ['variant_options.']
449
+
450
+ /** What a TeleportHQ / Postgres route's request may name: its schema, plus the products' virtual fields. */
451
+ export const sqlRouteFieldPolicy = (config: unknown, tableName: string): RequestFieldPolicy => {
452
+ const products = DataExposure.bareTableName(tableName) === 'teleport_products'
453
+ return {
454
+ columns: schemaColumnsOf(config, tableName) || [],
455
+ ...(products && {
456
+ virtualFields: PRODUCT_VIRTUAL_FIELDS,
457
+ virtualPrefixes: PRODUCT_VIRTUAL_PREFIXES,
458
+ }),
459
+ }
24
460
  }
25
461
 
26
462
  /**
@@ -29,11 +465,24 @@ ${generateBrowserRowPolicyCode()}`
29
465
  * (or `null` where the handler accepts none).
30
466
  */
31
467
  export const generateReadGuardCall = (tableName: string, rawQueryExpr: string | null): string => {
32
- return ` const __access = await __taGuardRead(req, ${JSON.stringify(tableName)}, ${
33
- rawQueryExpr || 'null'
34
- })
468
+ return ` const __exposure = await __taGuardExposure(req)
469
+ if (__exposure) {
470
+ return res.status(__exposure.status).json({ success: false, error: __exposure.message, timestamp: Date.now() })
471
+ }
472
+ const __access = await __taGuardRead(req, ${JSON.stringify(tableName)}, ${rawQueryExpr || 'null'})
35
473
  if (__access) {
36
474
  return res.status(__access.status).json({ success: false, error: __access.message, timestamp: Date.now() })
37
475
  }
38
- ${generateBrowserRowAccessCall(tableName, rawQueryExpr)}`
476
+ ${generateBrowserRowAccessCall(
477
+ tableName,
478
+ rawQueryExpr
479
+ )} // Every field the request names is one the export knows; a row-owned page's
480
+ // owner condition joins the request's own filters.
481
+ try {
482
+ __taAssertReadRequest(req)
483
+ } catch (__invalid) {
484
+ return res.status(400).json({ success: false, error: __invalid && __invalid.status ? __invalid.message : 'Invalid request', timestamp: Date.now() })
485
+ }
486
+ __taApplyOwnerScope(req)
487
+ `
39
488
  }
package/src/index.ts CHANGED
@@ -332,7 +332,7 @@ function wrapDataSourceExpressionsInAttributes(
332
332
  dependencies.DataProvider = {
333
333
  type: 'package',
334
334
  path: '@teleporthq/react-components',
335
- version: 'latest',
335
+ version: '1.0.13',
336
336
  meta: {
337
337
  namedImport: true,
338
338
  },
@@ -46,7 +46,10 @@ export type PaginationControlKind =
46
46
  */
47
47
  export const PAGINATION_FOR_ATTR = 'data-tq-pagination-for'
48
48
 
49
- /** The words of a `range` control, with `{from}`, `{to}` and `{total}` placeholders. */
49
+ /**
50
+ * The words of a `range` control, with `{from}`, `{to}`, `{total}`, `{page}`,
51
+ * `{pages}` and `{perPage}` placeholders — see `range-control.ts`.
52
+ */
50
53
  export const RANGE_TEMPLATE_ATTR = 'data-tq-range-template'
51
54
 
52
55
  /** What a `range` control reads when it carries no template. */
@@ -3401,6 +3401,7 @@ function wireRangeControlFor(
3401
3401
  wireRangeControl(control, {
3402
3402
  totalVar: vars.totalStateVar,
3403
3403
  currentPageExpr: currentPageExpr(),
3404
+ maxPagesVar: vars.maxPagesStateVar,
3404
3405
  perPage: usage.perPage,
3405
3406
  infiniteScroll: usage.infiniteScroll,
3406
3407
  })
@@ -11,17 +11,20 @@ import {
11
11
  * live row range of the mapper it belongs to.
12
12
  *
13
13
  * The author's words stay theirs — they are read off `data-tq-range-template`
14
- * with `{from}`, `{to}` and `{total}` standing for the numbers — and the drawn
15
- * text is kept, verbatim, until the count endpoint has answered: `ds_N_total`
16
- * is `null` until then, and a range of `NaN` or `undefined` is worse than the
17
- * placeholder the designer chose.
14
+ * with `{from}`, `{to}`, `{total}`, `{page}`, `{pages}` and `{perPage}` standing
15
+ * for the numbers — and the drawn text is kept, verbatim, until the count
16
+ * endpoint has answered: `ds_N_total` is `null` until then, and a range of
17
+ * `NaN` or `undefined` is worse than the placeholder the designer chose.
18
18
  *
19
19
  * buttons / numbered from = total === 0 ? 0 : (page − 1) × perPage + 1
20
20
  * infinite scroll from = total === 0 ? 0 : 1
21
21
  * both to = min(page × perPage, total)
22
+ * pages = total === 0 ? 0 : max(1, maxPages)
22
23
  *
23
24
  * In an appending list `page` is the number of pages loaded so far, so the
24
- * same `to` counts the rows on screen. Numbers go through
25
+ * same `to` counts the rows on screen and `{page}` reads the pages on screen.
26
+ * `{pages}` is the mapper's `ds_N_maxPages`, counted by the same response as
27
+ * the total; `{perPage}` the page size. Numbers go through
25
28
  * `toLocaleString("en-US")`, the way the canvas prints them.
26
29
  *
27
30
  * ⛔ The arithmetic must agree with the canvas renderer's
@@ -36,12 +39,14 @@ export interface RangeControlVars {
36
39
  totalVar: string
37
40
  /** The page the list stands on: `ds_N_page`, or `ds_N_state.page` beside a search. */
38
41
  currentPageExpr: types.Expression
42
+ /** `ds_N_maxPages` — 0 until counted. */
43
+ maxPagesVar: string
39
44
  perPage: number
40
45
  /** An appending list always shows from the first row. */
41
46
  infiniteScroll: boolean
42
47
  }
43
48
 
44
- const PLACEHOLDER = /\{(from|to|total)\}/g
49
+ const PLACEHOLDER = /\{(from|to|total|page|pages|perPage)\}/g
45
50
 
46
51
  const total = (vars: RangeControlVars): types.Identifier => types.identifier(vars.totalVar)
47
52
 
@@ -79,9 +84,36 @@ const buildTo = (vars: RangeControlVars): types.Expression =>
79
84
  total(vars),
80
85
  ])
81
86
 
87
+ /** `total === 0 ? 0 : Math.max(1, maxPages)` — the strip has one page as long as it has rows. */
88
+ const buildPages = (vars: RangeControlVars): types.Expression =>
89
+ unlessEmpty(
90
+ vars,
91
+ types.callExpression(
92
+ types.memberExpression(types.identifier('Math'), types.identifier('max')),
93
+ [types.numericLiteral(1), types.identifier(vars.maxPagesVar)]
94
+ )
95
+ )
96
+
97
+ const buildValue = (placeholder: string, vars: RangeControlVars): types.Expression => {
98
+ switch (placeholder) {
99
+ case 'from':
100
+ return buildFrom(vars)
101
+ case 'to':
102
+ return buildTo(vars)
103
+ case 'page':
104
+ return page(vars)
105
+ case 'pages':
106
+ return buildPages(vars)
107
+ case 'perPage':
108
+ // Parenthesised, or the printer writes `20 .toLocaleString(…)`.
109
+ return types.parenthesizedExpression(types.numericLiteral(vars.perPage))
110
+ default:
111
+ return total(vars)
112
+ }
113
+ }
114
+
82
115
  const buildNumber = (placeholder: string, vars: RangeControlVars): types.Expression => {
83
- const value =
84
- placeholder === 'from' ? buildFrom(vars) : placeholder === 'to' ? buildTo(vars) : total(vars)
116
+ const value = buildValue(placeholder, vars)
85
117
  return types.callExpression(types.memberExpression(value, types.identifier('toLocaleString')), [
86
118
  types.stringLiteral('en-US'),
87
119
  ])