domma-cms 0.116.8 → 0.117.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.
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Content API - /api/content/v1
3
+ *
4
+ * Read-only headless access to what the site publishes:
5
+ *
6
+ * GET /api/content/v1/pages - paged page summaries
7
+ * GET /api/content/v1/pages/* - one page (?format=html|markdown|both)
8
+ * GET /api/content/v1/menus/:location - the menu bound to a location, filtered for the caller
9
+ * GET /api/content/v1/site - the site settings public pages already show
10
+ * GET /api/content/v1/search?q= - site search
11
+ *
12
+ * Its own prefix on purpose: /api/v1/:slug matches any collection slug, and
13
+ * slugify() happily produces `pages` and `menus`, so living there would
14
+ * shadow real collections.
15
+ *
16
+ * Access is config/content-api.json `access`:
17
+ * - 'public' - anyone
18
+ * - 'token' - a project API token, strict (never a JWT), bound to the
19
+ * page's project, narrowed by `{content}` scopes
20
+ * - a role - a Bearer access JWT at that role's level or more senior; an
21
+ * unknown role admits level 0 only. Never the dm_session
22
+ * cookie: that is read only by the page renderer, so no
23
+ * API answers to ambient credentials.
24
+ * Public and token callers are served as an anonymous visitor.
25
+ *
26
+ * Every page decision is resolvePublishedPage() - the function the public
27
+ * site uses - so the two can never disagree.
28
+ */
29
+ import {getContentApiSettings, pageDetail, pageSummary, publicSiteSettings} from '../../services/contentApi.js';
30
+ import {canPreviewDrafts, resolvePublishedPage} from '../../services/pagePublishing.js';
31
+ import {getPage, getPageMtime, listPages} from '../../services/content.js';
32
+ import {getSearchSettings, isPubliclyListed, searchExclusions, searchPages} from '../../services/search.js';
33
+ import {resolveLocation} from '../../services/menus.js';
34
+ import {authenticateApiToken, contentScopeAllows} from '../../services/apiTokens.js';
35
+ import {getRoleLevel} from '../../services/roles.js';
36
+ import {getEffectiveLevel} from '../../services/userRoles.js';
37
+ import {getConfig} from '../../config.js';
38
+
39
+ const NOT_FOUND = {error: 'Not found'};
40
+ const DEFAULT_LIMIT = 50;
41
+ const FORMAT_VALUES = ['html', 'markdown', 'both'];
42
+
43
+ /** Menu slots the API never serves: the admin's own navigation, and page overlays. */
44
+ const PRIVATE_SLOTS = new Set(['admin-sidebar', 'overlay']);
45
+
46
+ /**
47
+ * Gate one request. Resolves to `{mode, viewer, token, settings}`, or sends
48
+ * the refusal and resolves to null.
49
+ *
50
+ * Token mode binds no project here: pages carry their own, so the routes
51
+ * check `inProject()` per page. Menus and site settings are site-wide and
52
+ * already shown on every public page, so any valid token may read them.
53
+ *
54
+ * @param {'pages'|'menus'|'site'|'search'} resource
55
+ * @returns {Promise<object|null>}
56
+ */
57
+ export async function checkContentAccess(resource, request, reply) {
58
+ const settings = getContentApiSettings();
59
+ if (!settings.enabled || !settings.resources[resource]?.enabled) {
60
+ reply.status(404).send(NOT_FOUND);
61
+ return null;
62
+ }
63
+
64
+ if (settings.access === 'public') return {mode: 'public', viewer: null, token: null, settings};
65
+
66
+ if (settings.access === 'token') {
67
+ const auth = await authenticateApiToken(request, {allows: (scopes) => contentScopeAllows(scopes, resource)});
68
+ if (!auth.ok) {
69
+ reply.status(auth.status).send({error: auth.error});
70
+ return null;
71
+ }
72
+ return {mode: 'token', viewer: null, token: auth.token, settings};
73
+ }
74
+
75
+ // Role mode. jwtVerify reads the Authorization header only; a refresh
76
+ // token is refused like any other non-access token.
77
+ let decoded;
78
+ try {
79
+ decoded = await request.jwtVerify();
80
+ } catch {
81
+ decoded = null;
82
+ }
83
+ if (!decoded || decoded.type !== 'access') {
84
+ reply.status(401).send({error: 'Unauthorised'});
85
+ return null;
86
+ }
87
+ // A role name that is not a role on this site fails closed to level 0,
88
+ // as checkPublicAccess() and checkVisibility() do.
89
+ const named = getRoleLevel(settings.access);
90
+ const required = Number.isFinite(named) ? named : 0;
91
+ if (getEffectiveLevel(decoded) > required) {
92
+ reply.status(403).send({error: 'Insufficient permissions'});
93
+ return null;
94
+ }
95
+ return {mode: 'role', viewer: decoded, token: null, settings};
96
+ }
97
+
98
+ /** A token sees only its own project's pages; other modes see every project. */
99
+ const inProject = (access, project) => access.mode !== 'token' || access.token.project === project;
100
+
101
+ /** Frontmatter `api: false` keeps a page off the API; it still renders on the site. */
102
+ const apiOff = (page) => page?.api === false;
103
+
104
+ function cacheHeaders(reply, access, {draft = false} = {}) {
105
+ if (draft || access.mode === 'role') reply.header('cache-control', 'no-store');
106
+ else if (access.mode === 'token') reply.header('cache-control', 'private, no-cache');
107
+ else reply.header('cache-control', 'public, max-age=60');
108
+ }
109
+
110
+ /**
111
+ * Which body formats a request asked for, or an error when the site does not
112
+ * offer one of them.
113
+ *
114
+ * @returns {{format: string} | {error: string}}
115
+ */
116
+ function pickFormat(raw, settings) {
117
+ const format = raw == null || raw === '' ? 'html' : String(raw);
118
+ if (!FORMAT_VALUES.includes(format)) return {error: `Unknown format "${format}" - use html, markdown or both`};
119
+ const wanted = format === 'both' ? ['html', 'markdown'] : [format];
120
+ if (!wanted.every(f => settings.formats.includes(f))) return {error: `Format "${format}" is not available on this site`};
121
+ return {format};
122
+ }
123
+
124
+ /**
125
+ * One page as the API serves it - the decision and the payload, without
126
+ * caching or replies. The admin's Try it console (routes/api/content-api.js)
127
+ * calls this too, so it shows exactly what a caller would get.
128
+ *
129
+ * @param {string} urlPath
130
+ * @param {{access: object, format: string, allowDrafts?: boolean}} opts
131
+ * @returns {Promise<{status: 200|403|404, body: object, page?: object, isDraft?: boolean}>}
132
+ */
133
+ export async function buildPageResponse(urlPath, {access, format, allowDrafts = false}) {
134
+ const verdict = await resolvePublishedPage(urlPath, access.viewer, {allowDrafts});
135
+ if (verdict.status === 'forbidden') return {status: 403, body: {error: 'Forbidden'}};
136
+ if (verdict.status !== 'ok' || apiOff(verdict.page) || !inProject(access, verdict.project)) {
137
+ return {status: 404, body: NOT_FOUND};
138
+ }
139
+ // The verdict's page was parsed anonymously. A signed-in caller gets the
140
+ // body rendered for them, as the site does - [menu] and row-scoped [view].
141
+ const page = access.viewer
142
+ ? (await getPage(verdict.page.urlPath, {user: access.viewer})) || verdict.page
143
+ : verdict.page;
144
+ return {status: 200, body: pageDetail(page, {format}), page, isDraft: verdict.isDraft};
145
+ }
146
+
147
+ const SORTS = {
148
+ title: (a, b) => String(a.title || '').localeCompare(String(b.title || '')),
149
+ updatedAt: (a, b) => String(a.updatedAt || '').localeCompare(String(b.updatedAt || '')),
150
+ createdAt: (a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || ''))
151
+ };
152
+
153
+ const intParam = (raw, fallback) => {
154
+ const n = parseInt(raw, 10);
155
+ return Number.isFinite(n) && n > 0 ? n : fallback;
156
+ };
157
+
158
+ /** Allowlisted menu items: what a navbar needs to draw them, nothing of their gating. */
159
+ function menuItems(items = []) {
160
+ return items.map(item => {
161
+ const out = {};
162
+ for (const k of ['type', 'text', 'url', 'icon', 'target', 'size', 'badge']) {
163
+ if (item[k] !== undefined) out[k] = item[k];
164
+ }
165
+ if (Array.isArray(item.items) && item.items.length) out.items = menuItems(item.items);
166
+ return out;
167
+ });
168
+ }
169
+
170
+ export async function contentPublicRoutes(fastify) {
171
+ // This prefix is exempt from the global /api no-store hook, so anything a
172
+ // route did not mark itself - every refusal - is no-store here. A 404 with
173
+ // no Cache-Control is heuristically cacheable, and would outlive the
174
+ // admin switching the API on. (Scoped: a hook in this plugin reaches only
175
+ // these routes.)
176
+ fastify.addHook('onSend', async (request, reply) => {
177
+ if (!reply.getHeader('cache-control')) reply.header('cache-control', 'no-store');
178
+ });
179
+
180
+ // -------------------------------------------------------------------------
181
+ // Pages
182
+ // -------------------------------------------------------------------------
183
+
184
+ fastify.get('/content/v1/pages', async (request, reply) => {
185
+ const access = await checkContentAccess('pages', request, reply);
186
+ if (!access) return;
187
+ if (!access.settings.listable) return reply.status(404).send(NOT_FOUND);
188
+
189
+ const q = request.query || {};
190
+ const limit = Math.min(access.settings.maxLimit, intParam(q.limit, DEFAULT_LIMIT));
191
+ const pageNo = intParam(q.page, 1);
192
+
193
+ const all = await listPages();
194
+ const exclusions = await searchExclusions(all);
195
+ let rows = all.filter(p => isPubliclyListed(p, access.viewer, exclusions)
196
+ && !apiOff(p)
197
+ && inProject(access, p.resolvedProject));
198
+ if (q.prefix) rows = rows.filter(p => String(p.urlPath || '').startsWith(String(q.prefix)));
199
+ if (q.tag) rows = rows.filter(p => Array.isArray(p.tags) && p.tags.includes(String(q.tag)));
200
+ if (q.category) rows = rows.filter(p => p.category === String(q.category));
201
+ if (q.project) rows = rows.filter(p => p.resolvedProject === String(q.project));
202
+ // Default order is listPages' own (sortOrder, then title).
203
+ if (SORTS[q.sort]) rows = [...rows].sort(SORTS[q.sort]);
204
+ if (q.order === 'desc') rows = [...rows].reverse();
205
+
206
+ cacheHeaders(reply, access);
207
+ const start = (pageNo - 1) * limit;
208
+ return {pages: rows.slice(start, start + limit).map(pageSummary), total: rows.length, page: pageNo, limit};
209
+ });
210
+
211
+ fastify.get('/content/v1/pages/*', async (request, reply) => {
212
+ const access = await checkContentAccess('pages', request, reply);
213
+ if (!access) return;
214
+ const picked = pickFormat(request.query?.format, access.settings);
215
+ if (picked.error) return reply.status(400).send({error: picked.error});
216
+ const {format} = picked;
217
+
218
+ const urlPath = '/' + (request.params['*'] || '');
219
+ // Drafts only for a role-mode caller who could read them in the admin.
220
+ const allowDrafts = access.mode === 'role' && request.query?.preview === '1' && canPreviewDrafts(access.viewer);
221
+
222
+ // A signed-in or draft render is that caller's alone and never cached.
223
+ if (access.viewer) {
224
+ const res = await buildPageResponse(urlPath, {access, format, allowDrafts});
225
+ cacheHeaders(reply, access, {draft: res.isDraft});
226
+ return reply.status(res.status).send(res.body);
227
+ }
228
+
229
+ // Anonymous callers (public and token). No response cache: the access
230
+ // decision has to parse the page anyway - exactly as the site's does -
231
+ // and the JSON is built straight from that parse. HTTP caching does the
232
+ // rest; the mtime ETag changes on any edit to the file, however made.
233
+ const res = await buildPageResponse(urlPath, {access, format});
234
+ if (res.status !== 200) return reply.status(res.status).send(res.body);
235
+ const mtime = await getPageMtime(res.page.urlPath || urlPath);
236
+ cacheHeaders(reply, access);
237
+ reply.header('etag', `W/"${mtime}-${format}"`);
238
+ return res.body;
239
+ });
240
+
241
+ // -------------------------------------------------------------------------
242
+ // Menus, site settings, search
243
+ // -------------------------------------------------------------------------
244
+
245
+ fastify.get('/content/v1/menus/:location', async (request, reply) => {
246
+ const access = await checkContentAccess('menus', request, reply);
247
+ if (!access) return;
248
+ const location = String(request.params.location || '');
249
+ if (PRIVATE_SLOTS.has(location) || location.startsWith('admin')) return reply.status(404).send(NOT_FOUND);
250
+
251
+ // ?page= lets page-bound menus resolve as they would on that page.
252
+ const urlPath = request.query?.page ? String(request.query.page) : undefined;
253
+ const menu = await resolveLocation(location, access.viewer, urlPath ? {urlPath} : {});
254
+ if (!menu) return reply.status(404).send(NOT_FOUND);
255
+
256
+ cacheHeaders(reply, access);
257
+ return {location, slug: menu.slug, name: menu.name, items: menuItems(menu.items)};
258
+ });
259
+
260
+ fastify.get('/content/v1/site', async (request, reply) => {
261
+ const access = await checkContentAccess('site', request, reply);
262
+ if (!access) return;
263
+ let site = {};
264
+ try { site = getConfig('site') || {}; } catch { /* no site.json - nothing to show */ }
265
+ cacheHeaders(reply, access);
266
+ return publicSiteSettings(site);
267
+ });
268
+
269
+ fastify.get('/content/v1/search', async (request, reply) => {
270
+ const access = await checkContentAccess('search', request, reply);
271
+ if (!access) return;
272
+ const settings = getSearchSettings();
273
+ // Search switched off for the site is off for the API too.
274
+ if (!settings.enabled) return reply.status(404).send(NOT_FOUND);
275
+
276
+ const q = String(request.query?.q || '').trim().slice(0, 200);
277
+ if (q.length < settings.minQueryLength) {
278
+ return reply.status(400).send({error: `Query must be at least ${settings.minQueryLength} characters`});
279
+ }
280
+ const maxResults = Math.min(settings.maxResults, intParam(request.query?.limit, settings.maxResults));
281
+
282
+ const all = await listPages();
283
+ const pages = all.filter(p => !apiOff(p) && inProject(access, p.resolvedProject));
284
+ const results = searchPages(pages, q, {
285
+ maxResults,
286
+ viewer: access.viewer,
287
+ exclusions: await searchExclusions(all),
288
+ onError: (err, page) => request.log.warn({err, page: page.urlPath}, '[content-api] search skipped an unreadable page')
289
+ });
290
+
291
+ // Search results are per viewer in role mode; never shared there.
292
+ cacheHeaders(reply, access);
293
+ return {
294
+ results: results.map(r => ({
295
+ urlPath: r.url, title: r.title, description: r.description, tags: r.tags, snippet: r.snippet, score: r.score
296
+ }))
297
+ };
298
+ });
299
+ }
@@ -9,11 +9,10 @@
9
9
  import {getPage, getPageMtime} from '../services/content.js';
10
10
  import {getProjectForPage, isProjectEnabled} from '../services/projects.js';
11
11
  import {renderPage} from '../services/renderer.js';
12
+ import {resolvePublishedPage} from '../services/pagePublishing.js';
12
13
  import {buildRobotsTxt, generate as generateSitemap} from '../services/sitemap.js';
13
- import {checkVisibility} from '../middleware/auth.js';
14
14
  import {resolveViewer} from '../services/viewerSession.js';
15
15
  import {touchPreviewLink, verifyPreviewToken} from '../services/previewLinks.js';
16
- import {getPermissionsFor} from '../services/roles.js';
17
16
  import {getEffectiveRoles} from '../services/userRoles.js';
18
17
  import {hooks} from '../services/hooks.js';
19
18
  import {getConfig} from '../config.js';
@@ -184,70 +183,36 @@ export async function publicRoutes(fastify) {
184
183
 
185
184
  const urlPath = '/' + (rawPath || '');
186
185
 
187
- // First-pass page lookup - used only for the visibility decision
188
- // (metadata-only is sufficient). We re-fetch below with `user` once
189
- // it is resolved so the body's [menu] shortcode renders against the
190
- // correct role.
191
- let page = await getPage(urlPath);
192
-
193
- // Try fetching index of a directory path
194
- if (!page && !urlPath.endsWith('/index')) {
195
- page = await getPage(urlPath.replace(/\/$/, '') || '/');
196
- }
197
-
198
- if (!page) {
199
- reply.status(404);
200
- return sendHtml(request, reply, await render404(urlPath));
201
- }
202
-
203
- // Hard kill-switch: a disabled project takes its pages off the public
204
- // site entirely - 404 (no oracle), regardless of page visibility.
205
- const pageProject = await getProjectForPage(page.urlPath || urlPath, page.project);
206
- if (!(await isProjectEnabled(pageProject))) {
207
- reply.status(404);
208
- return sendHtml(request, reply, await render404(urlPath));
209
- }
210
-
211
- // Draft preview: a page that is not published stays invisible to the
212
- // public, but is served in full to a signed-in user who could read it
213
- // in the admin anyway. Everyone else gets the same 404 as before - the
214
- // response must not reveal that an unpublished page exists there.
215
- const isDraft = page.status !== 'published';
216
-
217
- // Enforce page visibility. The viewer is resolved on EVERY request,
218
- // not only for gated pages: the navbar, footers, overlays and [menu]
219
- // shortcodes on a public page filter their items by role too, and a
220
- // signed-in visitor rendered as anonymous never sees the items meant
221
- // for them. Resolving is a cookie read plus a JWT verify, and returns
222
- // null at once for anonymous traffic, which still shares one entry.
186
+ // The viewer is resolved on EVERY request, not only for gated pages:
187
+ // the navbar, footers, overlays and [menu] shortcodes on a public page
188
+ // filter their items by role too, and a signed-in visitor rendered as
189
+ // anonymous never sees the items meant for them. Resolving is a cookie
190
+ // read plus a JWT verify, and returns null at once for anonymous
191
+ // traffic, which still shares one entry.
223
192
  //
224
- // `visibility` may be a string ('public' | 'private' | role name) or
225
- // an array of role names - see checkVisibility() for full semantics.
226
- // Effectively-public values (missing, 'public', or an array containing
227
- // 'public') skip viewer resolution entirely so anonymous traffic hits
228
- // the shared cache without auth cost - unless the page is a draft,
229
- // which needs the viewer regardless of its visibility.
230
193
  // The cache key carries every role the viewer holds (sorted), since
231
194
  // exact-role visibility (`=candidate`) makes an additional role change
232
195
  // what renders. Real sites hold a handful of role combinations, so
233
196
  // the entry count stays bounded by what users actually have.
234
- const vis = page.visibility;
235
- const isPublic = !vis
236
- || vis === 'public'
237
- || (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
238
-
239
197
  const userObj = await resolveViewer(request);
240
198
  const userRole = userObj ? [...getEffectiveRoles(userObj)].sort().join('+') : null;
241
199
 
242
- if (isDraft && !canPreviewDrafts(userObj)) {
200
+ // The access decision - the directory-index fallback, the project
201
+ // kill-switch, drafts and visibility - is resolvePublishedPage(), shared
202
+ // with the Content API so the two can never disagree. Drafts are allowed
203
+ // here: a viewer holding pages.read gets the real page plus a banner;
204
+ // everyone else gets the same 404 as a missing page, so the response
205
+ // never reveals that an unpublished page exists there.
206
+ const verdict = await resolvePublishedPage(urlPath, userObj, {allowDrafts: true});
207
+ if (verdict.status === 'not-found') {
243
208
  reply.status(404);
244
209
  return sendHtml(request, reply, await render404(urlPath));
245
210
  }
246
-
247
- if (!isPublic && !checkVisibility(userObj, vis)) {
211
+ if (verdict.status === 'forbidden') {
248
212
  reply.status(403);
249
213
  return reply.type('text/html; charset=utf-8').send(accessDeniedHtml(urlPath));
250
214
  }
215
+ const {page, isDraft} = verdict;
251
216
 
252
217
  const baseUrl = getBaseUrl(request);
253
218
 
@@ -294,22 +259,6 @@ export async function publicRoutes(fastify) {
294
259
  });
295
260
  }
296
261
 
297
- /**
298
- * May this viewer see unpublished pages on the public site?
299
- *
300
- * Gated on `pages.read` - the same permission that opens the Pages list in the
301
- * admin. Anyone who can already read the draft's source there loses nothing by
302
- * seeing it rendered, and nobody else gains anything.
303
- *
304
- * @param {object|null} user - Resolved viewer (`{role, additionalRoles}`) or null
305
- * @returns {boolean}
306
- */
307
- function canPreviewDrafts(user) {
308
- if (!user) return false;
309
- const allowed = getPermissionsFor('pages', 'read');
310
- return getEffectiveRoles(user).some(role => allowed.includes(role));
311
- }
312
-
313
262
  /**
314
263
  * Render a 404 response - tries content/pages/404.md first, falls back to
315
264
  * a minimal inline page so the site theme is applied when possible.
package/server/server.js CHANGED
@@ -460,14 +460,15 @@ app.setErrorHandler((error, request, reply) => {
460
460
  // ---------------------------------------------------------------------------
461
461
  // The admin SPA must always see the live state of the site - a stale list is
462
462
  // indistinguishable from a missing artefact, and the user is left arguing with
463
- // their own CMS. Everything under /api is no-store EXCEPT the two externally
463
+ // their own CMS. Everything under /api is no-store EXCEPT the three externally
464
464
  // consumable surfaces, which have their own caching contracts:
465
- // /api/v1/* - collection access by API token
466
- // /api/x/* - API Builder endpoints
465
+ // /api/v1/* - collection access by API token
466
+ // /api/x/* - API Builder endpoints
467
+ // /api/content/v1/* - the Content API (public / private / no-store per access mode)
467
468
  app.addHook('onSend', async (request, reply) => {
468
469
  const url = request.raw.url || '';
469
470
  if (!url.startsWith('/api/')) return;
470
- if (url.startsWith('/api/v1/') || url.startsWith('/api/x/')) return;
471
+ if (url.startsWith('/api/v1/') || url.startsWith('/api/x/') || url.startsWith('/api/content/v1/')) return;
471
472
  reply.header('Cache-Control', 'no-store, must-revalidate');
472
473
  reply.header('Pragma', 'no-cache');
473
474
  reply.header('Expires', '0');
@@ -497,6 +498,8 @@ const {rolesRoutes} = await import('./routes/api/roles.js');
497
498
  const {apiTokensRoutes} = await import('./routes/api/api-tokens.js');
498
499
  const {apiEndpointsRoutes} = await import('./routes/api/api-endpoints.js');
499
500
  const {endpointsPublicRoutes} = await import('./routes/api/endpoints-public.js');
501
+ const {contentPublicRoutes} = await import('./routes/api/content-public.js');
502
+ const {contentApiRoutes} = await import('./routes/api/content-api.js');
500
503
  const {sidebarRoutes} = await import('./routes/api/sidebar.js');
501
504
  const {documentationRoutes} = await import('./routes/api/documentation.js');
502
505
  const { mediaRoutes } = await import('./routes/api/media.js');
@@ -536,6 +539,8 @@ await app.register(rolesRoutes, {prefix: '/api'});
536
539
  await app.register(apiTokensRoutes, {prefix: '/api'});
537
540
  await app.register(apiEndpointsRoutes, {prefix: '/api'});
538
541
  await app.register(endpointsPublicRoutes, {prefix: '/api'});
542
+ await app.register(contentPublicRoutes, {prefix: '/api'});
543
+ await app.register(contentApiRoutes, {prefix: '/api'});
539
544
  await app.register(sidebarRoutes, {prefix: '/api'});
540
545
  await app.register(documentationRoutes, {prefix: '/api'});
541
546
  await app.register(mediaRoutes, { prefix: '/api' });
@@ -657,6 +662,7 @@ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Layouts
657
662
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Plugins', url: '#/plugins', icon: 'package', permission: 'plugins', countUrl: '/api/plugins/_count', inventory: true}});
658
663
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'API Tokens', url: '#/api-tokens', icon: 'key', permission: 'api-tokens', countUrl: '/api/api-tokens/_count'}});
659
664
  registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Search', url: '#/search', icon: 'search', permission: 'settings', countUrl: '/api/search/_count', inventory: true}});
665
+ registerSidebarItem({folder: 'system', item: {source: 'Built in', text: 'Content API', url: '#/content-api', icon: 'file-text', permission: 'settings', countUrl: '/api/content-api/_count', inventory: true}});
660
666
  // Documentation pages added after the sidebar menu was seeded (0.93.1). The menu is
661
667
  // seeded once, so new pages reach existing sites only by registering here. The two
662
668
  // sub-folders have no key in older menus; findFolder() falls back to their name.
@@ -666,6 +672,7 @@ registerSidebarItem({folder: 'Usage', item: {source: 'Built in', text: 'Editions
666
672
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Forms', url: '#/docs/api/forms', icon: 'layout'}});
667
673
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Scaffold', url: '#/docs/api/scaffold', icon: 'package'}});
668
674
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'External API & Tokens', url: '#/docs/api/external', icon: 'key'}});
675
+ registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'Content API', url: '#/docs/api/content', icon: 'file-text'}});
669
676
  registerSidebarItem({folder: 'API Reference', item: {source: 'Built in', text: 'API Builder', url: '#/docs/api/builder', icon: 'code'}});
670
677
 
671
678
  // Core's notification sources, before plugins add theirs (0.80).
@@ -17,6 +17,12 @@ export const API_TOKENS_COLLECTION_SLUG = 'api-tokens';
17
17
 
18
18
  const TOKEN_RE = /^dcms_[a-f0-9]{64}$/;
19
19
  const VERBS = ['create', 'read', 'update', 'delete'];
20
+
21
+ /**
22
+ * What a `{content}` scope may name - the Content API's resources. Content
23
+ * scopes are read-only (the API has no writes yet), so they carry no verbs.
24
+ */
25
+ export const CONTENT_RESOURCES = Object.freeze(['pages', 'menus', 'site', 'search']);
20
26
  const LAST_USED_THROTTLE_MS = 60_000;
21
27
 
22
28
  /** In-memory hash → entry cache; null = needs rebuild. */
@@ -75,16 +81,23 @@ function sanitise(entry) {
75
81
  }
76
82
 
77
83
  /**
78
- * Validate a scopes declaration: array of { collection, verbs[] }.
79
- * Empty array = the token covers every collection in its project.
84
+ * Validate a scopes declaration. Each scope is EITHER a collection scope
85
+ * `{collection, verbs?}` OR a content scope `{content}` (a Content API
86
+ * resource). Empty array = the token covers everything in its project.
80
87
  *
81
88
  * @param {*} scopes
82
89
  * @returns {string|null} Error message, or null when valid
83
90
  */
84
- function validateScopes(scopes) {
91
+ export function validateScopes(scopes) {
85
92
  if (scopes == null) return null;
86
93
  if (!Array.isArray(scopes)) return 'scopes must be an array';
87
94
  for (const s of scopes) {
95
+ if (s && typeof s === 'object' && s.content !== undefined) {
96
+ if (s.collection !== undefined) return 'a scope names either a collection or a content resource, not both';
97
+ if (!CONTENT_RESOURCES.includes(s.content)) return `unknown content resource "${s.content}"`;
98
+ if (s.verbs != null) return 'content scopes are read-only and take no verbs';
99
+ continue;
100
+ }
88
101
  if (!s || typeof s !== 'object' || typeof s.collection !== 'string' || !s.collection.trim()) {
89
102
  return 'each scope needs a collection slug';
90
103
  }
@@ -101,18 +114,61 @@ function validateScopes(scopes) {
101
114
  * Check whether a token's scopes permit an operation on a collection.
102
115
  * Empty/missing scopes → everything in the token's project is allowed.
103
116
  *
104
- * @param {Array<{collection: string, verbs?: string[]}>} scopes
117
+ * @param {Array<{collection?: string, verbs?: string[], content?: string}>} scopes
105
118
  * @param {string} collectionSlug
106
119
  * @param {string} verb - create | read | update | delete
107
120
  * @returns {boolean}
108
121
  */
109
122
  export function scopeAllows(scopes, collectionSlug, verb) {
110
123
  if (!Array.isArray(scopes) || scopes.length === 0) return true;
111
- const scope = scopes.find(s => s.collection === collectionSlug);
124
+ // Content scopes never match: a collection named `pages` is still a collection.
125
+ const scope = scopes.find(s => s.collection !== undefined && s.collection === collectionSlug);
112
126
  if (!scope) return false;
113
127
  return !Array.isArray(scope.verbs) || scope.verbs.length === 0 || scope.verbs.includes(verb);
114
128
  }
115
129
 
130
+ /**
131
+ * Does a token's scope list permit a Content API resource? Empty = everything
132
+ * in the project. A list that names no content resource permits none - a token
133
+ * scoped to collections is never widened by the Content API existing.
134
+ *
135
+ * @param {object[]} scopes
136
+ * @param {string} resource - one of CONTENT_RESOURCES
137
+ * @returns {boolean}
138
+ */
139
+ export function contentScopeAllows(scopes, resource) {
140
+ if (!Array.isArray(scopes) || scopes.length === 0) return true;
141
+ return scopes.some(s => s.content === resource);
142
+ }
143
+
144
+ const BEARER_RE = /^Bearer (dcms_[a-f0-9]{64})$/;
145
+
146
+ /**
147
+ * The strict token check, shared by every externally consumable gate - the
148
+ * collections API (and the API Builder endpoints that reuse it) and the
149
+ * Content API. A token is the ONLY credential accepted: callers decide when
150
+ * token mode applies, and this never looks at a JWT.
151
+ *
152
+ * `project` binds the token to the resource's project. Omit it when the
153
+ * caller binds per resource instead (a Content API page list spans projects
154
+ * and is filtered to the token's). `allows` receives the token's scopes.
155
+ *
156
+ * @param {{headers?: object}} request
157
+ * @param {{project?: string, allows: (scopes: object[]) => boolean, projectError?: string}} opts
158
+ * @returns {Promise<{ok: true, token: object} | {ok: false, status: 401|403, error: string}>}
159
+ */
160
+ export async function authenticateApiToken(request, {project, allows, projectError}) {
161
+ const match = (request.headers?.authorization || '').match(BEARER_RE);
162
+ if (!match) return {ok: false, status: 401, error: 'API token required'};
163
+ const token = await validateToken(match[1]);
164
+ if (!token) return {ok: false, status: 401, error: 'Invalid, disabled or expired API token'};
165
+ if (project !== undefined && token.project !== project) {
166
+ return {ok: false, status: 403, error: projectError || "Token is not valid for this resource's project"};
167
+ }
168
+ if (!allows(token.scopes)) return {ok: false, status: 403, error: 'Token scope does not permit this operation'};
169
+ return {ok: true, token};
170
+ }
171
+
116
172
  /**
117
173
  * Create a new token. The plaintext is returned ONCE here and never again.
118
174
  *
@@ -167,7 +167,7 @@ export async function createPage(urlPath, frontmatter, body) {
167
167
 
168
168
  await fs.writeFile(filePath, serialiseMarkdown(meta, body || ''), 'utf8');
169
169
  const page = await readPageFile(filePath);
170
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
170
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
171
171
  hooks.emit('content:pageCreated', {page, urlPath});
172
172
  return page;
173
173
  }
@@ -222,7 +222,7 @@ export async function updatePage(urlPath, frontmatter, body, {author} = {}) {
222
222
 
223
223
  await fs.writeFile(filePath, serialiseMarkdown(meta, body ?? existingContent), 'utf8');
224
224
  const page = await readPageFile(filePath);
225
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
225
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
226
226
  hooks.emit('content:pageUpdated', {page, urlPath});
227
227
  return page;
228
228
  }
@@ -240,7 +240,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
240
240
  const newFile = urlPathToFilePath(newUrlPath);
241
241
  await fs.mkdir(path.dirname(newFile), { recursive: true });
242
242
  await fs.rename(oldFile, newFile);
243
- await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap']);
243
+ await cache.invalidateTags([`page:${oldUrlPath}`, `page:${newUrlPath}`, 'sitemap', 'pages:list']);
244
244
  try {
245
245
  await renameVersionDir(oldUrlPath, newUrlPath);
246
246
  } catch {
@@ -259,7 +259,7 @@ export async function renamePage(oldUrlPath, newUrlPath) {
259
259
  export async function deletePage(urlPath) {
260
260
  const filePath = await resolveExistingFilePath(urlPath);
261
261
  await fs.unlink(filePath);
262
- await cache.invalidateTags([`page:${urlPath}`, 'sitemap']);
262
+ await cache.invalidateTags([`page:${urlPath}`, 'sitemap', 'pages:list']);
263
263
  hooks.emit('content:pageDeleted', {urlPath});
264
264
  try {
265
265
  await deleteAllVersions(urlPath);