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.
- package/CLAUDE.md +18 -9
- package/admin/js/lib/collection-editor-arrange.js +2 -2
- package/admin/js/lib/view-editor-arrange.js +1 -1
- package/admin/js/templates/collection-editor.html +3 -1
- package/admin/js/templates/docs/api-users.html +8 -8
- package/admin/js/templates/docs/api-views.html +5 -4
- package/admin/js/templates/docs/usage-users-roles.html +28 -48
- package/admin/js/templates/docs/usage-views.html +6 -4
- package/admin/js/views/collection-editor.js +4 -4
- package/admin/js/views/view-editor.js +1 -1
- package/package.json +1 -1
- package/plugins/feedback/CLAUDE.md +96 -0
- package/plugins/feedback/admin/css/index.css +80 -0
- package/plugins/feedback/admin/lib/kit.js +105 -0
- package/plugins/feedback/admin/templates/feedback.html +1 -0
- package/plugins/feedback/admin/views/feedback.js +63 -0
- package/plugins/feedback/admin/views/inbox.js +325 -0
- package/plugins/feedback/admin/views/send.js +223 -0
- package/plugins/feedback/collections/feedback-reports.json +24 -0
- package/plugins/feedback/config.js +15 -0
- package/plugins/feedback/lib/receiver.js +121 -0
- package/plugins/feedback/lib/sender.js +161 -0
- package/plugins/feedback/lib/shape.js +211 -0
- package/plugins/feedback/plugin.js +276 -0
- package/plugins/feedback/plugin.json +48 -0
- package/plugins/feedback/tests/api.test.js +219 -0
- package/plugins/feedback/tests/shape.test.js +87 -0
- package/plugins/free-tier.lock.json +22 -1
- package/server/routes/api/collections.js +8 -1
- package/server/routes/api/search.js +27 -6
- package/server/routes/api/views.js +19 -20
- package/server/routes/public.js +9 -0
- package/server/services/content.js +6 -4
- package/server/services/markdown.js +66 -33
- package/server/services/rowAccess.js +49 -0
- package/server/services/search.js +145 -17
- package/server/services/viewerSession.js +2 -2
- package/server/services/views.js +67 -5
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* public page carries them inline instead of fetching them.
|
|
11
11
|
*/
|
|
12
12
|
import {getConfig, saveConfig} from '../config.js';
|
|
13
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
14
|
+
import {normalisePath} from '../../admin/js/lib/seo-redirects.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* Settings, and the shape of them.
|
|
@@ -363,6 +365,115 @@ export function scorePage(page, query, words) {
|
|
|
363
365
|
return score;
|
|
364
366
|
}
|
|
365
367
|
|
|
368
|
+
/**
|
|
369
|
+
* Site-wide rules that take pages out of search whatever the viewer, which a
|
|
370
|
+
* page's own frontmatter does not carry - so they are resolved once per
|
|
371
|
+
* search (async) and handed to the sync checks below:
|
|
372
|
+
*
|
|
373
|
+
* - `offline`: pages in a DISABLED project. The public renderer 404s them
|
|
374
|
+
* (routes/public.js: getProjectForPage + isProjectEnabled - the same two
|
|
375
|
+
* calls here), so search must not read them out either.
|
|
376
|
+
* - `noindex`: pages the SEO screen marked noindex per URL
|
|
377
|
+
* (content/seo/overrides.json). The same store, keyed the same way
|
|
378
|
+
* (normalisePath), that the robots meta ('seo:meta') and the sitemap
|
|
379
|
+
* ('sitemap:entries') read - and, like them, only while SEO is switched on.
|
|
380
|
+
*
|
|
381
|
+
* @param {object[]} pages
|
|
382
|
+
* @param {object} [deps] - DI for tests
|
|
383
|
+
* @param {Function} [deps.projectOf] - async (urlPath, frontmatterProject) → slug
|
|
384
|
+
* @param {Function} [deps.isEnabled] - async (slug) → boolean
|
|
385
|
+
* @param {Function} [deps.listProjects] - async () → projects (the shortcut when none is disabled)
|
|
386
|
+
* @param {Function} [deps.seoOverrides] - () → {"/path": {noindex, ...}}
|
|
387
|
+
* @returns {Promise<{offline: Set<string>, noindex: Set<string>}>} urlPaths
|
|
388
|
+
*/
|
|
389
|
+
export async function searchExclusions(pages = [], deps = {}) {
|
|
390
|
+
const offline = new Set();
|
|
391
|
+
const noindex = new Set();
|
|
392
|
+
|
|
393
|
+
const projects = deps.projectOf && deps.isEnabled ? null : await import('./projects.js');
|
|
394
|
+
const projectOf = deps.projectOf || projects.getProjectForPage;
|
|
395
|
+
const isEnabled = deps.isEnabled || projects.isProjectEnabled;
|
|
396
|
+
const listAll = deps.listProjects || projects?.listProjects;
|
|
397
|
+
// Nothing is disabled on most sites - skip resolving every page's project.
|
|
398
|
+
let anyDisabled = true;
|
|
399
|
+
if (listAll) {
|
|
400
|
+
try { anyDisabled = (await listAll()).some(p => p?.enabled === false); } catch { anyDisabled = true; }
|
|
401
|
+
}
|
|
402
|
+
if (anyDisabled) {
|
|
403
|
+
const enabled = new Map();
|
|
404
|
+
for (const page of pages) {
|
|
405
|
+
if (!page?.urlPath) continue;
|
|
406
|
+
try {
|
|
407
|
+
const slug = await projectOf(page.urlPath, page.project);
|
|
408
|
+
if (!enabled.has(slug)) enabled.set(slug, await isEnabled(slug));
|
|
409
|
+
if (!enabled.get(slug)) offline.add(page.urlPath);
|
|
410
|
+
} catch { /* project store unreadable - leave the page to the other checks */ }
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
let overrides;
|
|
415
|
+
if (deps.seoOverrides) {
|
|
416
|
+
overrides = deps.seoOverrides() || {};
|
|
417
|
+
} else {
|
|
418
|
+
// Null while SEO is switched off - and then no override reaches the
|
|
419
|
+
// robots meta or the sitemap either.
|
|
420
|
+
const {seoEngine} = await import('./seo/index.js');
|
|
421
|
+
overrides = seoEngine()?.store?.overrides?.() || {};
|
|
422
|
+
}
|
|
423
|
+
for (const page of pages) {
|
|
424
|
+
if (!page?.urlPath) continue;
|
|
425
|
+
const key = normalisePath(page.urlPath) || '/';
|
|
426
|
+
if (overrides[key]?.noindex === true) noindex.add(page.urlPath);
|
|
427
|
+
}
|
|
428
|
+
return {offline, noindex};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Is this page noindex? Its frontmatter (`seo.noindex`, the field core SEO
|
|
433
|
+
* reads for the robots meta and the sitemap), or the SEO screen's per-URL
|
|
434
|
+
* override when `exclusions` (searchExclusions()) is given.
|
|
435
|
+
*
|
|
436
|
+
* @param {object} page
|
|
437
|
+
* @param {{noindex?: Set<string>}|null} [exclusions]
|
|
438
|
+
* @returns {boolean}
|
|
439
|
+
*/
|
|
440
|
+
export function isNoindex(page, exclusions = null) {
|
|
441
|
+
if (page?.seo?.noindex === true) return true;
|
|
442
|
+
return Boolean(exclusions?.noindex?.has(page?.urlPath));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Is this page in a disabled project (served as a 404)? Needs `exclusions`
|
|
447
|
+
* from searchExclusions(); without them nothing is.
|
|
448
|
+
*
|
|
449
|
+
* @param {object} page
|
|
450
|
+
* @param {{offline?: Set<string>}|null} [exclusions]
|
|
451
|
+
* @returns {boolean}
|
|
452
|
+
*/
|
|
453
|
+
export function isOffline(page, exclusions = null) {
|
|
454
|
+
return Boolean(exclusions?.offline?.has(page?.urlPath));
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* May this page appear in this viewer's search results? Drafts never; pages
|
|
459
|
+
* in a disabled project never; pages that ask not to be indexed (frontmatter
|
|
460
|
+
* or the SEO screen) never; otherwise exactly when the viewer could open the
|
|
461
|
+
* page - checkVisibility() is the one visibility rule, the same the public
|
|
462
|
+
* page renderer applies, so a role-gated page (`member`, `=candidate`,
|
|
463
|
+
* `private`) is found only by someone it would be served to.
|
|
464
|
+
*
|
|
465
|
+
* @param {object} page
|
|
466
|
+
* @param {object|null} [viewer] - resolved viewer, null for anonymous
|
|
467
|
+
* @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
|
|
468
|
+
* @returns {boolean}
|
|
469
|
+
*/
|
|
470
|
+
export function isSearchable(page, viewer = null, exclusions = null) {
|
|
471
|
+
if (!page || page.status === 'draft') return false;
|
|
472
|
+
if (isOffline(page, exclusions)) return false;
|
|
473
|
+
if (isNoindex(page, exclusions)) return false;
|
|
474
|
+
return checkVisibility(viewer, page.visibility);
|
|
475
|
+
}
|
|
476
|
+
|
|
366
477
|
/**
|
|
367
478
|
* Search a set of pages.
|
|
368
479
|
*
|
|
@@ -372,19 +483,22 @@ export function scorePage(page, query, words) {
|
|
|
372
483
|
* 500 to every query on an entire site, which reads to everyone as "search
|
|
373
484
|
* finds nothing".
|
|
374
485
|
*
|
|
486
|
+
* A result carries the title, description and a snippet of the body, so it
|
|
487
|
+
* is only ever built for a page the viewer could open - see isSearchable().
|
|
488
|
+
*
|
|
375
489
|
* @param {object[]} pages
|
|
376
490
|
* @param {string} query
|
|
377
|
-
* @param {{maxResults?: number, onError?: Function}} [options]
|
|
491
|
+
* @param {{maxResults?: number, onError?: Function, viewer?: object|null, exclusions?: object|null}} [options]
|
|
492
|
+
* viewer - the resolved viewer (resolveViewer()), null for anonymous
|
|
493
|
+
* exclusions - searchExclusions(pages): disabled projects, SEO noindex overrides
|
|
378
494
|
* @returns {object[]}
|
|
379
495
|
*/
|
|
380
|
-
export function searchPages(pages, query, {maxResults = 10, onError = () => {}} = {}) {
|
|
496
|
+
export function searchPages(pages, query, {maxResults = 10, onError = () => {}, viewer = null, exclusions = null} = {}) {
|
|
381
497
|
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
382
498
|
const results = [];
|
|
383
499
|
|
|
384
500
|
for (const page of pages) {
|
|
385
|
-
|
|
386
|
-
if (page.status === 'draft') continue;
|
|
387
|
-
if (page.visibility === 'private') continue;
|
|
501
|
+
if (!isSearchable(page, viewer, exclusions)) continue;
|
|
388
502
|
|
|
389
503
|
// One page must never be able to fail the whole search. The null-tag
|
|
390
504
|
// crash was exactly this shape and the next malformed field will be
|
|
@@ -417,34 +531,45 @@ export function searchPages(pages, query, {maxResults = 10, onError = () => {}}
|
|
|
417
531
|
// ---------------------------------------------------------------------------
|
|
418
532
|
|
|
419
533
|
/**
|
|
420
|
-
* Which pages search can return, and which it leaves
|
|
421
|
-
* searchPages() applies (
|
|
534
|
+
* Which pages an anonymous visitor's search can return, and which it leaves
|
|
535
|
+
* out - the same rule searchPages() applies (isSearchable() with no viewer).
|
|
536
|
+
* Each page left out is counted once, under the first reason that applies:
|
|
537
|
+
* a draft, then offline (its project is disabled), then restricted (private
|
|
538
|
+
* or role-gated), then noindex (frontmatter or the SEO screen). A signed-in
|
|
539
|
+
* visitor may find more than `searchable` - the gated pages they can open.
|
|
422
540
|
*
|
|
423
541
|
* @param {object[]} pages
|
|
424
|
-
* @
|
|
542
|
+
* @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
|
|
543
|
+
* @returns {{searchable: number, drafts: number, offline: number, restricted: number, noindex: number, total: number}}
|
|
425
544
|
*/
|
|
426
|
-
export function searchCoverage(pages = []) {
|
|
545
|
+
export function searchCoverage(pages = [], exclusions = null) {
|
|
427
546
|
let drafts = 0;
|
|
428
|
-
let
|
|
547
|
+
let offline = 0;
|
|
548
|
+
let restricted = 0;
|
|
549
|
+
let noindex = 0;
|
|
429
550
|
for (const p of pages) {
|
|
430
551
|
if (p?.status === 'draft') drafts++;
|
|
431
|
-
else if (p
|
|
552
|
+
else if (isOffline(p, exclusions)) offline++;
|
|
553
|
+
else if (!checkVisibility(null, p?.visibility)) restricted++;
|
|
554
|
+
else if (isNoindex(p, exclusions)) noindex++;
|
|
432
555
|
}
|
|
433
|
-
return {searchable: pages.length - drafts -
|
|
556
|
+
return {searchable: pages.length - drafts - offline - restricted - noindex,
|
|
557
|
+
drafts, offline, restricted, noindex, total: pages.length};
|
|
434
558
|
}
|
|
435
559
|
|
|
436
560
|
/**
|
|
437
561
|
* The sidebar badge on System > Search (its countUrl, GET /api/search/_count):
|
|
438
|
-
* how many pages
|
|
562
|
+
* how many pages an anonymous visitor's search can find, with whether search is on at all
|
|
439
563
|
* and how it is reached in the popover. An inventory, not a signal - plain text
|
|
440
564
|
* throughout, the popover sets it with textContent.
|
|
441
565
|
*
|
|
442
566
|
* @param {object} settings - normalised search settings
|
|
443
567
|
* @param {object[]} pages
|
|
568
|
+
* @param {{offline?: Set<string>, noindex?: Set<string>}|null} [exclusions] - searchExclusions()
|
|
444
569
|
*/
|
|
445
|
-
export function searchBadge(settings = SEARCH_DEFAULTS, pages = []) {
|
|
570
|
+
export function searchBadge(settings = SEARCH_DEFAULTS, pages = [], exclusions = null) {
|
|
446
571
|
const s = normaliseSearchSettings(settings);
|
|
447
|
-
const c = searchCoverage(pages);
|
|
572
|
+
const c = searchCoverage(pages, exclusions);
|
|
448
573
|
const pl = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
449
574
|
const trigger = s.displayMode === 'floating'
|
|
450
575
|
? `Floating button, ${s.position.replace('-', ' ')}`
|
|
@@ -462,10 +587,13 @@ export function searchBadge(settings = SEARCH_DEFAULTS, pages = []) {
|
|
|
462
587
|
{label: 'Trigger', value: s.enabled ? trigger : '-'},
|
|
463
588
|
{label: 'Keyboard shortcut', value: s.enabled && s.keyboardShortcut ? 'Ctrl+K / ⌘K' : 'Off'},
|
|
464
589
|
{label: 'Searchable pages', value: String(c.searchable)},
|
|
465
|
-
{label: 'Left out (drafts,
|
|
590
|
+
{label: 'Left out (drafts, disabled projects, private or role-gated, noindex)',
|
|
591
|
+
value: String(c.drafts + c.offline + c.restricted + c.noindex)}
|
|
466
592
|
],
|
|
467
593
|
items: [],
|
|
468
|
-
empty: s.enabled
|
|
594
|
+
empty: s.enabled
|
|
595
|
+
? 'Visitors can search every published page they are allowed to open, except noindex pages and pages in a disabled project.'
|
|
596
|
+
: 'Search is switched off - nothing is added to public pages.'
|
|
469
597
|
}
|
|
470
598
|
};
|
|
471
599
|
}
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* module reads it back.
|
|
12
12
|
*
|
|
13
13
|
* SCOPE - read this before reusing anything here:
|
|
14
|
-
* The cookie is consulted ONLY by
|
|
15
|
-
* effects). `authenticate()` in middleware/auth.js stays Bearer-only, so no
|
|
14
|
+
* The cookie is consulted ONLY by read-only public GETs with no side
|
|
15
|
+
* effects: the public page renderer and the site search (GET /api/search). `authenticate()` in middleware/auth.js stays Bearer-only, so no
|
|
16
16
|
* state-changing route is reachable with ambient credentials and the CSRF
|
|
17
17
|
* surface stays at zero. Do not wire `resolveViewer()` into a write path.
|
|
18
18
|
*/
|
package/server/services/views.js
CHANGED
|
@@ -29,7 +29,9 @@ import fs from 'fs/promises';
|
|
|
29
29
|
import path from 'path';
|
|
30
30
|
import {fileURLToPath} from 'url';
|
|
31
31
|
import {v4 as uuidv4} from 'uuid';
|
|
32
|
-
import {buildRowLevelMatch} from './rowAccess.js';
|
|
32
|
+
import {buildRowLevelMatch, filterEntriesByAccess, needsEntryCheck} from './rowAccess.js';
|
|
33
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
34
|
+
import {getEffectiveLevel} from './userRoles.js';
|
|
33
35
|
import {executePipeline} from './viewPipeline.js';
|
|
34
36
|
import * as cache from './cache/index.js';
|
|
35
37
|
|
|
@@ -293,7 +295,11 @@ export async function createView(data, userId = null) {
|
|
|
293
295
|
block: display?.block || ''
|
|
294
296
|
},
|
|
295
297
|
access: {
|
|
296
|
-
|
|
298
|
+
// No roles named = the admin tier (effective level 0 or 1), which
|
|
299
|
+
// canReadView() reads from role LEVELS at request time. Roles are
|
|
300
|
+
// dynamic, so a default stored by name ('admin') would silently
|
|
301
|
+
// stop meaning anything once that role is renamed or removed.
|
|
302
|
+
roles: Array.isArray(access?.roles) ? access.roles : [],
|
|
297
303
|
public: access?.public || false,
|
|
298
304
|
rowLevel: access?.rowLevel || null
|
|
299
305
|
},
|
|
@@ -385,9 +391,24 @@ async function executeMongoView(view, { page, limit, user }) {
|
|
|
385
391
|
const pipeline = buildPipelineStages(view.pipeline?.stages || []);
|
|
386
392
|
|
|
387
393
|
// Prepend row-level $match if configured
|
|
388
|
-
const
|
|
394
|
+
const rowLevel = view.access?.rowLevel;
|
|
395
|
+
const rowFilter = buildRowLevelMatch(user, rowLevel);
|
|
389
396
|
if (rowFilter) {
|
|
390
397
|
pipeline.unshift({$match: rowFilter});
|
|
398
|
+
} else if (needsEntryCheck(user, rowLevel)) {
|
|
399
|
+
// `reference` mode is a join a flat $match cannot express. Decide each
|
|
400
|
+
// SOURCE entry with checkEntryAccess() - the rule Actions apply - and
|
|
401
|
+
// restrict the pipeline to the ids that pass. It runs before $count and
|
|
402
|
+
// $skip/$limit, so the total and the pages describe only rows this
|
|
403
|
+
// viewer may see.
|
|
404
|
+
const field = rowLevel.field;
|
|
405
|
+
const candidates = field
|
|
406
|
+
? await db.collection(sourceCollection)
|
|
407
|
+
.find({}, {projection: {_id: 0, id: 1, [`data.${field}`]: 1}})
|
|
408
|
+
.toArray()
|
|
409
|
+
: [];
|
|
410
|
+
const allowed = await filterEntriesByAccess(candidates, user, rowLevel);
|
|
411
|
+
pipeline.unshift({$match: {id: {$in: allowed.map(d => d.id)}}});
|
|
391
412
|
}
|
|
392
413
|
|
|
393
414
|
// Count total without pagination
|
|
@@ -420,13 +441,19 @@ async function executeMongoView(view, { page, limit, user }) {
|
|
|
420
441
|
async function executeFileView(view, { page, limit, user }) {
|
|
421
442
|
const { getAdapter } = await import('./adapterRegistry.js');
|
|
422
443
|
const adapter = await getAdapter(view.pipeline.source);
|
|
423
|
-
|
|
444
|
+
let docs = await adapter.all(view.pipeline.source);
|
|
424
445
|
|
|
425
446
|
const stages = buildPipelineStages(view.pipeline?.stages || []);
|
|
426
447
|
|
|
427
|
-
const
|
|
448
|
+
const rowLevel = view.access?.rowLevel;
|
|
449
|
+
const rowFilter = buildRowLevelMatch(user, rowLevel);
|
|
428
450
|
if (rowFilter) {
|
|
429
451
|
stages.unshift({$match: rowFilter});
|
|
452
|
+
} else if (needsEntryCheck(user, rowLevel)) {
|
|
453
|
+
// `reference` mode (a join) - checkEntryAccess() per source entry,
|
|
454
|
+
// before the pipeline, so `total` and the pages count only rows this
|
|
455
|
+
// viewer may see.
|
|
456
|
+
docs = await filterEntriesByAccess(docs, user, rowLevel);
|
|
430
457
|
}
|
|
431
458
|
|
|
432
459
|
const all = await executePipeline(docs, stages, {
|
|
@@ -438,6 +465,36 @@ async function executeFileView(view, { page, limit, user }) {
|
|
|
438
465
|
return { results: all.slice(offset, offset + limit), total, page, limit };
|
|
439
466
|
}
|
|
440
467
|
|
|
468
|
+
/**
|
|
469
|
+
* May this viewer read a saved view's rows?
|
|
470
|
+
*
|
|
471
|
+
* The ONE decision about `view.access` - the `[view]` shortcode (static and
|
|
472
|
+
* interactive) and `GET /api/views/:slug/public` all ask it, so a page that
|
|
473
|
+
* embeds a view can never show more than the view's own endpoint would.
|
|
474
|
+
*
|
|
475
|
+
* `public: true` lets everyone in. Otherwise `roles` is read with page
|
|
476
|
+
* visibility semantics (`checkVisibility`): a ladder per role, `=role` exact,
|
|
477
|
+
* level 0 always passes. A view that names no roles is the admin tier only
|
|
478
|
+
* (effective level 0 or 1, as `requireAdmin` reads it) - the tier createView
|
|
479
|
+
* defaults to - never "anyone signed in".
|
|
480
|
+
*
|
|
481
|
+
* Row-level scoping is separate: it narrows WHICH rows, and executeView
|
|
482
|
+
* applies it from the same viewer.
|
|
483
|
+
*
|
|
484
|
+
* @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
|
|
485
|
+
* @param {object|null} view - Saved view config
|
|
486
|
+
* @returns {boolean}
|
|
487
|
+
*/
|
|
488
|
+
export function canReadView(user, view) {
|
|
489
|
+
if (!view) return false;
|
|
490
|
+
const access = view.access || {};
|
|
491
|
+
if (access.public === true) return true;
|
|
492
|
+
if (!user?.role) return false;
|
|
493
|
+
const roles = Array.isArray(access.roles) ? access.roles.filter(r => typeof r === 'string' && r) : [];
|
|
494
|
+
if (!roles.length) return getEffectiveLevel(user) <= 1;
|
|
495
|
+
return checkVisibility(user, roles);
|
|
496
|
+
}
|
|
497
|
+
|
|
441
498
|
/**
|
|
442
499
|
* Execute a view's pipeline and return paginated results.
|
|
443
500
|
*
|
|
@@ -469,6 +526,11 @@ export async function executeViewConfig(view, {page = 1, limit = 25, user = null
|
|
|
469
526
|
if (!view?.pipeline?.source) throw new Error('pipeline.source is required');
|
|
470
527
|
validateStages(view.pipeline?.stages || []);
|
|
471
528
|
|
|
529
|
+
// Row-level scoping matches rows to a user. With nobody signed in there is
|
|
530
|
+
// no one to match, and `{'meta.createdBy': undefined}` would match every
|
|
531
|
+
// row that lacks an owner - so an anonymous run of a row-scoped view is empty.
|
|
532
|
+
if (view.access?.rowLevel && !user) return {results: [], total: 0, page, limit};
|
|
533
|
+
|
|
472
534
|
// Dispatch on the SOURCE collection's storage adapter. A mongodb-declared
|
|
473
535
|
// schema whose connection is unavailable falls back to the JS evaluator -
|
|
474
536
|
// matching adapterRegistry's own file fallback.
|