domma-cms 1.6.60 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4344 -0
- package/README.md +1 -1
- package/admin/dist/domma/domma-tools.css +3 -3
- package/admin/dist/domma/domma-tools.min.js +3 -3
- package/admin/js/lib/effect-defs.js +1 -1
- package/admin/js/lib/effects-builder.js +2 -2
- package/admin/js/lib/page-editor-reference.js +5 -5
- package/admin/js/lib/shortcode-tags.js +1 -1
- package/admin/js/templates/docs/usage-shortcodes.html +39 -0
- package/docs/DommaFeatures.md +3005 -0
- package/docs/MultiTenant.md +176 -0
- package/docs/api-reference.md +1667 -0
- package/docs/cache.md +96 -0
- package/docs/configuration.md +1811 -0
- package/docs/editions-and-licences.md +190 -0
- package/docs/getting-started.md +201 -0
- package/docs/guides/form-triggers.md +260 -0
- package/docs/guides/mongodb-storage-adapter.md +282 -0
- package/docs/guides/multi-page-forms.md +121 -0
- package/docs/images/scribe-composer.png +0 -0
- package/docs/markdown-shortcodes.md +1738 -0
- package/docs/pentest-report-2026-04-03.md +181 -0
- package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
- package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
- package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
- package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
- package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
- package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
- package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
- package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
- package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
- package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
- package/docs/plans/2026-03-05-user-types-design.md +139 -0
- package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
- package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
- package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
- package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
- package/docs/plans/2026-06-10-core-project-design.md +240 -0
- package/docs/plugin-development.md +827 -0
- package/docs/recruitment-recipe.md +501 -0
- package/docs/scaffolding.md +312 -0
- package/docs/screenshots/calculator-floating.png +0 -0
- package/docs/screenshots/calculator-standard.png +0 -0
- package/docs/screenshots/email-settings.png +0 -0
- package/docs/screenshots/messages-count.png +0 -0
- package/docs/structure/user-types.md +107 -0
- package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
- package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
- package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
- package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
- package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
- package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
- package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
- package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
- package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
- package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
- package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
- package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
- package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
- package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
- package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
- package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
- package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
- package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
- package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
- package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
- package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
- package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
- package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
- package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
- package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
- package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
- package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
- package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
- package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
- package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
- package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
- package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
- package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
- package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
- package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
- package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
- package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
- package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
- package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
- package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
- package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
- package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
- package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
- package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
- package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
- package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
- package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
- package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
- package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
- package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
- package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
- package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
- package/docs/theming.md +320 -0
- package/package.json +2 -2
- package/public/js/celebrations/core/canvas.js +1 -1
- package/public/js/celebrations/index.js +1 -1
- package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
- package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
- package/public/js/celebrations/message-attrs.mjs +1 -0
- package/public/js/celebrations/themes/anniversary.js +1 -0
- package/public/js/celebrations/themes/autumn.js +1 -0
- package/public/js/celebrations/themes/birthday.js +1 -0
- package/public/js/celebrations/themes/butterfly-flight.js +1 -0
- package/public/js/celebrations/themes/calendar.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
- package/public/js/celebrations/themes/chinese-new-year.js +1 -0
- package/public/js/celebrations/themes/christmas.js +1 -1
- package/public/js/celebrations/themes/easter.js +1 -0
- package/public/js/celebrations/themes/garden-drawing.js +1 -0
- package/public/js/celebrations/themes/garden.js +1 -0
- package/public/js/celebrations/themes/graduation.js +1 -0
- package/public/js/celebrations/themes/greeting.js +1 -0
- package/public/js/celebrations/themes/make-it-rain.js +1 -0
- package/public/js/celebrations/themes/money-rain.js +1 -0
- package/public/js/celebrations/themes/new-year.js +1 -0
- package/public/js/celebrations/themes/occasion-drawing.js +1 -0
- package/public/js/celebrations/themes/occasions.js +1 -0
- package/public/js/celebrations/themes/rain-control.js +1 -0
- package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
- package/public/js/celebrations/themes/sparkler-font.js +1 -0
- package/public/js/celebrations/themes/sparkler-writing.js +1 -0
- package/public/js/celebrations/themes/summer-garden.js +1 -0
- package/public/js/celebrations/themes/winter.js +1 -0
- package/public/js/celebrations/trait-data.mjs +1 -1
- package/public/js/effects.js +1 -1
- package/scripts/build.js +4 -0
- package/server/services/markdown.js +5 -2
- package/server/templates/page.html +1 -1
|
@@ -0,0 +1,1192 @@
|
|
|
1
|
+
# Content API - Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Load the `domma-api-surface` skill before Tasks 2-5.
|
|
4
|
+
|
|
5
|
+
**Goal:** A read-only, versioned Content API at `/api/content/v1` - pages, menus, a public site-settings subset and search - gated by the same public / token / role modes as the Data API, and configured from a new **System › Content API** screen.
|
|
6
|
+
|
|
7
|
+
**Architecture:** The public page access decision is lifted out of `server/routes/public.js` into `server/services/pagePublishing.js`, and both the site and the API call it. Token checks are factored out of `checkPublicAccess()` into `authenticateApiToken()`, which the collection gate and the new content gate share. Settings live in `config/content-api.json` via a service shaped like `search.js`. The admin screen is a Domma reactive view (observables + `M.applyBindings`), with its pure logic in `admin/js/lib/content-api-arrange.js`.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Node 20+ ESM, Fastify 5, `node:test` + `assert/strict`, Domma (`M` domma-reactive, `E`, `I`, `S`, `$`), no build step.
|
|
10
|
+
|
|
11
|
+
**Spec:** `docs/superpowers/specs/2026-10-09-content-api-design.md`
|
|
12
|
+
|
|
13
|
+
**Spec open questions - settled here (change before Task 7 if wrong):**
|
|
14
|
+
1. Admin home: its **own screen**, System › Content API (`#/content-api`), permission `settings`.
|
|
15
|
+
2. Per-page opt-out: **yes**. Frontmatter `api: false` keeps a page off the API (it still renders on the site).
|
|
16
|
+
3. `requires` carries **no asset URLs** in v1.
|
|
17
|
+
4. Edition: same tier as the Data API (confirm against `docs/editions-and-licences.md` in Task 9).
|
|
18
|
+
|
|
19
|
+
**Test runs:** always `npm test -- <file>` (the runner copies the tree to a temp dir). Never bare `node --test` - many of these tests write pages and config.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## File Structure
|
|
24
|
+
|
|
25
|
+
| File | Responsibility |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `server/services/pagePublishing.js` | **New.** `resolvePublishedPage()`, `canPreviewDrafts()`, `isEffectivelyPublic()`. The one access decision for a single page. |
|
|
28
|
+
| `server/services/search.js` | `isPubliclyListed()` (new, exported); `isSearchable()` built on it; draft rule becomes `status !== 'published'`. |
|
|
29
|
+
| `server/routes/public.js` | Calls `resolvePublishedPage()`; keeps rendering, cache key and replies. |
|
|
30
|
+
| `server/services/content.js` | Page writes also invalidate the `pages:list` tag. |
|
|
31
|
+
| `server/services/apiTokens.js` | `authenticateApiToken()`; `{content}` scope shape in `validateScopes()`; `contentScopeAllows()`. |
|
|
32
|
+
| `server/routes/api/collections.js` | `checkPublicAccess()` token branch delegates to `authenticateApiToken()`. |
|
|
33
|
+
| `server/services/contentApi.js` | **New.** Settings defaults/normalise/get/save; `publicSiteSettings()`; page payload shaping (allowlists). |
|
|
34
|
+
| `server/routes/api/content-public.js` | **New.** `checkContentAccess()` + the five `GET` routes. |
|
|
35
|
+
| `server/routes/api/content-api.js` | **New.** Admin: `GET/PUT /api/content-api/settings`, `GET /api/content-api/_count`, `GET /api/content-api/preview`. |
|
|
36
|
+
| `server/server.js` | Register both route files; `onSend` no-store exemption; sidebar item. |
|
|
37
|
+
| `admin/js/lib/content-api-arrange.js` | **New.** Pure: form ↔ settings, dirty snapshot, chips, endpoint list, curl examples, preview row. |
|
|
38
|
+
| `admin/js/views/content-api.js` | **New.** The screen - observables, bindings, tabs, try-it console, save, draft, router guard. |
|
|
39
|
+
| `admin/js/templates/content-api.html` | **New.** Template, `data-model` / `data-bind-*` / `data-each`. |
|
|
40
|
+
| `admin/js/views/index.js`, `admin/js/app.js` | Register the view, route and Tool banner entry. |
|
|
41
|
+
| `admin/js/lib/api-tokens-arrange.js` | `@pages`-style content scope lines in `parseScopes()` / `scopesToText()` / `scopesSummary()`. |
|
|
42
|
+
| `admin/css/admin.css` | `.cax-*` styles for the screen. |
|
|
43
|
+
| Tests | Listed per task. |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Task 1: Extract the page access decision
|
|
48
|
+
|
|
49
|
+
The public site and the API must make the same decision, so the first step pulls it into a service, with no behaviour change.
|
|
50
|
+
|
|
51
|
+
**Files:**
|
|
52
|
+
- Create: `server/services/pagePublishing.js`
|
|
53
|
+
- Modify: `server/routes/public.js:177-260`
|
|
54
|
+
- Test: `tests/services/page-publishing.test.js` (new); existing `tests/routes/public-draft-preview.test.js`, `role-visibility.test.js`, `public-cache-headers.test.js`, `public-share-preview.test.js` must pass **unchanged**
|
|
55
|
+
|
|
56
|
+
- [ ] **Step 1: Write the failing test**
|
|
57
|
+
|
|
58
|
+
`tests/services/page-publishing.test.js`:
|
|
59
|
+
|
|
60
|
+
```js
|
|
61
|
+
/**
|
|
62
|
+
* pagePublishing.resolvePublishedPage - the single access decision for a
|
|
63
|
+
* public page, shared by the site (routes/public.js) and the Content API.
|
|
64
|
+
*/
|
|
65
|
+
import {after, before, test} from 'node:test';
|
|
66
|
+
import assert from 'node:assert/strict';
|
|
67
|
+
import {resolvePublishedPage, isEffectivelyPublic} from '../../server/services/pagePublishing.js';
|
|
68
|
+
import {createPage, deletePage} from '../../server/services/content.js';
|
|
69
|
+
import {createProject, deleteProject, updateProject} from '../../server/services/projects.js';
|
|
70
|
+
import {seedAll} from '../../server/services/presetCollections.js';
|
|
71
|
+
import {load as loadRoles} from '../../server/services/roles.js';
|
|
72
|
+
|
|
73
|
+
const P = '/zz-pp-' + Date.now();
|
|
74
|
+
const anon = null;
|
|
75
|
+
const user = {id: 'u', role: 'user', additionalRoles: []};
|
|
76
|
+
const admin = {id: 'a', role: 'admin', additionalRoles: []};
|
|
77
|
+
|
|
78
|
+
before(async () => {
|
|
79
|
+
await seedAll();
|
|
80
|
+
await loadRoles();
|
|
81
|
+
await createPage(`${P}/live`, {title: 'Live', status: 'published'}, 'x');
|
|
82
|
+
await createPage(`${P}/draft`, {title: 'Draft', status: 'draft'}, 'x');
|
|
83
|
+
await createPage(`${P}/sched`, {title: 'Sched', status: 'scheduled'}, 'x');
|
|
84
|
+
await createPage(`${P}/members`, {title: 'Members', status: 'published', visibility: 'user'}, 'x');
|
|
85
|
+
await createPage(`${P}/sec`, {title: 'Section', status: 'published'}, 'x'); // /sec
|
|
86
|
+
await createPage(`${P}/sec/index`, {title: 'Section index', status: 'published'}, 'x');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
after(async () => {
|
|
90
|
+
for (const p of ['live', 'draft', 'sched', 'members', 'sec', 'sec/index']) await deletePage(`${P}/${p}`).catch(() => {});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('published public page → ok for anyone', async () => {
|
|
94
|
+
const v = await resolvePublishedPage(`${P}/live`, anon);
|
|
95
|
+
assert.equal(v.status, 'ok');
|
|
96
|
+
assert.equal(v.page.title, 'Live');
|
|
97
|
+
assert.equal(v.isDraft, false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('missing page → not-found', async () => {
|
|
101
|
+
assert.equal((await resolvePublishedPage(`${P}/nope`, anon)).status, 'not-found');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('any non-published status is not-found without allowDrafts', async () => {
|
|
105
|
+
assert.equal((await resolvePublishedPage(`${P}/draft`, admin)).status, 'not-found');
|
|
106
|
+
assert.equal((await resolvePublishedPage(`${P}/sched`, anon)).status, 'not-found');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('allowDrafts admits a pages.read viewer only', async () => {
|
|
110
|
+
assert.equal((await resolvePublishedPage(`${P}/draft`, admin, {allowDrafts: true})).status, 'ok');
|
|
111
|
+
assert.equal((await resolvePublishedPage(`${P}/draft`, user, {allowDrafts: true})).status, 'not-found');
|
|
112
|
+
assert.equal((await resolvePublishedPage(`${P}/draft`, anon, {allowDrafts: true})).status, 'not-found');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('visibility: forbidden for anon, ok for the role and above', async () => {
|
|
116
|
+
assert.equal((await resolvePublishedPage(`${P}/members`, anon)).status, 'forbidden');
|
|
117
|
+
assert.equal((await resolvePublishedPage(`${P}/members`, user)).status, 'ok');
|
|
118
|
+
assert.equal((await resolvePublishedPage(`${P}/members`, admin)).status, 'ok');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('forbidden verdict carries no page body', async () => {
|
|
122
|
+
const v = await resolvePublishedPage(`${P}/members`, anon);
|
|
123
|
+
assert.equal(v.page, undefined);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('disabled project → not-found, whatever the visibility', async () => {
|
|
127
|
+
const slug = 'zz-pp-proj-' + Date.now();
|
|
128
|
+
await createProject({slug, name: 'PP'});
|
|
129
|
+
await createPage(`${P}/inproj`, {title: 'In project', status: 'published', project: slug}, 'x');
|
|
130
|
+
try {
|
|
131
|
+
assert.equal((await resolvePublishedPage(`${P}/inproj`, anon)).status, 'ok');
|
|
132
|
+
await updateProject(slug, {enabled: false});
|
|
133
|
+
assert.equal((await resolvePublishedPage(`${P}/inproj`, admin)).status, 'not-found');
|
|
134
|
+
} finally {
|
|
135
|
+
await deletePage(`${P}/inproj`).catch(() => {});
|
|
136
|
+
await deleteProject(slug).catch(() => {});
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('isEffectivelyPublic', () => {
|
|
141
|
+
assert.equal(isEffectivelyPublic(undefined), true);
|
|
142
|
+
assert.equal(isEffectivelyPublic('public'), true);
|
|
143
|
+
assert.equal(isEffectivelyPublic([]), true);
|
|
144
|
+
assert.equal(isEffectivelyPublic(['user', 'public']), true);
|
|
145
|
+
assert.equal(isEffectivelyPublic('user'), false);
|
|
146
|
+
assert.equal(isEffectivelyPublic('private'), false);
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Check `updateProject`'s real name and the field that disables a project in `server/services/projects.js` before running; adjust the two lines if they differ.
|
|
151
|
+
|
|
152
|
+
- [ ] **Step 2: Run it - expect failure** (module not found)
|
|
153
|
+
|
|
154
|
+
`npm test -- tests/services/page-publishing.test.js`
|
|
155
|
+
|
|
156
|
+
- [ ] **Step 3: Create `server/services/pagePublishing.js`**
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
/**
|
|
160
|
+
* Page publishing - may this viewer see this page?
|
|
161
|
+
*
|
|
162
|
+
* The one implementation of the public page access decision. The public site
|
|
163
|
+
* (routes/public.js) and the Content API (routes/api/content-public.js) both
|
|
164
|
+
* call it, so a page reachable over the API is reachable on the site by the
|
|
165
|
+
* same viewer and the reverse. It decides; it never replies or renders.
|
|
166
|
+
*
|
|
167
|
+
* Order matters and matches what the site has always done:
|
|
168
|
+
* 1. the page (with the directory-index fallback)
|
|
169
|
+
* 2. its project's kill-switch - a disabled project is a 404, no oracle
|
|
170
|
+
* 3. drafts - anything not `published` is a 404 unless drafts are allowed
|
|
171
|
+
* AND the viewer holds pages.read
|
|
172
|
+
* 4. visibility - checkVisibility(), the ladder / `=role` rule
|
|
173
|
+
*/
|
|
174
|
+
import {getPage} from './content.js';
|
|
175
|
+
import {getProjectForPage, isProjectEnabled} from './projects.js';
|
|
176
|
+
import {checkVisibility} from '../middleware/auth.js';
|
|
177
|
+
import {getPermissionsFor} from './roles.js';
|
|
178
|
+
import {getEffectiveRoles} from './userRoles.js';
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Does a visibility value let everyone in without resolving a viewer?
|
|
182
|
+
* Missing, 'public', an empty array, or an array containing 'public'.
|
|
183
|
+
*
|
|
184
|
+
* @param {string|string[]|undefined} vis
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function isEffectivelyPublic(vis) {
|
|
188
|
+
return !vis
|
|
189
|
+
|| vis === 'public'
|
|
190
|
+
|| (Array.isArray(vis) && (vis.length === 0 || vis.includes('public')));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* May this viewer see unpublished pages? Gated on `pages.read` - the same
|
|
195
|
+
* permission that opens the Pages list in the admin.
|
|
196
|
+
*
|
|
197
|
+
* @param {object|null} user
|
|
198
|
+
* @returns {boolean}
|
|
199
|
+
*/
|
|
200
|
+
export function canPreviewDrafts(user) {
|
|
201
|
+
if (!user) return false;
|
|
202
|
+
const allowed = getPermissionsFor('pages', 'read');
|
|
203
|
+
return getEffectiveRoles(user).some(role => allowed.includes(role));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {string} urlPath - '/about', '/services', '/'
|
|
208
|
+
* @param {object|null} viewer - resolveViewer() result, or a JWT user, or null
|
|
209
|
+
* @param {{allowDrafts?: boolean}} [opts]
|
|
210
|
+
* @returns {Promise<
|
|
211
|
+
* {status: 'ok', page: object, project: string, isDraft: boolean, isPublic: boolean} |
|
|
212
|
+
* {status: 'not-found'} |
|
|
213
|
+
* {status: 'forbidden'}
|
|
214
|
+
* >}
|
|
215
|
+
*/
|
|
216
|
+
export async function resolvePublishedPage(urlPath, viewer, {allowDrafts = false} = {}) {
|
|
217
|
+
let page = await getPage(urlPath);
|
|
218
|
+
if (!page && !urlPath.endsWith('/index')) {
|
|
219
|
+
page = await getPage(urlPath.replace(/\/$/, '') || '/');
|
|
220
|
+
}
|
|
221
|
+
if (!page) return {status: 'not-found'};
|
|
222
|
+
|
|
223
|
+
const project = await getProjectForPage(page.urlPath || urlPath, page.project);
|
|
224
|
+
if (!(await isProjectEnabled(project))) return {status: 'not-found'};
|
|
225
|
+
|
|
226
|
+
const isDraft = page.status !== 'published';
|
|
227
|
+
if (isDraft && !(allowDrafts && canPreviewDrafts(viewer))) return {status: 'not-found'};
|
|
228
|
+
|
|
229
|
+
const isPublic = isEffectivelyPublic(page.visibility);
|
|
230
|
+
if (!isPublic && !checkVisibility(viewer, page.visibility)) return {status: 'forbidden'};
|
|
231
|
+
|
|
232
|
+
return {status: 'ok', page, project, isDraft, isPublic};
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
- [ ] **Step 4: Switch `server/routes/public.js` to it**
|
|
237
|
+
|
|
238
|
+
Replace the first-pass lookup, project check, draft check and visibility check in the `/*` handler with:
|
|
239
|
+
|
|
240
|
+
```js
|
|
241
|
+
const urlPath = '/' + (rawPath || '');
|
|
242
|
+
|
|
243
|
+
// The access decision - shared with the Content API (services/pagePublishing.js).
|
|
244
|
+
// The viewer is resolved on EVERY request: navbars, footers, overlays and [menu]
|
|
245
|
+
// on a public page filter by role too. Anonymous traffic resolves to null at once.
|
|
246
|
+
const userObj = await resolveViewer(request);
|
|
247
|
+
const userRole = userObj ? [...getEffectiveRoles(userObj)].sort().join('+') : null;
|
|
248
|
+
|
|
249
|
+
const verdict = await resolvePublishedPage(urlPath, userObj, {allowDrafts: true});
|
|
250
|
+
if (verdict.status === 'not-found') {
|
|
251
|
+
reply.status(404);
|
|
252
|
+
return sendHtml(request, reply, await render404(urlPath));
|
|
253
|
+
}
|
|
254
|
+
if (verdict.status === 'forbidden') {
|
|
255
|
+
reply.status(403);
|
|
256
|
+
return reply.type('text/html; charset=utf-8').send(accessDeniedHtml(urlPath));
|
|
257
|
+
}
|
|
258
|
+
const {page, isDraft} = verdict;
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Keep everything from `const baseUrl = getBaseUrl(request);` onwards as it is. Delete the local `canPreviewDrafts()` and import it from `pagePublishing.js` if anything else in the file still uses it (`/_preview` may; grep). Drop the now-unused imports (`getProjectForPage`, `isProjectEnabled`, `checkVisibility`, `getPermissionsFor`) only if nothing else in the file uses them.
|
|
262
|
+
|
|
263
|
+
Keep the existing comments about per-viewer caching and draft `no-store` - they still describe the code below.
|
|
264
|
+
|
|
265
|
+
- [ ] **Step 5: Run the new and the existing public tests**
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
npm test -- tests/services/page-publishing.test.js
|
|
269
|
+
npm test -- tests/routes/public-draft-preview.test.js
|
|
270
|
+
npm test -- tests/routes/role-visibility.test.js
|
|
271
|
+
npm test -- tests/routes/public-cache-headers.test.js
|
|
272
|
+
npm test -- tests/routes/public-share-preview.test.js
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
All pass, and the four existing files are **unmodified**.
|
|
276
|
+
|
|
277
|
+
- [ ] **Step 6: Commit** - `refactor(public): extract the page access decision into pagePublishing`
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Task 2: One rule for "listed", and search brought into line
|
|
282
|
+
|
|
283
|
+
**Files:**
|
|
284
|
+
- Modify: `server/services/search.js` (`isSearchable`, around :472)
|
|
285
|
+
- Test: `tests/routes/search-visibility.test.js` (extend), `tests/services/search-listed.test.js` (new)
|
|
286
|
+
|
|
287
|
+
- [ ] **Step 1: Failing test** - `tests/services/search-listed.test.js`:
|
|
288
|
+
|
|
289
|
+
```js
|
|
290
|
+
/** isPubliclyListed - the list rule shared by search and the Content API. */
|
|
291
|
+
import {test} from 'node:test';
|
|
292
|
+
import assert from 'node:assert/strict';
|
|
293
|
+
import {isPubliclyListed, isSearchable} from '../../server/services/search.js';
|
|
294
|
+
|
|
295
|
+
const page = (extra = {}) => ({urlPath: '/a', status: 'published', ...extra});
|
|
296
|
+
|
|
297
|
+
test('only published pages are listed - not just "not draft"', () => {
|
|
298
|
+
assert.equal(isPubliclyListed(page(), null), true);
|
|
299
|
+
assert.equal(isPubliclyListed(page({status: 'draft'}), null), false);
|
|
300
|
+
assert.equal(isPubliclyListed(page({status: 'scheduled'}), null), false);
|
|
301
|
+
assert.equal(isPubliclyListed(page({status: undefined}), null), false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('a page in a disabled project is not listed', () => {
|
|
305
|
+
assert.equal(isPubliclyListed(page(), null, {offline: new Set(['/a']), noindex: new Set()}), false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test('visibility applies', () => {
|
|
309
|
+
assert.equal(isPubliclyListed(page({visibility: 'user'}), null), false);
|
|
310
|
+
assert.equal(isPubliclyListed(page({visibility: 'user'}), {role: 'user', additionalRoles: []}), true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('noindex hides from search but not from the list rule', () => {
|
|
314
|
+
const p = page({seo: {noindex: true}});
|
|
315
|
+
assert.equal(isPubliclyListed(p, null), true);
|
|
316
|
+
assert.equal(isSearchable(p, null), false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test('search no longer finds a scheduled page', () => {
|
|
320
|
+
assert.equal(isSearchable(page({status: 'scheduled'}), null), false);
|
|
321
|
+
});
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The visibility test needs roles loaded - add `before(async () => { await seedAll(); await loadRoles(); })` as in Task 1.
|
|
325
|
+
|
|
326
|
+
- [ ] **Step 2: Run - expect failure** (`isPubliclyListed` not exported).
|
|
327
|
+
|
|
328
|
+
- [ ] **Step 3: Implement** in `server/services/search.js`, above `isSearchable`:
|
|
329
|
+
|
|
330
|
+
```js
|
|
331
|
+
/**
|
|
332
|
+
* Could this viewer open this page from a list - published, in an enabled
|
|
333
|
+
* project, visible to them? The rule the Content API's page list and search
|
|
334
|
+
* share; noindex is search's own business and is not part of it.
|
|
335
|
+
*
|
|
336
|
+
* `status !== 'published'`, not `=== 'draft'`: the public route has always
|
|
337
|
+
* hidden every unpublished status, and search used to find a `scheduled` page
|
|
338
|
+
* that then 404ed.
|
|
339
|
+
*/
|
|
340
|
+
export function isPubliclyListed(page, viewer = null, exclusions = null) {
|
|
341
|
+
if (!page || page.status !== 'published') return false;
|
|
342
|
+
if (isOffline(page, exclusions)) return false;
|
|
343
|
+
return checkVisibility(viewer, page.visibility);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function isSearchable(page, viewer = null, exclusions = null) {
|
|
347
|
+
return isPubliclyListed(page, viewer, exclusions) && !isNoindex(page, exclusions);
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
Update `isSearchable`'s doc comment ("Drafts never" → "Unpublished pages never").
|
|
352
|
+
|
|
353
|
+
- [ ] **Step 4: Run** `tests/services/search-listed.test.js`, `tests/routes/search-visibility.test.js`, `tests/routes/search-admin.test.js`. If a search fixture relied on a page with no `status` being found, that fixture was wrong (the site 404s it) - fix the fixture and say so in the commit.
|
|
354
|
+
|
|
355
|
+
- [ ] **Step 5: Commit** - `fix(search): hide every unpublished status, not only drafts`
|
|
356
|
+
|
|
357
|
+
---
|
|
358
|
+
|
|
359
|
+
## Task 3: A list-level cache tag for pages
|
|
360
|
+
|
|
361
|
+
**Files:** `server/services/content.js:170, 225, 243, 262`; test `tests/services/cache-invalidation.test.js` (extend)
|
|
362
|
+
|
|
363
|
+
- [ ] **Step 1: Failing test** - add a case that `cache.wrap('t', fn, {tags: ['pages:list']})`, then `createPage(...)`, then the next `wrap` re-runs `fn`. Repeat for `updatePage`, `renamePage`, `deletePage`. Follow the file's existing setup for enabling the memory driver.
|
|
364
|
+
- [ ] **Step 2: Run - expect failure.**
|
|
365
|
+
- [ ] **Step 3:** Add `'pages:list'` to each of the four `cache.invalidateTags([...])` calls in `content.js`.
|
|
366
|
+
- [ ] **Step 4: Run - pass.**
|
|
367
|
+
- [ ] **Step 5: Commit** - `feat(cache): invalidate a pages list tag on every page write`
|
|
368
|
+
|
|
369
|
+
---
|
|
370
|
+
|
|
371
|
+
## Task 4: Shared token authentication and the `{content}` scope
|
|
372
|
+
|
|
373
|
+
**Files:**
|
|
374
|
+
- Modify: `server/services/apiTokens.js` (`validateScopes` :84, `scopeAllows` :110; new exports)
|
|
375
|
+
- Modify: `server/routes/api/collections.js` (`checkPublicAccess` token branch)
|
|
376
|
+
- Test: `tests/services/api-tokens-content-scope.test.js` (new); `tests/routes/collections-token-access.test.js`, `tests/routes/api-tokens.test.js`, `tests/routes/endpoints-execution.test.js` must pass unchanged
|
|
377
|
+
|
|
378
|
+
- [ ] **Step 1: Failing test**
|
|
379
|
+
|
|
380
|
+
```js
|
|
381
|
+
/** Content scopes on API tokens, and the shared Bearer check. */
|
|
382
|
+
import {before, test} from 'node:test';
|
|
383
|
+
import assert from 'node:assert/strict';
|
|
384
|
+
import {
|
|
385
|
+
CONTENT_RESOURCES, authenticateApiToken, contentScopeAllows, createToken, revokeToken, scopeAllows, validateScopes
|
|
386
|
+
} from '../../server/services/apiTokens.js';
|
|
387
|
+
import {seedAll} from '../../server/services/presetCollections.js';
|
|
388
|
+
|
|
389
|
+
before(async () => { await seedAll(); });
|
|
390
|
+
|
|
391
|
+
test('validateScopes accepts {content} and {collection}, refuses a mix or an unknown resource', () => {
|
|
392
|
+
assert.equal(validateScopes([{content: 'pages'}, {collection: 'jobs', verbs: ['read']}]), null);
|
|
393
|
+
assert.match(validateScopes([{content: 'widgets'}]), /unknown content resource/);
|
|
394
|
+
assert.match(validateScopes([{content: 'pages', collection: 'jobs'}]), /either a collection or a content/);
|
|
395
|
+
assert.deepEqual([...CONTENT_RESOURCES], ['pages', 'menus', 'site', 'search']);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test('contentScopeAllows: unscoped allows all; scoped allows only listed', () => {
|
|
399
|
+
assert.equal(contentScopeAllows([], 'pages'), true);
|
|
400
|
+
assert.equal(contentScopeAllows([{content: 'pages'}], 'pages'), true);
|
|
401
|
+
assert.equal(contentScopeAllows([{content: 'pages'}], 'menus'), false);
|
|
402
|
+
// A token scoped to collections only gets no content - scoping never widens.
|
|
403
|
+
assert.equal(contentScopeAllows([{collection: 'jobs'}], 'pages'), false);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test('scopeAllows ignores content scopes when matching collections', () => {
|
|
407
|
+
assert.equal(scopeAllows([{content: 'pages'}], 'jobs', 'read'), false);
|
|
408
|
+
assert.equal(scopeAllows([{content: 'pages'}, {collection: 'jobs'}], 'jobs', 'read'), true);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('authenticateApiToken: shape, validity, project, scope', async () => {
|
|
412
|
+
const {entry, plaintext} = await createToken({name: 't', project: 'core', scopes: [{content: 'pages'}]});
|
|
413
|
+
const req = (h) => ({headers: h ? {authorization: h} : {}});
|
|
414
|
+
try {
|
|
415
|
+
assert.deepEqual(await authenticateApiToken(req(null), {project: 'core', allows: () => true}),
|
|
416
|
+
{ok: false, status: 401, error: 'API token required'});
|
|
417
|
+
assert.equal((await authenticateApiToken(req('Bearer dcms_' + '0'.repeat(64)), {project: 'core', allows: () => true})).status, 401);
|
|
418
|
+
assert.equal((await authenticateApiToken(req(`Bearer ${plaintext}`), {project: 'other', allows: () => true})).status, 403);
|
|
419
|
+
assert.equal((await authenticateApiToken(req(`Bearer ${plaintext}`),
|
|
420
|
+
{project: 'core', allows: (s) => contentScopeAllows(s, 'menus')})).status, 403);
|
|
421
|
+
const ok = await authenticateApiToken(req(`Bearer ${plaintext}`),
|
|
422
|
+
{project: 'core', allows: (s) => contentScopeAllows(s, 'pages')});
|
|
423
|
+
assert.equal(ok.ok, true);
|
|
424
|
+
assert.equal(ok.token.id, entry.id);
|
|
425
|
+
} finally {
|
|
426
|
+
await revokeToken(entry.id);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
- [ ] **Step 2: Run - expect failure.**
|
|
432
|
+
|
|
433
|
+
- [ ] **Step 3: Implement in `apiTokens.js`**
|
|
434
|
+
|
|
435
|
+
```js
|
|
436
|
+
/** The resources a `{content}` scope may name - the Content API's surface. */
|
|
437
|
+
export const CONTENT_RESOURCES = Object.freeze(['pages', 'menus', 'site', 'search']);
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`validateScopes` - each scope is EITHER `{collection, verbs?}` OR `{content}`:
|
|
441
|
+
|
|
442
|
+
```js
|
|
443
|
+
for (const s of scopes) {
|
|
444
|
+
if (!s || typeof s !== 'object') return 'each scope must be an object';
|
|
445
|
+
const hasCollection = s.collection !== undefined;
|
|
446
|
+
const hasContent = s.content !== undefined;
|
|
447
|
+
if (hasCollection && hasContent) return 'a scope names either a collection or a content resource, not both';
|
|
448
|
+
if (hasContent) {
|
|
449
|
+
if (!CONTENT_RESOURCES.includes(s.content)) return `unknown content resource "${s.content}"`;
|
|
450
|
+
if (s.verbs != null) return 'content scopes are read-only and take no verbs';
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
// …existing collection checks, unchanged…
|
|
454
|
+
}
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
`scopeAllows` - match only collection scopes:
|
|
458
|
+
|
|
459
|
+
```js
|
|
460
|
+
const scope = scopes.find(s => s.collection !== undefined && s.collection === collectionSlug);
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
New:
|
|
464
|
+
|
|
465
|
+
```js
|
|
466
|
+
/**
|
|
467
|
+
* Does a token's scope list permit a Content API resource? Empty = everything
|
|
468
|
+
* in the project. A list that names no content resource permits none - a token
|
|
469
|
+
* scoped to collections is never widened by the Content API existing.
|
|
470
|
+
*/
|
|
471
|
+
export function contentScopeAllows(scopes, resource) {
|
|
472
|
+
if (!Array.isArray(scopes) || scopes.length === 0) return true;
|
|
473
|
+
return scopes.some(s => s.content === resource);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const BEARER = /^Bearer (dcms_[a-f0-9]{64})$/;
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* The strict token check, shared by every externally consumable gate
|
|
480
|
+
* (collections, API Builder endpoints, the Content API). A token is the ONLY
|
|
481
|
+
* credential accepted - callers decide when token mode applies; this never
|
|
482
|
+
* looks at a JWT.
|
|
483
|
+
*
|
|
484
|
+
* @param {object} request - needs only request.headers
|
|
485
|
+
* @param {{project: string, allows: (scopes: object[]) => boolean}} opts
|
|
486
|
+
* @returns {Promise<{ok: true, token: object} | {ok: false, status: 401|403, error: string}>}
|
|
487
|
+
*/
|
|
488
|
+
export async function authenticateApiToken(request, {project, allows}) {
|
|
489
|
+
const match = (request.headers?.authorization || '').match(BEARER);
|
|
490
|
+
if (!match) return {ok: false, status: 401, error: 'API token required'};
|
|
491
|
+
const token = await validateToken(match[1]);
|
|
492
|
+
if (!token) return {ok: false, status: 401, error: 'Invalid, disabled or expired API token'};
|
|
493
|
+
if (token.project !== project) return {ok: false, status: 403, error: "Token is not valid for this resource's project"};
|
|
494
|
+
if (!allows(token.scopes)) return {ok: false, status: 403, error: 'Token scope does not permit this operation'};
|
|
495
|
+
return {ok: true, token};
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
The project-mismatch message changes from "…this collection's project" to "…this resource's project". Grep the tests for the old string; if a test asserts it, keep the old wording for collections by passing an optional `projectError` in `opts` rather than editing the test.
|
|
500
|
+
|
|
501
|
+
- [ ] **Step 4: `checkPublicAccess` delegates**
|
|
502
|
+
|
|
503
|
+
```js
|
|
504
|
+
if (access.access === 'token') {
|
|
505
|
+
const project = resolveArtefactProject(schema);
|
|
506
|
+
const auth = await authenticateApiToken(request, {
|
|
507
|
+
project,
|
|
508
|
+
allows: (scopes) => scopeAllows(scopes, schema.slug, operation)
|
|
509
|
+
});
|
|
510
|
+
if (!auth.ok) return reply.status(auth.status).send({error: auth.error});
|
|
511
|
+
request.apiToken = auth.token;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Update the import from `apiTokens.js`.
|
|
517
|
+
|
|
518
|
+
- [ ] **Step 5: Run** the new test plus `collections-token-access`, `api-tokens`, `api-endpoints`, `endpoints-execution`. All pass unchanged.
|
|
519
|
+
|
|
520
|
+
- [ ] **Step 6: Commit** - `refactor(api-tokens): shared token check; content scopes`
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## Task 5: Content API settings and payload shaping
|
|
525
|
+
|
|
526
|
+
**Files:**
|
|
527
|
+
- Create: `server/services/contentApi.js`
|
|
528
|
+
- Test: `tests/services/content-api-settings.test.js`, `tests/services/content-api-payload.test.js`
|
|
529
|
+
|
|
530
|
+
- [ ] **Step 1: Failing tests**
|
|
531
|
+
|
|
532
|
+
`content-api-settings.test.js`:
|
|
533
|
+
|
|
534
|
+
```js
|
|
535
|
+
import {test} from 'node:test';
|
|
536
|
+
import assert from 'node:assert/strict';
|
|
537
|
+
import {CONTENT_API_DEFAULTS, normaliseContentApiSettings} from '../../server/services/contentApi.js';
|
|
538
|
+
|
|
539
|
+
test('off by default - an upgrade exposes nothing', () => {
|
|
540
|
+
const s = normaliseContentApiSettings({});
|
|
541
|
+
assert.equal(s.enabled, false);
|
|
542
|
+
assert.deepEqual(s, CONTENT_API_DEFAULTS);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('access: public | token | a role name; anything else falls back to token', () => {
|
|
546
|
+
assert.equal(normaliseContentApiSettings({access: 'public'}).access, 'public');
|
|
547
|
+
assert.equal(normaliseContentApiSettings({access: 'token'}).access, 'token');
|
|
548
|
+
assert.equal(normaliseContentApiSettings({access: ' user '}).access, 'user');
|
|
549
|
+
assert.equal(normaliseContentApiSettings({access: ''}).access, 'token');
|
|
550
|
+
assert.equal(normaliseContentApiSettings({access: 42}).access, 'token');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
test('resources: whitelisted, unknown keys dropped, missing = on', () => {
|
|
554
|
+
const s = normaliseContentApiSettings({resources: {pages: {enabled: false}, widgets: {enabled: true}}});
|
|
555
|
+
assert.deepEqual(Object.keys(s.resources), ['pages', 'menus', 'site', 'search']);
|
|
556
|
+
assert.equal(s.resources.pages.enabled, false);
|
|
557
|
+
assert.equal(s.resources.menus.enabled, true);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test('formats: subset of html/markdown, never empty', () => {
|
|
561
|
+
assert.deepEqual(normaliseContentApiSettings({formats: ['markdown', 'pdf']}).formats, ['markdown']);
|
|
562
|
+
assert.deepEqual(normaliseContentApiSettings({formats: []}).formats, ['html']);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
test('listable and maxLimit', () => {
|
|
566
|
+
assert.equal(normaliseContentApiSettings({listable: false}).listable, false);
|
|
567
|
+
assert.equal(normaliseContentApiSettings({maxLimit: 5000}).maxLimit, 200);
|
|
568
|
+
assert.equal(normaliseContentApiSettings({maxLimit: 0}).maxLimit, 1);
|
|
569
|
+
});
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
`content-api-payload.test.js`:
|
|
573
|
+
|
|
574
|
+
```js
|
|
575
|
+
import {test} from 'node:test';
|
|
576
|
+
import assert from 'node:assert/strict';
|
|
577
|
+
import {pageDetail, pageSummary, publicSiteSettings} from '../../server/services/contentApi.js';
|
|
578
|
+
|
|
579
|
+
const page = {
|
|
580
|
+
urlPath: '/about', title: 'About', description: 'd', layout: 'default', tags: ['a'], category: 'c',
|
|
581
|
+
seo: {title: 'T', description: 'D', image: '/i.png', secretKey: 'x'},
|
|
582
|
+
createdAt: '2026-01-01', updatedAt: '2026-02-02', status: 'published', visibility: 'public',
|
|
583
|
+
dconfig: {x: 1}, sidebar: {y: 1}, internalNote: 'do not leak',
|
|
584
|
+
content: '## Hi [tabs]', html: '<h2>Hi</h2>', usedComponents: ['tabs'], perViewer: false, cacheTags: ['block:x']
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
test('summary: allowlisted keys only, no body', () => {
|
|
588
|
+
assert.deepEqual(Object.keys(pageSummary(page)).sort(),
|
|
589
|
+
['category', 'description', 'layout', 'tags', 'title', 'updatedAt', 'urlPath'].sort());
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('detail: allowlist; unknown frontmatter never appears', () => {
|
|
593
|
+
const d = pageDetail(page, {format: 'both'});
|
|
594
|
+
for (const k of ['internalNote', 'dconfig', 'sidebar', 'status', 'visibility', 'cacheTags', 'content']) {
|
|
595
|
+
assert.equal(k in d, false, `${k} leaked`);
|
|
596
|
+
}
|
|
597
|
+
assert.deepEqual(d.seo, {title: 'T', description: 'D', image: '/i.png'});
|
|
598
|
+
assert.equal(d.html, '<h2>Hi</h2>');
|
|
599
|
+
assert.equal(d.markdown, '## Hi [tabs]');
|
|
600
|
+
assert.deepEqual(d.requires, {domma: true, components: ['tabs']});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test('detail: format picks the body', () => {
|
|
604
|
+
assert.equal('markdown' in pageDetail(page, {format: 'html'}), false);
|
|
605
|
+
assert.equal('html' in pageDetail(page, {format: 'markdown'}), false);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
test('site settings: allowlist - smtp never present', () => {
|
|
609
|
+
const s = publicSiteSettings({title: 'S', tagline: 't', smtp: {pass: 'p'}, footer: {text: 'f'}, jwt: 'x'});
|
|
610
|
+
assert.equal('smtp' in s, false);
|
|
611
|
+
assert.equal('jwt' in s, false);
|
|
612
|
+
assert.equal(s.title, 'S');
|
|
613
|
+
});
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
Before writing `publicSiteSettings`, open `config/site.json` and `server/services/renderer.js` (where `siteJson` is built) and pick the allowlist from keys that **already** reach every public page source - nothing new is exposed by the API.
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 2: Run - expect failure.**
|
|
619
|
+
|
|
620
|
+
- [ ] **Step 3: Implement `server/services/contentApi.js`**
|
|
621
|
+
|
|
622
|
+
```js
|
|
623
|
+
/**
|
|
624
|
+
* Content API - settings and payloads.
|
|
625
|
+
*
|
|
626
|
+
* Settings live in config/content-api.json (optional; absence = the defaults,
|
|
627
|
+
* which are OFF - an upgrade must not expose a site that never chose to).
|
|
628
|
+
*
|
|
629
|
+
* Every payload is built from an ALLOWLIST. Frontmatter is open-ended (authors
|
|
630
|
+
* and plugins add keys freely) and site.json holds SMTP credentials, so a
|
|
631
|
+
* spread-then-delete is one forgotten key away from a leak.
|
|
632
|
+
*/
|
|
633
|
+
import {getConfig, saveConfig} from '../config.js';
|
|
634
|
+
|
|
635
|
+
export const CONTENT_RESOURCE_KEYS = Object.freeze(['pages', 'menus', 'site', 'search']);
|
|
636
|
+
export const FORMATS = Object.freeze(['html', 'markdown']);
|
|
637
|
+
|
|
638
|
+
export const CONTENT_API_DEFAULTS = Object.freeze({
|
|
639
|
+
enabled: false,
|
|
640
|
+
access: 'token',
|
|
641
|
+
resources: Object.freeze(Object.fromEntries(CONTENT_RESOURCE_KEYS.map(k => [k, Object.freeze({enabled: true})]))),
|
|
642
|
+
formats: Object.freeze(['html']),
|
|
643
|
+
listable: true,
|
|
644
|
+
maxLimit: 200
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
const clamp = (v, lo, hi, fb) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, Math.trunc(n))) : fb; };
|
|
648
|
+
|
|
649
|
+
export function normaliseContentApiSettings(raw = {}) {
|
|
650
|
+
const access = typeof raw.access === 'string' && raw.access.trim() ? raw.access.trim() : CONTENT_API_DEFAULTS.access;
|
|
651
|
+
const resources = Object.fromEntries(CONTENT_RESOURCE_KEYS.map(k => [k, {enabled: raw.resources?.[k]?.enabled !== false}]));
|
|
652
|
+
const formats = Array.isArray(raw.formats) ? FORMATS.filter(f => raw.formats.includes(f)) : [...CONTENT_API_DEFAULTS.formats];
|
|
653
|
+
return {
|
|
654
|
+
enabled: raw.enabled === true,
|
|
655
|
+
access,
|
|
656
|
+
resources,
|
|
657
|
+
formats: formats.length ? formats : ['html'],
|
|
658
|
+
listable: raw.listable !== false,
|
|
659
|
+
maxLimit: clamp(raw.maxLimit, 1, 200, CONTENT_API_DEFAULTS.maxLimit)
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function getContentApiSettings() {
|
|
664
|
+
try { return normaliseContentApiSettings(getConfig('content-api')); }
|
|
665
|
+
catch { return normaliseContentApiSettings({}); }
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function saveContentApiSettings(patch) {
|
|
669
|
+
const next = normaliseContentApiSettings({...getContentApiSettings(), ...(patch || {})});
|
|
670
|
+
saveConfig('content-api', next);
|
|
671
|
+
return next;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
const SUMMARY_KEYS = ['urlPath', 'title', 'description', 'tags', 'category', 'layout', 'updatedAt'];
|
|
675
|
+
const DETAIL_KEYS = [...SUMMARY_KEYS, 'createdAt'];
|
|
676
|
+
const SEO_KEYS = ['title', 'description', 'image', 'canonical', 'noindex'];
|
|
677
|
+
|
|
678
|
+
const pick = (obj, keys) => Object.fromEntries(keys.filter(k => obj?.[k] !== undefined).map(k => [k, obj[k]]));
|
|
679
|
+
|
|
680
|
+
export function pageSummary(page) {
|
|
681
|
+
return pick(page, SUMMARY_KEYS);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* @param {object} page - a getPage() result the caller is allowed to see
|
|
686
|
+
* @param {{format: 'html'|'markdown'|'both'}} opts
|
|
687
|
+
*/
|
|
688
|
+
export function pageDetail(page, {format}) {
|
|
689
|
+
const out = pick(page, DETAIL_KEYS);
|
|
690
|
+
if (page.seo && typeof page.seo === 'object') out.seo = pick(page.seo, SEO_KEYS);
|
|
691
|
+
if (format === 'html' || format === 'both') out.html = page.html || '';
|
|
692
|
+
if (format === 'markdown' || format === 'both') out.markdown = page.content || '';
|
|
693
|
+
const components = Array.isArray(page.usedComponents) ? page.usedComponents : [];
|
|
694
|
+
out.requires = {domma: components.length > 0, components};
|
|
695
|
+
out.perViewer = Boolean(page.perViewer);
|
|
696
|
+
return out;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
/** Site settings safe for any caller - see the allowlist note in the plan, Task 5. */
|
|
700
|
+
export function publicSiteSettings(site = {}) {
|
|
701
|
+
// Fill from the keys renderer.js already embeds as __CMS_SITE__ - and no others.
|
|
702
|
+
return pick(site, ['title', 'tagline', 'description', 'language', 'brand', 'footer']);
|
|
703
|
+
}
|
|
704
|
+
```
|
|
705
|
+
|
|
706
|
+
Widen `requires.domma` beyond `usedComponents` only if a test shows a Domma-dependent shortcode (tabs, carousel) that does not appear there - check `parseMarkdown` output for a `[tabs]` page first.
|
|
707
|
+
|
|
708
|
+
- [ ] **Step 4: Run - pass.**
|
|
709
|
+
- [ ] **Step 5: Commit** - `feat(content-api): settings and allowlisted payloads`
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
## Task 6: The public routes
|
|
714
|
+
|
|
715
|
+
**Files:**
|
|
716
|
+
- Create: `server/routes/api/content-public.js`
|
|
717
|
+
- Modify: `server/server.js` (register; `onSend` exemption at :470)
|
|
718
|
+
- Test: `tests/routes/content-api.test.js` (new), `tests/routes/content-api-parity.test.js` (new)
|
|
719
|
+
|
|
720
|
+
- [ ] **Step 1: Failing route tests** - `tests/routes/content-api.test.js`. Harness as in `collections-token-access.test.js` (Fastify + `@fastify/jwt` + the routes). Settings are written with `saveContentApiSettings()` in each test and restored in `after()`. Cases, one `test()` each:
|
|
721
|
+
|
|
722
|
+
1. Disabled (default) → every endpoint `404`.
|
|
723
|
+
2. `access: 'public'`: `GET /api/content/v1/pages` → `200`, `{pages, total, page, limit}`; draft, scheduled, `visibility: user` and `api: false` pages absent.
|
|
724
|
+
3. Single page `200` with `html`; `?format=markdown` → `400` while `formats: ['html']`; `200` once `formats` includes it; `?format=both` has both.
|
|
725
|
+
4. `/pages/` → home; `/pages/<dir>` falls back to `<dir>/index`.
|
|
726
|
+
5. Draft → `404`; `visibility: user` → `403` anonymous; `api: false` → `404`.
|
|
727
|
+
6. `listable: false` → list `404`, single `200`.
|
|
728
|
+
7. `resources.menus.enabled: false` → `/menus/navbar` `404`.
|
|
729
|
+
8. `access: 'token'`: no header `401`; JWT `401`; token of another project → `404` for a page in this project (the project is the token's whole world); `{content: 'menus'}`-only token → `403` on `/pages`; `{collection: 'jobs'}`-only token → `403`; unscoped `core` token → `200` on a core page and `404` on a page in another project.
|
|
730
|
+
9. `access: 'user'` (role): JWT for `user` → `200`; anonymous `401`; with `access: 'nonexistent-role'` a `user` JWT gets `403` and an `admin`-level-0 JWT `200`.
|
|
731
|
+
10. Role mode `?preview=1` with `pages.read` → draft `200` and `cache-control: no-store`; without `pages.read` → `404`.
|
|
732
|
+
11. `/site` never contains `smtp`.
|
|
733
|
+
12. `/search?q=` → results; `config/search.json` `enabled: false` → `404`.
|
|
734
|
+
13. Headers: public → `cache-control: public, max-age=60` + `etag`; token → `private`; role → `no-store`.
|
|
735
|
+
14. `limit=0` → default 50; `limit=999` → `maxLimit`.
|
|
736
|
+
|
|
737
|
+
`tests/routes/content-api-parity.test.js`: for fixture pages {public, draft, `user`, `=user`, `admin`, disabled-project} × viewers {anon, user, admin}, assert the allow/deny of `GET <path>` (via `publicRoutes`) matches `GET /api/content/v1/pages<path>` (role mode, JWT on both). `200 ↔ 200`, `404 ↔ 404`, `403 ↔ 403`.
|
|
738
|
+
|
|
739
|
+
- [ ] **Step 2: Run - expect failure.**
|
|
740
|
+
|
|
741
|
+
- [ ] **Step 3: Implement `server/routes/api/content-public.js`**
|
|
742
|
+
|
|
743
|
+
```js
|
|
744
|
+
/**
|
|
745
|
+
* Content API - /api/content/v1
|
|
746
|
+
*
|
|
747
|
+
* Read-only headless access to what the site publishes. Its own prefix:
|
|
748
|
+
* /api/v1/:slug matches any collection slug, and slugify() produces `pages`
|
|
749
|
+
* and `menus` happily, so living under /api/v1 would shadow real collections.
|
|
750
|
+
*
|
|
751
|
+
* GET /content/v1/pages - paged summaries
|
|
752
|
+
* GET /content/v1/pages/* - one page (?format=html|markdown|both)
|
|
753
|
+
* GET /content/v1/menus/:location - bound menu, filtered for the caller
|
|
754
|
+
* GET /content/v1/site - public site settings subset
|
|
755
|
+
* GET /content/v1/search?q= - site search
|
|
756
|
+
*
|
|
757
|
+
* Access: config/content-api.json `access` - 'public', 'token' (strict,
|
|
758
|
+
* project-bound, {content} scopes) or a role name (JWT, ladder, unknown role
|
|
759
|
+
* fails closed to level 0). Page decisions go through resolvePublishedPage(),
|
|
760
|
+
* the same function the public site uses.
|
|
761
|
+
*/
|
|
762
|
+
import {getContentApiSettings, pageDetail, pageSummary, publicSiteSettings} from '../../services/contentApi.js';
|
|
763
|
+
import {resolvePublishedPage, canPreviewDrafts} from '../../services/pagePublishing.js';
|
|
764
|
+
import {listPages, getPageMtime} from '../../services/content.js';
|
|
765
|
+
import {isPubliclyListed, searchExclusions, searchPages, getSearchSettings} from '../../services/search.js';
|
|
766
|
+
import {resolveLocation} from '../../services/menus.js';
|
|
767
|
+
import {authenticateApiToken, contentScopeAllows} from '../../services/apiTokens.js';
|
|
768
|
+
import {getRoleLevel} from '../../services/roles.js';
|
|
769
|
+
import {getEffectiveLevel} from '../../services/userRoles.js';
|
|
770
|
+
import {getConfig} from '../../config.js';
|
|
771
|
+
import {getBaseUrl} from '../public.js';
|
|
772
|
+
import * as cache from '../../services/cache/index.js';
|
|
773
|
+
|
|
774
|
+
const NOT_FOUND = {error: 'Not found'};
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Gate one request. Resolves to {mode, viewer, token} or sends the refusal and
|
|
778
|
+
* resolves to null. `project` is the resource's project for token binding -
|
|
779
|
+
* 'core' for site-wide resources; for a page the route re-checks after
|
|
780
|
+
* resolving it.
|
|
781
|
+
*/
|
|
782
|
+
export async function checkContentAccess(resource, request, reply) {
|
|
783
|
+
const s = getContentApiSettings();
|
|
784
|
+
if (!s.enabled || !s.resources[resource]?.enabled) { reply.status(404).send(NOT_FOUND); return null; }
|
|
785
|
+
|
|
786
|
+
if (s.access === 'public') return {mode: 'public', viewer: null, token: null, settings: s};
|
|
787
|
+
|
|
788
|
+
if (s.access === 'token') {
|
|
789
|
+
// No project here: pages carry their own, so the routes bind the token
|
|
790
|
+
// to it (inProject). See "Project binding" below.
|
|
791
|
+
const auth = await authenticateApiToken(request, {allows: (sc) => contentScopeAllows(sc, resource)});
|
|
792
|
+
if (!auth.ok) { reply.status(auth.status).send({error: auth.error}); return null; }
|
|
793
|
+
return {mode: 'token', viewer: null, token: auth.token, settings: s};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
try { await request.jwtVerify(); } catch { reply.status(401).send({error: 'Unauthorised'}); return null; }
|
|
797
|
+
const named = getRoleLevel(s.access);
|
|
798
|
+
const required = Number.isFinite(named) ? named : 0;
|
|
799
|
+
if (getEffectiveLevel(request.user) > required) { reply.status(403).send({error: 'Insufficient permissions'}); return null; }
|
|
800
|
+
return {mode: 'role', viewer: request.user, token: null, settings: s};
|
|
801
|
+
}
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
**Project binding:** in `authenticateApiToken` (Task 4), make `project` optional - compare only when it is given (`if (project !== undefined && token.project !== project)`), and add a Task 4 test that omitting it skips the check. The rule for content:
|
|
805
|
+
- `pages` (single and list) and `search` results: only pages whose project is the token's (`inProject`). A page in another project is `404`.
|
|
806
|
+
- `menus` and `site`: readable by any valid token. They are site-wide and already shown on every public page.
|
|
807
|
+
|
|
808
|
+
Write this rule into the spec's "Tokens and projects" section in the same commit; it replaces "Menus and site settings … belong to `core`".
|
|
809
|
+
|
|
810
|
+
Routes (inside `export async function contentPublicRoutes(fastify)`):
|
|
811
|
+
|
|
812
|
+
```js
|
|
813
|
+
const headersFor = (reply, access) => {
|
|
814
|
+
if (access.mode === 'public') reply.header('cache-control', 'public, max-age=60');
|
|
815
|
+
else if (access.mode === 'token') reply.header('cache-control', 'private, max-age=0, must-revalidate');
|
|
816
|
+
else reply.header('cache-control', 'no-store');
|
|
817
|
+
};
|
|
818
|
+
const inProject = (access, project) => access.mode !== 'token' || access.token.project === project;
|
|
819
|
+
const apiOff = (page) => page?.api === false;
|
|
820
|
+
|
|
821
|
+
fastify.get('/content/v1/pages', async (request, reply) => {
|
|
822
|
+
const access = await checkContentAccess('pages', request, reply);
|
|
823
|
+
if (!access) return;
|
|
824
|
+
if (!access.settings.listable) return reply.status(404).send(NOT_FOUND);
|
|
825
|
+
const q = request.query || {};
|
|
826
|
+
const limit = Math.min(access.settings.maxLimit, parseInt(q.limit, 10) || 50);
|
|
827
|
+
const pageNo = Math.max(1, parseInt(q.page, 10) || 1);
|
|
828
|
+
|
|
829
|
+
const all = await listPages();
|
|
830
|
+
const exclusions = await searchExclusions(all);
|
|
831
|
+
let rows = all.filter(p => isPubliclyListed(p, access.viewer, exclusions)
|
|
832
|
+
&& !apiOff(p)
|
|
833
|
+
&& inProject(access, p.resolvedProject));
|
|
834
|
+
if (q.prefix) rows = rows.filter(p => (p.urlPath || '').startsWith(String(q.prefix)));
|
|
835
|
+
if (q.tag) rows = rows.filter(p => Array.isArray(p.tags) && p.tags.includes(String(q.tag)));
|
|
836
|
+
if (q.category) rows = rows.filter(p => p.category === String(q.category));
|
|
837
|
+
if (q.project) rows = rows.filter(p => p.resolvedProject === String(q.project));
|
|
838
|
+
// sort: sortOrder (listPages' own order) | title | updatedAt | createdAt
|
|
839
|
+
// …implement with a small comparator; order=desc reverses
|
|
840
|
+
|
|
841
|
+
headersFor(reply, access);
|
|
842
|
+
const start = (pageNo - 1) * limit;
|
|
843
|
+
return {pages: rows.slice(start, start + limit).map(pageSummary), total: rows.length, page: pageNo, limit};
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
fastify.get('/content/v1/pages/*', async (request, reply) => {
|
|
847
|
+
const access = await checkContentAccess('pages', request, reply);
|
|
848
|
+
if (!access) return;
|
|
849
|
+
const format = String(request.query?.format || 'html');
|
|
850
|
+
const allowed = format === 'both' ? ['html', 'markdown'] : [format];
|
|
851
|
+
if (!['html', 'markdown', 'both'].includes(format) || !allowed.every(f => access.settings.formats.includes(f))) {
|
|
852
|
+
return reply.status(400).send({error: `Format "${format}" is not available on this site`});
|
|
853
|
+
}
|
|
854
|
+
const urlPath = '/' + (request.params['*'] || '');
|
|
855
|
+
const preview = access.mode === 'role' && request.query?.preview === '1' && canPreviewDrafts(access.viewer);
|
|
856
|
+
const verdict = await resolvePublishedPage(urlPath, access.viewer, {allowDrafts: preview});
|
|
857
|
+
if (verdict.status === 'not-found' || apiOff(verdict.page) || (verdict.status === 'ok' && !inProject(access, verdict.project))) {
|
|
858
|
+
return reply.status(404).send(NOT_FOUND);
|
|
859
|
+
}
|
|
860
|
+
if (verdict.status === 'forbidden') return reply.status(403).send({error: 'Forbidden'});
|
|
861
|
+
|
|
862
|
+
const build = () => pageDetail(verdict.page, {format});
|
|
863
|
+
if (verdict.isDraft || access.mode === 'role') {
|
|
864
|
+
reply.header('cache-control', 'no-store');
|
|
865
|
+
return build();
|
|
866
|
+
}
|
|
867
|
+
const mtime = await getPageMtime(verdict.page.urlPath || urlPath);
|
|
868
|
+
const key = `content:v1:page:${verdict.page.urlPath}:${format}:m${mtime}:o${getBaseUrl(request)}`;
|
|
869
|
+
const tags = [`page:${verdict.page.urlPath}`, ...(verdict.page.cacheTags || []), 'nav', 'site'];
|
|
870
|
+
const body = await cache.wrap(key, async () => build(), {tags});
|
|
871
|
+
headersFor(reply, access);
|
|
872
|
+
reply.header('etag', `W/"${mtime}-${format}"`);
|
|
873
|
+
return body;
|
|
874
|
+
});
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
`menus/:location`, `site`, `search`: same shape - gate, build, headers. Menus: `resolveLocation(request.params.location, access.viewer, {urlPath: request.query.page})`; `null` → 404; refuse `location === 'overlay'` (404) and the `admin-sidebar` slot (404 - never expose the admin menu). Search: 404 when `getSearchSettings().enabled === false`; filter with `isSearchable` (already in `searchPages`) plus `!apiOff` and `inProject`; clamp `limit` to `getSearchSettings().maxResults`. Site: `publicSiteSettings(getConfig('site'))`.
|
|
878
|
+
|
|
879
|
+
Page payloads in role mode for a `perViewer` page are rendered for that viewer: `resolvePublishedPage` fetches anonymously, so re-fetch with `getPage(verdict.page.urlPath, {user: access.viewer})` when `access.viewer` is set - as `public.js`'s `render()` does.
|
|
880
|
+
|
|
881
|
+
- [ ] **Step 4: Wire into `server/server.js`**
|
|
882
|
+
|
|
883
|
+
```js
|
|
884
|
+
const {contentPublicRoutes} = await import('./routes/api/content-public.js');
|
|
885
|
+
await app.register(contentPublicRoutes, {prefix: '/api'});
|
|
886
|
+
```
|
|
887
|
+
|
|
888
|
+
and in the `onSend` hook:
|
|
889
|
+
|
|
890
|
+
```js
|
|
891
|
+
if (url.startsWith('/api/v1/') || url.startsWith('/api/x/') || url.startsWith('/api/content/v1/')) return;
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
Update the comment above it to list the third surface. Check `server.js:141` (CORS open reads) - if `/api/v1/` is listed there for cross-origin reads, add `/api/content/v1/` alongside, since headless front ends are cross-origin by nature.
|
|
895
|
+
|
|
896
|
+
- [ ] **Step 5: Run** both new test files, then `npm test` (full suite) once.
|
|
897
|
+
- [ ] **Step 6: Commit** - `feat(content-api): /api/content/v1 pages, menus, site, search`
|
|
898
|
+
|
|
899
|
+
---
|
|
900
|
+
|
|
901
|
+
## Task 7: Admin API for the screen
|
|
902
|
+
|
|
903
|
+
**Files:**
|
|
904
|
+
- Create: `server/routes/api/content-api.js`
|
|
905
|
+
- Modify: `server/server.js` (register the routes; the sidebar item moves to Task 8, with the screen it links to)
|
|
906
|
+
- Test: `tests/routes/content-api-admin.test.js`
|
|
907
|
+
|
|
908
|
+
- [ ] **Step 1: Failing test** - `GET /api/content-api/settings` needs `settings.read` (401 anon, 200 admin, returns normalised defaults); `PUT` needs `settings.update`, normalises (`formats: []` stored as `['html']`), returns `{ok, settings}`; `GET /api/content-api/_count` returns `{count, label, tone, details}` where `count` is the number of pages the API would list anonymously (or `0` + label `Off` when disabled); `GET /api/content-api/preview?path=/about&format=html&as=anon` renders through the same `pageDetail` and `resolvePublishedPage` as the public route, ignoring `enabled` (so the screen can test before switching on), and refuses `as` values other than `anon`.
|
|
909
|
+
|
|
910
|
+
- [ ] **Step 2: Run - fail.**
|
|
911
|
+
|
|
912
|
+
- [ ] **Step 3: Implement** - mirror `server/routes/api/search.js`: `authenticate` + `requirePermission('settings', 'read'|'update')`; badge details built with the helpers in `server/services/sidebarBadges.js` (rows: Access, Formats, Listable; tone `info` when on, nothing when off). Preview reuses an extracted function from `content-public.js` (`buildPageResponse(urlPath, {viewer, format, allowDrafts})`) - do not re-implement it.
|
|
913
|
+
|
|
914
|
+
- [ ] **Step 4: Sidebar + register**
|
|
915
|
+
|
|
916
|
+
```js
|
|
917
|
+
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}});
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
- [ ] **Step 5: Run - pass. Commit** - `feat(content-api): admin settings, badge and preview endpoints`
|
|
921
|
+
|
|
922
|
+
---
|
|
923
|
+
|
|
924
|
+
## Task 8: System › Content API screen (Domma reactive)
|
|
925
|
+
|
|
926
|
+
The screen follows **System › Search** (`admin/js/views/search.js` + `admin/js/templates/search.html` + `admin/js/lib/search-settings-arrange.js`) in every structural respect. The rules, all Domma:
|
|
927
|
+
|
|
928
|
+
- **State is domma-reactive.** One `M.observable` per form field, `M.computed` for derived values (dirty, chips, endpoints, curl), `M.effect` for side effects, every effect's disposer pushed through `scope()`. No hand-held state variables beyond the `active` handle.
|
|
929
|
+
- **Bindings, not DOM writes.** `M.applyBindings(model, root, {methods})` with `data-model`, `data-bind-text`, `data-bind-hidden`, `data-bind-class`, `data-bind-disabled`, `data-on-click`, `data-each`. Repeated rows use an inline row template inserted by the view (the router renders `templateUrl` through `_.render`, which eats `{{…}}`).
|
|
930
|
+
- **Components:** `E.tabs` for the tabs, `E.toast` for outcomes, `E.confirm` for leave/restore, `E.contextMenu` (with `openMenuAt` from `tool-kit.js`) on try-it results, `helpPopovers()` for `data-help`, `addBannerAction()` for Save and Copy base URL, `I.scan(root)` for icons, `S` for the draft, `apiRequest` (`admin/js/api.js`) for HTTP.
|
|
931
|
+
- **Loading:** first load through `whileLoading(slot, …, SHAPES.rows)` from `admin/js/lib/loading.js` - a skeleton, not `E.loader` and not "Loading…" text. `E.loader` only for work in progress (Save).
|
|
932
|
+
- **Leaving:** the router guard RETURNS the `E.confirm` promise (`tests/admin/nav-guard-contract.test.js`); tear-down on `router:afterChange`; unsaved changes kept as a draft (`content-api.draft`) and offered back; `beforeunload` asks.
|
|
933
|
+
- **Ctrl+S** saves (capture + `stopImmediatePropagation`, as Search does).
|
|
934
|
+
- **Pure logic in the arrange lib**, tested under `tests/admin/`. The view holds wiring only.
|
|
935
|
+
|
|
936
|
+
**Files:**
|
|
937
|
+
- Create: `admin/js/lib/content-api-arrange.js`, `admin/js/views/content-api.js`, `admin/js/templates/content-api.html`
|
|
938
|
+
- Modify: `server/server.js` - the System sidebar entry, moved here from Task 7 so it never links to a screen that does not exist yet:
|
|
939
|
+
`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}});` (check the icon exists in Domma's set)
|
|
940
|
+
- Modify: `admin/js/views/index.js` (import + `contentApi: contentApiView`), `admin/js/app.js` (route `{path: '/content-api', view: 'contentApi', title: 'Content API - Domma CMS'}` beside `/search`; Tool banner map `contentApi: {displayName: 'Content API', icon: 'file-text'}`), `admin/css/admin.css` (`.cax-*`)
|
|
941
|
+
- Test: `tests/admin/content-api-arrange.test.js`; `tests/admin/nav-guard-contract.test.js` (add the view if it enumerates views)
|
|
942
|
+
|
|
943
|
+
- [ ] **Step 1: Failing arrange tests** - `tests/admin/content-api-arrange.test.js`:
|
|
944
|
+
|
|
945
|
+
```js
|
|
946
|
+
/** System › Content API - admin/js/lib/content-api-arrange.js. */
|
|
947
|
+
import {test} from 'node:test';
|
|
948
|
+
import assert from 'node:assert/strict';
|
|
949
|
+
import {
|
|
950
|
+
curlExample, endpointRows, formFromSettings, liveChips, payloadFromForm, previewRow, snapshot
|
|
951
|
+
} from '../../admin/js/lib/content-api-arrange.js';
|
|
952
|
+
|
|
953
|
+
const S = {enabled: true, access: 'token', resources: {pages: {enabled: true}, menus: {enabled: false}, site: {enabled: true}, search: {enabled: true}},
|
|
954
|
+
formats: ['html'], listable: true, maxLimit: 200};
|
|
955
|
+
|
|
956
|
+
test('form round-trips settings', () => {
|
|
957
|
+
assert.deepEqual(payloadFromForm(formFromSettings(S)), S);
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
test('form flattens resources and formats into plain fields for data-model', () => {
|
|
961
|
+
const f = formFromSettings(S);
|
|
962
|
+
assert.equal(f.resPages, true);
|
|
963
|
+
assert.equal(f.resMenus, false);
|
|
964
|
+
assert.equal(f.fmtHtml, true);
|
|
965
|
+
assert.equal(f.fmtMarkdown, false);
|
|
966
|
+
assert.equal(f.accessMode, 'token');
|
|
967
|
+
assert.equal(f.accessRole, '');
|
|
968
|
+
});
|
|
969
|
+
|
|
970
|
+
test('a role access mode splits into mode=role + the role name', () => {
|
|
971
|
+
const f = formFromSettings({...S, access: 'user'});
|
|
972
|
+
assert.equal(f.accessMode, 'role');
|
|
973
|
+
assert.equal(f.accessRole, 'user');
|
|
974
|
+
assert.equal(payloadFromForm(f).access, 'user');
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
test('turning both formats off keeps html - the server would anyway', () => {
|
|
978
|
+
assert.deepEqual(payloadFromForm({...formFromSettings(S), fmtHtml: false, fmtMarkdown: false}).formats, ['html']);
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
test('snapshot is order-stable for the dirty check', () => {
|
|
982
|
+
assert.equal(snapshot(formFromSettings(S)), snapshot({...formFromSettings(S)}));
|
|
983
|
+
assert.notEqual(snapshot(formFromSettings(S)), snapshot({...formFromSettings(S), listable: false}));
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
test('live chips describe the SAVED state', () => {
|
|
987
|
+
assert.deepEqual(liveChips({...S, enabled: false}).map(c => c.text), ['Off']);
|
|
988
|
+
const on = liveChips(S, {listed: 12}).map(c => c.text);
|
|
989
|
+
assert.ok(on.includes('On'));
|
|
990
|
+
assert.ok(on.includes('Token'));
|
|
991
|
+
assert.ok(on.includes('12 pages'));
|
|
992
|
+
});
|
|
993
|
+
|
|
994
|
+
test('endpoint rows: disabled resources are marked, list hidden when not listable', () => {
|
|
995
|
+
const rows = endpointRows('https://ex.com', S);
|
|
996
|
+
const menus = rows.find(r => r.path.startsWith('/api/content/v1/menus'));
|
|
997
|
+
assert.equal(menus.state, 'off');
|
|
998
|
+
assert.equal(rows.find(r => r.path === '/api/content/v1/pages').state, 'on');
|
|
999
|
+
const unlisted = endpointRows('https://ex.com', {...S, listable: false});
|
|
1000
|
+
assert.equal(unlisted.find(r => r.path === '/api/content/v1/pages').state, 'off');
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
test('curl: token mode carries a Bearer placeholder, never a real token', () => {
|
|
1004
|
+
const c = curlExample('https://ex.com', '/about', S);
|
|
1005
|
+
assert.match(c, /Authorization: Bearer dcms_…/);
|
|
1006
|
+
assert.match(c, /https:\/\/ex\.com\/api\/content\/v1\/pages\/about/);
|
|
1007
|
+
assert.doesNotMatch(curlExample('https://ex.com', '/about', {...S, access: 'public'}), /Authorization/);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test('preview row: status, size and what the client must load', () => {
|
|
1011
|
+
const r = previewRow({status: 200, body: {title: 'About', html: '<p>x</p>', requires: {domma: true, components: ['tabs']}}});
|
|
1012
|
+
assert.equal(r.statusText, '200');
|
|
1013
|
+
assert.equal(r.tone, 'ok');
|
|
1014
|
+
assert.equal(r.requiresText, 'Needs Domma: tabs');
|
|
1015
|
+
assert.equal(previewRow({status: 404, body: {error: 'Not found'}}).tone, 'bad');
|
|
1016
|
+
});
|
|
1017
|
+
```
|
|
1018
|
+
|
|
1019
|
+
- [ ] **Step 2: Run - fail.** `npm test -- tests/admin/content-api-arrange.test.js`
|
|
1020
|
+
|
|
1021
|
+
- [ ] **Step 3: Implement `admin/js/lib/content-api-arrange.js`** - pure ESM, no `$`/`E`/`M`, no DOM. Exports exactly those in the test plus `ACCESS_LABEL = {public: 'Public', token: 'Token', role: 'Role'}`. `endpointRows` returns `{method: 'GET', path, label, state: 'on'|'off', note}` for the five endpoints (menus path shown as `/api/content/v1/menus/navbar`).
|
|
1022
|
+
|
|
1023
|
+
- [ ] **Step 4: Run - pass.**
|
|
1024
|
+
|
|
1025
|
+
- [ ] **Step 5: Template `admin/js/templates/content-api.html`**
|
|
1026
|
+
|
|
1027
|
+
Header comment as Search's (bound with `M.applyBindings`; row templates inserted by the view; no page heading - the Tool banner is the heading). Structure:
|
|
1028
|
+
|
|
1029
|
+
```html
|
|
1030
|
+
<div class="cax">
|
|
1031
|
+
<div class="cax-note dm-loading-slot" data-bind-hidden="!loading.value">Loading…</div>
|
|
1032
|
+
|
|
1033
|
+
<p class="cax-alert is-dirty" data-bind-hidden="!dirty.value">
|
|
1034
|
+
<span data-icon="edit-3" data-icon-size="16"></span>
|
|
1035
|
+
<span>Unsaved changes - Save (Ctrl+S) to apply them. Leave and they are kept as a draft for next time.</span>
|
|
1036
|
+
<span class="cax-spacer"></span>
|
|
1037
|
+
<button type="button" class="btn btn-sm btn-ghost" data-on-click="discard">Discard</button>
|
|
1038
|
+
<button type="button" class="btn btn-sm btn-primary" data-on-click="save" data-bind-disabled="saving.value">Save</button></p>
|
|
1039
|
+
|
|
1040
|
+
<div class="tabs cax-tabs" data-bind-hidden="loading.value">
|
|
1041
|
+
<div class="tab-list">
|
|
1042
|
+
<button type="button" class="tab-item active">Access</button>
|
|
1043
|
+
<button type="button" class="tab-item">Endpoints</button>
|
|
1044
|
+
<button type="button" class="tab-item">Try it</button>
|
|
1045
|
+
<button type="button" class="tab-item">How it works</button>
|
|
1046
|
+
</div>
|
|
1047
|
+
|
|
1048
|
+
<!-- Access -->
|
|
1049
|
+
<div class="tab-panel active">
|
|
1050
|
+
<div class="cax-card">
|
|
1051
|
+
<div class="cax-card-title">Live now
|
|
1052
|
+
<span data-help-title="Live now" data-help="What callers get at the moment - the saved settings, not what you are editing."></span></div>
|
|
1053
|
+
<div class="cax-chips" data-each="chips.value key=text"></div>
|
|
1054
|
+
</div>
|
|
1055
|
+
<div class="cax-card">
|
|
1056
|
+
<div class="cax-setting">
|
|
1057
|
+
<label class="form-label" for="cax-enabled">Enable the Content API
|
|
1058
|
+
<span data-help="Off answers 404 on every /api/content/v1 address. Pages on the site are not affected."></span></label>
|
|
1059
|
+
<input id="cax-enabled" type="checkbox" class="form-switch" data-model="enabled.value">
|
|
1060
|
+
</div>
|
|
1061
|
+
<label class="form-label" for="cax-access">Who may call it
|
|
1062
|
+
<span data-help="Public: anyone. Token: an API token from System › API Tokens, bound to a project. Role: a signed-in user of that role or more senior."></span></label>
|
|
1063
|
+
<select id="cax-access" class="form-select" data-model="accessMode.value">
|
|
1064
|
+
<option value="token">API token</option>
|
|
1065
|
+
<option value="public">Public</option>
|
|
1066
|
+
<option value="role">A role…</option>
|
|
1067
|
+
</select>
|
|
1068
|
+
<div data-bind-hidden="accessMode.value !== 'role'">
|
|
1069
|
+
<label class="form-label" for="cax-role">Role</label>
|
|
1070
|
+
<select id="cax-role" class="form-select cax-roles" data-model="accessRole.value" data-each="roles.value key=name"></select>
|
|
1071
|
+
<small class="cax-err" data-bind-hidden="!roleErr.value" data-bind-text="roleErr.value"></small>
|
|
1072
|
+
</div>
|
|
1073
|
+
</div>
|
|
1074
|
+
<div class="cax-card">
|
|
1075
|
+
<div class="cax-card-title">Resources</div>
|
|
1076
|
+
<!-- one cax-setting + form-switch per resource: resPages, resMenus, resSite, resSearch -->
|
|
1077
|
+
<!-- listable switch, fmtHtml / fmtMarkdown switches, maxLimit number input (1-200) -->
|
|
1078
|
+
</div>
|
|
1079
|
+
</div>
|
|
1080
|
+
|
|
1081
|
+
<!-- Endpoints: data-each="endpoints.value key=path" list + copy buttons -->
|
|
1082
|
+
<!-- Try it: path input (data-model="tryPath.value"), format select, Run button,
|
|
1083
|
+
result: status chip, requiresText, <pre class="cax-json" data-bind-text="tryJson.value">,
|
|
1084
|
+
curl <pre data-bind-text="curl.value"> with a Copy button -->
|
|
1085
|
+
<!-- How it works: static prose - drafts, visibility, projects, api: false, formats, caching -->
|
|
1086
|
+
</div>
|
|
1087
|
+
</div>
|
|
1088
|
+
```
|
|
1089
|
+
|
|
1090
|
+
Write the elided cards in full in the real file, using the same `cax-setting` / `form-switch` / `data-help` pattern shown. The Markdown format switch's help says: *"Markdown is the page source - shortcodes included, which can name collections, views and forms the HTML does not show."*
|
|
1091
|
+
|
|
1092
|
+
- [ ] **Step 6: View `admin/js/views/content-api.js`**
|
|
1093
|
+
|
|
1094
|
+
Mirror `search.js` section by section - header comment, `SCREEN` regex `/^\/content-api(\?.*)?$/`, `wireRouter()` guard returning the confirm, `active` handle, `onMount`:
|
|
1095
|
+
|
|
1096
|
+
```js
|
|
1097
|
+
import {apiRequest} from '../api.js';
|
|
1098
|
+
import {addBannerAction} from '../lib/plugin-chrome.js';
|
|
1099
|
+
import {helpPopovers} from '../lib/help-popover.js';
|
|
1100
|
+
import {openMenuAt, sidebarCount} from '../lib/tool-kit.js';
|
|
1101
|
+
import {SHAPES, whileLoading} from '../lib/loading.js';
|
|
1102
|
+
import {fmtDateTime} from '../lib/dates.js';
|
|
1103
|
+
import {
|
|
1104
|
+
ACCESS_LABEL, curlExample, endpointRows, formFromSettings, liveChips, payloadFromForm, previewRow, snapshot
|
|
1105
|
+
} from '../lib/content-api-arrange.js';
|
|
1106
|
+
|
|
1107
|
+
const SCREEN = /^\/content-api(\?.*)?$/;
|
|
1108
|
+
const SIDEBAR_URL = '#/content-api';
|
|
1109
|
+
const DRAFT_KEY = 'content-api.draft';
|
|
1110
|
+
|
|
1111
|
+
const CHIP_TEMPLATE = `<span class="cax-chip" data-bind-class="cls">{{text}}</span>`;
|
|
1112
|
+
const ENDPOINT_TEMPLATE = `
|
|
1113
|
+
<div class="cax-row" data-bind-class="cls" data-path="{{path}}">
|
|
1114
|
+
<span class="cax-method">{{method}}</span>
|
|
1115
|
+
<span class="cax-main"><code>{{path}}</code><small>{{label}}</small></span>
|
|
1116
|
+
<span class="cax-chip">{{stateText}}</span>
|
|
1117
|
+
<button type="button" class="cax-kebab" aria-label="Actions for {{path}}" title="Actions">⋮</button>
|
|
1118
|
+
</div>`;
|
|
1119
|
+
const ROLE_TEMPLATE = `<option value="{{name}}">{{label}}</option>`;
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
In `onMount`:
|
|
1123
|
+
|
|
1124
|
+
1. `wireRouter()`; dispose any previous `active`; `disposers` / `scope()` / `here()` exactly as Search.
|
|
1125
|
+
2. `const loading = M.observable(true);` - then
|
|
1126
|
+
`const [settings, badge, roles] = await whileLoading(root.querySelector('.dm-loading-slot'), Promise.all([apiRequest('/content-api/settings'), apiRequest('/content-api/_count').catch(() => null), apiRequest('/roles').catch(() => [])]), SHAPES.rows);`
|
|
1127
|
+
then `loading.value = false`. On error: `E.toast(…, {type: 'error'})` and return. Check the real roles list endpoint and its shape before wiring (`server/routes/api/roles.js`).
|
|
1128
|
+
3. State: `saved = M.observable(settings)`; `f = {}` of `M.observable` per key of `formFromSettings(settings)`; `form()` / `peekForm()`; `savedSnap`; `dirty = M.computed(() => snapshot(form()) !== savedSnap.value)`; `saving`; `chips` via `M.effect` from `liveChips(saved.value, {listed: badge?.count})`; `endpoints = M.computed(() => endpointRows(location.origin, payloadFromForm(form())).map(r => ({...r, stateText: r.state === 'on' ? 'On' : 'Off', cls: r.state === 'off' ? 'is-off' : ''})))`; `roleErr = M.computed(() => f.accessMode.value === 'role' && !f.accessRole.value ? 'Pick a role.' : '')`; `roles = M.observable(roleOptions)`.
|
|
1129
|
+
4. Try it: `tryPath`, `tryFormat`, `tryJson`, `tryRow` observables; `curl = M.computed(() => curlExample(location.origin, tryPath.value || '/', payloadFromForm(form())))`; `run()` calls `apiRequest('/content-api/preview?path=…&format=…&as=anon')` with a sequence guard like Search's `seq`, sets `tryJson.value = JSON.stringify(body, null, 2)` and `tryRow.value = previewRow({status, body})`. Run on the button, not on every keystroke.
|
|
1130
|
+
5. `save()` - as Search: `PUT /content-api/settings` with `payloadFromForm(peekForm())`, refuse while `roleErr.peek()`, take the server's normalised version as the new baseline, clear the draft, `sidebarCount(SIDEBAR_URL)`, toast `Saved - the Content API is on.` / `Saved - the Content API is off.`
|
|
1131
|
+
6. `discard()` - reset `f` from `saved.peek()`.
|
|
1132
|
+
7. Insert row templates into `.cax-chips`, `.cax-endpoints`, `.cax-roles`; `const tabs = E.tabs(root.querySelector('.cax-tabs'), {tabSelector: '.tab-item', panelSelector: '.tab-panel'});`; `const bindings = M.applyBindings(model, root, {methods: {save, discard, run, copyCurl, copyBase}});` and `scope(() => bindings.dispose())`; `scope(helpPopovers(root, {position: 'bottom'}))`.
|
|
1133
|
+
8. Endpoint rows: `E.contextMenu(list, {cursor: 'glyph', match: '.cax-row', inherit: false, density: 'compact', items: (row) => […]})` with Copy URL, Copy curl, Open in new tab (GET only, public mode only); ⋮ → `openMenuAt`; Shift+F10 on a focused row. Clipboard failure falls back to `E.toast(text, {type: 'info', duration: 8000})` as Search does.
|
|
1134
|
+
9. Banner actions: `addBannerAction($container, {id: 'content-api-save', icon: 'save', title: 'Save (Ctrl+S)', onClick: () => save()})` and `{id: 'content-api-tokens', icon: 'key', title: 'API Tokens', onClick: () => { location.hash = '#/api-tokens'; }}`.
|
|
1135
|
+
10. Ctrl+S, `beforeunload`, `keepDraft`, `active = {dirty, dispose}`, `I.scan(root)`, draft restore with `E.confirm` - copied from Search with the key and wording changed.
|
|
1136
|
+
|
|
1137
|
+
- [ ] **Step 7: Register** in `views/index.js` and `app.js` (route + Tool banner map). Add `.cax-*` CSS to `admin/css/admin.css` by copying the `.ssx-*` block's structure (card, setting, chips, rows, alert) - reuse the same tokens, no new colours.
|
|
1138
|
+
|
|
1139
|
+
- [ ] **Step 8: Contract tests** - run `tests/admin/nav-guard-contract.test.js` and any `tests/admin/*` that enumerate views or templates (grep for `search.html` / `searchView` in `tests/admin/`); add the new view wherever Search appears.
|
|
1140
|
+
|
|
1141
|
+
- [ ] **Step 9: Run it** - start the app (`run` skill), open `#/content-api`, and check: the skeleton appears then goes; toggling marks the alert dirty; Discard resets; Save toasts and the sidebar badge updates; leaving with changes asks, and the draft is offered back on return; Try it on `/` returns 200 JSON; Endpoints right-click copies; both themes look right.
|
|
1142
|
+
|
|
1143
|
+
- [ ] **Step 10: Commit** - `feat(admin): System › Content API screen`
|
|
1144
|
+
|
|
1145
|
+
---
|
|
1146
|
+
|
|
1147
|
+
## Task 9: Token editor - content scopes
|
|
1148
|
+
|
|
1149
|
+
**Files:** `admin/js/lib/api-tokens-arrange.js`, `admin/js/views/api-tokens.js` (scopes help text and placeholder only), `server/services/scaffold*.js` (only if recipe `apiTokens[].scopes` is validated separately - grep); test `tests/admin/api-tokens-arrange.test.js` (extend)
|
|
1150
|
+
|
|
1151
|
+
Text syntax: a line starting `@` is a content scope - `@pages`, `@menus`, `@site`, `@search`. Collection slugs come from `slugify()` and can never start with `@`, so there is no ambiguity.
|
|
1152
|
+
|
|
1153
|
+
- [ ] **Step 1: Failing tests** (add to the existing file):
|
|
1154
|
+
|
|
1155
|
+
```js
|
|
1156
|
+
test('content scopes: @resource lines round-trip, take no verbs, unknown refused', () => {
|
|
1157
|
+
const scopes = [{collection: 'jobs', verbs: ['read']}, {content: 'pages'}, {content: 'menus'}];
|
|
1158
|
+
assert.equal(scopesToText(scopes), 'jobs: read\n@pages\n@menus');
|
|
1159
|
+
assert.deepEqual(parseScopes('jobs: read\n@pages\n @menus '), {scopes});
|
|
1160
|
+
assert.match(parseScopes('@widgets').error, /not a content resource/);
|
|
1161
|
+
assert.match(parseScopes('@pages: read').error, /take no verbs/);
|
|
1162
|
+
assert.match(parseScopes('@pages\n@pages').error, /twice/);
|
|
1163
|
+
assert.equal(scopesSummary([{content: 'pages'}]), 'content: pages');
|
|
1164
|
+
});
|
|
1165
|
+
```
|
|
1166
|
+
|
|
1167
|
+
- [ ] **Step 2: Run - fail. Step 3: Implement** (export `CONTENT_RESOURCES` from the arrange lib with the same four values; keep in step with `apiTokens.js` - add a test that imports both and compares). Placeholder becomes `jobs: read enquiries: create, read @pages`; help text gains one sentence on `@pages` / `@menus` / `@site` / `@search`.
|
|
1168
|
+
|
|
1169
|
+
- [ ] **Step 4: Run - pass. Commit** - `feat(api-tokens): content scopes in the token editor`
|
|
1170
|
+
|
|
1171
|
+
---
|
|
1172
|
+
|
|
1173
|
+
## Task 10: Docs
|
|
1174
|
+
|
|
1175
|
+
- [ ] `docs/api-reference.md` - new section **Content API (`/api/content/v1/*`)** after "External API & API tokens": endpoints, query params, response shapes, access modes, token project/scope rules (`{content}` / `@pages`), errors, caching, `api: false`, formats and `requires`.
|
|
1176
|
+
- [ ] In-admin API reference: a **Content API** page beside `apiRefExternal` (find its view with `grep -rn apiRefExternal admin/js`), route `#/docs/api/content`, sidebar entry under **API Reference** (`server.js:668`). Same component style as the External API page.
|
|
1177
|
+
- [ ] `.claude/skills/domma-api-surface/SKILL.md` - a **Content API** section: own prefix and why, `resolvePublishedPage` is the one decision, `authenticateApiToken` shared, `{content}` scopes never widen a scoped token, allowlisted payloads, `api: false`.
|
|
1178
|
+
- [ ] `CLAUDE.md` - extend the "External API & API tokens" paragraph by one sentence naming `/api/content/v1`.
|
|
1179
|
+
- [ ] `docs/configuration.md` - `config/content-api.json` schema; add it to the CLAUDE.md config table (Owner: Yours).
|
|
1180
|
+
- [ ] `FEATURES.md`, `docs/DommaFeatures.md`, `docs/editions-and-licences.md` (confirm the tier), `CHANGELOG.md` (Added: Content API; Fixed: search found unpublished non-draft pages).
|
|
1181
|
+
- [ ] Mark the spec **Status: Approved - implemented in <version>** and fold in the project-binding rule settled in Task 6.
|
|
1182
|
+
- [ ] **Commit** - `docs: Content API`
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## Done when
|
|
1187
|
+
|
|
1188
|
+
- `npm test` passes in full.
|
|
1189
|
+
- The four existing public-route test files and the token/endpoint test files are unmodified.
|
|
1190
|
+
- The parity test passes for every page × viewer combination.
|
|
1191
|
+
- The screen has been driven in the real app (Task 8, Step 9).
|
|
1192
|
+
- No endpoint returns `smtp`, unknown frontmatter, a draft to a non-`pages.read` caller, or anything with the API off.
|