domma-cms 0.91.1 → 0.92.1

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 (38) hide show
  1. package/CLAUDE.md +18 -9
  2. package/admin/js/lib/collection-editor-arrange.js +2 -2
  3. package/admin/js/lib/view-editor-arrange.js +1 -1
  4. package/admin/js/templates/collection-editor.html +3 -1
  5. package/admin/js/templates/docs/api-users.html +8 -8
  6. package/admin/js/templates/docs/api-views.html +5 -4
  7. package/admin/js/templates/docs/usage-users-roles.html +28 -48
  8. package/admin/js/templates/docs/usage-views.html +6 -4
  9. package/admin/js/views/collection-editor.js +4 -4
  10. package/admin/js/views/view-editor.js +1 -1
  11. package/package.json +1 -1
  12. package/plugins/feedback/CLAUDE.md +96 -0
  13. package/plugins/feedback/admin/css/index.css +80 -0
  14. package/plugins/feedback/admin/lib/kit.js +105 -0
  15. package/plugins/feedback/admin/templates/feedback.html +1 -0
  16. package/plugins/feedback/admin/views/feedback.js +63 -0
  17. package/plugins/feedback/admin/views/inbox.js +325 -0
  18. package/plugins/feedback/admin/views/send.js +223 -0
  19. package/plugins/feedback/collections/feedback-reports.json +24 -0
  20. package/plugins/feedback/config.js +15 -0
  21. package/plugins/feedback/lib/receiver.js +121 -0
  22. package/plugins/feedback/lib/sender.js +161 -0
  23. package/plugins/feedback/lib/shape.js +211 -0
  24. package/plugins/feedback/plugin.js +276 -0
  25. package/plugins/feedback/plugin.json +48 -0
  26. package/plugins/feedback/tests/api.test.js +219 -0
  27. package/plugins/feedback/tests/shape.test.js +87 -0
  28. package/plugins/free-tier.lock.json +22 -1
  29. package/server/routes/api/collections.js +8 -1
  30. package/server/routes/api/search.js +27 -6
  31. package/server/routes/api/views.js +19 -20
  32. package/server/routes/public.js +9 -0
  33. package/server/services/content.js +6 -4
  34. package/server/services/markdown.js +66 -33
  35. package/server/services/rowAccess.js +49 -0
  36. package/server/services/search.js +145 -17
  37. package/server/services/viewerSession.js +2 -2
  38. package/server/services/views.js +67 -5
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
3
- "source": "dcms-marketplace@466a485",
3
+ "source": "dcms-marketplace@9434f03",
4
4
  "plugins": {
5
5
  "blog": {
6
6
  "version": "1.9.0",
@@ -71,6 +71,27 @@
71
71
  "tests/public.test.js": "a6fcdc4ede242aa050ffe5a52f970d6bec1f4a70ae78967eb8a7bdeec73df19d"
72
72
  }
73
73
  },
74
+ "feedback": {
75
+ "version": "1.0.0",
76
+ "files": {
77
+ "CLAUDE.md": "8cdfa2ccf028cb6b9e4a185dc81e5547927132bda9b6326725b99fc221bc6758",
78
+ "admin/css/index.css": "f2e934c8b94f883340148aba17945dfa725eeb2b0ddb6cabb8a8c17e2487c1de",
79
+ "admin/lib/kit.js": "3e153c8e1f14ab88c2ed847e65d3b3f597ef833603f2e4a50aa878f10f8c490e",
80
+ "admin/templates/feedback.html": "a366dce78f6c3e84a7cb0ac63eba53e1980a4eb44154496a0536f5214c5bb36f",
81
+ "admin/views/feedback.js": "11da66fd3aa18dabdc6a334ebd0eb55040ece95128a8f1cfbf08d89b1765cac1",
82
+ "admin/views/inbox.js": "649b86844230be73939d82aa7c97432252c0b119baa102ebf53808e1d5f23737",
83
+ "admin/views/send.js": "1a876a20cf34e1348a6ee7239432801dc9f26852a2e65d386c34b0f9674856b2",
84
+ "collections/feedback-reports.json": "a072291b52463f7785a77f5f77d36b8238ba8f89cfc2672c6642eccdb3144486",
85
+ "config.js": "29d9999d1028c2c11931f67fb9abdf35132430b7cf58c161681e734c0b45143a",
86
+ "lib/receiver.js": "39b2ebac0a705b4cf182913ec8d006dff4b522fb5aaf6023747b48137559ea6d",
87
+ "lib/sender.js": "efb00d06ec149843ffd746f68f3c1ba94333f2205942a786bb37fa91eb3e9806",
88
+ "lib/shape.js": "67acf62f812c2c9a01b52776641ed7ae1601c59ebab8ea278a07d1087a9f299b",
89
+ "plugin.js": "a2f4cccc7ec38cf3877db8390a880e45d1b0e46a61df8dc60ac1300285fe66ae",
90
+ "plugin.json": "4c92ad716deb845058db90cbc67c2e386e6d9f85d1c1d60481eb6b130dbc427e",
91
+ "tests/api.test.js": "6984a2a8c31b06b0ccb1092853567856fc79027345b26e8114dcce631a0949e9",
92
+ "tests/shape.test.js": "5133578142a79cc1a100b3215436372129f01fb0de8c757fd743ff99e1d7e37f"
93
+ }
94
+ },
74
95
  "mail-reader": {
75
96
  "version": "1.2.1",
76
97
  "files": {
@@ -232,7 +232,14 @@ export async function checkPublicAccess(schema, operation, request, reply) {
232
232
  }
233
233
 
234
234
  const user = request.user;
235
- const requiredLevel = roleLevel(access.access);
235
+ // A role name that is not a role on this site (deleted, mistyped, or one of
236
+ // the subscriber/editor/manager names older collection editors offered)
237
+ // resolves to level Infinity, and `userLevel > Infinity` is never true - so
238
+ // it used to let ANY signed-in user through. Fail closed instead: only the
239
+ // level-0 role passes, the same rule checkVisibility() applies to a page
240
+ // whose visibility names an unknown role (middleware/auth.js).
241
+ const namedLevel = roleLevel(access.access);
242
+ const requiredLevel = Number.isFinite(namedLevel) ? namedLevel : 0;
236
243
  // Effective level - multi-role users get the lowest (most privileged) level
237
244
  // across their primary + additional roles for this access decision.
238
245
  const userLevel = getEffectiveLevel(user);
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * Search API Routes
3
3
  *
4
- * GET /api/search?q=… - public: full-text search across published pages
4
+ * GET /api/search?q=… - public: full-text search across the published pages
5
+ * the viewer may open (resolveViewer(): Bearer, else the dm_session cookie
6
+ * the overlay's same-origin fetch carries) - noindex pages (frontmatter or
7
+ * the SEO screen's per-URL override) and pages in a disabled project never
5
8
  * GET /api/search/settings - public: the search UI's own configuration
6
9
  * PUT /api/search/settings - admin-only: save it
7
10
  * GET /api/search/preview?q=…&max=… - admin-only: the Search screen's "Try it" -
8
11
  * the same search, run with the max-results being edited (not yet saved)
9
- * and without the minimum length, so a setting can be tried before Save
12
+ * and without the minimum length, so a setting can be tried before Save.
13
+ * Runs as an anonymous visitor, so it shows what the public finds
10
14
  * GET /api/search/_count - admin-only: the sidebar badge (searchBadge)
11
15
  *
12
16
  * The public settings endpoint is kept for anything already calling it (and
@@ -17,17 +21,20 @@
17
21
  import {authenticate as defaultAuthenticate, requireAdmin as defaultRequireAdmin,
18
22
  requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
19
23
  import {listPages} from '../../services/content.js';
20
- import {getSearchSettings, saveSearchSettings, searchBadge, searchCoverage, searchPages} from '../../services/search.js';
24
+ import {getSearchSettings, saveSearchSettings, searchBadge, searchCoverage, searchExclusions, searchPages}
25
+ from '../../services/search.js';
26
+ import {resolveViewer as defaultResolveViewer} from '../../services/viewerSession.js';
21
27
 
22
28
  /**
23
29
  * @param {import('fastify').FastifyInstance} fastify
24
- * @param {{authenticate?: Function, requireAdmin?: Function}} [opts] - DI for tests
30
+ * @param {{authenticate?: Function, requireAdmin?: Function, resolveViewer?: Function}} [opts] - DI for tests
25
31
  */
26
32
  export async function searchRoutes(fastify, opts = {}) {
27
33
  const authenticate = opts.authenticate || defaultAuthenticate;
28
34
  const requireAdmin = opts.requireAdmin || defaultRequireAdmin;
29
35
  const adminOnly = {preHandler: [authenticate, requireAdmin]};
30
36
  const requirePermission = opts.requirePermission || defaultRequirePermission;
37
+ const resolveViewer = opts.resolveViewer || defaultResolveViewer;
31
38
  // The badge follows the Search sidebar entry's own permission (settings), so whoever
32
39
  // sees the entry can read it - admin-only, it 403'd every minute for the rest. It
33
40
  // tells nothing a visitor's search does not: public pages only.
@@ -43,9 +50,18 @@ export async function searchRoutes(fastify, opts = {}) {
43
50
  });
44
51
  }
45
52
 
53
+ // A read with no side effects, like a page navigation - so the viewer is
54
+ // resolved the way the public renderer does it, and a result is only
55
+ // ever a page this viewer would be served. authenticate() stays out of
56
+ // it: an anonymous search is the common case, not a 401. Responses are
57
+ // no-store (the /api onSend hook) and never enter the response cache,
58
+ // so nothing gated can be replayed to another viewer.
59
+ const viewer = await resolveViewer(request);
46
60
  const pages = await listPages();
47
61
  const results = searchPages(pages, q, {
48
62
  maxResults: settings.maxResults,
63
+ viewer,
64
+ exclusions: await searchExclusions(pages),
49
65
  onError: (err, page) =>
50
66
  request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
51
67
  });
@@ -66,12 +82,17 @@ export async function searchRoutes(fastify, opts = {}) {
66
82
  const asked = parseInt(request.query.max, 10);
67
83
  const maxResults = Number.isFinite(asked) ? Math.min(50, Math.max(1, asked)) : settings.maxResults;
68
84
  const pages = await listPages();
85
+ const exclusions = await searchExclusions(pages);
69
86
  const results = searchPages(pages, q, {
70
87
  maxResults,
88
+ exclusions,
71
89
  onError: (err, page) => request.log.warn({err, page: page.urlPath}, '[search] skipped an unreadable page')
72
90
  });
73
- return {q, maxResults, results, coverage: searchCoverage(pages)};
91
+ return {q, maxResults, results, coverage: searchCoverage(pages, exclusions)};
74
92
  });
75
93
 
76
- fastify.get('/search/_count', canReadBadge, async () => searchBadge(getSearchSettings(), await listPages()));
94
+ fastify.get('/search/_count', canReadBadge, async () => {
95
+ const pages = await listPages();
96
+ return searchBadge(getSearchSettings(), pages, await searchExclusions(pages));
97
+ });
77
98
  }
@@ -12,13 +12,11 @@
12
12
  * POST /views/_preview - Run an UNSAVED config (the editor's Results): {source, connection, stages, limit}
13
13
  * GET /views/collection/:slug - List views targeting a collection
14
14
  *
15
- * Public endpoint (role-checked per view access config):
15
+ * Public endpoint (checked against the view's own access - canReadView):
16
16
  * GET /views/:slug/public - Execute view publicly
17
17
  */
18
- import {createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
18
+ import {canReadView, createView, deleteView, executeView, executeViewConfig, getView, listViews, updateView, viewsBadge} from '../../services/views.js';
19
19
  import {authenticate, requirePermission} from '../../middleware/auth.js';
20
- import {getRoleLevel} from '../../services/roles.js';
21
- import {getEffectiveLevel} from '../../services/userRoles.js';
22
20
 
23
21
  /**
24
22
  * Every collection a pipeline's $lookup stages read, as slugs (`cms_` stripped - Mongo-authored views
@@ -203,29 +201,30 @@ export async function viewsRoutes(fastify) {
203
201
 
204
202
  if (!view) return reply.status(404).send({ error: 'View not found' });
205
203
 
206
- if (!view.access?.public) {
207
- try {
208
- await request.jwtVerify();
209
- } catch {
210
- return reply.status(401).send({ error: 'Unauthorised' });
211
- }
212
-
213
- const user = request.user;
214
- const allowedRoles = view.access?.roles || ['admin', 'super-admin'];
215
- const userLevel = getEffectiveLevel(user);
216
- const minAllowed = Math.min(...allowedRoles.map(r => getRoleLevel(r)));
204
+ // Identify the caller even on a public view: row-level scoping needs
205
+ // them, and an anonymous run of a row-scoped view is empty.
206
+ let user = null;
207
+ try {
208
+ const decoded = await request.jwtVerify();
209
+ if (decoded?.type === 'access') user = request.user || null;
210
+ } catch {
211
+ user = null;
212
+ }
217
213
 
218
- if (userLevel > minAllowed) {
219
- return reply.status(403).send({ error: 'Insufficient permissions' });
220
- }
214
+ // The same decision the [view] shortcode makes (canReadView), so the
215
+ // page and the endpoint it pages from never disagree.
216
+ if (!canReadView(user, view)) {
217
+ return user
218
+ ? reply.status(403).send({ error: 'Insufficient permissions' })
219
+ : reply.status(401).send({ error: 'Unauthorised' });
221
220
  }
222
221
 
223
222
  const { page, limit } = request.query;
224
223
  try {
225
- return await executeView(view.slug, {
224
+ return await executeViewConfig(view, {
226
225
  page: parseInt(page, 10) || 1,
227
226
  limit: parseInt(limit, 10) || 25,
228
- user: request.user || null
227
+ user
229
228
  });
230
229
  } catch (err) {
231
230
  return reply.status(400).send({ error: err.message });
@@ -273,6 +273,15 @@ export async function publicRoutes(fastify) {
273
273
  return reply.type('text/html').send(await render());
274
274
  }
275
275
 
276
+ // A row-scoped [view] shows each signed-in user their OWN rows, so the
277
+ // render differs per user, not per role set - two users sharing a role
278
+ // must not share an entry. Anonymous viewers of such a view get no rows
279
+ // at all (views.js), which is one output, so they still share the cache.
280
+ if (page.perViewer && userObj) {
281
+ hooks.emit('content:pageViewed', {urlPath, title: page.title || ''});
282
+ return sendHtml(request, reply, await render());
283
+ }
284
+
276
285
  // mtime in the key: any change to the backing file - editor save,
277
286
  // script, git pull - yields a fresh entry even if no invalidation
278
287
  // hook fired. Stale-mtime entries age out via TTL/LRU.
@@ -98,9 +98,9 @@ export async function getPage(urlPath, opts = {}) {
98
98
  */
99
99
  export async function buildPreviewPage(urlPath, frontmatter, body, opts = {}) {
100
100
  const raw = serialiseMarkdown({...(frontmatter || {})}, body || '');
101
- const {data, content, html, usedComponents, tags} =
101
+ const {data, content, html, usedComponents, tags, perViewer} =
102
102
  await parseMarkdown(raw, {user: opts.user || null, urlPath});
103
- return {...data, urlPath, content, html, usedComponents, cacheTags: tags};
103
+ return {...data, urlPath, content, html, usedComponents, cacheTags: tags, perViewer};
104
104
  }
105
105
 
106
106
  /**
@@ -411,14 +411,16 @@ async function readPageFile(filePath, opts = {}) {
411
411
  const urlPath = filePathToUrlPath(filePath);
412
412
  // urlPath is passed through so `[menu location="…"]` in the body resolves
413
413
  // against menus bound to this page or its project, exactly as the navbar does.
414
- const { data, content, html, usedComponents, tags } =
414
+ const { data, content, html, usedComponents, tags, perViewer } =
415
415
  await parseMarkdown(raw, {user: opts.user || null, urlPath});
416
416
  // parseMarkdown's `tags` are CACHE tags (`block:x`, `collection:y`), not
417
417
  // the page's editorial tags. Spreading them last used to shadow the
418
418
  // frontmatter's `tags`, so the editor loaded `block:feedback-card` into
419
419
  // the tag pillbox and wrote it back on save, and the pages list showed
420
420
  // cache internals in its Tags column. Keep them apart.
421
- return { ...data, urlPath, content, html, usedComponents, cacheTags: tags };
421
+ // perViewer: a row-scoped [view] renders per user - the public route keeps
422
+ // such a page out of the role-keyed response cache for signed-in viewers.
423
+ return { ...data, urlPath, content, html, usedComponents, cacheTags: tags, perViewer };
422
424
  }
423
425
 
424
426
  /**
@@ -14,7 +14,7 @@ import {getCollection, listEntries} from './collections.js';
14
14
  import {getMenu, resolveLocation, resolveMenuDecorations} from './menus.js';
15
15
  import {buildMenuNav} from './menuRender.js';
16
16
  import {checkVisibility} from '../middleware/auth.js';
17
- import {getRoleHierarchy} from './roles.js';
17
+ import {getRoleHierarchy, getRoleLevel} from './roles.js';
18
18
  import {buildTextStyle} from '../../public/js/text-style.mjs';
19
19
 
20
20
  const __dirname_md = path.dirname(fileURLToPath(import.meta.url));
@@ -243,15 +243,20 @@ export function resolveExportConfig(schemaExport, attrs, defaultRole) {
243
243
  * test on the client.
244
244
  *
245
245
  * @param {string} access - 'public', or a role name
246
- * @returns {string[]} empty for 'public' (no check needed) or an unknown role
246
+ * @returns {string[]} empty for 'public' (no check needed); the level-0
247
+ * role(s) only for a role name that is not a role on this site
247
248
  */
248
249
  function rolesAtOrAbove(access) {
249
250
  if (access === 'public') return [];
250
251
  const hierarchy = getRoleHierarchy(); // most → least privileged
251
252
  const idx = hierarchy.indexOf(access);
252
- // Unknown role name: fail closed with an empty list rather than granting
253
- // everyone, so a typo in schema.json cannot open export up.
254
- return idx === -1 ? [] : hierarchy.slice(0, idx + 1);
253
+ // Unknown role name (deleted, mistyped, an old editor's preset name):
254
+ // fail closed - only the level-0 role passes, the same rule
255
+ // checkPublicAccess() (routes/api/collections.js) and page visibility
256
+ // (checkSingleVisibility in middleware/auth.js) apply. An empty list here
257
+ // would hide export from everybody, super-admin included.
258
+ if (idx === -1) return hierarchy.filter(r => getRoleLevel(r) === 0);
259
+ return hierarchy.slice(0, idx + 1);
255
260
  }
256
261
 
257
262
  /**
@@ -869,10 +874,21 @@ function renderCollectionListgroup(entries, opts, ctxOpts = {}) {
869
874
  * Executes the View's aggregation pipeline and renders results using the
870
875
  * same render functions as [collection].
871
876
  *
877
+ * The view's own `access` decides what the viewer gets, exactly as it does on
878
+ * `GET /api/views/:slug/public` (`canReadView`): a viewer it refuses gets the
879
+ * empty state - no rows, no field names - and the pipeline never runs. A
880
+ * viewer it admits runs the pipeline AS themselves, so row-level scoping
881
+ * applies. A page holding a row-scoped view renders per user, not per role
882
+ * set, and `flags.perViewer` tells the public route to keep it out of the
883
+ * shared response cache.
884
+ *
872
885
  * @param {string} markdown
886
+ * @param {Set<string>} [tagSet]
887
+ * @param {object|null} [user] - Resolved viewer, null for anonymous
888
+ * @param {{perViewer?: boolean}} [flags] - Set when output differs per user
873
889
  * @returns {Promise<string>}
874
890
  */
875
- async function processViewBlocks(markdown, tagSet) {
891
+ async function processViewBlocks(markdown, tagSet, user = null, flags = {}) {
876
892
  const {scrubbed, restore} = scrubCodeRegions(markdown);
877
893
  const pattern = /\[view([^\]]*?)\/\]/gi;
878
894
  const matches = [...scrubbed.matchAll(pattern)];
@@ -890,13 +906,18 @@ async function processViewBlocks(markdown, tagSet) {
890
906
  }
891
907
  tagSet?.add(`view:${slug}`);
892
908
 
909
+ const {canReadView, getView} = await import('./views.js');
910
+ const viewConfig = await getView(slug).catch(() => null);
911
+ if (viewConfig?.access?.rowLevel) flags.perViewer = true;
912
+ const allowed = canReadView(user, viewConfig);
913
+
893
914
  // Interactive `[view]` - when searchable / sortable / paginate is set,
894
915
  // emit a Collection Browser shell against the view's executed dataset.
895
916
  // No filter rail (views own their own filter logic) but search, sort,
896
917
  // and pagination work as on `[collection]`. This is how cross-collection
897
918
  // browsing happens without the shortcode itself having to learn joins.
898
919
  if (isInteractiveCollection(attrs)) {
899
- const shell = await renderViewBrowserShell(attrs);
920
+ const shell = await renderViewBrowserShell(attrs, {viewConfig, allowed, user});
900
921
  result = result.replace(fullMatch, shell);
901
922
  continue;
902
923
  }
@@ -918,12 +939,16 @@ async function processViewBlocks(markdown, tagSet) {
918
939
 
919
940
  let replacement = `<div class="dm-collection-display dm-collection-empty"><p>${escapeHtmlText(emptyMsg)}</p></div>`;
920
941
 
942
+ // Missing view or a viewer its access refuses: the empty state, and
943
+ // the pipeline is never run.
944
+ if (!viewConfig || !allowed) {
945
+ result = result.replace(fullMatch, replacement);
946
+ continue;
947
+ }
948
+
921
949
  try {
922
- const {executeView, getView} = await import('./views.js');
923
- const [{results}, viewConfig] = await Promise.all([
924
- executeView(slug, {page: 1, limit: limitAttr}),
925
- getView(slug).catch(() => null)
926
- ]);
950
+ const {executeViewConfig} = await import('./views.js');
951
+ const {results} = await executeViewConfig(viewConfig, {page: 1, limit: limitAttr, user});
927
952
 
928
953
  // Map MongoDB docs to { id, data } shape. MongoAdapter docs have a top-level id field.
929
954
  const entries = results.map(doc => ({id: doc.id || '', data: doc.data || doc}));
@@ -932,7 +957,7 @@ async function processViewBlocks(markdown, tagSet) {
932
957
  // permission. Views don't own a schema themselves - [dis]allowing
933
958
  // export must fall back CLOSED if the source collection can't be
934
959
  // resolved, since an open fallback would be a permission bypass.
935
- const sourceSlug = viewConfig?.pipeline?.source || '';
960
+ const sourceSlug = viewConfig.pipeline?.source || '';
936
961
  const sourceSchema = sourceSlug ? await getCollection(sourceSlug) : null;
937
962
  const ctxOpts = sourceSchema
938
963
  ? {
@@ -1279,34 +1304,37 @@ async function renderCollectionBrowserShell(attrs) {
1279
1304
  * filters by setting `filterable="…"`, but the field names must exist on the
1280
1305
  * view's projected document shape.
1281
1306
  *
1307
+ * The embedded first page of data (`data-cb-data`) obeys the view's access
1308
+ * the same way the static render does: a refused viewer gets an empty shell,
1309
+ * and server-mode paging hits `/api/views/:slug/public`, which asks the same
1310
+ * `canReadView`.
1311
+ *
1282
1312
  * @param {Record<string, string>} attrs
1313
+ * @param {object} ctx
1314
+ * @param {object|null} ctx.viewConfig - The saved view, null when missing
1315
+ * @param {boolean} ctx.allowed - canReadView() for this viewer
1316
+ * @param {object|null} ctx.user - Viewer, for row-level scoping
1283
1317
  * @returns {Promise<string>}
1284
1318
  */
1285
- async function renderViewBrowserShell(attrs) {
1319
+ async function renderViewBrowserShell(attrs, {viewConfig = null, allowed = false, user = null} = {}) {
1286
1320
  const slug = attrs.slug;
1287
1321
  const pageSize = parseInt(attrs['page-size'], 10) || parseInt(attrs.limit, 10) || 12;
1288
1322
  const declaredMode = attrs.mode === 'server' ? 'server' : 'client';
1289
1323
 
1290
1324
  let entries = [];
1291
1325
  let total = 0;
1292
- let viewConfig = null;
1293
1326
 
1294
- try {
1295
- const {executeView, getView} = await import('./views.js');
1296
- viewConfig = await getView(slug).catch(() => null);
1297
-
1298
- if (declaredMode === 'client') {
1299
- // For views we cap aggressively - views can be expensive aggregations.
1300
- const r = await executeView(slug, {page: 1, limit: 500});
1301
- entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
1302
- total = r.total ?? entries.length;
1303
- } else {
1304
- const r = await executeView(slug, {page: 1, limit: pageSize});
1327
+ if (viewConfig && allowed) {
1328
+ try {
1329
+ const {executeViewConfig} = await import('./views.js');
1330
+ // For views we cap aggressively in client mode - views can be expensive aggregations.
1331
+ const limit = declaredMode === 'client' ? 500 : pageSize;
1332
+ const r = await executeViewConfig(viewConfig, {page: 1, limit, user});
1305
1333
  entries = (r.results || []).map(doc => ({id: doc.id || '', data: doc.data || doc}));
1306
1334
  total = r.total ?? entries.length;
1335
+ } catch {
1336
+ // Pipeline error - emit empty shell.
1307
1337
  }
1308
- } catch {
1309
- // executeView errored (view missing or pipeline error) - emit empty shell.
1310
1338
  }
1311
1339
 
1312
1340
  // Derive a schema-like fields list from the first row so the browser can
@@ -1315,7 +1343,7 @@ async function renderViewBrowserShell(attrs) {
1315
1343
  ? Object.keys(entries[0].data || {})
1316
1344
  .filter(k => !['_id', 'id', 'meta'].includes(k))
1317
1345
  .map(k => ({name: k, label: k, type: 'text'}))
1318
- : (viewConfig?.fields || []);
1346
+ : (allowed ? (viewConfig?.fields || []) : []);
1319
1347
 
1320
1348
  const filterable = (attrs.filterable && typeof attrs.filterable === 'string')
1321
1349
  ? attrs.filterable.split(',').map(s => s.trim()).filter(Boolean) : [];
@@ -4178,12 +4206,16 @@ function processCtaBlocks(markdown) {
4178
4206
  * @param {object} [opts]
4179
4207
  * @param {object|null} [opts.user] - Authenticated user (`{role, additionalRoles}`)
4180
4208
  * or null for anonymous. Used to filter menu items gated by `visibility` in
4181
- * the `[menu]` shortcode. Backwards compatible - defaults to anonymous.
4209
+ * the `[menu]` shortcode, and as the viewer a `[view]`'s own access and
4210
+ * row-level scoping are checked against. Backwards compatible - defaults to
4211
+ * anonymous.
4182
4212
  * @param {string} [opts.urlPath] - URL of the page being parsed. Lets
4183
4213
  * `[menu location="…"]` honour menus bound to this page or its project.
4184
4214
  * @param {string|null} [opts.project] - Project the page belongs to; resolved
4185
4215
  * from `urlPath` when omitted.
4186
- * @returns {{ data: object, content: string, html: string }}
4216
+ * @returns {{ data: object, content: string, html: string, tags: string[], perViewer: boolean }}
4217
+ * `perViewer` - the output differs per signed-in USER, not just per role set
4218
+ * (a row-scoped `[view]`), so it must not share a role-keyed cache entry.
4187
4219
  */
4188
4220
  export async function parseMarkdown(raw, opts = {}) {
4189
4221
  const {data, content} = matter(raw);
@@ -4196,8 +4228,9 @@ export async function parseMarkdown(raw, opts = {}) {
4196
4228
  const preprocessed = applyTransforms('markdown:beforeParse', content);
4197
4229
  const {output: withComponents, used: usedComponents} = collectAndRewriteComponents(preprocessed);
4198
4230
  const tagSet = new Set();
4231
+ const flags = {perViewer: false};
4199
4232
  const withCollection = await processCollectionBlocks(withComponents, tagSet);
4200
- const withView = await processViewBlocks(withCollection, tagSet);
4233
+ const withView = await processViewBlocks(withCollection, tagSet, opts.user || null, flags);
4201
4234
  const withStaticBlock = await processStaticBlocks(withView, tagSet);
4202
4235
  const menuCtx = opts.urlPath
4203
4236
  ? {urlPath: opts.urlPath, project: opts.project ?? await resolveProjectForUrl(opts.urlPath)}
@@ -4308,7 +4341,7 @@ export async function parseMarkdown(raw, opts = {}) {
4308
4341
  const allowed = new Set(_dmTagAllowlist.map(t => t.replace(/^dm-/, '')));
4309
4342
  const filteredUsed = [...usedComponents].filter(name => allowed.has(name));
4310
4343
 
4311
- return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet]};
4344
+ return {data, content, html, usedComponents: filteredUsed, tags: [...tagSet], perViewer: flags.perViewer};
4312
4345
  }
4313
4346
 
4314
4347
  /**
@@ -175,3 +175,52 @@ export function buildRowLevelMatch(user, rowLevel) {
175
175
  // per-entry instead. Return null so the upstream code knows to do that.
176
176
  return null;
177
177
  }
178
+
179
+ /**
180
+ * Whether `rowLevel` has to be enforced entry by entry for this user - a rule
181
+ * is set, the user is not level 0, and buildRowLevelMatch() cannot express it
182
+ * as a flat match (`mode: 'reference'`, or a misconfigured/unknown rule, which
183
+ * checkEntryAccess() then fails closed).
184
+ *
185
+ * @param {object|null} user
186
+ * @param {object|null} rowLevel
187
+ * @returns {boolean}
188
+ */
189
+ export function needsEntryCheck(user, rowLevel) {
190
+ if (!rowLevel || isAdmin(user)) return false;
191
+ return buildRowLevelMatch(user, rowLevel) === null;
192
+ }
193
+
194
+ /**
195
+ * Keep only the entries `user` may access under `rowLevel` - checkEntryAccess()
196
+ * per entry, the same decision Actions make for a single entry. Target reads
197
+ * are memoised for the call, so many entries pointing at one parent cost one
198
+ * read of it, not one each.
199
+ *
200
+ * @param {object[]} entries
201
+ * @param {object|null} user
202
+ * @param {object|null} rowLevel
203
+ * @param {object} [opts]
204
+ * @param {Function} [opts.adapterResolver] - Optional async (slug) → adapter - for tests
205
+ * @returns {Promise<object[]>}
206
+ */
207
+ export async function filterEntriesByAccess(entries, user, rowLevel, opts = {}) {
208
+ if (!rowLevel || isAdmin(user)) return entries;
209
+ const resolver = opts.adapterResolver || getAdapter;
210
+ const adapters = new Map();
211
+ const targets = new Map();
212
+ const memoResolver = (slug) => {
213
+ if (!adapters.has(slug)) {
214
+ adapters.set(slug, Promise.resolve().then(() => resolver(slug)).then(adapter => ({
215
+ get(collection, id) {
216
+ const key = `${collection}\u0000${id}`;
217
+ if (!targets.has(key)) targets.set(key, Promise.resolve().then(() => adapter.get(collection, id)));
218
+ return targets.get(key);
219
+ }
220
+ })));
221
+ }
222
+ return adapters.get(slug);
223
+ };
224
+ const keep = await Promise.all(entries.map(e => checkEntryAccess(e, user, rowLevel, {adapterResolver: memoResolver})));
225
+ return entries.filter((_, i) => keep[i]);
226
+ }