domma-cms 1.6.59 → 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.
Files changed (143) hide show
  1. package/CHANGELOG.md +4344 -0
  2. package/README.md +1 -1
  3. package/admin/dist/domma/domma-tools.css +3 -3
  4. package/admin/dist/domma/domma-tools.min.js +3 -3
  5. package/admin/js/api.js +1 -1
  6. package/admin/js/app.js +2 -2
  7. package/admin/js/lib/draft-owner.js +1 -0
  8. package/admin/js/lib/effect-defs.js +1 -1
  9. package/admin/js/lib/effects-builder.js +2 -2
  10. package/admin/js/lib/page-editor-reference.js +5 -5
  11. package/admin/js/lib/shortcode-tags.js +1 -1
  12. package/admin/js/templates/docs/usage-shortcodes.html +39 -0
  13. package/admin/js/views/user-editor.js +1 -1
  14. package/docs/DommaFeatures.md +3005 -0
  15. package/docs/MultiTenant.md +176 -0
  16. package/docs/api-reference.md +1667 -0
  17. package/docs/cache.md +96 -0
  18. package/docs/configuration.md +1811 -0
  19. package/docs/editions-and-licences.md +190 -0
  20. package/docs/getting-started.md +201 -0
  21. package/docs/guides/form-triggers.md +260 -0
  22. package/docs/guides/mongodb-storage-adapter.md +282 -0
  23. package/docs/guides/multi-page-forms.md +121 -0
  24. package/docs/images/scribe-composer.png +0 -0
  25. package/docs/markdown-shortcodes.md +1738 -0
  26. package/docs/pentest-report-2026-04-03.md +181 -0
  27. package/docs/plans/2026-02-28-admin-topbar-login-forms.md +349 -0
  28. package/docs/plans/2026-02-28-domma-ui-polish.md +286 -0
  29. package/docs/plans/2026-02-28-reset-seed-fresh-commands.md +520 -0
  30. package/docs/plans/2026-03-02-cookie-consent-design.md +86 -0
  31. package/docs/plans/2026-03-02-cookie-consent-plugin.md +453 -0
  32. package/docs/plans/2026-03-02-tooltip-sweep-and-back-to-top-plugin-design.md +126 -0
  33. package/docs/plans/2026-03-02-tooltip-sweep-back-to-top.md +427 -0
  34. package/docs/plans/2026-03-05-cookie-consent-btt-core.md +115 -0
  35. package/docs/plans/2026-03-05-custom-css-migrate-to-core-design.md +35 -0
  36. package/docs/plans/2026-03-05-custom-css-migrate-to-core.md +352 -0
  37. package/docs/plans/2026-03-05-scribe-script-shortcode.md +373 -0
  38. package/docs/plans/2026-03-05-user-types-design.md +139 -0
  39. package/docs/plans/2026-03-05-user-types-implementation.md +1035 -0
  40. package/docs/plans/2026-03-09-page-editor-view-cta-inserts-design.md +78 -0
  41. package/docs/plans/2026-03-09-page-editor-view-cta-inserts.md +388 -0
  42. package/docs/plans/2026-03-09-pro-documentation-design.md +80 -0
  43. package/docs/plans/2026-03-09-pro-documentation.md +1017 -0
  44. package/docs/plans/2026-06-10-core-project-design.md +240 -0
  45. package/docs/plugin-development.md +827 -0
  46. package/docs/recruitment-recipe.md +501 -0
  47. package/docs/scaffolding.md +312 -0
  48. package/docs/screenshots/calculator-floating.png +0 -0
  49. package/docs/screenshots/calculator-standard.png +0 -0
  50. package/docs/screenshots/email-settings.png +0 -0
  51. package/docs/screenshots/messages-count.png +0 -0
  52. package/docs/structure/user-types.md +107 -0
  53. package/docs/superpowers/plans/2026-05-25-admin-sidebar-via-menus.md +1112 -0
  54. package/docs/superpowers/plans/2026-06-08-navbar-mobile-drawer-and-menu-enrichment.md +1161 -0
  55. package/docs/superpowers/plans/2026-06-25-domma-docs-project.md +695 -0
  56. package/docs/superpowers/plans/2026-06-26-surveys-HANDOFF.md +68 -0
  57. package/docs/superpowers/plans/2026-06-26-surveys-pro-plugin.md +1902 -0
  58. package/docs/superpowers/plans/2026-08-19-collection-context-sort-export.md +1577 -0
  59. package/docs/superpowers/plans/2026-10-09-content-api.md +1192 -0
  60. package/docs/superpowers/specs/2026-03-12-form-followup-actions-design.md +152 -0
  61. package/docs/superpowers/specs/2026-03-12-page-editor-form-insert-and-per-page-theme-design.md +115 -0
  62. package/docs/superpowers/specs/2026-03-17-nav-hidden-items-design.md +85 -0
  63. package/docs/superpowers/specs/2026-03-19-accordion-insert-design.md +175 -0
  64. package/docs/superpowers/specs/2026-03-25-static-block-shortcode-design.md +153 -0
  65. package/docs/superpowers/specs/2026-03-25-text-shortcode-design.md +168 -0
  66. package/docs/superpowers/specs/2026-04-05-card-builder-design.md +315 -0
  67. package/docs/superpowers/specs/2026-04-06-insert-builders-design.md +278 -0
  68. package/docs/superpowers/specs/2026-04-06-layouts-design.md +226 -0
  69. package/docs/superpowers/specs/2026-04-07-blog-plugin-design.md +300 -0
  70. package/docs/superpowers/specs/2026-04-07-calendar-plugin-design.md +538 -0
  71. package/docs/superpowers/specs/2026-04-17-components-area-design.md +368 -0
  72. package/docs/superpowers/specs/2026-05-02-effects-slideover-overhaul-design.md +193 -0
  73. package/docs/superpowers/specs/2026-05-06-dashboard-rebuild-design.md +257 -0
  74. package/docs/superpowers/specs/2026-05-24-menus-design.md +466 -0
  75. package/docs/superpowers/specs/2026-05-25-admin-sidebar-via-menus-design.md +358 -0
  76. package/docs/superpowers/specs/2026-05-25-projects-design.md +380 -0
  77. package/docs/superpowers/specs/2026-06-08-navbar-mobile-drawer-and-menu-enrichment-design.md +292 -0
  78. package/docs/superpowers/specs/2026-06-08-sidebar-tools-plugins-folders-design.md +59 -0
  79. package/docs/superpowers/specs/2026-06-25-domma-docs-project-design.md +192 -0
  80. package/docs/superpowers/specs/2026-06-26-domma-charts-upstream-proposal.md +44 -0
  81. package/docs/superpowers/specs/2026-06-26-manager-scheduler-upstream-proposal.md +46 -0
  82. package/docs/superpowers/specs/2026-06-26-surveys-pro-plugin-design.md +206 -0
  83. package/docs/superpowers/specs/2026-07-02-shortcode-context-menu-design.md +96 -0
  84. package/docs/superpowers/specs/2026-08-19-collection-context-sort-export-design.md +330 -0
  85. package/docs/superpowers/specs/2026-09-22-dcms-marketplace-design.md +1150 -0
  86. package/docs/superpowers/specs/2026-10-09-content-api-design.md +325 -0
  87. package/docs/superpowers/specs/plans/2026-03-12-form-followup-actions.md +624 -0
  88. package/docs/superpowers/specs/plans/2026-03-12-page-editor-form-insert-and-per-page-theme.md +125 -0
  89. package/docs/superpowers/specs/plans/2026-03-17-nav-hidden-items.md +578 -0
  90. package/docs/superpowers/specs/plans/2026-03-19-accordion-insert.md +635 -0
  91. package/docs/superpowers/specs/plans/2026-03-25-text-shortcode.md +545 -0
  92. package/docs/superpowers/specs/plans/2026-04-05-card-builder.md +1970 -0
  93. package/docs/superpowers/specs/plans/2026-04-06-insert-builders.md +1049 -0
  94. package/docs/superpowers/specs/plans/2026-04-06-layouts.md +1015 -0
  95. package/docs/superpowers/specs/plans/2026-04-07-calendar-plugin.md +1526 -0
  96. package/docs/superpowers/specs/plans/2026-04-08-plugin-monetisation-split.md +242 -0
  97. package/docs/superpowers/specs/plans/2026-04-17-components-area.md +2038 -0
  98. package/docs/superpowers/specs/plans/2026-05-02-effects-slideover-overhaul.md +2335 -0
  99. package/docs/superpowers/specs/plans/2026-05-06-dashboard-rebuild.md +1999 -0
  100. package/docs/superpowers/specs/plans/2026-05-17-cache-system.md +1436 -0
  101. package/docs/superpowers/specs/plans/2026-05-24-menus.md +3096 -0
  102. package/docs/superpowers/specs/plans/2026-05-25-projects.md +2869 -0
  103. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step2-consolidation.md +505 -0
  104. package/docs/superpowers/specs/plans/2026-09-22-marketplace-step3-catalogue-and-key.md +154 -0
  105. package/docs/superpowers/specs/plans/2026-09-22-tools-promotion.md +260 -0
  106. package/docs/theming.md +320 -0
  107. package/package.json +2 -2
  108. package/plugins/_lib/admin/mail/keyboard.js +49 -0
  109. package/plugins/_lib/admin/mail/reader-view.js +4 -2
  110. package/public/js/celebrations/core/canvas.js +1 -1
  111. package/public/js/celebrations/index.js +1 -1
  112. package/public/js/celebrations/licenses/ABeeZee-Regular-OFL.txt +93 -0
  113. package/public/js/celebrations/licenses/Allura-Regular-OFL.txt +93 -0
  114. package/public/js/celebrations/message-attrs.mjs +1 -0
  115. package/public/js/celebrations/themes/anniversary.js +1 -0
  116. package/public/js/celebrations/themes/autumn.js +1 -0
  117. package/public/js/celebrations/themes/birthday.js +1 -0
  118. package/public/js/celebrations/themes/butterfly-flight.js +1 -0
  119. package/public/js/celebrations/themes/calendar.js +1 -0
  120. package/public/js/celebrations/themes/chinese-new-year-dates.js +1 -0
  121. package/public/js/celebrations/themes/chinese-new-year.js +1 -0
  122. package/public/js/celebrations/themes/christmas.js +1 -1
  123. package/public/js/celebrations/themes/easter.js +1 -0
  124. package/public/js/celebrations/themes/garden-drawing.js +1 -0
  125. package/public/js/celebrations/themes/garden.js +1 -0
  126. package/public/js/celebrations/themes/graduation.js +1 -0
  127. package/public/js/celebrations/themes/greeting.js +1 -0
  128. package/public/js/celebrations/themes/make-it-rain.js +1 -0
  129. package/public/js/celebrations/themes/money-rain.js +1 -0
  130. package/public/js/celebrations/themes/new-year.js +1 -0
  131. package/public/js/celebrations/themes/occasion-drawing.js +1 -0
  132. package/public/js/celebrations/themes/occasions.js +1 -0
  133. package/public/js/celebrations/themes/rain-control.js +1 -0
  134. package/public/js/celebrations/themes/sparkler-font-data.js +1 -0
  135. package/public/js/celebrations/themes/sparkler-font.js +1 -0
  136. package/public/js/celebrations/themes/sparkler-writing.js +1 -0
  137. package/public/js/celebrations/themes/summer-garden.js +1 -0
  138. package/public/js/celebrations/themes/winter.js +1 -0
  139. package/public/js/celebrations/trait-data.mjs +1 -1
  140. package/public/js/effects.js +1 -1
  141. package/scripts/build.js +4 -0
  142. package/server/services/markdown.js +5 -2
  143. package/server/templates/page.html +1 -1
@@ -0,0 +1,325 @@
1
+ # Content API - headless read access to pages, menus and site content - design
2
+
3
+ **Date:** 2026-10-09
4
+ **Status:** Implemented on `feat/content-api` (unreleased) - see `docs/superpowers/plans/2026-10-09-content-api.md`
5
+ **Builds on:** the external API surface (`/api/v1/:slug`, API tokens, API Builder) - see the
6
+ `domma-api-surface` skill
7
+
8
+ Adds a versioned, externally consumable **Content API** beside the existing **Data API**
9
+ (`/api/v1/:slug`). The Data API exposes collection entries; the Content API exposes what the CMS
10
+ *publishes*: pages, menus, a safe subset of site settings, and search. Its first consumers are
11
+ headless front ends (a Next/Astro/mobile client), a static-site exporter and the Manager.
12
+
13
+ ## Goal
14
+
15
+ 1. A third-party client can list and fetch published pages, with frontmatter, as Markdown, rendered
16
+ HTML or both.
17
+ 2. **One access decision.** A page reachable over the API is reachable on the public site by the
18
+ same viewer, and the reverse - no second implementation of drafts, visibility or the project
19
+ kill-switch.
20
+ 3. The same credentials model as the Data API: public, a role (JWT), or a project-bound `dcms_`
21
+ token - with the same strictness rules.
22
+ 4. Cached and invalidated by the existing tag cache, with no new invalidation paths.
23
+
24
+ Non-goals for v1: writes (create/update/delete pages), media upload, block/component source,
25
+ theme CSS. See [Phase 2](#phase-2---writes).
26
+
27
+ ## What already exists
28
+
29
+ Verified against 0.116.8:
30
+
31
+ - `getPage(urlPath, {user})` / `listPages()` (`server/services/content.js`) return frontmatter,
32
+ `urlPath`, raw `content`, rendered `html`, `usedComponents`, `cacheTags`, `perViewer` - through
33
+ the same `parseMarkdown` pipeline the public site uses. `listPages()` also stamps
34
+ `resolvedProject`.
35
+ - The full public access decision lives inline in the `/*` route at `server/routes/public.js:177`:
36
+ project kill-switch → 404, `status !== 'published'` → 404 unless `canPreviewDrafts`,
37
+ `checkVisibility()` → 403, cache key `page:<url>:m<mtime>:role<roles>:o<baseUrl>` with tags
38
+ `page:<url>`, `nav`, `site` plus the page's own.
39
+ - `resolveViewer(request)` (`server/services/viewerSession.js`) - cookie/JWT viewer resolution.
40
+ - `resolveLocation(slot, user, ctx)` (`server/services/menus.js`) - bound menu for a slot, items
41
+ already filtered for the viewer.
42
+ - `searchPages(pages, query, {viewer, exclusions})` + `isSearchable()` (`server/services/search.js`).
43
+ - `validateToken()`, `scopeAllows()` and `validateScopes()` (`server/services/apiTokens.js`) -
44
+ scopes are `{collection, verbs[]}` only.
45
+ - `checkPublicAccess()` (`server/routes/api/collections.js`) - the token/role/public gate, keyed off
46
+ `schema.api.<verb>`. Pages have no schema, so it cannot be reused as-is.
47
+ - The `onSend` hook in `server/server.js` that marks `/api/*` `no-store` already exempts
48
+ `/api/v1/` and `/api/x/`; the Content API needs the same exemption.
49
+
50
+ ## Findings that shape the design
51
+
52
+ 1. **`/api/v1/pages` is not free.** `/api/v1/:slug` matches any collection slug, and `slugify()`
53
+ happily produces `pages`, `menus` or `content`. Fastify's static-beats-param rule would silently
54
+ shadow an existing collection of that name. The Content API therefore gets its own prefix.
55
+ 2. **The access decision is not extractable by import** - it is inline in a route handler that
56
+ writes HTML replies. It must be lifted into a service first, or the API will drift from the site.
57
+ 3. **Two draft rules exist today.** `public.js` hides `status !== 'published'`; `isSearchable()`
58
+ hides only `status === 'draft'`. Any other status (e.g. `scheduled`, `archived`) is hidden on the
59
+ site but searchable. The Content API uses the public rule, and search should be brought into
60
+ line in the same change.
61
+ 4. **Rendered HTML is Domma-dependent.** Tabs, carousels, accordions, `[collection]` displays and
62
+ effects emit markup that needs Domma's CSS/JS (and `__CMS_DCONFIG__`) to behave. Clients need to
63
+ be told, not left to discover it.
64
+ 5. **Frontmatter is open-ended.** Plugins and authors add keys freely; `dconfig`, `sidebar` and
65
+ `seo` carry implementation detail. Output must be an allowlist, never a spread.
66
+ 6. **`site.json` holds SMTP credentials.** No endpoint may serialise it wholesale - including the
67
+ `__CMS_SITE__` object, whose shape should be checked before being reused as the settings payload.
68
+ 7. **Per-viewer renders exist.** `[menu]` filters by role and a row-scoped `[view]` renders each
69
+ user their own rows (`perViewer`). A token has no user.
70
+
71
+ ## URL surface
72
+
73
+ Prefix: **`/api/content/v1`** (unused today; no collection slug can reach it).
74
+
75
+ | Method | Path | Returns |
76
+ |--------|------------------------------------|---------|
77
+ | `GET` | `/api/content/v1/pages` | Paged list of page summaries |
78
+ | `GET` | `/api/content/v1/pages/*` | One page by URL path (`/pages/` = home, `/pages/services/web-dev`) |
79
+ | `GET` | `/api/content/v1/menus/:location` | The menu bound to a location, filtered for the caller |
80
+ | `GET` | `/api/content/v1/site` | Public site settings subset |
81
+ | `GET` | `/api/content/v1/search?q=` | Search results |
82
+
83
+ ### Pages list
84
+
85
+ Query: `page`, `limit` (default 50, max 200 - and `limit=0` means the default, matching
86
+ `/api/v1`), `prefix` (URL path prefix, e.g. `/services`), `tag`, `category`, `project`, `sort`
87
+ (`sortOrder` default | `title` | `updatedAt` | `createdAt`), `order`.
88
+
89
+ Response mirrors the Data API list shape:
90
+
91
+ ```json
92
+ {
93
+ "pages": [
94
+ { "urlPath": "/about", "title": "About", "description": "…", "tags": ["company"],
95
+ "category": "general", "layout": "default", "updatedAt": "2026-09-30T10:12:00.000Z" }
96
+ ],
97
+ "total": 14, "page": 1, "limit": 50
98
+ }
99
+ ```
100
+
101
+ Summaries never include a body - the list must stay cheap enough to run over every page without
102
+ rendering any.
103
+
104
+ ### Single page
105
+
106
+ `GET /api/content/v1/pages/about?format=html|markdown|both` (default `html`).
107
+
108
+ ```json
109
+ {
110
+ "urlPath": "/about",
111
+ "title": "About",
112
+ "description": "…",
113
+ "layout": "default",
114
+ "tags": ["company"],
115
+ "category": "general",
116
+ "seo": { "title": "…", "description": "…", "image": "…" },
117
+ "createdAt": "…", "updatedAt": "…",
118
+ "html": "<section>…</section>",
119
+ "markdown": "## About us\n…",
120
+ "requires": { "domma": true, "components": ["tabs", "carousel"] },
121
+ "perViewer": false
122
+ }
123
+ ```
124
+
125
+ - `html` is the body only (`page.html`), not the full rendered document with navbar and footer.
126
+ - `requires.components` names the Domma markup in the body that `public/js/site.js` initialises
127
+ (tabs, accordion, carousel, countdown, tooltip, reveal, …), plus any custom `[component]`s.
128
+ `usedComponents` alone is not enough: it lists only custom components, and a page of tabs
129
+ reports none. `requires.domma` is true when the list is non-empty, so a client can decide
130
+ whether to load Domma or render the Markdown itself.
131
+ - `markdown` is the raw source. It contains shortcodes, which are documented in
132
+ `docs/markdown-shortcodes.md`; a client choosing Markdown takes on interpreting them.
133
+ - `seo` is allowlisted (`title`, `description`, `image`, `canonical`, `noindex`) - not passed through.
134
+
135
+ Resolution follows the public route: `/services` falls back to `/services/index`.
136
+
137
+ ### Menus
138
+
139
+ `GET /api/content/v1/menus/navbar?page=/about` - `resolveLocation(slot, viewer, {urlPath})`.
140
+ Returns `{location, slug, name, items}` with items in the same nested `items` shape the navbar
141
+ reads, each item allowlisted (`type`, `text`, `url`, `icon`, `target`, `size`, `badge`, `items`) -
142
+ none of its gating. Structural items (`separator`, `spacer`) are kept: a client rendering them is
143
+ better than indices shifting. The `admin-sidebar` and `overlay` slots, and any slot starting
144
+ `admin`, are never served (404).
145
+
146
+ ### Site
147
+
148
+ A fixed allowlist built in one function (`publicSiteSettings()`), never a filtered copy of
149
+ `site.json`, holding exactly what every public page already shows: `title`, `brand` (text, logo,
150
+ url), `footer` (hidden links removed, as the renderer does), `social`, the SEO defaults in the meta
151
+ tags (`defaultTitle`, `titleSeparator`, `defaultDescription`) and the `theme` id. `tagline` and
152
+ `url` never reach a public page, so they are left out. Anything added later is added to the
153
+ allowlist explicitly.
154
+
155
+ ### Search
156
+
157
+ `GET /api/content/v1/search?q=…&limit=10` - `searchPages(listPages(), q, {viewer, exclusions})`,
158
+ returning `{results: [{urlPath, title, excerpt, score}]}`. Respects `config/search.json`
159
+ `enabled: false` (404).
160
+
161
+ ## Access
162
+
163
+ ### Configuration
164
+
165
+ New file **`config/content-api.json`** (yours; absence = disabled):
166
+
167
+ ```json
168
+ {
169
+ "enabled": true,
170
+ "access": "public",
171
+ "resources": {
172
+ "pages": { "enabled": true },
173
+ "menus": { "enabled": true },
174
+ "site": { "enabled": true },
175
+ "search": { "enabled": true }
176
+ },
177
+ "formats": ["html", "markdown"],
178
+ "listable": true
179
+ }
180
+ ```
181
+
182
+ - `access` is `"public"`, `"token"` or a role name - the same three modes and the same ladder /
183
+ unknown-role-fails-closed rule as `schema.api.<verb>.access`.
184
+ - `formats` lets a site refuse Markdown (shortcode source can reveal collection slugs, view names
185
+ and form ids that the HTML does not).
186
+ - `listable: false` keeps single-page fetch but turns the list off, for sites that do not want
187
+ their full URL inventory enumerable.
188
+ - **Off by default.** An upgrade must not expose anything a site has not chosen to.
189
+
190
+ Edited in the admin at **System → API Tokens**, on a new "Content API" tab, or a small screen of
191
+ its own - see [Open questions](#open-questions).
192
+
193
+ ### The gate
194
+
195
+ `checkContentAccess(resource, request, reply)` - a sibling of `checkPublicAccess`, reading
196
+ `config/content-api.json` instead of a schema. The token branch is shared, not copied: factor the
197
+ Bearer-parse → `validateToken` → project check → scope check out of `checkPublicAccess` into
198
+ `authenticateApiToken(request, {project, scope})`, used by both gates.
199
+
200
+ Strictness carries over unchanged: a token never satisfies a role mode, a JWT never satisfies
201
+ token mode.
202
+
203
+ ### Tokens and projects
204
+
205
+ - A token is bound to one project. For pages, the project is `getProjectForPage()`; a token for
206
+ `world-cup` lists and fetches only pages resolving to `world-cup` (`core` tokens see `core`
207
+ pages) - single pages, the list and search results alike; a page in another project is `404`.
208
+ Menus and site settings are site-wide and already shown on every public page, so any valid
209
+ token may read them. The gate binds no project itself (`authenticateApiToken` without
210
+ `project`); the routes bind per page.
211
+ - **Scopes gain a second shape:** `{ "content": "pages" | "menus" | "site" | "search" }`. A scope
212
+ is `{collection, verbs}` OR `{content}`. `validateScopes()` accepts both; `scopeAllows()` gains a
213
+ `contentScopeAllows(scopes, resource)` sibling. Empty scopes still mean "everything in the
214
+ project" - which now includes content. That widens existing unscoped tokens, so it is gated on
215
+ the site enabling `access: "token"` - an unscoped token gets content only on sites that opted in.
216
+
217
+ ### Viewer and per-viewer content
218
+
219
+ - Public and token callers render as **anonymous** (`user: null`). Role-mode callers render as
220
+ themselves.
221
+ - Role mode accepts a Bearer **access** JWT only (a refresh token is refused) - never the
222
+ `dm_session` cookie, which only the page renderer reads, so no API answers to ambient
223
+ credentials.
224
+ - A page with `perViewer: true` is served as rendered for that viewer, and the flag is returned so
225
+ a client knows the body is not shareable between users.
226
+ - Drafts: never served in public or token mode. In role mode a caller holding `pages.read` may
227
+ pass `?preview=1` to receive drafts, mirroring `canPreviewDrafts` - responses are then
228
+ `no-store` and never cached.
229
+
230
+ ## The shared page resolver
231
+
232
+ Lift the decision out of `public.js` into `server/services/pagePublishing.js`:
233
+
234
+ ```js
235
+ /**
236
+ * @returns {Promise<
237
+ * {status: 'ok', page, project, isDraft} |
238
+ * {status: 'not-found'} | {status: 'forbidden'}
239
+ * >}
240
+ */
241
+ export async function resolvePublishedPage(urlPath, viewer, {allowDrafts = false} = {})
242
+ ```
243
+
244
+ It does the index fallback, the project kill-switch, the draft rule and `checkVisibility()`, and
245
+ returns a verdict - no replies, no HTML. `public.js` keeps its rendering and cache key and calls
246
+ this for the decision; the Content API calls it too. **This refactor lands first, on its own, with
247
+ the existing public-route tests passing unchanged** - it is the step that makes "one access
248
+ decision" true rather than aspirational.
249
+
250
+ `listPages()` filtering for the list endpoint uses the same predicate (`isPubliclyListed(page,
251
+ viewer)`), and `isSearchable()` is changed to call it - closing finding #3.
252
+
253
+ ## Caching
254
+
255
+ - **No server-side response cache.** The access decision has to parse the page on every request -
256
+ exactly as the site's does - and the JSON is built straight from that parse, so caching the
257
+ payload would save nothing. (The site caches its *rendered document*, which is the expensive
258
+ part; the API has no document.)
259
+ - Page writes clear a `pages:list` tag (not `pages`, which `cache-invalidation.test.js` pins as
260
+ carrying nothing), ready for when a list cache is worth having.
261
+ - Token responses: `Cache-Control: private, no-cache` so intermediaries never share a
262
+ token-gated body.
263
+ - Public responses: `Cache-Control: public, max-age=60` plus a weak `ETag` from mtime.
264
+ - Role and preview responses: `no-store`.
265
+ - Refusals (404/401/403/400): `no-store`, set by a hook scoped to the Content API routes - a 404
266
+ with no `Cache-Control` is heuristically cacheable and would outlive switching the API on.
267
+ - `server.js` `onSend`: add `/api/content/v1/` to the exempt prefixes.
268
+
269
+ ## Errors
270
+
271
+ Same as the Data API, with no oracle:
272
+
273
+ | Case | Status |
274
+ |------|--------|
275
+ | Content API disabled, resource disabled, page missing, draft, disabled project | `404` |
276
+ | Token/JWT missing or invalid | `401` |
277
+ | Token wrong project or scope, visibility refused | `403` - except a page outside the token's project, which is `404` (the project is the token's whole world) |
278
+ | Format not allowed | `400` |
279
+
280
+ A visibility-gated page answers `403` exactly as the site does, so the API leaks no more than the
281
+ site already does.
282
+
283
+ ## Testing
284
+
285
+ - `pagePublishing.js` unit tests: every verdict, including index fallback, unknown role in
286
+ visibility, `=role` exact match, disabled project, non-`published` statuses.
287
+ - Public route regression: the existing suite passes unchanged after the extraction.
288
+ - Parity test: for a fixture site, every page × {anon, user, admin} gets the same allow/deny from
289
+ `GET /about` and `GET /api/content/v1/pages/about`.
290
+ - Token tests: project binding, `{content}` scopes, unscoped token with Content API off/on,
291
+ JWT refused in token mode.
292
+ - Leakage tests: unknown frontmatter keys never appear; `site` never contains `smtp`; Markdown
293
+ refused when `formats` excludes it.
294
+ - Cache: a page save changes the next API response; a token response carries `private`.
295
+
296
+ ## Build order
297
+
298
+ 1. Extract `resolvePublishedPage` / `isPubliclyListed`; switch `public.js` and `isSearchable()`.
299
+ 2. Factor `authenticateApiToken()` out of `checkPublicAccess()`.
300
+ 3. `config/content-api.json` loader + `checkContentAccess()`.
301
+ 4. Routes (`server/routes/api/content-public.js`), pages first, then menus, site, search.
302
+ 5. Token scope shape `{content}` - service, admin token editor, scaffolder `apiTokens` block.
303
+ 6. Admin settings UI.
304
+ 7. Docs: `docs/api-reference.md` section, in-admin API reference page, `domma-api-surface` skill,
305
+ `FEATURES.md`, `CHANGELOG.md`.
306
+
307
+ ## Phase 2 - writes
308
+
309
+ Not in v1, recorded so v1 does not paint it out:
310
+
311
+ - `POST/PUT/DELETE /api/content/v1/pages/*` through `createPage` / `updatePage` / `deletePage`,
312
+ so versioning (`content/versions`), audit and cache invalidation come for free.
313
+ - Token-only (no public writes, ever), with a `{content: "pages", verbs: [...]}` scope - which is
314
+ why the scope shape above is an object rather than a bare string.
315
+ - Author attribution `token:<id>`, as the Data API does for entries.
316
+ - Media upload, blocks and menus as separate, later decisions.
317
+
318
+ ## Settled questions
319
+
320
+ Settled in the plan and kept through implementation:
321
+
322
+ 1. **Admin home:** its own screen, System › Content API (`#/content-api`, permission `settings`).
323
+ 2. **Per-page opt-out:** yes - front matter `api: false` (404, not listed, not searched).
324
+ 3. **Asset URLs in `requires`:** not in v1. `requires` names components only.
325
+ 4. **Edition:** the same as the Data API - free core (`docs/editions-and-licences.md`).