@cogenta/cli 0.3.0 → 0.6.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 (183) hide show
  1. package/CHANGELOG.md +3856 -0
  2. package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -0
  3. package/dist/admin-assets/assets/index-fYXyHpn8.js +1090 -0
  4. package/dist/admin-assets/assets/vips-_dmTUXFO.wasm +0 -0
  5. package/dist/admin-assets/assets/vips-es6-DrQTTS8Q.js +7 -0
  6. package/dist/admin-assets/branding/logo-cogenta-dark.png +0 -0
  7. package/dist/admin-assets/branding/logo-cogenta-icon.png +0 -0
  8. package/dist/admin-assets/branding/logo-cogenta-light.png +0 -0
  9. package/dist/admin-assets/branding/logo-cogenta-small.png +0 -0
  10. package/dist/admin-assets/branding/logo-cogenta-transparent.png +0 -0
  11. package/dist/admin-assets/branding/logo-cogenta.png +0 -0
  12. package/dist/admin-assets/favicon.png +0 -0
  13. package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
  14. package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
  15. package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
  16. package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
  17. package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
  18. package/dist/admin-assets/fonts/jetbrains-mono-var.woff2 +0 -0
  19. package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
  20. package/dist/admin-assets/index.html +3 -2
  21. package/dist/bin.js +6 -0
  22. package/dist/bin.js.map +1 -1
  23. package/dist/commands/admin-assets.d.ts +1 -0
  24. package/dist/commands/admin-assets.d.ts.map +1 -1
  25. package/dist/commands/admin-assets.js +19 -1
  26. package/dist/commands/admin-assets.js.map +1 -1
  27. package/dist/commands/agent-runtime.d.ts +151 -0
  28. package/dist/commands/agent-runtime.d.ts.map +1 -0
  29. package/dist/commands/agent-runtime.js +519 -0
  30. package/dist/commands/agent-runtime.js.map +1 -0
  31. package/dist/commands/assistant.d.ts +89 -3
  32. package/dist/commands/assistant.d.ts.map +1 -1
  33. package/dist/commands/assistant.js +206 -39
  34. package/dist/commands/assistant.js.map +1 -1
  35. package/dist/commands/audit-integrity-alert.d.ts +23 -0
  36. package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
  37. package/dist/commands/audit-integrity-alert.js +33 -0
  38. package/dist/commands/audit-integrity-alert.js.map +1 -0
  39. package/dist/commands/backup.d.ts +78 -0
  40. package/dist/commands/backup.d.ts.map +1 -0
  41. package/dist/commands/backup.js +238 -0
  42. package/dist/commands/backup.js.map +1 -0
  43. package/dist/commands/channels.d.ts +47 -0
  44. package/dist/commands/channels.d.ts.map +1 -0
  45. package/dist/commands/channels.js +277 -0
  46. package/dist/commands/channels.js.map +1 -0
  47. package/dist/commands/default-logo.d.ts +27 -0
  48. package/dist/commands/default-logo.d.ts.map +1 -0
  49. package/dist/commands/default-logo.js +30 -0
  50. package/dist/commands/default-logo.js.map +1 -0
  51. package/dist/commands/doctor.d.ts +5 -1
  52. package/dist/commands/doctor.d.ts.map +1 -1
  53. package/dist/commands/doctor.js +74 -2
  54. package/dist/commands/doctor.js.map +1 -1
  55. package/dist/commands/export.d.ts +22 -0
  56. package/dist/commands/export.d.ts.map +1 -0
  57. package/dist/commands/export.js +182 -0
  58. package/dist/commands/export.js.map +1 -0
  59. package/dist/commands/forms-page.d.ts +75 -0
  60. package/dist/commands/forms-page.d.ts.map +1 -0
  61. package/dist/commands/forms-page.js +191 -0
  62. package/dist/commands/forms-page.js.map +1 -0
  63. package/dist/commands/import.d.ts.map +1 -1
  64. package/dist/commands/import.js +13 -1
  65. package/dist/commands/import.js.map +1 -1
  66. package/dist/commands/mcp.d.ts +35 -0
  67. package/dist/commands/mcp.d.ts.map +1 -0
  68. package/dist/commands/mcp.js +277 -0
  69. package/dist/commands/mcp.js.map +1 -0
  70. package/dist/commands/roles.d.ts +22 -0
  71. package/dist/commands/roles.d.ts.map +1 -0
  72. package/dist/commands/roles.js +79 -0
  73. package/dist/commands/roles.js.map +1 -0
  74. package/dist/commands/search-page.d.ts +15 -0
  75. package/dist/commands/search-page.d.ts.map +1 -1
  76. package/dist/commands/search-page.js +22 -16
  77. package/dist/commands/search-page.js.map +1 -1
  78. package/dist/commands/seo.d.ts +92 -4
  79. package/dist/commands/seo.d.ts.map +1 -1
  80. package/dist/commands/seo.js +168 -4
  81. package/dist/commands/seo.js.map +1 -1
  82. package/dist/commands/serve.d.ts +489 -6
  83. package/dist/commands/serve.d.ts.map +1 -1
  84. package/dist/commands/serve.js +3883 -164
  85. package/dist/commands/serve.js.map +1 -1
  86. package/dist/commands/site-plan.d.ts +27 -1
  87. package/dist/commands/site-plan.d.ts.map +1 -1
  88. package/dist/commands/site-plan.js +71 -4
  89. package/dist/commands/site-plan.js.map +1 -1
  90. package/dist/commands/term-archive-page.d.ts +94 -0
  91. package/dist/commands/term-archive-page.d.ts.map +1 -0
  92. package/dist/commands/term-archive-page.js +195 -0
  93. package/dist/commands/term-archive-page.js.map +1 -0
  94. package/dist/commands/theme-css.d.ts +22 -7
  95. package/dist/commands/theme-css.d.ts.map +1 -1
  96. package/dist/commands/theme-css.js +33 -8
  97. package/dist/commands/theme-css.js.map +1 -1
  98. package/dist/commands/theme-registry.d.ts +94 -0
  99. package/dist/commands/theme-registry.d.ts.map +1 -0
  100. package/dist/commands/theme-registry.js +130 -0
  101. package/dist/commands/theme-registry.js.map +1 -0
  102. package/dist/commands/theme-render.d.ts +360 -2
  103. package/dist/commands/theme-render.d.ts.map +1 -1
  104. package/dist/commands/theme-render.js +984 -29
  105. package/dist/commands/theme-render.js.map +1 -1
  106. package/dist/commands/theme-wiring.d.ts +107 -0
  107. package/dist/commands/theme-wiring.d.ts.map +1 -0
  108. package/dist/commands/theme-wiring.js +295 -0
  109. package/dist/commands/theme-wiring.js.map +1 -0
  110. package/dist/commands/tools.d.ts +71 -0
  111. package/dist/commands/tools.d.ts.map +1 -0
  112. package/dist/commands/tools.js +330 -0
  113. package/dist/commands/tools.js.map +1 -0
  114. package/dist/commands/update.d.ts +25 -0
  115. package/dist/commands/update.d.ts.map +1 -0
  116. package/dist/commands/update.js +193 -0
  117. package/dist/commands/update.js.map +1 -0
  118. package/dist/commands/users.d.ts.map +1 -1
  119. package/dist/commands/users.js +6 -36
  120. package/dist/commands/users.js.map +1 -1
  121. package/dist/index.d.ts +15 -0
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +146 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/invite-mail.d.ts +39 -0
  126. package/dist/invite-mail.d.ts.map +1 -0
  127. package/dist/invite-mail.js +39 -0
  128. package/dist/invite-mail.js.map +1 -0
  129. package/dist/reset-mail.d.ts +39 -0
  130. package/dist/reset-mail.d.ts.map +1 -0
  131. package/dist/reset-mail.js +45 -0
  132. package/dist/reset-mail.js.map +1 -0
  133. package/dist/update/apply.d.ts +54 -0
  134. package/dist/update/apply.d.ts.map +1 -0
  135. package/dist/update/apply.js +77 -0
  136. package/dist/update/apply.js.map +1 -0
  137. package/dist/update/changelog-risk.d.ts +20 -0
  138. package/dist/update/changelog-risk.d.ts.map +1 -0
  139. package/dist/update/changelog-risk.js +55 -0
  140. package/dist/update/changelog-risk.js.map +1 -0
  141. package/dist/update/check.d.ts +60 -0
  142. package/dist/update/check.d.ts.map +1 -0
  143. package/dist/update/check.js +56 -0
  144. package/dist/update/check.js.map +1 -0
  145. package/dist/update/contract-risk.d.ts +55 -0
  146. package/dist/update/contract-risk.d.ts.map +1 -0
  147. package/dist/update/contract-risk.js +75 -0
  148. package/dist/update/contract-risk.js.map +1 -0
  149. package/dist/update/history.d.ts +30 -0
  150. package/dist/update/history.d.ts.map +1 -0
  151. package/dist/update/history.js +38 -0
  152. package/dist/update/history.js.map +1 -0
  153. package/dist/update/index.d.ts +28 -0
  154. package/dist/update/index.d.ts.map +1 -0
  155. package/dist/update/index.js +11 -0
  156. package/dist/update/index.js.map +1 -0
  157. package/dist/update/list-restore-points.d.ts +20 -0
  158. package/dist/update/list-restore-points.d.ts.map +1 -0
  159. package/dist/update/list-restore-points.js +35 -0
  160. package/dist/update/list-restore-points.js.map +1 -0
  161. package/dist/update/npm-registry.d.ts +11 -0
  162. package/dist/update/npm-registry.d.ts.map +1 -0
  163. package/dist/update/npm-registry.js +74 -0
  164. package/dist/update/npm-registry.js.map +1 -0
  165. package/dist/update/restore-point.d.ts +26 -0
  166. package/dist/update/restore-point.d.ts.map +1 -0
  167. package/dist/update/restore-point.js +23 -0
  168. package/dist/update/restore-point.js.map +1 -0
  169. package/dist/update/tar.d.ts +27 -0
  170. package/dist/update/tar.d.ts.map +1 -0
  171. package/dist/update/tar.js +56 -0
  172. package/dist/update/tar.js.map +1 -0
  173. package/dist/update/version-compare.d.ts +17 -0
  174. package/dist/update/version-compare.d.ts.map +1 -0
  175. package/dist/update/version-compare.js +42 -0
  176. package/dist/update/version-compare.js.map +1 -0
  177. package/dist/version.d.ts +13 -0
  178. package/dist/version.d.ts.map +1 -0
  179. package/dist/version.js +18 -0
  180. package/dist/version.js.map +1 -0
  181. package/package.json +33 -13
  182. package/dist/admin-assets/assets/index-Buwdj1V2.js +0 -71
  183. package/dist/admin-assets/assets/index-Csef0SiA.css +0 -1
package/CHANGELOG.md ADDED
@@ -0,0 +1,3856 @@
1
+ # @cogenta/cli
2
+
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`87f6e5d`](https://github.com/cogenta-cms/cogenta/commit/87f6e5dc6fb250067d1c8bc3bec7c4a10da93639) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `POST /api/theme/gallery-preview` accepts an optional `tokens` field: preview a Theme Creator candidate's own skin against a theme this site is not currently running — the one combination `/api/theme/preview` (locked to the active theme) and the gallery's own default-skin render (locked to each theme's on-disk default) previously had no way to express together. Omitting `tokens` keeps the exact previous behaviour.
8
+
9
+ - [`df06c56`](https://github.com/cogenta-cms/cogenta/commit/df06c56cf17b17fe7a636e03e712698d895e5db4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Fix the Appearance screen (and `theme.propose_theme`) reporting "no LLM provider configured" even after one is set up — two compounding bugs, both real:
10
+
11
+ 1. `theme-wiring.ts` resolved a provider **once**, at `cogenta serve` boot, and captured it — a provider registered afterwards through `/admin/providers` never took effect for the rest of that process's life, unlike every other agent's client (which is refreshed live). `resolveThemeProvider` is now called fresh on every request; `SkinGeneratorLike` gains `isAvailable()` so `GET /api/theme`'s `aiAvailable` reflects the live state instead of a snapshot.
12
+ 2. Provider *choice* was a hardcoded `{preferred: 'anthropic', fallback: 'openai'}` guess, duplicating — and never actually reading — the "Cogenta Theme Creator" agent's own admin-configurable `model.preferred`/`model.fallback`. An admin who repointed that agent at a different provider from its own settings screen saw the theme generator keep ignoring the choice. The theme generator and `theme.propose_theme` now both read that agent's live declaration (`THEME_CREATOR_AGENT_NAME`, newly exported from `@cogenta/agents`), falling back to the old hardcoded pair only when no agent record exists (a bare `Site` built by hand, tests included).
13
+
14
+ `ProposeThemeToolOptions.resolveProvider` replaces the old fixed `client`/`model` fields for the same reason — resolved on every `execute()`, not once at tool-registration time.
15
+
16
+ ### Patch Changes
17
+
18
+ - [`e6e0c55`](https://github.com/cogenta-cms/cogenta/commit/e6e0c55fcd5750d9b537825b454653a96cafcb61) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now emits the shared theme-toggle script once in `<head>`, right after the `color-scheme` meta tag, on every page shell (entry pages, generic pages, the theme gallery preview) — the host's job per contract D, not a theme's.
19
+
20
+ - [`76c000f`](https://github.com/cogenta-cms/cogenta/commit/76c000f12a5200d0664cc904bf52b90343da0768) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` swaps `POST /api/theme/generate`'s implementation to call `@cogenta/agents`' `proposeThemeCandidates` (theme choice + skin tokens) instead of `generateSkinCandidates` directly, threading through the new `attachments`/`baseline` fields. Registers the Theme Creator's `theme.propose_theme` tool (`@cogenta/agents-builtin`, new dependency) into the live agent tool registry whenever an LLM provider is configured — absent otherwise (R2).
21
+ - Updated dependencies [[`74e07e9`](https://github.com/cogenta-cms/cogenta/commit/74e07e92fda41c0d0d573a59e8bfafdecd48fbfc), [`73827ec`](https://github.com/cogenta-cms/cogenta/commit/73827ec14ed11b7b34e2cfa2138f795790e889e4), [`b85ce4e`](https://github.com/cogenta-cms/cogenta/commit/b85ce4edad72ff065cd63c852a9f42aeefc5ab9a), [`cdd004d`](https://github.com/cogenta-cms/cogenta/commit/cdd004d863e6c26f1646fb18081d6a459cbfa3f4), [`9da8702`](https://github.com/cogenta-cms/cogenta/commit/9da8702147864416ea2c27f47dd534444999d9da), [`0c42a6e`](https://github.com/cogenta-cms/cogenta/commit/0c42a6e1459d03f16c281befb36889c3ecac8e7c), [`bde02b5`](https://github.com/cogenta-cms/cogenta/commit/bde02b518f98a8d4cbc58544ea809c658b8dee7b), [`06c6177`](https://github.com/cogenta-cms/cogenta/commit/06c61776844c6d2e2bf5bfca7a1425e32c7d2ed6), [`87ae89a`](https://github.com/cogenta-cms/cogenta/commit/87ae89ab5e3fdb5197da821ffedc53a2471349db), [`74b05dc`](https://github.com/cogenta-cms/cogenta/commit/74b05dc34d5dcaac3c87c92a7735246e3c68aaa2), [`e6e0c55`](https://github.com/cogenta-cms/cogenta/commit/e6e0c55fcd5750d9b537825b454653a96cafcb61), [`e421dde`](https://github.com/cogenta-cms/cogenta/commit/e421dde6162a8a8e81f5c4b95ef99efd6af69128), [`76c000f`](https://github.com/cogenta-cms/cogenta/commit/76c000f12a5200d0664cc904bf52b90343da0768), [`76c000f`](https://github.com/cogenta-cms/cogenta/commit/76c000f12a5200d0664cc904bf52b90343da0768), [`80ae76d`](https://github.com/cogenta-cms/cogenta/commit/80ae76db6578f34d3292a088b857dcf14c8acf31), [`ccd5dd3`](https://github.com/cogenta-cms/cogenta/commit/ccd5dd3e7bc148ddbfd0c6ade2c9d09df5e82b10), [`c87bdf8`](https://github.com/cogenta-cms/cogenta/commit/c87bdf8b0f54b945c1c79ea5a18d58b54cb9aab5), [`bb04899`](https://github.com/cogenta-cms/cogenta/commit/bb04899e5f4d041efcf9c6a4ceaf3033fe897415), [`2b1c836`](https://github.com/cogenta-cms/cogenta/commit/2b1c8363fbc399be24f8c2eddec3be115d1a5c1a), [`df06c56`](https://github.com/cogenta-cms/cogenta/commit/df06c56cf17b17fe7a636e03e712698d895e5db4), [`41336c2`](https://github.com/cogenta-cms/cogenta/commit/41336c23787a1d07f1bca14d760c684878157c8e), [`76c000f`](https://github.com/cogenta-cms/cogenta/commit/76c000f12a5200d0664cc904bf52b90343da0768), [`98f54ab`](https://github.com/cogenta-cms/cogenta/commit/98f54ab9883d492890251ef7fc3310c83e8fac8b), [`e6e0c55`](https://github.com/cogenta-cms/cogenta/commit/e6e0c55fcd5750d9b537825b454653a96cafcb61)]:
22
+ - @cogenta/auth@0.5.0
23
+ - @cogenta/api@2.1.0
24
+ - @cogenta/commerce@0.4.0
25
+ - @cogenta/core@0.6.0
26
+ - @cogenta/mcp@0.3.0
27
+ - @cogenta/agents@0.4.0
28
+ - @cogenta/theme-association@0.3.0
29
+ - @cogenta/theme-blog@0.3.0
30
+ - @cogenta/theme-canonical@1.1.0
31
+ - @cogenta/agents-builtin@0.3.0
32
+ - @cogenta/theme-docs@0.3.0
33
+ - @cogenta/theme-ecommerce@1.1.0
34
+ - @cogenta/theme-entreprise@1.1.0
35
+ - @cogenta/theme-magazine@1.1.0
36
+ - @cogenta/theme-portfolio@1.1.0
37
+ - @cogenta/theme-restaurant@0.3.0
38
+ - @cogenta/theme-saas@0.3.0
39
+ - @cogenta/theme-kit@0.3.0
40
+ - @cogenta/export@0.2.1
41
+ - @cogenta/import@0.2.1
42
+ - @cogenta/analytics@0.3.1
43
+ - @cogenta/blocks@1.0.1
44
+ - @cogenta/channels@0.3.1
45
+ - @cogenta/comments@0.2.1
46
+ - @cogenta/forms@0.2.1
47
+ - @cogenta/observability@0.2.1
48
+ - @cogenta/plugins@0.3.1
49
+ - @cogenta/render@0.2.1
50
+ - @cogenta/schema@0.4.1
51
+ - @cogenta/seo@0.3.1
52
+
53
+ ## 0.5.0
54
+
55
+ ### Minor Changes
56
+
57
+ - 154a751: Fiche 22 tâche 8 (finitions d'admin) — several small, independently useful
58
+ changes across the published packages:
59
+
60
+ `@cogenta/core`'s `package.json` now declares `"./package.json"` in its
61
+ `exports` map, so a dependent (`@cogenta/cli`) can resolve its own real
62
+ installed version through Node's standard ESM resolution instead of a
63
+ hand-maintained copy. Purely additive; nothing else in the package changes.
64
+
65
+ `@cogenta/schema`'s `SITE_SETTINGS_REGISTRY` gains a `navigation` group and
66
+ four new keys (`navigation.sectionOrder`, `navigation.hiddenSections`,
67
+ `navigation.itemOrder`, `navigation.hiddenItems`) — site-wide admin sidebar
68
+ reordering and hiding (e.g. "hide the Commerce section on a portfolio
69
+ site"), stored the same comma-separated-list way `content.
70
+ newEntryDefaultBlocks` already is. Additive to the registry; no existing key
71
+ changes shape or default.
72
+
73
+ `@cogenta/api`'s `ShellStatus` (and `createShellStatusRouter`'s
74
+ `ShellStatusRouterOptions`) gains `cogentaVersion: string` — the installed
75
+ `@cogenta/core` version, answered to every actor including an anonymous
76
+ one (never secret), consumed by the admin footer/topbar. A caller that does
77
+ not pass `cogentaVersion` gets `'0.0.0'` rather than `undefined`.
78
+
79
+ `@cogenta/cli` gains `getCogentaVersion()` (`commands/cogenta-version.ts`),
80
+ resolving `@cogenta/core`'s own `package.json` version through
81
+ `import.meta.resolve` and caching it. `cogenta serve` now threads this
82
+ version into `GET /api/shell-status` and, when Cogenta's own branding stays
83
+ on, into the public site footer next to its existing credit — extending
84
+ `ThemeRenderOptions`'s `BrandingSettings` with an optional `cogentaVersion`
85
+ field, never duplicating the branding on/off logic itself.
86
+
87
+ `@cogenta/theme-canonical`'s `base.css` gains a small `.cg-site-footer__version`
88
+ rule for the version text above, and a `gap` on `.cg-site-footer__branding a`
89
+ so the logo and the version sit apart cleanly — no structural change to the
90
+ footer markup beyond the one optional `<span>`.
91
+ - 5c5ffbd: L21 task 2 — a runtime template + personalisation system for the admin's
92
+ own interface, the counterpart `packages/admin/src/routes/appearance.tsx`
93
+ already gave the public site (contract D) but the admin itself never had:
94
+ before this, `theme.css` was a single hard-coded design with no selector
95
+ and no override mechanism at all.
96
+
97
+ **`@cogenta/core`:** two new error codes, `ADMIN_THEME_TEMPLATE_UNKNOWN`
98
+ and `ADMIN_THEME_INVALID`.
99
+
100
+ **`@cogenta/schema`:** a new `admin-theme-templates.ts` — two complete,
101
+ built-in token sets (`ADMIN_THEME_TEMPLATES`): "Nightops" (the current
102
+ dark-first, signal-green console — copied verbatim from `theme.css`) and
103
+ "Atelier" (the warm, printed-paper design that shipped immediately before
104
+ the Nightops reskin, recovered from git history rather than approximated
105
+ from memory) — plus `adminThemeOverridesSchema`, the small, curated set of
106
+ personalisation levers a template can be customised with (primary/
107
+ background/text colour, display font, body font, corner radius, an
108
+ optional logo media id) without ever rewriting the built-in template
109
+ itself. `ensureAdminThemeTable`/`createAdminThemeStore` persist exactly one
110
+ choice (a template id plus its overrides) in a new fixed table
111
+ (`cogenta_admin_theme`, the same one-table-no-migration-file treatment
112
+ `menu-tables.ts`/`site-settings-tables.ts` already use for admin-editable,
113
+ non-schema-declared state).
114
+
115
+ **`@cogenta/api`:** `createAdminThemeRouter` — `GET|PUT /api/admin-theme`.
116
+ Read needs no session at all (the admin's own `/login` screen has to paint
117
+ in the chosen template before one exists); write needs the `admin` role,
118
+ checked by the router itself.
119
+
120
+ **`@cogenta/cli`:** `cogenta serve` mounts the new store and router, and
121
+ audits every successful `PUT` the same way `/api/settings` already does.
122
+
123
+ No breaking changes — a site that never calls `PUT /api/admin-theme` keeps
124
+ `theme.css`'s own "Nightops" defaults exactly as before. `@cogenta/admin`
125
+ (private, no changeset) gains the settings screen ("Apparence de l'admin",
126
+ deliberately a separate nav entry from the public site's own "Apparence"),
127
+ `AdminThemeProvider` (injects the computed CSS as a `<style>` tag,
128
+ cascading over `theme.css`'s own tokens), and a personalised logo in the
129
+ top bar when one is set.
130
+ - 0e88f30: L22 task 1/1bis: the agent runtime is real. `AgentRegistry` used to only enable/disable a fixed, in-memory declaration array — nothing ever executed. `@cogenta/agents` gains a real execution loop wiring (`createAgentRunner`, `agents/orchestrator.ts`) together with everything the loop needed but never had a home for: persistent, editable agent declarations (`createFileAgentDeclarationStore`), a persistent, encrypted-at-rest LLM provider store (`createFileProviderConfigStore`, AES-256-GCM keyed from `COGENTA_AUTH_SIGNING_KEY`, R7), and a "skills" instruction-text library (`createFileAgentSkillStore`, `skills/library.ts` — deliberately distinct from L7's marketplace skill registry). Three built-ins are seeded on first boot: the superagent ("Cogenta Agent", enabled by default, autonomy `propose`) and two disabled examples (a dependency-scanner agent backed by the new `deps.scan` tool, and a content-watch example). Autonomy has a new three-level UI mapping (`report-only`/`co-pilot`/`autopilot`) onto contract C's frozen `AutonomyLevel` vocabulary (`autonomy/levels.ts`) — the contract itself is unchanged.
131
+
132
+ `@cogenta/api`'s `agents-router.ts` gains real `create`/`update`/`remove`/`run` capabilities (all optional on `AgentRegistryLike`, backward compatible with a caller that only ever built a fixed `createAgentRegistry`); two new routers, `providers-router.ts` and `agent-skills-router.ts`. All three routers now correctly `decodeURIComponent` path segments — a pre-existing gap in `agents-router.ts` this lot's own end-to-end test caught (the seeded superagent's name, "Cogenta Agent", contains a space). New `ErrorCode`s (`@cogenta/core`): `AGENT_DUPLICATE`, `AGENT_DISABLED`, `AGENT_NO_PROVIDER` (501, mirrors `SITE_PLAN_NO_PROVIDER` — R2's "no provider configured" is not a failure), `AGENT_BUILTIN_UNDELETABLE`, `PROVIDER_NOT_CONFIGURED`, `AGENT_SKILL_UNKNOWN`/`AGENT_SKILL_DUPLICATE`/`AGENT_SKILL_BUILTIN_UNDELETABLE`, `AGENT_REGISTRY_READ_ONLY` (501), `AGENT_RUNTIME_UNAVAILABLE` (503, mirrors `ASSIST_UNAVAILABLE`).
133
+
134
+ **Breaking, within pre-alpha's existing minor-only convention** (see prior changesets' own note): `createAgentDelegateTool`'s tool name is no longer the fixed `agent.delegate` — it is now `agent.delegate.<slug-of-subagent-name>`, so an orchestrator offering several named sub-agents can expose each as a distinct, nameable tool instead of one ambiguous generic call. The permission stays the single, taxonomy-fixed `agent.delegate` (`tools@1.0`); no contract change.
135
+
136
+ `@cogenta/cli`'s `cogenta serve` now always constructs this runtime (three file stores under `.cogenta/agents-runtime/`) and mounts `/api/agents`, `/api/providers` and `/api/agent-skills` unconditionally — R2 still holds: without a configured provider, every route above works except `POST /api/agents/:name/run`, which refuses with `AGENT_NO_PROVIDER` before any network call (proven end to end in `packages/cli/test/serve-agents.test.ts`, including a real tool-calling loop and an R4 permission-refusal case against a local HTTP double of the Anthropic Messages API). `create-cogenta` seeds the same three built-ins at scaffold time.
137
+
138
+ The admin's "Agents" screen (`packages/admin`, private, no changeset) is genuinely editable now — create/edit/run/delete a sub-agent, per-tool permission checklist, autonomy/budget/skills/sub-agents — and gains two new screens, "Providers" and "Skills".
139
+ - 750a10b: L24 task 4: the admin "Skills" screen (`AgentSkillStore`, L22 task 1bis) now stores each skill the same way L7's marketplace registry already does — `<dir>/<id>/SKILL.md` (frontmatter + body), the exact format a real Claude Code/Codex skill ships as — instead of one JSON file per record. The point is portability: a `SKILL.md` copied verbatim from `.claude/skills/` (or any other standard agent) drops straight into the store's directory and reads back correctly.
140
+
141
+ `@cogenta/agents`: `parseSkillFile` (`skills/frontmatter.ts`) no longer requires a `version` field — a real Claude Code/Codex skill only ever carries `name` and `description`, and requiring a third field it doesn't have refused the exact copy-paste this task exists to support. `SkillMetadata.version` becomes optional (`file-store.ts`'s marketplace registry, which does need one to compare installed-vs-available, still writes it — this only relaxes what a skill *file* is allowed to omit). New `renderSkillFile`, the inverse of `parseSkillFile`, now exported alongside it. `AgentSkillStore`'s own contract (`list`/`get`/`create`/`update`/`remove`, and the shape of `AgentSkillInput`/`AgentSkillPatch`) is unchanged; `AgentSkill` gains a `content` field — the exact `SKILL.md` text the record renders to, always the canonical rendering of the structured fields, never a second independently-edited copy. The `enabledByDefault`/`builtin`/`createdAt`/`updatedAt` bookkeeping a portable `SKILL.md` has no room for lives in a sidecar `.meta.json` next to `SKILL.md`, deliberately kept out of the frontmatter — folding it in would leave every skill this store touches carrying Cogenta-only keys forever, defeating the point of the migration. A `SKILL.md` dropped into the store's directory with no sidecar reads fine, with sensible defaults, rather than failing.
142
+
143
+ `@cogenta/api`: `/api/agent-skills`'s `POST`/`PATCH` now take `{ content: string }` (a raw `SKILL.md`) instead of separate `name`/`description`/`instructions` fields — parsed server-side with the same `parseSkillFile`, so a malformed submission fails with the same `SKILL_DEFINITION_INVALID` a file-based store would raise (newly mapped to HTTP 400 in `statusFor`). Every response now also carries `content`. **Breaking wire change** for any caller of this admin-only route (the admin app is the only one, and is updated in this same change).
144
+
145
+ `@cogenta/cli`: no interface change — `agent-runtime.ts`'s use of `createFileAgentSkillStore`/`ensureBuiltinAgentSkills` is unaffected, since `AgentSkillStore`'s own contract did not change; called out here only because the on-disk format of a site's `.cogenta/agents-runtime/skills/` directory changes on next write (existing sites keep working — nothing migrates old `<id>.json` records automatically, since none exist yet on any real site this project has shipped to).
146
+
147
+ `@cogenta/plugins`: `createSkillRegistry`'s marketplace submission handler (`registries/skills.ts`) now records a submission with no `version` field (a real Claude Code/Codex `SKILL.md`) as `skillVersion: null` instead of failing to compile against the now-optional `SkillMetadata.version` — no behaviour change for a submission that does carry one.
148
+ - c489fde: API keys gain the last two lifecycle actions fiche 20 left open: purge and
149
+ recovery from a mistaken revocation (fiche 62).
150
+
151
+ `ApiKeyStore` (`@cogenta/auth`) gains `purge(id)` — a real, permanent
152
+ `DELETE` of the key row and its usage history, refused unless the key has
153
+ been revoked for at least `MIN_PURGE_AFTER_REVOKED_DAYS` (30, newly exported)
154
+ — and `recover(id)` — mints a replacement carrying the same name, scope and
155
+ quota as a key revoked by mistake, without ever lifting that key's
156
+ `revokedAt` back to `null`. Recovery only works within
157
+ `RECOVERY_WINDOW_MS` (24h, newly exported) of the revocation; past that
158
+ window, or for a key that was never revoked, both throw the two new error
159
+ codes below. This is decision (b) from fiche 62's own recommendation: a
160
+ revoked key is usually revoked for a security reason, so recovery mints a
161
+ new credential rather than silently reactivating a possibly compromised one.
162
+
163
+ `@cogenta/core` gains two error codes: `API_KEY_PURGE_INVALID` and
164
+ `API_KEY_RECOVERY_INVALID` (both mapped to HTTP 409 — the id names something
165
+ real, refused only because of its current state).
166
+
167
+ `@cogenta/api`'s `/api/api-keys` router gains `DELETE .../purge` and
168
+ `POST .../recover`, both admin-only, following the same request/response
169
+ shape as the existing `rotate` route (the raw key appears exactly once, in
170
+ the `recover` response).
171
+
172
+ `cogenta serve` (`@cogenta/cli`) records `apikey.purge` and `apikey.recover`
173
+ in the audit log, alongside the `apikey.create`/`apikey.rotate`/
174
+ `apikey.revoke` entries that already existed — every API key lifecycle
175
+ mutation now produces a verifiable audit entry, closing the gap fiche 20
176
+ first flagged.
177
+ - 54ca689: API key lifecycle, rotation and a per-key request quota (fiche 20).
178
+
179
+ **Breaking (`@cogenta/api`):** `POST /api/api-keys` no longer mints a key that
180
+ never expires by default. A request that omits `expiresAt` now gets a
181
+ 90-day expiry — a real, generous but bounded default, since a key with no
182
+ expiry is a key that leaks forever. Pass `neverExpires: true` explicitly to
183
+ keep the old "never expires" behaviour. Any script that creates API keys
184
+ without setting `expiresAt` will see its keys start expiring after 90 days;
185
+ set `neverExpires: true` (or a longer `expiresAt`) if that is not wanted.
186
+
187
+ New, additive:
188
+
189
+ - `POST /api/api-keys/{id}/rotate` (`@cogenta/api`, `@cogenta/auth`'s
190
+ `ApiKeyStore.rotate`): mints a replacement carrying the same name, scope
191
+ and quota, and lets the original keep authenticating for a chosen grace
192
+ window (1h/24h/7d) instead of dying mid-flight. The new key's raw value is
193
+ returned exactly once, the same rule `POST /api/api-keys` already follows.
194
+ - A per-key request quota (`rateLimitPerMinute`, `@cogenta/auth`), enforced
195
+ once per request by `resolveActor` when a `RateLimitDriver` is supplied.
196
+ Exceeding it answers `429` with `Retry-After` and `RateLimit-*` headers.
197
+ `@cogenta/core` gains the `rateLimit` driver need (`createRateLimitRegistry`,
198
+ a Redis driver and an in-process one — R1: works with no Redis at all) and
199
+ a matching `rateLimit` configuration section; `cogenta serve`/`doctor` wire
200
+ and report it.
201
+ - Aggregated 7- and 30-day call counts per key (`ApiKeyStore.usage`), and a
202
+ new admin notice when a key is within seven days of expiring
203
+ (`createApiKeyExpiryNoticeSource`).
204
+ - `ApiKey` gains `rateLimitPerMinute` and `supersededBy` (set once a key has
205
+ been rotated). `ApiKeyStore` gains `getById`, `rotate` and `usage`.
206
+
207
+ New error codes: `API_KEY_RATE_LIMITED` (429), `API_KEY_ROTATION_INVALID`
208
+ (409 — a revoked or expired key cannot be rotated), `RATE_LIMIT_FAILED`.
209
+
210
+ The property that a raw API key is shown exactly once, never twice, holds
211
+ for the new rotate response too: `listApiKeys` and the `previous` half of a
212
+ rotation response never carry key material.
213
+ - 23299e9: The assistant's vector index is now explained and manageable, not just a raw
214
+ count (L22 task 4).
215
+
216
+ - `GET /api/assistant` now reports, per content collection, whether it is
217
+ included in the index and how many chunks it contributes
218
+ (`vector.collections`), plus the reserved pseudo-collection name reference
219
+ documents are stored under (`vector.referenceCollection`).
220
+ - A new site setting, `assistant.indexedCollections` (`GET|PATCH
221
+ /api/settings`, `admin` only), lets an operator exclude a collection —
222
+ published articles included — from the index. The change is read live: it
223
+ applies on the next content save, with no restart, and the existing
224
+ "Reindex vectors" tool applies it to already-indexed content.
225
+ - A document upload flow — `GET/POST /api/assistant/documents` and `DELETE
226
+ /api/assistant/documents/:id` — lets an admin add reference material (PDF,
227
+ DOCX, Markdown, plain text) to the same index the site's own content feeds,
228
+ reusing the existing `document.extract_text` → `chunkDocument` →
229
+ `EmbeddingProvider.embed` pipeline rather than a second one. Each document
230
+ tracks its own `pending`/`indexed`/`error` state.
231
+ - `@cogenta/agents` gains `createReferenceDocumentStore`,
232
+ `ingestReferenceDocument`/`removeReferenceDocumentVectors`, and the
233
+ `REFERENCE_DOCUMENT_COLLECTION`/`REFERENCE_DOCUMENT_LOCALE`/`REFERENCE_DOCUMENT_STATUS`
234
+ constants a caller needs to retrieve them (e.g. via `assist.chat`'s
235
+ `collections` input).
236
+ - `@cogenta/core` gains one error code, `ASSIST_DOCUMENT_NOT_FOUND` (404).
237
+
238
+ All of this is additive and degrades the same way the rest of L18 does: a
239
+ site with no embeddings provider gets none of it, and every other feature
240
+ works unchanged (R2).
241
+ - 0692713: Fiche 30 — agents and assistant admin:
242
+
243
+ - `@cogenta/core`: adds a resolved `assistant.monthlyTokenLimit` config section (default one million tokens a month) and a new `ASSIST_BUDGET_EXCEEDED` error code.
244
+ - `@cogenta/agents`: adds `createAssistUsageTracker`, a per-tool, calendar-bucketed token/call counter for the writing assistant (distinct from the existing per-agent `BudgetTracker`), wired into `createAssistToolset` and `createAssistRuntime` (`AssistRuntimeOptions.onUsage`, `AssistRequest.tool`). `AssistToolset` gains optional `model` and `usage` fields.
245
+ - `@cogenta/api`: `GET /api/assistant` now reports `model`, `usage` (when a tracker is configured) and `vector` (driver/dimensions/count/lastIndexedAt, when a vector store exists). `POST /api/assistant/run` refuses with `ASSIST_BUDGET_EXCEEDED` (429) once the monthly cap is reached, before the provider is called. `createAssistantRouter` gains an optional `vectorInfo` option.
246
+ - `@cogenta/cli`: `AssistantAssembly` gains `vectorInfo` (vector index visibility) and wires a usage tracker into the assistant toolset from `config.assistant.monthlyTokenLimit`. `withVectorIndexing` gains an optional `onIndexed` callback. `recordContentAudit` now records an accepted assistant suggestion's `field`/`tool` (sent by the admin as `assistApplied` on a content save) distinctly in the audit diff, alongside contract A's existing `provenance`/`provenanceDetail`.
247
+
248
+ All additive — a site with no `assistant` config section gets the same default cap as before, and a site with no AI provider sees no `usage`/`model`/`vector` fields at all.
249
+ - 36744d3: Fiche 21: the audit log gains what the state-of-the-art comparison named as
250
+ missing — a real entry detail, filters that reach a date range, an export,
251
+ an actually-scheduled integrity check, and a way to tell a human's action
252
+ from an agent's.
253
+
254
+ **Task 1 — detail.** `GET /api/audit/{id}` (`@cogenta/api`'s `audit-router.ts`)
255
+ answers with the entry, its resolved actor kind and label (an email, or an
256
+ API key's name), and — for a `content.create`/`update`/`restore` action — the
257
+ same structural diff `GET /{collection}/{id}/diff` already computes, called
258
+ through rather than recomputed (the fiche's own warning against duplicating
259
+ it). This needed a place to keep which content version an action produced:
260
+ `RecordAuditInput`/`AuditEntry` gain `version`, stored in a new nullable
261
+ `cogenta_audit_log.version` column added with a `try`/`catch` `alter table`
262
+ (no portable `add column if not exists` across SQLite/Postgres/MySQL) — and
263
+ **deliberately excluded from the hash `computeHash` chains together**. Adding
264
+ a field to that canonical list would change what every already-recorded hash
265
+ means, and every site's existing chain would fail `verify()` the moment this
266
+ code ran. The fields that matter for accountability — who, when, what
267
+ action, on what — are untouched; `version` is UI-convenience metadata, not
268
+ inside the tamper-evidence boundary. A permission refusal on the diff's own
269
+ collection (an admin who was never granted an authoring role there) degrades
270
+ to `diffUnavailable`, not a 403 for the whole entry.
271
+
272
+ **Task 2 — dates, export, pagination.** `since`/`until`/`actorKind` filters
273
+ on `GET /api/audit`, and `GET /api/audit/export?format=csv|json` (bounded to
274
+ 10,000 entries) for the filtered view. The export is itself an audit-worthy
275
+ event — a personal-data extraction, per the fiche — recorded as
276
+ `audit.export` (format and count only, never the exported rows) at the same
277
+ transport-boundary layer `cogenta serve` already records every other
278
+ mutation at.
279
+
280
+ **Task 3 — scheduled integrity, for real.** `@cogenta/auth` gains
281
+ `AuditLog.verifyRange`/`get` (a bounded, checkpoint-resuming form of
282
+ `verify()`) and `createAuditIntegrityStore`, which persists the last
283
+ check's outcome across a restart. `cogenta serve` runs it once at startup
284
+ and then on its own `setInterval` (daily by default,
285
+ `ServeOptions.auditIntegrityTickMs` overridable for tests) — the same
286
+ accepted trade-off as the scheduled-publication tick. Most runs are
287
+ incremental (only entries after the last checkpoint); a full replay runs
288
+ weekly on its own as the backstop the fiche asks for, since an incremental
289
+ check cannot see tampering in already-checkpointed history. A break sends
290
+ one signed channel alert (`security.audit_integrity_broken`, only on the run
291
+ that first finds it — never once per tick) and a non-dismissible, danger-
292
+ severity admin notice that clears itself once a forced full check reports
293
+ the chain intact again. `GET`/`POST /api/audit/integrity` expose the status
294
+ and the "verify now" that persists its result, alongside the untouched,
295
+ stateless `GET /api/audit/verify`.
296
+
297
+ **Task 4 — distinguishing actors.** `classifyAuditActor` (`@cogenta/auth`)
298
+ reads signals the log already carried — `actorId === null` is `system`, the
299
+ `apikey:` prefix `resolveActor` has minted since L13 is `api_key`, the
300
+ `agent.tool.` prefix `withAudit` has minted since L4 is `agent`, everything
301
+ else is `human` — no schema change needed. `withAudit` (`@cogenta/agents`)
302
+ gains optional `model`/`autonomyLevel`, carried into the recorded diff when
303
+ a caller tracks them. `?actorKind=` filters `GET /api/audit`.
304
+
305
+ **Task 5 — retention, honestly.** No purge is wired into a schedule in this
306
+ pass — `AuditLog.prune(olderThan)` exists, tested, and safe (it refuses to
307
+ purge a segment that does not itself verify first, and records a genesis
308
+ anchor so the surviving chain keeps verifying from a documented truncation
309
+ point rather than silently going quiet about it), but nothing calls it
310
+ automatically yet. The admin screen says so plainly: this journal keeps
311
+ every entry and grows without limit until an operator acts.
312
+
313
+ None of this is a breaking change: `AuditLog.verify()`'s signature and every
314
+ existing route's response shape are unchanged, and the new column/tables
315
+ are additive (a fresh `ensureAuthTables` run tolerates them being already
316
+ there, an existing install picks them up the same way).
317
+ - 722fc6b: The site's logo, dark logo, favicon and share image now reach the rendered page
318
+ (contract D `theme@1.3`, additive).
319
+
320
+ All four were writable from the admin's Appearance screen, saved, and read back —
321
+ and read by nothing else at all. A site that uploaded its logo still served Cogenta's
322
+ default favicon and its own name as plain text on every page.
323
+
324
+ - `@cogenta/theme-kit` gains `ChromeBrand`, the optional `ChromeInput.brand`, and
325
+ `renderBrandMark()` — one `<picture>` with a `prefers-color-scheme` source, the
326
+ site name always written as `alt`. A theme that ignores `brand` renders exactly as
327
+ before; nothing about `theme@1.2` changed.
328
+ - The five built-in themes each place the mark in their own chrome (a header bar, a
329
+ masthead nameplate, a storefront bar), never a shared template, and each keeps the
330
+ site name in text somewhere on the page so a failed logo never leaves it unnamed.
331
+ - `cogenta serve` resolves the four media ids live per request, through the same
332
+ `/_image` endpoint and the same batch media loader every other image uses. A media
333
+ that is missing, or is not an image, falls back rather than emitting a broken tag.
334
+
335
+ Two decisions worth knowing:
336
+
337
+ - `shareImageMediaId` is now a **source for** `seo.defaultSocialImageUrl`, not a rival
338
+ to it: the SEO pipeline still reads one field, and the appearance screen's picker
339
+ wins when it is set. Neither of the two competing settings is left silently dead.
340
+ - The favicon fallback is branding-aware. Cogenta's default icon *is* Cogenta's logo,
341
+ so a white-labelled site falls back to its own replacement logo, and to no
342
+ `<link rel="icon">` at all when it has none — rather than getting somebody else's
343
+ mark back in the browser tab.
344
+ - af57fa2: L22 task 2: the inbound side of `@cogenta/channels` (L6) is wired for real. Until now, `@cogenta/channels`' identity-linking protocol, command router, and Telegram/Slack/Discord adapters were built and tested but never connected to anything — only outbound notice delivery was live.
345
+
346
+ `@cogenta/channels` gains a plug-in point `createCommandRouter`'s own header comment named as explicitly out of scope for L6: `CommandRouterOptions.chat`. A message that matches no *registered* command name now falls through to this optional handler instead of `{kind: 'unrecognized'}` — same `authorizeInboundCommand` gate as a named command, evaluated against `chat.requiredRoles`. `createAgentChatBridge` builds the handler itself: it resolves an optional `"@Agent Name: message"` mention (falling back to a configured default agent, with a warning, on an unknown name), calls a structural `AgentRunnerLike.run(name, instruction, trigger?)` — deliberately the same three-argument shape `@cogenta/api`'s `agents-router.ts` already declares, not `@cogenta/agents`' raw options-object `AgentRunner['run']` — and replies with the result, truncated and flattened to fit the existing `NotificationChannelMessage` budget (`REPORT_SCREEN_BUDGET_CHARS`) rather than stretching an ill-fitting type. The one rule this whole module exists to keep: a linked channel identity is authorized against the *Cogenta account's own* roles before the runner is ever called, and defaults to requiring `admin` — the same role `POST /api/agents/:name/run` itself requires, so a channel can never grant more access than the linked account's own standing already would over HTTP (R4). Zero changes to any provider adapter (`telegram`/`slack`/`discord` `inbound.ts`) were needed — all three already call `router.route()` uniformly for every linked-identity message.
347
+
348
+ `@cogenta/cli` gains a new, separate command: `cogenta channels`. Telegram long-polling is safe per replica only with exactly one dedicated process; Slack Socket Mode and Discord Gateway are each inherently a single persistent connection. None of the three ever start inside `cogenta serve` — this command is a standalone, optional, single-instance process (built the same way `cogenta mcp` is: a second independent entry point onto the same database and the same `.cogenta/agents-runtime` agent declarations, never a second copy of either) whose only job is connecting configured channels and routing authorized chat messages to a real agent run. Bot credentials are read from the environment only (`COGENTA_CHANNELS_TELEGRAM_BOT_TOKEN`, `COGENTA_CHANNELS_SLACK_BOT_TOKEN`/`COGENTA_CHANNELS_SLACK_APP_TOKEN`, `COGENTA_CHANNELS_DISCORD_BOT_TOKEN`) and never written to `cogenta.config.mjs` (R7); a provider with no token configured is simply not started (R1). `buildAgentRuntime`'s options gain an injectable `approvalQueue` (defaulting to a fresh in-memory one, unchanged behaviour for every existing caller), and `AgentRuntimeAssembly` now exposes the live instance it actually uses — every side-effecting core tool (`content.write_draft` included) is `reversible: false`, so `with-autonomy.ts`'s `forcedApproval` always routes it through this queue regardless of autonomy level; exposing it is what let this lot's own end-to-end test (`packages/cli/test/channels-chat.test.ts`) prove a chat message drives the *correct* contract-C tool through to a real approved, created entry, rather than only proving a tool was proposed.
349
+
350
+ The admin (`@cogenta/admin`, private, no changeset) gains a "Canaux" screen in the IA section (any signed-in role, since linking is personal and used for notices too) — reusing the exact `/api/notices/channels/*` linking endpoints fiche 38 already exposed, no new linking mechanism — and extends the existing "MCP" screen with a "Chat API" key purpose: generates an `admin`-scoped API key (the same mechanism `/api/api-keys` already provides) and documents the `POST /api/agents/:name/run` request/response format, the same single-call-per-turn shape the admin's own new floating chat widget (bottom-right, on every authenticated screen) uses via the existing `runAgent` client function — no second streaming protocol.
351
+
352
+ `@cogenta/core` gains one `ErrorCode`: `CHANNEL_PROVIDER_NOT_CONFIGURED`, thrown by `cogenta channels` when a provider's required environment variables are absent (caught internally and logged as "skipped", never surfaced as a failure — R1).
353
+
354
+ Left honestly open: the approval queue `cogenta channels` (and any future admin approvals screen) would decide a pending write against has no REST surface yet — a real, pre-existing gap this lot's own test works around directly rather than papering over, not something to fix here.
355
+ - e8296a5: L25 task A0b — `selectMediaImageProcessor`, `createMediaImageProcessor`,
356
+ `variantName`, `variantWidthsFor`, `contentTypeOf`, `VARIANT_FORMAT` and
357
+ `MediaImageProcessorOptions` are now exported from `@cogenta/cli`'s public
358
+ entrypoint (previously only reachable by importing `./commands/media-images.js`
359
+ directly). `create-cogenta`'s `scaffoldSite` uses `selectMediaImageProcessor` to
360
+ give its own real media ingestion (`seedDemoMedia`) the same image-processing
361
+ pipeline `cogenta serve` uses for a live upload.
362
+ - 4335296: `ThemeRenderOptions` gains an optional `blocks` field (a `BlockRegistry`), threaded
363
+ through to `theme.renderPage`/`ThemeModule.renderPage`'s new optional fourth parameter
364
+ (fiche 43, sous-chantier C(ii)). Lets a site with blocks of its own pass its registry so
365
+ an active theme that does not implement one of them renders its declared `fallback`
366
+ instead of a blank slot. Absent by default — no site declares custom blocks today, so
367
+ this is forward wiring with no behaviour change for an existing `cogenta serve`.
368
+ - 322d1a3: Fiche 52 — Cogenta Commerce: orders and customers, the trailing gap this
369
+ audit's own opening line named ("le modèle de commande n'a aucune adresse
370
+ postale structurée"). **Contains a breaking change**, called out below —
371
+ contract E is deliberately not yet frozen (ADR-0024), so this ships as
372
+ `minor` rather than `major` per this project's existing pre-alpha practice
373
+ (see the taxonomies/trash changesets), with the break stated plainly here.
374
+
375
+ **Breaking**: `POST /api/commerce/payments/{id}/refund` now requires a
376
+ non-empty `reason` in the request body ("motif obligatoire" — task 6) and
377
+ answers `{ refund, creditNote }` instead of the bare refund record. A caller
378
+ sending no reason now gets `400 COMMERCE_AMOUNT_INVALID` instead of a
379
+ refund with no stated cause.
380
+
381
+ `@cogenta/commerce`:
382
+ - `Order` gains six structured shipping-address fields
383
+ (`shippingAddressLine1/2`, `shippingCity`, `shippingPostalCode`,
384
+ `shippingRecipient`, `shippingPhone`) and four shipment-tracking fields
385
+ (`trackingCarrier`, `trackingNumber`, `trackingUrl`, `shippedAt`) — all
386
+ nullable, added in place to `cogenta_commerce_orders` on an
387
+ already-deployed site (idempotent `alter table`, same idiom as
388
+ `menu-tables.ts`'s `location` column; no down path exists or is needed for
389
+ an additive nullable column).
390
+ - `OrderStore` gains `placeManual` (a shopkeeper-entered order — phone,
391
+ trade-show, correction — that opens a real cart and calls `place()`
392
+ internally, never a second placement path), `update` (corrects the e-mail
393
+ and/or address while `pending`; refuses with `COMMERCE_ORDER_LOCKED` once
394
+ paid) and `setTracking` (attaches carrier/number/url; moving a `paid`
395
+ order to `shipped` is a side effect of attaching tracking, not a separate
396
+ step). `OrderListOptions` gains `placedFrom`/`placedTo`.
397
+ - New module `order/notify.ts`: `createOrderEmailQueue`, a persisted,
398
+ retried (`MAX_ATTEMPTS = 5`) transactional e-mail queue built on
399
+ `@cogenta/channels`'s existing `createEmailAdapter` — never a second
400
+ transport. A new direct dependency on `@cogenta/channels` follows (R9:
401
+ reuse over reinvention, same package this project already depends on
402
+ elsewhere).
403
+ - New module `order/csv.ts`: `ordersToCsv`, an RFC 4180 accounting export,
404
+ zero dependency (R9) — one row per order (reference, date, status, email,
405
+ the four summed figures, invoice number when one exists). Decision this
406
+ fiche had to make and is documenting here: row-per-order rather than
407
+ row-per-line, matching the fiche's own singular "export comptable" wording.
408
+ - `CustomerStore` gains `anonymize` (GDPR erasure of the customer record —
409
+ email/name only; an order's own historical copy of the email is
410
+ deliberately retained as a financial record).
411
+ - New module `invoice/credit-note.ts`: `createCreditNoteStore`, one credit
412
+ note per refund (its own `CN-2026` series, sharing the same
413
+ compare-and-set sequence table as invoices via the newly extracted
414
+ `invoice/sequence.ts`) — issued automatically by the refund route once
415
+ billing is configured, never a second manual step.
416
+ - `CommerceAdminRouter` gains routes: `POST/GET/PATCH /orders`,
417
+ `PUT /orders/{id}/tracking`, `GET /orders/{id}/emails`,
418
+ `GET /orders/{id}/credit-notes`, `GET /orders/export.csv`,
419
+ `GET /payments/{id}/refunds`, `GET/POST /customers/{id}`,
420
+ `POST /customers/{id}/export`, `POST /customers/{id}/anonymize`.
421
+ `CommerceResponse.body` can now also be a plain `string` (the CSV export),
422
+ alongside the existing JSON/`Uint8Array` shapes.
423
+ - `@cogenta/core` gains four error codes: `COMMERCE_CUSTOMER_NOT_FOUND`,
424
+ `COMMERCE_ORDER_LOCKED`, `COMMERCE_TRACKING_INVALID`,
425
+ `COMMERCE_CREDIT_NOTE_NOT_FOUND`.
426
+
427
+ `@cogenta/cli` wires all of the above into `cogenta serve`: the order-email
428
+ queue (built whenever an e-mail transport is configured — always, in
429
+ practice, since `runServe` builds the degraded `FileEmailTransport`
430
+ unconditionally) and the credit-note store (built whenever `billing` is
431
+ configured, the same gate invoicing already uses) are passed to
432
+ `createCommerceAdminRouter`; a new scheduled task, `commerce-order-emails`
433
+ (`COMMERCE_EMAIL_TICK_MS = 60_000`, overridable via `commerceEmailTickMs`
434
+ for tests), flushes the retry queue — and is correctly folded into the
435
+ scheduler's own heartbeat interval calculation, a real bug this fiche found
436
+ and fixed (the heartbeat previously only ran as often as the *slowest* of
437
+ the other seven tasks needed, so a fast test override on this one alone
438
+ would never actually fire). The transport layer gains a `text/csv` branch
439
+ alongside the existing JSON/PDF ones.
440
+ - c392e24: Redirects: 404 log, prefix patterns, editing, CSV import/export, automatic
441
+ redirect on slug rename, and 307/308/410 status codes (fiche 12).
442
+
443
+ **`@cogenta/core`**: gains a `notFoundLog` config section (`enabled`,
444
+ `maxPaths`, `retainDays`) — on by default, bounded, purged past its
445
+ retention. Never stores an IP address or a user agent.
446
+
447
+ **`@cogenta/schema`**:
448
+ - `RedirectStatus` widens from `301 | 302` to `301 | 302 | 307 | 308 | 410`.
449
+ A 410 (Gone) row needs no `to`. Consumers that exhaustively switch on
450
+ `RedirectStatus` — a rare pattern, but a real one — need a case for the
451
+ three new values.
452
+ - `RedirectStore` gains `update(from, { to?, status? })` — implementors of
453
+ the interface (not typical callers) must add it. `RedirectStore.add`'s
454
+ `to` is now optional, required only when `status` is not 410.
455
+ - New: `createNotFoundLogStore`/`NotFoundLogStore` (the 404 log — aggregated
456
+ by path, capped at `maxPaths` distinct paths, no personal data ever) and
457
+ `createRedirectPatternStore`/`RedirectPatternStore` (prefix redirects —
458
+ `/blog/*` to `/actualites/*` — matched by `startsWith`, never a regular
459
+ expression, so the public routing path can never be exposed to
460
+ catastrophic backtracking).
461
+ - New: `withRedirectTracking` — wraps a `ContentStore` so renaming the slug
462
+ of a **published** entry writes a 301 from the old path to the new one on
463
+ its own, reversibly (renaming back makes the redirect disappear), and a
464
+ chain of renames stays flattened to one hop.
465
+
466
+ **`@cogenta/api`**: `redirect-router.ts` gains `PATCH /api/redirects` (edit
467
+ in place), `?q=`/`?limit=`/`?offset=` on the list, `/api/redirects/patterns`
468
+ (prefix redirects), and `/api/redirects/export` / `/api/redirects/import`
469
+ (CSV, always previewed before anything is written — pass `apply: true` to
470
+ commit). New `createNotFoundRouter` (`GET`/`DELETE /api/not-found`). New
471
+ `parseCsv`/`stringifyCsv` — hand-written, zero dependency (R9).
472
+
473
+ **`@cogenta/cli`**: `cogenta serve` mounts `/api/not-found` and the new
474
+ `/api/redirects/*` routes, applies prefix-redirect resolution after the
475
+ exact-match table finds nothing, answers a 410 with no `Location` header,
476
+ records every public GET that matches no route into the 404 log (never for
477
+ `/api/*`), and purges the log past its retention on a daily tick (new
478
+ `ServeOptions.notFoundPurgeTickMs` overrides it, for tests). Renaming the
479
+ slug of a published entry now writes its redirect automatically, wired
480
+ through `withRedirectTracking`.
481
+ - 967ec5a: Add editorial SEO controls: the conventional `seoTitle`/`seoDescription`/`seoImage`/`seoNoindex`/`seoCanonical` override fields, a title-template option, and an admin-only door onto what `@cogenta/seo` actually computes (fiche 13).
482
+
483
+ - `@cogenta/seo`'s `buildMetaTags` now reads the conventional `seoTitle`, `seoDescription`,
484
+ `seoImage` and `seoCanonical` fields when a collection declares them — an ordinary field a
485
+ site's own schema adds, never a contract A change. A collection that declares none of
486
+ them behaves exactly as it did before this change. `MetadataOptions` gains
487
+ `titleTemplate`/`collectionTitleTemplates` (`%title% — %site%`-style composition, applied
488
+ only to a *derived* title, never to an explicit `seoTitle` override). `isIndexable` now
489
+ also excludes an entry whose collection declares `seoNoindex` and has it switched on, via
490
+ the new exported `isSeoNoindexed` — this is also what keeps a noindexed page out of
491
+ `/sitemap.xml` while it still carries `noindex` in its own `<head>`.
492
+ - `@cogenta/api` gains `createSeoRouter` (`SeoRouter`, `SeoRouterOptions`, `SeoDiagnostics`):
493
+ `POST /api/seo/preview` computes the real head for one unsaved edit (gated by `update` on
494
+ the named collection), and `GET /api/seo/diagnostics` is a site-wide, admin-only report —
495
+ sitemap size and inclusion reasons per collection, `robots.txt`, and content-quality
496
+ anomalies (missing descriptions, titles over 60 characters, duplicate titles, and the
497
+ "published but the sitemap would be empty" class of bug this fiche is named for). Both
498
+ routes call the exact same `buildMetaTags`/`isIndexable`/`isPublished` the public render
499
+ path calls — neither one re-derives anything. `@cogenta/api` gains a new dependency on
500
+ `@cogenta/seo`.
501
+ - `@cogenta/cli` mounts `/api/seo` in `cogenta serve`, next to `/api/redirects` and
502
+ `/api/search`.
503
+
504
+ All additions are additive and backward compatible: a collection that declares none of the
505
+ conventional SEO fields, and a caller that never sends `titleTemplate`, see no behaviour
506
+ change.
507
+ - 562c9c1: Add the "Apparence" admin screen (fiche 14) — the CMS's most-differentiating
508
+ feature, AI skin generation, was previously exposed only through the CLI.
509
+
510
+ - `@cogenta/render` gains `mergeSkinTokens` (`SkinTokenOverrides`): overlays a
511
+ partial token tree onto a complete base skin, group by group, key by key.
512
+ - `@cogenta/schema` gains `createThemeStore`/`ensureThemeTable` — one row of
513
+ theme overrides (a partial token overlay, additional CSS, and four identity
514
+ media references), the database half of the two-source-of-truth design
515
+ task 0 settles on: `theme.tokens.json` stays the versioned file default,
516
+ the database holds what an `admin` changed from the admin screen.
517
+ - `@cogenta/plugins`'s `SkinGalleryEntry` now carries the accepted skin's real
518
+ `tokens` (`null` for a rejected entry) — needed to render a swatch or apply
519
+ a gallery skin, previously only metadata.
520
+ - `@cogenta/api` gains `createThemeRouter` (`GET/PUT/DELETE /api/theme[/overrides]`,
521
+ `GET /api/theme/skins`, `POST /api/theme/skins/:id/apply`,
522
+ `POST /api/theme/generate`, `POST /api/theme/export`), plus the
523
+ `SKIN_*`/`THEME_*` error-code → HTTP-status mappings it needs.
524
+ - `@cogenta/cli` wires it all into `cogenta serve`/`dev`: `resolveStyles()`
525
+ recomputes the served stylesheet on every request (file tokens merged with
526
+ saved overrides plus additional CSS), which is what makes a saved change
527
+ visible on the very next page view instead of only after a restart — the
528
+ "hot swap" contract D already promised for the file alone. A new
529
+ `POST /api/theme/preview` route renders the real home page with a candidate
530
+ overlay nobody has saved yet, the same iframe-on-the-real-render decision
531
+ L16 made for the page builder. Exporting the merged tokens back into
532
+ `theme.tokens.json` is gated to `cogenta dev` only, mirroring the
533
+ ADR-0010 rule L19's site-plan applier already uses for the schema file.
534
+
535
+ R2 verified: without an LLM provider, `GET /api/theme` reports
536
+ `aiAvailable: false` and the admin's AI section does not render at all — no
537
+ error, no dead link. R6 verified: an AI-generated candidate or a chosen
538
+ gallery skin is never applied automatically; a save is always a separate,
539
+ explicit action.
540
+ - edf5623: Fiche 15 — comments (ADR-0025, new contract F, `comments@1.0`):
541
+
542
+ - **New package `@cogenta/comments`**: the comment model and store
543
+ (`CommentStore`) — plain-text body only (R3: no HTML tags accepted, ever),
544
+ hashed IP (never stored in clear, RGPD), moderation status
545
+ (`pending`/`approved`/`spam`/`trash`), threading via `parentId`,
546
+ `provenance`. A reversible migration (`ensureCommentsTables`/
547
+ `dropCommentsTables`), tested up/down/up on SQLite; Postgres/MySQL/MariaDB
548
+ integration tests are written (`test/integration/tables.test.ts`) but not
549
+ executed this session (no local Docker). `createCommentsRouter` is the
550
+ CMS's first public write route (`POST /api/comments`, no actor required)
551
+ plus the admin moderation queue, both behind contract F's own permission
552
+ vocabulary (`comments.read`/`moderate`/`reply`/`purge`/`settings`, distinct
553
+ from contract A's five frozen actions). The public route enforces, from
554
+ day one: rate limiting by IP and by target (`createCommentRateLimiter`),
555
+ a honeypot field, a minimum fill-delay, non-AI spam heuristics
556
+ (`checkSpamHeuristics`), and the WordPress "auto-approve a returning
557
+ commenter" rule. A no-JS `<form method=post>` gets a `303` redirect back to
558
+ its own page (`redirectTo`, validated against open-redirect and HTTP
559
+ response-splitting) instead of a raw JSON body.
560
+ - **`@cogenta/core`**: ten new error codes (`COMMENT_NOT_FOUND`,
561
+ `COMMENT_BODY_INVALID`, `COMMENT_AUTHOR_INVALID`, `COMMENT_TARGET_INVALID`,
562
+ `COMMENT_TARGET_CLOSED`, `COMMENT_PARENT_INVALID`,
563
+ `COMMENT_PARENT_TOO_DEEP`, `COMMENT_STATUS_INVALID`,
564
+ `COMMENT_RATE_LIMITED`, `COMMENT_SPAM_DETECTED`).
565
+ - **`@cogenta/schema`**: `SITE_SETTINGS_REGISTRY` gains the `discussion`
566
+ group (`discussion.enabled`/`moderationRequired`/`allowAnonymous`/
567
+ `autoCloseDays`/`maxNestingDepth`/`notifyEmail`) — the site-wide defaults
568
+ a collection or an entry can still override from `@cogenta/comments`'s own
569
+ settings store (per-collection/per-entry overrides deliberately do not
570
+ live in this registry, which is site/locale scoped only).
571
+ - **`@cogenta/api`**: `shell-status-router.ts` gains `commentsPending` (a
572
+ structural `CommentsQueueLike`, the same pattern `commerceOrdersPending`
573
+ already uses) — additive, existing callers that never pass `comments` see
574
+ `null` exactly as before.
575
+ - **`@cogenta/theme-canonical`**: `renderCommentsSection` — the comment
576
+ thread and its plain-HTML submission form, built through the existing
577
+ `h()`/`text()` tree (no `raw()` escape hatch exists in this package, which
578
+ is what makes "no visitor HTML ever reaches the page" structural rather
579
+ than a habit to remember). Rendered by `renderEntryPage`
580
+ (`@cogenta/cli`'s `theme-render.ts`) after the page's own `<main>`, on both
581
+ the published page and the L16 page-builder preview's own draft render —
582
+ except the preview, which never shows it (its `_ts` anti-spam field cannot
583
+ be identical across two separate renders, so byte-identity there would be
584
+ comparing two different legitimate values; `serve-builder.test.ts`'s
585
+ fidelity test now documents this as a deliberate, checked difference).
586
+ Contract B is untouched — no `comments` block, same reasoning L10 gave for
587
+ `/search`.
588
+ - **`@cogenta/import`**: `importWordPress` gains an optional `comments`
589
+ option (a `CommentStore`) — when given, every importable WordPress comment
590
+ is written with its real status (`wp:comment_approved` mapped to
591
+ pending/approved/spam/trash, not just `'1'`), real threading
592
+ (`wp:comment_parent`), on **both** posts and pages. Pages never imported a
593
+ single comment before this — a real, independent bug, not something this
594
+ fiche introduced, found while checking what the importer does today per
595
+ the fiche's own instruction. Inline HTML a legacy WordPress comment form
596
+ allowed (`<a>`, `<em>`, …) is stripped to plain text and reported (R3: no
597
+ escape hatch). Absent `comments` keeps the pre-fiche-15 behaviour
598
+ unchanged (approved-only, posts-only, the synthetic `comment` collection)
599
+ for a caller that has not wired `@cogenta/comments` yet — its `post` field
600
+ is a hard `relation` to the `post` collection specifically, so extending
601
+ it to pages was never an option, only the real store is.
602
+ - **`@cogenta/cli`**: `cogenta serve` mounts `/api/comments` (public POST +
603
+ moderation queue), extends `readBody` to also parse
604
+ `application/x-www-form-urlencoded` (the no-JS form's own content type —
605
+ every other route still only ever sends JSON), wires the comment thread
606
+ into `theme-render.ts`'s page render, and passes a real `CommentStore`
607
+ into every `importWordPress` call site (the terminal command and the
608
+ admin's import screen alike). `cogenta doctor`/`serve` create contract F's
609
+ tables idempotently, the same way commerce's tables are created — a site
610
+ that never receives a comment never pays for them.
611
+
612
+ Admin (`@cogenta/admin`, private, no changeset): a moderation queue screen
613
+ (`/comments`, counters, bulk actions, search, reply-from-the-admin), a
614
+ pending-count nav badge, `assist.moderate` reused verbatim as an indicator
615
+ (never an action — its own closed `none`/`review` union already guarantees
616
+ that, per the fiche's own instruction not to build a second decision path),
617
+ a "Discussion" settings tab (previously a placeholder), and a per-entry
618
+ comments toggle in the entry editor sidebar.
619
+ - db307e0: Add form definitions and submissions — contract G (`forms@1.0`, ADR-0026, fiche 16). A site can now build a form in the admin and receive real submissions, without JavaScript and without an AI provider.
620
+
621
+ - New package **`@cogenta/forms`**: `FormDefinition`/`FormSubmission` model (nine field kinds — text, longText, email, phone, number, date, choiceSingle, choiceMulti, consent; no `file` field in this first version, a deliberate scope cut), `createFormStore` (definitions CRUD, `submit`/`list`/`markStatus`/`bulkMarkStatus`/`searchByEmail`/`deleteByEmail`/`purgeExpired`), full server-side `validateSubmission` (independent of any client-side check, for every field kind), anti-abuse primitives (`checkHoneypot`, `checkFillDelay`, `checkSubmitRateLimit`), and `notifyNewSubmission`/`sendAutoresponder` — both built on `@cogenta/channels`'s existing email adapter, never a second transport. `ensureFormsTables` follows the same `create table if not exists` shape as `@cogenta/commerce`'s tables — a site that never builds a form still creates them, since (unlike commerce) forms tables are cheap enough not to gate.
622
+ - `@cogenta/core` gains eleven `FORM_*` error codes.
623
+ - `@cogenta/api` gains `createFormsRouter` (`/api/forms/*`): admin-only CRUD on definitions and submissions (bulk mark/delete, unread count, CSV-ready listing, GDPR search/erase by e-mail), plus the CMS's **second public write route**, `POST /api/forms/{name}/submit` — no actor check, its own defences (honeypot, minimum fill delay, per-IP rate limit, full server-side validation) stand in for one. The client's IP is read from the resolved request context, never from a client-supplied `X-Forwarded-For` header — trusting that header would let an attacker rotate it per request and step around the rate limiter entirely. `ShellStatus` gains `formSubmissionsUnread` for the admin's nav badge (additive).
624
+ - `@cogenta/cli` wires it all into `cogenta serve`: `GET /forms/{name}` is the public, no-JavaScript "route dédiée" ADR-0026 chose over a contract B block (a bloc `form` RFC is left open in parallel); a plain HTML form post is answered with a real redirect on success or an accessible re-display of the visitor's own values and per-field error (`aria-invalid`/`aria-describedby`) on failure; notifications reuse the same `FileEmailTransport` already built for account invitations; submissions past a form's own `retainDays` are purged automatically on a daily tick, the same `retainDays`/`purgeExpired` model ADR-0022 established for the trash.
625
+ - Admin (`@cogenta/admin`, private, no changeset): `routes/forms.tsx` (the builder, reusing fiche 03's `RepeaterField` for the field list rather than a second repeater) and `routes/form-submissions.tsx` (list/filter/detail/bulk actions/CSV export via `lib/csv.ts`/GDPR search & erase by e-mail), with an unread-count nav badge.
626
+ - 49815b9: Account lifecycle: invitation by email, search/pagination/bulk actions, a
627
+ self-service public profile, dormant/MFA-recommended signals, and
628
+ irreversible anonymization (fiche 17).
629
+
630
+ **Breaking (`@cogenta/auth`), in the same pre-1.0 sense the taxonomies/trash
631
+ and redirects changesets already used this bump for**: `User['status']`
632
+ widens from `'active' | 'disabled'` to also include `'invited'` and
633
+ `'anonymized'` — an exhaustive `switch` on the old two-value union needs a
634
+ new case. `User` also gains four new non-optional fields (`displayName`,
635
+ `avatarMediaId`, `bio`, `locale`, all `string | null`) — code that builds a
636
+ `User` object literal by hand (rather than reading one back from
637
+ `UserStore`) needs to add them. `CreateUserInput` gains an optional `status`
638
+ (defaults to `active`, so existing callers are unaffected).
639
+
640
+ **`@cogenta/auth`**:
641
+ - `UserStore` gains `updateProfile` (self-service, fiche 17 task 3),
642
+ `delete` (real hard delete — safe only for a never-accepted `invited`
643
+ account, see its doc comment for why that does not contradict "accounts
644
+ are disabled, never removed"), and `anonymize` (RGPD-erasure: replaces the
645
+ email with a non-reversible `@anonymized.invalid` token, clears the
646
+ profile fields, sets `status: 'anonymized'`).
647
+ - `SessionStore` gains `lastSeenByUser()` — the last activity timestamp for
648
+ every account in one query, across every session ever held (revoked and
649
+ expired included), for the "last sign-in" column and the dormant-account
650
+ signal.
651
+ - `PasswordResetStore` gains `pending(userId)` — the still-usable token for
652
+ a user, if any, without ever returning the token itself. Used by fiche
653
+ 17's invitation to answer "invitation sent on …" and to support resend.
654
+ - New table columns on `cogenta_users` (`display_name`, `avatar_media_id`,
655
+ `bio`, `locale`), added the same additive, catch-and-ignore way the API
656
+ key lifecycle columns were.
657
+ - New error codes: `AUTH_INVITE_UNAVAILABLE` (503), `AUTH_INVITE_INVALID_STATE`
658
+ (409), `AUTH_ACCOUNT_ANONYMIZED` (409), `AUTH_ANONYMIZE_CONFIRMATION_MISMATCH`
659
+ (400).
660
+
661
+ **`@cogenta/api`**: `users-router.ts` grows substantially, entirely additive
662
+ at the route level —
663
+ - `POST /api/users` accepts `invite: true`. With `onInvite` wired, it
664
+ creates an `invited` account and hands the invitation token to the
665
+ callback instead of returning a password — the same single-use token
666
+ primitive `/forgot-password` already uses, reused rather than
667
+ reimplemented. Without `onInvite` wired (or the flag omitted), the route
668
+ behaves exactly as it always has: a generated password, shown once (R1's
669
+ mandatory fallback). The response gains `invited`/`emailSent` alongside
670
+ the (now optional) `password`.
671
+ - `GET /api/users` gains `?sort=`, `?after=`, `?limit=`, and a substring
672
+ match on display name as well as email for `?q=`. The response gains
673
+ `page: { hasMore, nextCursor }` and `meta: { invitationEmailAvailable }`
674
+ — `data` is unchanged.
675
+ - `POST /api/users/{id}/invite` (resend) and `DELETE .../invite` (cancel —
676
+ a real delete, safe for the reason above) are new.
677
+ - `POST /api/users/bulk` (`disable`/`enable`/`setRoles` over several ids at
678
+ once, `Promise.allSettled`, a report naming every failure) is new.
679
+ - `PATCH /api/users/me/profile` (self-only, mirrors the existing
680
+ self-only `/me/password`) is new.
681
+ - `POST /api/users/{id}/anonymize` (admin-only, confirmed by typing the
682
+ account's current email, refuses the last active admin the same way
683
+ disabling one already did, writes one `user.anonymize` audit entry that
684
+ never carries the erased address) is new.
685
+ - `auth-router.ts`'s `POST /api/auth/reset-password` gains one line: an
686
+ `invited` account is flipped to `active` the moment its token is
687
+ redeemed — the only place in the product that changes that bit, and the
688
+ reason the invitation never needed a second token type.
689
+ - `statusFor()` gains the four new codes above.
690
+
691
+ **`@cogenta/cli`**: `cogenta serve` wires the users router's `collections`
692
+ (for the MFA-recommended signal) and a new `onInvite` callback, delivered
693
+ through a new `invite-mail.ts` (the file-transport email, sibling to the
694
+ existing `reset-mail.ts`) pointed at the same `/admin/reset-password` screen
695
+ `onForgotPassword` already uses — accepting an invitation and resetting a
696
+ forgotten password redeem the identical token type.
697
+
698
+ Tests: `@cogenta/auth` 189 (19 new), `@cogenta/api` 582 (78 new across
699
+ `users-router.test.ts` and `auth-router.test.ts`), `@cogenta/cli` 236 (11
700
+ new in `test/serve-users.test.ts`, end to end over real HTTP against a real
701
+ mail directory — invite, read the mail, redeem, sign in; single-use and
702
+ expiry; resend/cancel; bulk actions; self-service profile; anonymization
703
+ with audit-log coherence). `@cogenta/admin` (private, no changeset) gains
704
+ 26 new UI tests across `test/users/users.test.tsx` and
705
+ `test/users/profile.test.tsx`.
706
+ - 122da7a: Fiche 18 (profile and authentication): TOTP recovery codes, readable sessions
707
+ with bulk sign-out, an account's own activity feed, and a fetchable password
708
+ policy.
709
+
710
+ **`@cogenta/core`** gains two error codes: `AUTH_RECOVERY_CODE_INVALID` and
711
+ `AUTH_RECOVERY_CODES_UNAVAILABLE`.
712
+
713
+ **`@cogenta/auth`** (the priority of this fiche): confirming TOTP enrolment
714
+ now mints ten single-use recovery codes in the same step and hands them back
715
+ — `confirmTotpEnrolment` returns `Promise<RecoveryCodesIssued>` instead of
716
+ `Promise<void>`. New `AuthService` methods: `recoveryCodeLogin`,
717
+ `regenerateRecoveryCodes`, `recoveryCodesStatus`. `passwordLogin`, `totpLogin`
718
+ and `completeWebAuthnLogin` accept an optional `LoginContext` (`userAgent`,
719
+ `ttlMs`) for "remember me" and readable sessions. `SessionStore` gains
720
+ `revokeAllExcept` ("sign out everywhere else") and every session now reports
721
+ a `browser`/`device` pair distilled from the `User-Agent` at creation —
722
+ never the raw header, never an IP address. `CredentialStore` gains
723
+ `setRecoveryCodes`/`recoveryCodesStatus`/`consumeRecoveryCode`/`removeRecoveryCodes`.
724
+ New exports: `generateRecoveryCodes`, `hashRecoveryCode`, `verifyRecoveryCode`,
725
+ `normaliseRecoveryCode`, `RECOVERY_CODE_COUNT`, `parseUserAgent`,
726
+ `ParsedUserAgent`, `LoginContext`, `RecoveryCodesIssued`. Consumption is a
727
+ real compare-and-set on the stored batch (the same idiom `resets.ts` already
728
+ used for password-reset tokens), with a bounded retry against the fresher row
729
+ on a lost race — proven under genuine two-connection SQLite concurrency, code
730
+ by code, in `packages/auth/test/recovery-code-concurrency.test.ts`, alongside
731
+ a naive-control test showing the read-then-write shape it replaces really
732
+ would let one code work twice.
733
+
734
+ **Breaking, honestly**: `confirmTotpEnrolment`'s return type change and the
735
+ new required members on `SessionStore`/`CredentialStore` are real breaks for
736
+ anyone who type-pinned the old signatures or hand-rolled an implementation of
737
+ either store interface — real callers of `createAuthStore`/`createAuthService`
738
+ (the only supported way to get one) are unaffected. Marked `minor` rather than
739
+ `major` per this project's existing 0.x convention (no package has used
740
+ `major` yet, and one now would jump straight to `1.0.0`, which contradicts
741
+ "pre-alpha") — human judgement invited to confirm.
742
+
743
+ **`@cogenta/api`**: new routes `POST /api/auth/recovery-code`,
744
+ `GET /api/auth/password-policy`, `GET /api/auth/totp/recovery-codes`,
745
+ `POST /api/auth/totp/recovery-codes/regenerate`, `POST
746
+ /api/users/me/sessions/revoke-others`, and `GET /api/audit/me` (the one audit
747
+ route open to a non-admin — force-scoped server-side to the caller, never a
748
+ client-supplied id). `POST /api/auth/totp/enrol/confirm`'s response gains
749
+ `recoveryCodes`; `GET /api/users/{id}/sessions` entries gain `browser`,
750
+ `device` and `isCurrent`. New export: `createRecoveryCodeUsedNoticeSource`
751
+ (the security notice a recovery-code sign-in triggers).
752
+
753
+ **`@cogenta/cli`**: `cogenta serve` wires all of the above — the new notice
754
+ source is registered, and a recovery-code sign-in is recorded in the audit
755
+ log as `auth.recovery_code_used` instead of the generic `auth.login`.
756
+ - 2fb2101: Add the editorial site settings screen (fiche 23, ADR-0025's third settings
757
+ category between `cogenta.config.mjs` — infrastructure, read-only — and
758
+ `localStorage` — personal preference).
759
+
760
+ - `@cogenta/schema` gains a typed key/value site-settings store
761
+ (`createSiteSettingsStore`) backed by a closed registry: general (title,
762
+ tagline, admin email, time zone, date/time style), reading (home path,
763
+ posts per page), media (max upload size), and privacy (policy path, cookie
764
+ banner). Every setting has a declared scope (site or per-locale), a default,
765
+ and a required permission; writing an undeclared key is refused.
766
+ - `@cogenta/api` gains `createSitePlanRouter`'s sibling `GET|PATCH
767
+ /api/settings` and extends `GET /api/config-status` with `storage`,
768
+ `llm`/`embeddings`/`imageGeneration`/`vector`, and `billingConfigured` —
769
+ never a secret, never a credential.
770
+ - `@cogenta/cli` wires the new store into `cogenta serve`/`dev`, and
771
+ `theme-render.ts` now serves the configured home path instead of always
772
+ falling back to the hardcoded `/home`.
773
+ - `@cogenta/core` adds `SITE_SETTING_UNKNOWN`/`SITE_SETTING_INVALID` and a
774
+ `secret-hygiene` module the settings screen uses to detect a
775
+ `database.url` with embedded credentials, or a `.env` file readable by
776
+ other users on shared hosting.
777
+ - `create-cogenta` now writes the generated `.env` (which holds
778
+ `COGENTA_AUTH_SIGNING_KEY`) with mode `0o600` instead of the default —
779
+ closing the shared-hosting exposure `docs/hebergement-mutualise.md`
780
+ already named as a known gap.
781
+
782
+ The admin's old single-control "Paramètres" screen (the signed-in account's
783
+ own interface language) moves to "My profile"; `/settings` is now the
784
+ site-wide editorial screen.
785
+ - 0e90b32: Add the "Santé" and "Outils" admin screens (fiche 24), maintenance mode, and a bounded server error journal.
786
+
787
+ - `@cogenta/core`: adds `createErrorLog`, a bounded, redacted ring buffer for the last N server errors — the admin's substitute for reading `stdout` on a host with no access to the process.
788
+ - `@cogenta/schema`: adds `createMaintenanceStore`/`ensureMaintenanceTable` (a one-row on/off switch with a visitor-facing message) and exports `reindexAll`/`reindexEntry` from the search indexer, so a full rebuild reuses exactly what the write path already does on save.
789
+ - `@cogenta/api`: adds `createHealthRouter` (`GET /api/health-report` — literally `cogenta doctor`'s own report, over HTTP; migrations status/apply; audit chain integrity; disk usage; the error log; maintenance mode get/set) and `createToolsRouter` (`GET /api/tools`, `POST /api/tools/{id}/run`, `GET /api/tools/runs[/…]` — seven maintenance tools, always queued, never run inline in the request). Adds a `pending-migrations` notice source.
790
+ - `@cogenta/cli`: `cogenta serve` wires all of the above — `runDoctor` reused unchanged, migrations applied only up to the first destructive one (the CLI is named for the rest), the seven tools (purge caches, reindex search/vectors, regenerate image variants, check links, test email, purge expired trash) running through the existing database-queue driver's degraded tier, and a maintenance-mode gate that serves an uncacheable 503 with a wait page to every anonymous visitor while `/api/*` and `/admin*` stay reachable.
791
+
792
+ Purely additive: `createRequestListener`'s new third parameter is optional, and every `AssembleSiteOptions` addition is optional — a caller that builds a `Site` by hand, or does not pass a migrator, keeps working unchanged.
793
+ - d0bfa1d: Add `@cogenta/export`: content export/import (`export@1.0`, NDJSON, permission-aware),
794
+ media archive export (streaming ZIP, references or full bytes), full-site backup and
795
+ restore (`cogenta-backup@1.0`, engine-independent, checksummed, optionally encrypted
796
+ with a passphrase), and GDPR/RGPD personal-data export by email — fiche 26.
797
+
798
+ `@cogenta/core` gains nine error codes (`EXPORT_*`, `BACKUP_*`, `RESTORE_*`) and exports
799
+ `MEDIA_TABLE`, its media table's physical name, so a caller assembling a full-site
800
+ backup can name every table without depending on `@cogenta/core`'s internals.
801
+
802
+ `@cogenta/cli` gains four new commands: `cogenta export`, `cogenta import content`,
803
+ `cogenta backup create|list`, and `cogenta restore preview|apply`. Restoring a full
804
+ backup is **CLI-only, by design** — it overwrites the database an admin session would
805
+ be running against, so it is never exposed over HTTP; an admin instead applies a
806
+ *content* export (additive, reversible through the trash).
807
+ - 95acedf: Analytics drill-down (fiche 27): pages, referrers, period comparison, custom
808
+ date range, entry-editor stats, CSV export, and configurable, automatically
809
+ purged retention — the gaps found against Jetpack Stats/Plausible/Matomo. No
810
+ new field is collected: every addition is computed from the events row this
811
+ package already wrote (path, referrer domain, device, daily-salted session
812
+ hash), so the site's cookie-free, no-consent-banner posture is unchanged.
813
+
814
+ **`@cogenta/analytics`**: `AnalyticsStore.getSummary` now returns
815
+ `previousTotalViews`/`previousUniqueVisitors`/`viewsChangePercent` — the
816
+ equal-length window immediately before the requested one, with `null` (never
817
+ a misleading `0`) when there is no previous traffic to compare against.
818
+ `getPageStats(path, window)` reports one page's views, previous-period views
819
+ and rank among every path seen in the window — what an entry-editor sidebar
820
+ needs, without pulling the whole top-N list. `purgeEvents(retainDays)` and
821
+ `purgeSalts(retainDays)` delete rows past a configured retention; the events
822
+ table is the largest table on a site with real traffic, and there is no way
823
+ to disable purging outright, only to choose how long to keep.
824
+
825
+ **`@cogenta/core`**: new config section `analytics.retainDays` (default 400
826
+ days), resolved alongside every other site setting.
827
+
828
+ **`@cogenta/api`**: `createAnalyticsRouter`'s `GET /api/analytics/summary`
829
+ accepts a custom `?since=&until=` range (alongside the existing `?days=`),
830
+ reports the period-over-period comparison, and — when the caller wires in
831
+ `resolvePage` — enriches each top page with its entry's title and admin edit
832
+ link. A new `GET /api/analytics/page?path=` answers the same admin-only stats
833
+ for one page. `retainDays`, when wired in, is echoed back as `retentionDays`
834
+ so the admin screen can show a real number instead of a promise.
835
+
836
+ **`@cogenta/cli`**: `cogenta serve` wires the new `analytics.retainDays`
837
+ config into a daily purge tick (same shape as the existing scheduled-publish
838
+ tick — a sweep right away, then one every 24h) and resolves top pages against
839
+ the site's real routes and permission-checked content gateway, so the summary
840
+ screen can link straight to the entry in the admin.
841
+
842
+ Purely additive: a site that never reads `/api/analytics/summary` behaves
843
+ exactly as before.
844
+ - bebbab8: Add store settings for the shop (fiche 34): tax zones/rates with a simulator, shipping
845
+ zones/methods with a simulator, payment driver activation (presence-only for keys, never
846
+ values), general store settings, and a configurable invoice template.
847
+
848
+ - `@cogenta/core` gains a `payment` configuration section (`driver`, `testMode`,
849
+ `manualInstructions`) following the exact `llm`/`billing` pattern: the Stripe secret key
850
+ and webhook secret are never declared in the schema and are refused with
851
+ `CONFIG_SECRET_IN_FILE` if written to `cogenta.config.mjs` — they come only from
852
+ `COGENTA_PAYMENT_STRIPE_SECRET_KEY`/`COGENTA_PAYMENT_STRIPE_WEBHOOK_SECRET`.
853
+ - `@cogenta/schema`'s site-settings registry (fiche 23) gains a `commerce` group
854
+ (currency, tax-inclusive/exclusive display, countries served, minimum order, default
855
+ backorder policy, ToS/return-policy page paths — pointers to real content entries, not
856
+ text fields — and invoice series prefix/payment terms/language) and a new `select`
857
+ `uiType` for closed-choice settings.
858
+ - `@cogenta/commerce`'s admin router gains `GET|POST /tax/rules`, `DELETE
859
+ /tax/rules/{id}`, `POST /tax/simulate` (calls the real resolver, never a second
860
+ implementation), the shipping equivalents (`/shipping/methods`, `/shipping/simulate`),
861
+ and `GET /payment/drivers` / `POST /payment/drivers/{name}/test-connection` (presence
862
+ and live health only, never a key's value). `CommerceAdminRouterOptions` gains required
863
+ `tax`/`shipping` fields and an optional `payment` field — **a breaking change** for any
864
+ direct caller of `createCommerceAdminRouter` that does not yet pass them.
865
+ - `@cogenta/cli`'s `cogenta serve` now selects a real payment gateway through
866
+ `createPaymentRegistry` (Stripe when a key is configured and reachable, bank transfer
867
+ otherwise) instead of a hardcoded manual gateway, and mounts the new commerce settings
868
+ routes.
869
+ - `@cogenta/admin` (private, no changeset) gains four screens under "Boutique": Tax,
870
+ Shipping, Payment, and Store settings (general + invoice template), all `admin`-only.
871
+
872
+ Deliberately not built in this fiche: an inbound `POST /api/commerce/payments/webhook`
873
+ route. `PaymentStore.handleWebhook` is already implemented and tested; wiring it needs
874
+ the raw (non-JSON-parsed) request body, which `cogenta serve`'s shared body reader does
875
+ not yet support for any route. The payment screen shows the webhook URL a deployer would
876
+ configure at Stripe, honestly labelled as not yet receiving events. See `BLOCKERS.md` §15.
877
+ - a8199ea: Media library folders, and the fiche 11 search/filter/sort/pagination/tags/usage/replace
878
+ work — already written and tested, never wired into the admin screen — finally called by
879
+ it (fiche 46).
880
+
881
+ **`@cogenta/core`**: `MediaAsset` gains `folderId: string | null` (`null` means
882
+ unclassified — every asset uploaded before this fiche keeps that value forever, nothing
883
+ backfills it). `CreateMediaInput`/`UpdateMediaInput` gain an optional `folderId`.
884
+ `ListMediaOptions` gains `folderId` (exact match, `null` for unclassified) and
885
+ `folderIds` (an already-resolved set, for "include subfolders"). New: `MediaFolder`,
886
+ `MediaFolderStore`, `createDatabaseMediaFolderStore` — a materialised-path tree
887
+ (same technique as the taxonomy tree, ADR-0022, kept as a *local* copy in
888
+ `folder-path.ts` since `@cogenta/core` cannot depend on `@cogenta/schema`), one
889
+ `cogenta_media_folders` table, folder names unique among siblings. New error codes:
890
+ `MEDIA_FOLDER_NOT_FOUND`, `MEDIA_FOLDER_INVALID`, `MEDIA_FOLDER_NAME_TAKEN`,
891
+ `MEDIA_FOLDER_NOT_EMPTY`, `MEDIA_FOLDER_CYCLE`, `MEDIA_FOLDER_TOO_DEEP`.
892
+
893
+ **`@cogenta/api`**: `media-router.ts` gains `/api/media/folders` (CRUD),
894
+ `/api/media/folders/{id}/move`, `/api/media/{id}/move`, `/api/media/-/bulk-move`, and
895
+ `?folderId=`/`?includeSubfolders=` on `GET /api/media`. `MediaRouterOptions` gains an
896
+ optional `folders?: MediaFolderStore` — absent, the folder routes answer 404 (the same
897
+ graceful-absence shape `usage` already had) and `?folderId=` still works as a plain
898
+ exact match. `STATUS_BY_CODE` gains the six new codes.
899
+
900
+ **`@cogenta/agents`** (no changeset — no observable change): `media.read`/`media.write`
901
+ (contract C) keep exactly the wire output they had before this fiche. `MediaAsset`
902
+ gaining `folderId` would otherwise have grown their shared output schema too — but
903
+ contract C treats an existing tool's signature as figured with no "additive is minor"
904
+ exception (unlike contract A/D, which carry one explicitly), so `folderId` is now
905
+ stripped before that shape is built at all (`toToolAsset`). Exposing it to an agent
906
+ needs a deliberate governance call — a new `tools@1.5` entry permitting additive
907
+ tool-output growth, or a separate tool — left to the human rather than decided here.
908
+
909
+ **`@cogenta/cli`**: `cogenta serve` creates the folder store and bootstraps a default
910
+ `contents` root folder once, idempotently, on every startup; wires `folders` and (a real
911
+ gap found while wiring this fiche's own admin panel — `usage` was written and tested in
912
+ fiche 11 but never actually passed to `createMediaRouter`) `usage` into the media router.
913
+ - 16f63f6: Bring form definitions and submissions closer to parity with premium form plugins (Gravity Forms/WPForms) — fiche 47, tasks 1-4 and 6-11 (task 5, a contract B `form` block, stays out of scope pending its own RFC).
914
+
915
+ - **`@cogenta/forms`**: the field vocabulary gains a tenth kind, **`file`** — a deliberate reopening of ADR-0026's own renoncement, decided live with the user (fiche 47 §8). A `file` field's bytes are sniffed against a closed category vocabulary (`image`/`pdf`/`document`/`text`, via `sniffFormFileCategory`/`assertAllowedFormFile`) — never trusted from a filename or declared `Content-Type` — with a hard, unconfigurable size ceiling (`FORM_FILE_HARD_MAX_BYTES`) on top of any per-field `maxSizeBytes`. `FormFieldDefinition` gains `showIf` (task 1: a field masked by an unmet condition is neither required nor validated, evaluated server-side against the raw submission — `evaluateCondition`/`isFieldVisible`) and `acceptCategories`. `FormDefinition` gains `steps` (task 2: real multi-step forms, validated so every field belongs to exactly one step), `notifyChannels` (task 4: extra Slack/Discord/Telegram/webhook targets via `@cogenta/channels`'s existing `ChannelRegistry`/adapters, never a new transport — `notifyChannels()`) and `captcha` (task 10: optional, off by default, Cloudflare Turnstile verification via `verifyCaptcha`, a single HTTP call, no client SDK dependency). `FormDefinitionStore` gains `duplicate` (task 11: an independent, inactive copy, never carrying submissions over). `FormSubmissionStore` gains `addNote`/`listNotes` (task 8: operator-only notes, never exported) and `list()` gains `query`/`from`/`to` (task 7: full-text search across a submission's own values plus a date range, SQL-filtered then bounded in-memory for the text match — the same honest tradeoff `searchByEmail` already makes). New `csv.ts` (`csvField`/`toCsvRow`/`csvHeaderRow`/`csvSubmissionRow`) mirrors `packages/admin/src/lib/csv.ts`'s CWE-1236 formula-injection guard for the new server-side streamed export (task 9).
916
+ - **`@cogenta/core`**: four new `FORM_*` error codes (`FORM_FILE_REJECTED`, `FORM_CAPTCHA_REQUIRED`, `FORM_CAPTCHA_FAILED`, `FORM_STEP_INVALID`), each mapped to a 4xx status in `@cogenta/api`'s `STATUS_BY_CODE`.
917
+ - **`@cogenta/api`**: `createFormsRouter` gains `storage` (a `StorageDriver`, for the `file` field — absent means every upload is refused rather than silently accepted) and `channelRegistry` options; `POST /api/forms/{name}/submit` now accepts `multipart/form-data` (sniffing and storing any uploaded file before validation), understands multi-step submissions (`_step`/`_accumulated`, answering `202 {status:'step', nextStep, values}` for every step but the last, exactly as before for a single-page form), verifies the CAPTCHA on the final step when a form has one enabled, and dispatches `notifyChannels` alongside the existing e-mail notification. New routes: `POST /api/forms/{id}/duplicate`, `GET`/`POST /api/forms/submissions/{id}/notes`, and `?q=`/`?from=`/`?to=` on `GET /api/forms/submissions`. New export `streamSubmissionsCsv` — an async generator, never buffering the whole export in memory (a single-form export uses that form's own field names as fixed CSV columns; a cross-form export pays one bounded pre-pass to discover columns before streaming rows for real).
918
+ - **`@cogenta/cli`**: `readBody` (`serve.ts`) now parses a real `multipart/form-data` body (reusing `@cogenta/api`'s existing zero-dependency parser) as raw bytes rather than corrupting it through a UTF-8 text decode — this is what makes a `<form enctype="multipart/form-data">` post work with no JavaScript at all, for `/api/forms/*` and (latent, previously dead in production) `/api/media` alike. `forms-page.ts` renders a `file` input, one step at a time for a multi-step form (each step a plain chained `<form method="post">`, no client framework — the original page-load timestamp is carried forward unchanged rather than refreshed, so the anti-abuse fill-delay check keeps its meaning across the whole flow), and the Turnstile widget only on the final step of a form that opted into the CAPTCHA. New route `GET /api/forms/submissions/export.csv` (admin-only, streamed directly to the response, outside `RestResponse`'s JSON-only shape — same reasoning as `/api/media/{id}/file`).
919
+ - Admin (`@cogenta/admin`, private, no changeset): `routes/forms.tsx` gains per-field `showIf`/step/file-category editing (plain text columns on the existing field repeater, not a second visual builder), `notifyChannels`/CAPTCHA configuration, and a Duplicate action; `routes/form-submissions.tsx` gains a search box, a date range filter, internal notes, the referrer (stored since fiche 16 but never shown before), and a server-streamed CSV download (`downloadSubmissionsCsv`) replacing the old 200-row-capped client-side export.
920
+
921
+ A form with none of these features enabled behaves exactly as it did before this change — `steps`/`notifyChannels` default to empty and `captcha` defaults to disabled, and no field's `showIf` means no field's requiredness changed. The form stays fully functional with no JavaScript at every task except the CAPTCHA widget itself, which is opt-in and inherently third-party script.
922
+ - a15b1ae: Theme manifest gains `description`/`author` (`theme@1.2`, additive), and the
923
+ "Apparence" admin screen splits into a theme gallery and a "Personnaliser"
924
+ screen reached from it (fiche 48).
925
+
926
+ - `@cogenta/render`'s `ThemeManifest` gains optional `description?: string`
927
+ and `author?: string` (`theme@1.2`). Both are optional so a manifest
928
+ written before this version, or a third-party theme that simply omits
929
+ them, keeps validating unchanged — the appearance gallery falls back to
930
+ the registry's own `label` when `description` is absent, and shows no
931
+ author line at all when `author` is absent.
932
+ - The five built-in themes (`@cogenta/theme-canonical`, `-ecommerce`,
933
+ `-entreprise`, `-magazine`, `-portfolio`) now declare `description` and
934
+ `author: 'Cogenta'` in `theme.config.ts`. Patch releases: no rendering
935
+ behaviour changed, only manifest metadata.
936
+ - `@cogenta/api`'s `AvailableThemeLike` (and `GET /api/theme`'s
937
+ `availableThemes`) gains `version: string` and `author: string | null`,
938
+ read straight from each theme's manifest rather than duplicated by hand —
939
+ editing a theme's `theme.config.ts` alone now changes what the API
940
+ returns.
941
+ - `@cogenta/cli`'s `theme-registry.ts` `availableThemes()` becomes
942
+ **async** (breaking for any direct caller — it now has to load and cache
943
+ each theme's manifest, which is an ESM dynamic import): it reads
944
+ `label` from the registry as before, but now reads `description`,
945
+ `version` and `author` from the theme's own manifest instead of a
946
+ hand-duplicated string. Both call sites in `cogenta serve` were updated
947
+ to `await` it.
948
+ - The admin's "Apparence" screen (`packages/admin`, unpublished) is split
949
+ into two screens: a gallery (theme preview, name, description, version,
950
+ author, and a "Personnaliser" action on whichever theme is active) and a
951
+ personalization screen (tokens, contrast warnings, additional CSS,
952
+ identity, skin gallery, AI generation) — previously one dense, continuous
953
+ screen. Purely a navigation change: every existing action still does
954
+ exactly what it did before, just behind one more click.
955
+ - 656163e: LLM provider catalog (fiche 56): OpenRouter, DeepSeek, Qwen and GLM are now
956
+ configurable from the admin's "Providers" screen alongside Anthropic, OpenAI
957
+ and Google, plus an explicit "custom provider" option for any other
958
+ OpenAI-compatible endpoint (a self-hosted proxy, or a vendor not yet
959
+ catalogued). No new network code: every OpenAI-compatible entry (OpenRouter,
960
+ DeepSeek, Qwen, GLM, custom) reuses `createOpenAiClient` unmodified, only
961
+ pointed at a different `baseUrl`.
962
+
963
+ **`@cogenta/agents`**: `provider` widens from the closed 3-literal union
964
+ (`'anthropic' | 'openai' | 'google'`) to a plain string, validated at the
965
+ write boundary instead of by a type — `@cogenta/core`'s own
966
+ `llmSchema.provider` was already a free string before this fiche. New
967
+ `providers/catalog.ts`: `KNOWN_PROVIDER_CATALOG` (id/label/wireFormat/
968
+ defaultBaseUrl/knownModels per vendor) and `findProviderCatalogEntry`.
969
+ `createProviderRegistry` resolves a name via the catalog when it knows one
970
+ (dispatching to the right adapter by `wireFormat`), and otherwise requires
971
+ the entry's own `baseUrl` — that pairing (no catalog entry + a `baseUrl`) is
972
+ what "custom provider" means structurally, with no separate flag to keep in
973
+ sync. `createOpenAiClient` gains an optional `name` (defaults to `'openai'`)
974
+ so a client built for OpenRouter/DeepSeek/Qwen/GLM/a custom endpoint reports
975
+ its own id via `ProviderClient.name` — needed for the privacy allowlist
976
+ (`assertProviderAllowed`) to recognise the right vendor rather than every
977
+ OpenAI-compatible client misreporting itself as literally `'openai'`.
978
+ `createFileProviderConfigStore.upsert`/`updateSettings` reject a malformed
979
+ provider id (`PROVIDER_ID_INVALID`) or one outside the catalog with no
980
+ resolvable `baseUrl` (`PROVIDER_CUSTOM_BASE_URL_REQUIRED`) — the write-time
981
+ checks that make network-time resolution failures unreachable.
982
+
983
+ **Breaking (`@cogenta/agents`):** `PROVIDER_NAMES` (the fixed 3-name array)
984
+ is removed — read `KNOWN_PROVIDER_CATALOG` instead, or accept that
985
+ `ProviderName` is now `string`. `ProviderRegistryConfig`'s value shape gains
986
+ nothing new but is now keyed by an open string rather than the closed union.
987
+
988
+ **`@cogenta/core`**: two new error codes, `PROVIDER_ID_INVALID` and
989
+ `PROVIDER_CUSTOM_BASE_URL_REQUIRED` (both 400).
990
+
991
+ **Breaking (`@cogenta/api`):** `providers-router.ts`'s `ProviderRegistryLike`
992
+ gains a required `catalog: readonly ProviderCatalogEntrySummary[]` — any
993
+ caller implementing this interface directly (rather than using
994
+ `@cogenta/cli`'s adapter) must supply it. New route `GET
995
+ /api/providers/catalog` (admin-only) serves it; `catalog` is a reserved
996
+ provider id as a result (a provider literally named "catalog" can no longer
997
+ be created). `POST /api/providers` no longer rejects a provider name outside
998
+ a fixed 3-name list — it rejects a name outside the catalog **only when no
999
+ `baseUrl` is given** (`PROVIDER_CUSTOM_BASE_URL_REQUIRED`, still 400, but a
1000
+ different code than the previous generic "not a supported LLM provider"
1001
+ `QUERY_INVALID`). `PATCH`/`DELETE /api/providers/:provider` no longer gate
1002
+ on a fixed name list at all — they resolve against whatever the store
1003
+ actually has saved (a legitimately-saved custom provider used to be
1004
+ unreachable by these two verbs; the store's own `PROVIDER_NOT_CONFIGURED`
1005
+ already covered "this was never saved").
1006
+
1007
+ **`@cogenta/cli`**: `packages/cli/src/commands/agent-runtime.ts`'s
1008
+ `createProviderRegistryAdapter` now supplies `names`/`catalog` from
1009
+ `KNOWN_PROVIDER_CATALOG` instead of the removed `PROVIDER_NAMES`, and no
1010
+ longer narrows an arbitrary string against a closed `ProviderName` union
1011
+ before trusting the live registry's own `has`/`get`. `assistant.ts`'s single-
1012
+ provider (`cogenta.config.mjs`'s `llm` section) resolution now accepts any
1013
+ provider `createProviderRegistry` itself can resolve — a catalog id, or a
1014
+ custom id paired with a `baseUrl` — rather than duplicating a fixed 3-name
1015
+ allowlist a second time (the exact desynchronisation risk this repo already
1016
+ hit once with `CONTRACT_C_PERMISSIONS`).
1017
+
1018
+ **`@cogenta/admin`** (unpublished, no changeset entry): the "Providers"
1019
+ screen's "add" form is now catalog-driven — a provider `<select>` populated
1020
+ from `GET /api/providers/catalog`, a known-models picker per selected
1021
+ provider, and an explicit "custom provider" choice (its own id field, and a
1022
+ `baseUrl` the form requires before Save is enabled).
1023
+
1024
+ Not included, by the fiche's own scope: Replicate (a different, asynchronous
1025
+ prediction-and-polling API, not OpenAI-compatible — a separate adapter, left
1026
+ for a later task).
1027
+ - 4513a71: Import gains a real preview/apply/undo flow (fiche 25), on top of the existing
1028
+ one-shot WordPress uploader, which is unchanged and still works.
1029
+
1030
+ `@cogenta/import`:
1031
+ - `analyzeWordPress(xml)` previews a WXR export — counts, proposed collection mapping,
1032
+ authors, media URLs and volume, slug conflicts and everything that will be skipped —
1033
+ without writing anything.
1034
+ - `importWordPress` accepts `{ tracking, runId }`: passed, every post/page/comment it
1035
+ writes is recorded, a second call with the same `runId` resumes without duplicating,
1036
+ and `undoImport` can trash everything the run created (never `purge`, so an
1037
+ over-eager undo is itself reversible from the trash).
1038
+ - New sources: `parseCsv`/`csvToRecords` (zero dependency, RFC 4180), `feedToRecords`
1039
+ (RSS 2.0 and Atom), `parseJsonImport`/`analyzeJson`/`applyJson` (a minimal Cogenta
1040
+ JSON import format). CSV and RSS/Atom share a generic mapping/apply engine
1041
+ (`analyzeGeneric`/`applyGeneric`, `proposeFieldMapping`/`resolveMapping`) against any
1042
+ collection the target site declares — real field correspondence, not a fixed shape.
1043
+ - `createImportTrackingStore` — two new tables (`cogenta_import_runs`/
1044
+ `cogenta_import_items`), owned entirely by this package, never a field on contract A.
1045
+ - Outbound media downloads are now guarded against SSRF (private/loopback/link-local
1046
+ addresses refused, including on a DNS-rebound host name), capped in size and count,
1047
+ and time out.
1048
+
1049
+ `@cogenta/core`: new error codes (`IMPORT_RUN_NOT_FOUND`, `IMPORT_SOURCE_INVALID`,
1050
+ `IMPORT_ALREADY_APPLIED`, `IMPORT_MAPPING_INVALID`, `IMPORT_MEDIA_URL_UNSAFE`,
1051
+ `IMPORT_CSV_INVALID`, `IMPORT_FEED_INVALID`).
1052
+
1053
+ `@cogenta/api`: `createImportRouter` gains `POST /api/import/analyze`,
1054
+ `GET /api/import/runs`, `GET /api/import/runs/{id}`, `POST /api/import/runs/{id}/apply`
1055
+ and `POST /api/import/runs/{id}/cancel`, behind five new optional `ImportRouterOptions`
1056
+ callbacks (`analyze`/`apply`/`getRun`/`listRuns`/`cancel`). All admin-only. The legacy
1057
+ `POST /api/import/wordpress` route is untouched.
1058
+
1059
+ `@cogenta/cli`: `cogenta serve` wires the full flow — WordPress, CSV, JSON and RSS/Atom
1060
+ — through the site's own stores, storage driver and read-only guard.
1061
+ - bdcb563: L20 audit — six real bugs in the admin's diagnostic and dashboard screens,
1062
+ fixed:
1063
+
1064
+ **`@cogenta/core`:** `DriverSelection`/`SkippedDriver` gain `reasonCode`
1065
+ (`DriverSelectionReason`/`SkipReasonCode`) alongside the existing `reason`
1066
+ string — a stable code a translated UI can look up instead of showing
1067
+ `createDriverRegistry`'s English prose ("named in the configuration", "redis
1068
+ not available") verbatim. Purely additive; `reason` is unchanged.
1069
+
1070
+ **`@cogenta/api`:** `HealthDoctorCheck` gains an optional `reasonCode`
1071
+ field, carrying the same information through `GET /api/health-report`.
1072
+
1073
+ **`@cogenta/cli`:** `cogenta serve` now actually constructs a
1074
+ `ScheduledTaskRegistry` and mounts `createScheduledTasksRouter` under
1075
+ `/api/scheduled-tasks` — before this, the admin's "Tâches planifiées" screen
1076
+ had real, tested client and server code on both ends, but nothing ever
1077
+ wired them together, so every request 404'd through the generic content
1078
+ router. The seven recurring jobs that used to run on independent
1079
+ `setInterval`s (scheduled publication, the tools-queue drain, the 404 log
1080
+ purge, audit integrity, the trash sweep, forms GDPR retention, channel
1081
+ notification flush, analytics retention) now run through one heartbeat
1082
+ driving `registry.tick()`, at the same per-task cadence as before — "run
1083
+ now" from the admin is real, and last-run/next-run/history reflect the
1084
+ actual thing. `RuntimeExtras` gains an optional `scheduledTasksRouter`.
1085
+ `DoctorCheck` gains `reasonCode` and a typed `skipped` shape, matching
1086
+ `@cogenta/core`.
1087
+
1088
+ No breaking changes. A caller that never touches the new fields is
1089
+ unaffected; a site with no `ScheduledTaskRegistry` constructed by hand
1090
+ (a test harness building a bare `Site`) simply never gets the route mounted,
1091
+ same degradation as `agentsRouter`.
1092
+
1093
+ Also fixed, admin-only (`@cogenta/admin`, private, no changeset): the
1094
+ "Vues par jour" analytics chart now draws one bar per calendar day of the
1095
+ selected period — zero-filled where the server sent nothing — instead of
1096
+ stretching a sparse response into a single filled rectangle; the "Interroger
1097
+ le site" assistant tab shows an honest state instead of rendering blank when
1098
+ a provider is configured but `assist.chat` specifically is disabled; and the
1099
+ Agents screen degrades to its already-honest "no agent running" empty state
1100
+ instead of showing the raw `"No route matches this path."` wire text when no
1101
+ `AgentRegistry` is mounted (still the case on every real `cogenta serve`
1102
+ today — that gap is documented, not new).
1103
+ - 3cbd6d7: L22 task 5 — OpenTelemetry request tracing, a configurable log level, and
1104
+ an admin "Exploitation" > Observability screen, all on by default and
1105
+ working with zero external service (R1).
1106
+
1107
+ **New package `@cogenta/observability`:** wraps `@opentelemetry/api` +
1108
+ `@opentelemetry/sdk-trace-base` (a real new dependency — see the task
1109
+ report for size and maintenance detail; this is the industry-standard
1110
+ choice, never a hand-rolled tracer). `createObservabilityRuntime` builds
1111
+ one server span per HTTP request (`withRequestTracing`) and a bounded,
1112
+ in-process "recent events" buffer (`ObservabilityRecentStore`, same ring-
1113
+ buffer shape `@cogenta/core`'s `createErrorLog` already uses) that the
1114
+ admin reads. A local NDJSON exporter runs always, needing no external
1115
+ service; an OTLP HTTP exporter runs in addition when an endpoint is
1116
+ configured — never one hardcoded vendor, any OTLP-speaking backend
1117
+ (Grafana, Datadog, …) works. `withRecentLogCapture` wraps any
1118
+ `@cogenta/core` `Logger` so its records also feed the same buffer, gated
1119
+ by a dynamic level getter rather than the logger's own fixed threshold.
1120
+ Every field passes through `@cogenta/core`'s `redact()` before storage —
1121
+ the same discipline the audit log already applies — and a trace only ever
1122
+ carries a request's method, path (query string stripped) and status code,
1123
+ never a header, cookie, or body.
1124
+
1125
+ **`@cogenta/core`:** a new `observability` config section
1126
+ (`cogenta.config.mjs`) — `serviceName` and `otlpEndpoint`, resolved
1127
+ always, defaults needing nothing external. No `otlpHeaders` field, on
1128
+ purpose (rule R7, same shape as `payment`'s missing `stripeSecretKey`):
1129
+ those come from `COGENTA_OTLP_HEADERS`/`OTEL_EXPORTER_OTLP_HEADERS` only,
1130
+ refused if written to the file (`CONFIG_SECRET_IN_FILE`). `serviceName`
1131
+ and `otlpEndpoint` also honour the standard `OTEL_SERVICE_NAME`/
1132
+ `OTEL_EXPORTER_OTLP_ENDPOINT` environment variables as a fallback.
1133
+
1134
+ **`@cogenta/schema`:** `SITE_SETTINGS_REGISTRY` gains a new `observability`
1135
+ group with two editorial settings — `observability.enabled` (default on)
1136
+ and `observability.logLevel` (`error`/`warn`/`info`/`debug`, default
1137
+ `info`) — changeable from the admin with no restart, unlike the OTLP
1138
+ export destination above.
1139
+
1140
+ **`@cogenta/api`:** `createObservabilityRouter` — `GET /api/observability`,
1141
+ admin-only, read-only, answering the current `enabled` state plus the
1142
+ recent traces and logs.
1143
+
1144
+ **`@cogenta/cli`:** `cogenta serve` wires all of the above — the HTTP
1145
+ listener is wrapped with `withRequestTracing`, the shared logger is
1146
+ wrapped with `withRecentLogCapture`, and `observability.enabled`/
1147
+ `observability.logLevel` are polled from the settings store every 15s
1148
+ (configurable via `ServeOptions.observabilitySettingsTickMs`, a test
1149
+ seam) so an admin's change takes effect without a restart.
1150
+ - 249eb6f: Add the update system (L22 task 9): checking npm for a newer `@cogenta/core`/
1151
+ `@cogenta/cli`, and applying one with a mandatory restore point first — never an
1152
+ update with no safety net.
1153
+
1154
+ `@cogenta/core` gains `readOwnPackageVersion` (self-describing package version,
1155
+ read from a package's own `package.json`, never bundled at build time) and
1156
+ `getCoreVersion`, its own version computed with it — **lazily, cached after the
1157
+ first real call, never a top-level constant**: a top-level `CORE_VERSION =
1158
+ readOwnPackageVersion(...)` was the first design, and it broke every
1159
+ `@cogenta/admin` test that happened to pull `@cogenta/core` in transitively,
1160
+ because that suite's `import.meta.url` is not a `file://` URL under
1161
+ Vitest+jsdom's Vite transform. `@cogenta/core` is imported (for types) by
1162
+ enough of this monorepo, including browser-bundled code, that nothing at its
1163
+ top level may assume a real Node `file://` module URL — fixed before it ever
1164
+ shipped, but worth naming so the next self-describing constant doesn't repeat
1165
+ it. New error codes: `PACKAGE_VERSION_UNREADABLE`, `UPDATE_CHECK_FAILED`,
1166
+ `UPDATE_RESTORE_POINT_FAILED`, `UPDATE_APPLY_FAILED`, `UPDATE_NOT_AVAILABLE`,
1167
+ `UPDATE_CONFIRMATION_REQUIRED`, `UPDATE_POLICY_INVALID`.
1168
+
1169
+ `@cogenta/schema` gains one new site-settings-registry entry,
1170
+ `updates.autoUpdatePolicy` (`off`/`patch`/`patch-minor`/`patch-minor-major`, off by
1171
+ default) — a normal editorial setting through the existing generic settings store,
1172
+ no new persistence mechanism.
1173
+
1174
+ `@cogenta/api` gains `createUpdateRouter`: `GET /api/updates/status` (a live
1175
+ version check against npm, per package), `GET /api/updates/history` (past
1176
+ checks/applies plus the restore points they took), and `POST /api/updates/apply`
1177
+ (admin-only, every route).
1178
+
1179
+ `@cogenta/cli` gains `cogenta update check|apply|history`, wired the same way into
1180
+ `cogenta serve`'s admin API and into a new daily `updates-auto-check` scheduled
1181
+ task that honours `updates.autoUpdatePolicy` — never auto-applies a version whose
1182
+ changelog scan flagged a frozen contract, and never re-applies the same version on
1183
+ every tick after a successful auto-apply (this process's own version constant
1184
+ cannot change without an actual restart).
1185
+
1186
+ A **real bug fix**, found while wiring `getCliVersion`: `bin.ts` never passed its
1187
+ own version to `run()`, so `cogenta version`/`cogenta --version` always printed the
1188
+ fallback `"0.0.0"` regardless of what was actually installed. Fixed.
1189
+
1190
+ **Contract-risk detection is real but honestly limited.** It reads the target
1191
+ version's own published `CHANGELOG.md`, fetched from its npm tarball
1192
+ (`registry.npmjs.org` only, a small zero-dependency ustar/pax reader — no `tar`
1193
+ dependency, R9) and scanned for a frozen-contract mention. `@cogenta/core` and
1194
+ `@cogenta/cli` add `CHANGELOG.md` to their own `"files"` for this to work — every
1195
+ version already published before this ships has no `CHANGELOG.md` in its tarball
1196
+ (verified with a real `npm pack` while building this), so the check reports an
1197
+ honest "could not determine" for those rather than a false "no risk found." Even
1198
+ once readable, this is a keyword scan of prose, not comprehension — a strong hint
1199
+ an admin reviews before confirming, never a certification.
1200
+
1201
+ **Out of scope, deliberately**: this updates a site's npm packages only — `cogenta
1202
+ build`/`deploy` remain honestly deferred (L9), and no migration ever runs
1203
+ automatically (`cogenta migrate status`/`migrate up` stay a separate, explicitly
1204
+ confirmed step, exactly as today).
1205
+ - 29e4982: Add `@cogenta/theme-ecommerce`, a second installable public-site theme
1206
+ ("Storefront") built on the `@cogenta/theme-kit` foundation (fiche L23):
1207
+ a confident, product-grid-native retail identity across all twelve
1208
+ contract-B blocks — shoppable cards with consistent aspect ratios and hover
1209
+ lift, a full-bleed accent-colour promotional panel for `cta`, a horizontal
1210
+ "as seen in" trust strip for `logos`, tabular social-proof numbers for
1211
+ `stats` — with its own `renderChrome` (a bolder header, a multi-column
1212
+ footer) and a genuinely distinct light/dark design system (bright,
1213
+ high-contrast light mode by default; a real, separately designed dark mode,
1214
+ not an inversion). Registered in `theme-registry.ts`'s `BUILTIN_THEMES` and
1215
+ `@cogenta/cli`'s own `dependencies`, so it is selectable from the appearance
1216
+ screen's theme picker alongside the canonical reference theme, with no
1217
+ change to any existing site's rendering. Zero client JavaScript, WCAG 2.2 AA
1218
+ contrast verified in both colour schemes by a real computed-contrast test
1219
+ suite (233 tests).
1220
+ - dda55d6: Fiche L23 (le thème unique, enfin réel) — l'infrastructure qui rend un second
1221
+ thème de site public installable, sans laquelle le reste du lot (les thèmes
1222
+ eux-mêmes, l'écran de sélection) n'aurait rien à brancher.
1223
+
1224
+ **Le vrai verrou, précisément nommé** : `cogenta serve` importait
1225
+ `@cogenta/theme-canonical` de façon statique dans `theme-render.ts` — `renderPage`
1226
+ et, plus contraignant encore, le `<header>`/`<footer>` du site étaient
1227
+ littéralement écrits en dur dans le CLI, aux classes CSS de ce seul thème.
1228
+ Un second thème ne pouvait donc pas simplement fournir d'autres blocs : il
1229
+ lui fallait aussi un point d'extension pour sa propre bannière, qui
1230
+ n'existait pas.
1231
+
1232
+ **Nouveau paquet `@cogenta/theme-kit`** : le contrat partagé qu'un thème
1233
+ implémente (`RenderContext`, l'arbre HTML sans échappatoire `raw()`, le texte
1234
+ riche, la section de commentaires, les aides d'entrée, `PageContent`, et les
1235
+ nouveaux types `ChromeInput`/`ChromeResult` du point d'extension) — sorti de
1236
+ `@cogenta/theme-canonical`, qui portait depuis L3 un commentaire s'excusant
1237
+ déjà que ce code soit une « maison temporaire ». Une seule copie, revue une
1238
+ fois, au lieu d'une copie par thème qui aurait fini par diverger — en
1239
+ particulier `ImageSource`/`ImageOptions` gagnent au passage `kind`/`poster`
1240
+ (contract D `theme@1.1`, déjà utilisé par `describeMedia` mais jamais exposé
1241
+ au thème lui-même) : le premier vrai support d'une vidéo en `hero`/
1242
+ `mediaFigure`, gratuit pour tous les thèmes à la fois. `@cogenta/theme-canonical`
1243
+ réexporte tout à l'identique — sa propre surface publique ne change pas.
1244
+
1245
+ **Le registre de thèmes** (`@cogenta/cli`, `theme-registry.ts`) : une
1246
+ résolution par nom, mémoïsée, repliant tout nom absent ou inconnu sur
1247
+ `@cogenta/theme-canonical` plutôt que de refuser de servir (R1/R2).
1248
+
1249
+ **Le point d'extension chrome** : `theme.renderChrome(input)` remplace le
1250
+ gabarit figé — chaque thème dessine désormais son propre en-tête/pied de
1251
+ page ; `cogenta serve` ne fait plus que résoudre la navigation et la mention
1252
+ de marque (toujours de sa responsabilité, jamais celle d'un thème) et les
1253
+ transmet. `@cogenta/theme-canonical` gagne ce `renderChrome`, produisant un
1254
+ HTML strictement identique à l'ancien gabarit — aucune régression visuelle
1255
+ pour un site existant.
1256
+
1257
+ **Sélection en direct, sans redémarrage** : `cogenta_theme` (la même table
1258
+ que les réglages d'apparence) gagne une colonne `active_theme`, ajoutée en
1259
+ place à une table existante (le même geste que `menu-tables.ts` avait déjà
1260
+ fait pour `location`) — une base déjà provisionnée n'est jamais perdue.
1261
+ `GET/PUT /api/theme` connaît désormais la liste des thèmes installés et
1262
+ refuse un nom que cette instance ne sait pas résoudre (`THEME_NOT_FOUND`,
1263
+ 404, nouveau dans la table de statuts). La feuille de style du thème actif
1264
+ est mémoïsée par nom (`createThemeCssResolver`) : changer de thème depuis
1265
+ l'écran d'apparence prend effet à la prochaine page vue, exactement la même
1266
+ promesse que la personnalisation de couleurs tient déjà.
1267
+
1268
+ **Vérifié de bout en bout** : le thème canonique sert un document identique
1269
+ à l'ancien via `renderPageChrome`/`renderEntryPage` (472 tests `@cogenta/cli`,
1270
+ dont `serve.test.ts`/`serve-builder.test.ts` — la fidélité octet pour octet
1271
+ du constructeur de page L16 tient toujours), 121/121 `@cogenta/theme-canonical`,
1272
+ 652/652 `@cogenta/schema`, 1052/1052 `@cogenta/api`. `pnpm turbo run typecheck`
1273
+ et `pnpm turbo run build` : 52/52 et 27/27 tâches, espace de travail entier.
1274
+
1275
+ Ce lot n'ajoute encore aucun second thème installable — c'est la matière du
1276
+ prochain changeset. Sans cette fondation, un second thème n'aurait eu nulle
1277
+ part où brancher sa propre bannière.
1278
+ - befad6d: `cogenta serve` fills contract D `theme@1.4`'s new `ChromeInput` fields and
1279
+ `PageContent.entry` (L25 D2).
1280
+
1281
+ A new `resolveChromeExtras` (`theme-render.ts`) reads `general.tagline`/
1282
+ `general.socialLinks`/`general.footerNote` through whichever `ThemeRenderOptions.chromeExtras`/
1283
+ `PageChromeOptions.chromeExtras` reader the caller wires (`chromeExtrasForSite`, `serve.ts`),
1284
+ and resolves `headerAction` from the first link of the menu assigned to the
1285
+ `header-action` location — the same generic, name-free menu-location mechanism
1286
+ `header-nav`/`footer-nav` already use. Wired into all three `renderChrome` call sites
1287
+ (`renderPageChrome`, the entry page renderer, and — with fixed synthetic values, since
1288
+ that route reads no database by design — the theme gallery preview).
1289
+
1290
+ An entry page (`renderRequestedPage`/`renderDraftPage`) now also builds `PageContent.entry`:
1291
+ cover image (`entryImage`), excerpt, publication/update dates, the entry's author (its
1292
+ `createdBy` resolved to a display name through the existing user store, via the new
1293
+ `ThemeRenderOptions.authorFor`/`authorForSite`), its classified taxonomy terms (via the
1294
+ new `ThemeRenderOptions.resolveTerm`, reusing the same lookup `resolveMenuTerm` already
1295
+ made for a menu item pointing at a term — exposed on `Site` as `resolveTaxonomyTerm`),
1296
+ and a reading-time estimate computed from the collection's `richText` field
1297
+ (~200 words/minute). Every one of these is optional and additive: a caller that never
1298
+ wires the new options renders exactly as before.
1299
+ - cb62917: `cogenta mcp --api-key <key>` — resolve the MCP server's actor from a real API
1300
+ key, and a dedicated admin screen to mint one (L21 task 6).
1301
+
1302
+ Until now, `cogenta mcp` could only run as a real user (`--email`), a
1303
+ synthetic test actor (`--role`), or anonymous — there was no way to generate
1304
+ a credential for an MCP client from the admin, the way REST already lets you
1305
+ via "Clés API". `--api-key` closes that gap by resolving through the exact
1306
+ same `ApiKeyStore` (`@cogenta/auth`) and "roles = scope" mapping REST's own
1307
+ `resolveActor` uses for a `cogenta_sk_…` bearer token — one store, two
1308
+ callers, never a second lookup path. A role the key was not granted is
1309
+ refused by the same `PermissionLayer` REST uses, exactly as it would be over
1310
+ HTTP (R4). `@cogenta/core` gains `MCP_ACTOR_API_KEY_INVALID` for an unknown,
1311
+ revoked or expired key.
1312
+
1313
+ The admin gains a new **MCP** screen (`@cogenta/admin`, private, no
1314
+ changeset entry of its own), parallel to "Agents" rather than folded into
1315
+ the generic "Clés API" screen — same underlying key store, different
1316
+ audience: generating a key here also shows a ready-to-paste `cogenta mcp
1317
+ --api-key …` command and a standard MCP client JSON configuration block,
1318
+ both built from the raw key the server just returned, shown exactly once,
1319
+ same as the existing screen's own raw-key handling.
1320
+
1321
+ See `packages/mcp/README.md` for the updated connection instructions.
1322
+ - 5e43b20: `cogenta mcp` — a real MCP (Model Context Protocol) server, wired in (L20 audit).
1323
+
1324
+ `@cogenta/mcp`'s server/transport existed, tested and unused since it shipped:
1325
+ no CLI command ever invoked it. `cogenta mcp` starts it for real, on the
1326
+ process's own stdin/stdout, built from this site's actual tool manifest
1327
+ (`buildManifest`, `@cogenta/agents`) — the same shape `cogenta serve` builds
1328
+ for REST/GraphQL, not a second implementation.
1329
+
1330
+ **R4 applied for real**: `--email <email>` resolves the acting user from the
1331
+ site's own user store and runs every tool call with that user's real roles,
1332
+ checked by the same `PermissionLayer` REST and GraphQL use. `--role
1333
+ <role,role>` hands a synthetic actor for local testing. With neither, calls
1334
+ run as the anonymous `public` actor — content tools stay on the manifest and
1335
+ stay permission-checked (a public actor sees only what a public actor may
1336
+ see); media, site-config and HTTP-fetch tools, which have no permission
1337
+ check of their own, are left off the manifest entirely rather than exposed
1338
+ by default.
1339
+
1340
+ `@cogenta/core` gains three error codes: `MCP_ACTOR_OPTIONS_CONFLICT`,
1341
+ `MCP_ACTOR_USER_NOT_FOUND`, `MCP_ACTOR_ROLE_EMPTY`.
1342
+
1343
+ See `packages/mcp/README.md` for how to connect Claude Desktop, Claude Code
1344
+ or Cursor, and `BLOCKERS.md` §18 for the one known limitation: content
1345
+ written through this path is not (yet) re-indexed for search/vectors or
1346
+ redirect-tracked the way `cogenta serve`'s own write path is.
1347
+ - b8d307a: Fiche 58: the "MCP" admin screen renamed to "MCP Server" (nav/i18n only, no
1348
+ functional change — task 1), and a real MCP **client**: this site's own agents can
1349
+ now consume external MCP servers, gated by a security review (`security-reviewer`,
1350
+ 2026-08-26 — NO-GO as originally written, GO conditional on a sandboxing floor,
1351
+ re-reviewed against this final implementation before merge).
1352
+
1353
+ **`@cogenta/mcp`**: `createMcpStdioClient` no longer inherits `process.env` —
1354
+ `spawn` receives exactly `options.env ?? {}`, never the host's real environment
1355
+ (the critical finding: the previous default handed a spawned third-party process
1356
+ every secret this server had, `COGENTA_AUTH_SIGNING_KEY` included, before
1357
+ `initialize()` was ever called). `stdio` is always `['pipe', 'pipe', 'pipe']`,
1358
+ never `inherit` — stderr is captured and logged through the structured logger,
1359
+ capped in size. Every JSON-RPC call has a hard timeout that kills the process and
1360
+ rejects every pending call on the connection; `wrapMcpTool`'s `execute` now honours
1361
+ `ctx.signal` too, so a run's own cancellation reaches the remote process the same
1362
+ way. A best-effort memory/CPU watchdog polls the spawned PID (`ps`/PowerShell, no
1363
+ native dependency — R9/R10); the real limit is host-level (cgroup, Job Object),
1364
+ documented as a prerequisite, not a guarantee.
1365
+
1366
+ New `packages/mcp/src/registry/`: `McpConnectionStore` (table `mcp_connections`,
1367
+ secret encrypted at rest with the same AES-256-GCM/`COGENTA_AUTH_SIGNING_KEY`
1368
+ scheme as `@cogenta/agents`' `ProviderConfigStore` — R7), `discoverMcpConnection`
1369
+ (a real `initialize()` + `tools/list()` probe through the sandboxed client),
1370
+ `buildMcpToolDefinitions` (wires every enabled connection's checked tools into
1371
+ Contract C `ToolDefinition`s). `McpConnectionStore.create()` structurally refuses a
1372
+ `stdio` connection without `confirmUnsandboxed: true` — the mandatory, honest
1373
+ acknowledgement that this binary runs with the Cogenta process's own full OS
1374
+ privileges, unsandboxed beyond this package's floor; a UI can show the warning, but
1375
+ the refusal itself lives here. `setExposedTools()` refuses a remote tool name never
1376
+ actually seen in the connection's last discovered list — "absent, pas refusée": a
1377
+ tool the admin never checked is never wrapped for any agent. `http` is a stored
1378
+ transport (forward-compatible schema) with no working client yet — honestly
1379
+ refused (`discoverMcpConnection`), never silently pretended to work.
1380
+
1381
+ **Contract C → `tools@1.4`** (`docs/04-contrats.md`): the parameterised permission
1382
+ `mcp.external:<connectionId>.<remoteToolName>` — one permission per checked remote
1383
+ tool, never per connection (`mcp.external.<connexion>` was rejected by the security
1384
+ review: it would grant every checked tool on a connection indifferently of its own
1385
+ risk, contradicting the "case à cocher par outil" principle and weakening R4). No
1386
+ existing tool signature changes — additive to an open taxonomy, the same kind of
1387
+ change `document.extract`/`logs.read`/`redirects.write`/`code.patch` already were.
1388
+
1389
+ **`@cogenta/core`**: ten new error codes — `MCP_CLIENT_CALL_TIMEOUT`,
1390
+ `MCP_CLIENT_CALL_ABORTED`, `MCP_CLIENT_PROCESS_EXITED`, `MCP_CLIENT_SPAWN_FAILED`,
1391
+ `MCP_CLIENT_CLOSED`, `MCP_CLIENT_RESOURCE_EXCEEDED`, `MCP_CONNECTION_NOT_FOUND`,
1392
+ `MCP_CONNECTION_INVALID`, `MCP_CONNECTION_AUTH_INVALID`,
1393
+ `MCP_CONNECTION_CONFIRMATION_REQUIRED`, `MCP_CONNECTION_TOOL_NOT_DISCOVERED`.
1394
+
1395
+ **`@cogenta/api`**: new `createMcpConnectionsRouter` (`/api/mcp-connections`,
1396
+ admin-only) — list/create/enable-disable/remove, `POST .../test` (a real discovery
1397
+ probe), `PUT .../exposed-tools` (the admin's checkbox decision). A new direct
1398
+ dependency on `@cogenta/mcp` (internal workspace package, not a third-party
1399
+ addition) for `discoverMcpConnection` and the store's types.
1400
+
1401
+ **`@cogenta/cli`**: `cogenta serve` creates the connection table and store
1402
+ unconditionally (usable even without an LLM provider configured, same posture as
1403
+ `/api/api-keys`); `packages/cli/src/commands/agent-runtime.ts`'s `buildAgentRuntime`
1404
+ merges every enabled connection's checked tools into the site's real tool registry
1405
+ through a live-swappable wrapper (`createLiveToolRegistry`) — a connection
1406
+ created/tested/exposed from the admin screen becomes callable by an agent on its
1407
+ very next lookup, no `cogenta serve` restart, the same "no restart needed"
1408
+ guarantee `/api/providers` already gives. `AgentRuntimeAssembly` gains
1409
+ `refreshMcpTools()` and `mcpDispose()` (closes every spawned `McpClient` and
1410
+ removes every sandbox working directory on server shutdown). The fiche names
1411
+ `packages/agents/src/runtime/` for this wiring; it lives in `@cogenta/mcp`/
1412
+ `@cogenta/cli` instead — `@cogenta/mcp` already depends on `@cogenta/agents`, so
1413
+ the reverse dependency the fiche's own path would need is a package cycle. Deviation
1414
+ signalled, not silently worked around.
1415
+
1416
+ Tests: `@cogenta/mcp` — the sandboxing floor (no inherited environment variable
1417
+ proven by inspecting what `spawn` actually receives while a real host secret is
1418
+ set; a hung server killed and rejected under a configured timeout; per-call abort;
1419
+ stderr capture), the connection store (confirmation requirement, encrypted secret,
1420
+ "absent, pas refusée"), discovery, and `buildMcpToolDefinitions` (one client shared
1421
+ across a connection's tools, a failed connection skipped not thrown, an end-to-end
1422
+ call through a fake stdio server). `@cogenta/api` — admin-only, the confirmation
1423
+ refusal, "absent, pas refusée" at the REST boundary. `@cogenta/cli` — a real,
1424
+ spawned `node` process (`test/fixtures/fake-mcp-server.mjs`) driven end to end
1425
+ through a real `cogenta serve`/SQLite/HTTP stack: connection created, tested,
1426
+ exposed, called by a real agent run with a scripted LLM vendor, proving the actual
1427
+ child process received none of the host's real environment
1428
+ (`COGENTA_AUTH_SIGNING_KEY` included) and that disabling a connection removes its
1429
+ tool from what an agent can call without a restart.
1430
+ - 2285720: Menus gain a real editor (fiche `docs/plans/09-menus.md`):
1431
+
1432
+ - **Edit an item in place.** `PATCH /api/menus/{id}/items/{itemId}` now accepts `label`, `kind`, the target fields, `title` and `openInNewTab` — no more delete-and-recreate to fix a typo. Changing `kind` clears the previous target rather than keeping a value that no longer applies. `parent` is deliberately not accepted here; re-parenting still goes through `POST .../move`.
1433
+ - **Bulk, transactional reorder.** `MenuStore.reorderItems` and `PATCH /api/menus/{id}/items` rewrite `parent`/`position` for any number of items in a single transaction, so a drag-and-drop or keyboard reordering session commits (or fails) as one unit — never a partially-rewritten tree if the network drops mid-session.
1434
+ - **Menu locations.** `Menu` gains `location: string | null` (`byLocation`, `GET /api/menus/by-location/{location}`) — where a menu renders (`primary`, `footer`, …), carried by the menu itself rather than baked into a theme's name convention. `@cogenta/cli`'s `ThemeRenderOptions` gains `headerMenuLocation`/`footerMenuLocation`, resolved generically by location with a fallback to the legacy `main`/`footer` name lookup, so an existing site's navigation keeps rendering unchanged. `@cogenta/core` gains the `MENU_LOCATION_TAKEN` error code for the one-menu-per-location-per-locale rule.
1435
+ - **Two new item kinds.** `taxonomy` (links to a term) and `home` (always resolves to `/`) join `entry`/`url`/`submenu-placeholder`.
1436
+ - **Target health.** A menu item resolver may now report `health` (`published`/`draft`/`scheduled`/`archived`/`trashed`) for an `entry` item — computed only for an actor whose role already has draft access to the target collection, so a public read never learns that a draft exists. `cogenta serve`'s public render hides a dead `entry`/`taxonomy`/`home` link entirely rather than serving one.
1437
+
1438
+ All additions are backward compatible: `resolveEntry` gained a third `context` parameter and an optional `health` on its result, but a two-argument resolver still satisfies the type; every new field is optional or nullable on the wire.
1439
+ - a6530f6: Taxonomy terms finally have a public page (contract D `theme@1.3`, additive).
1440
+
1441
+ ADR-0022 shipped native taxonomies and the admin has let an editor point a menu item
1442
+ at a term ever since — and `resolveMenuTerm` answered `route: null` for every one of
1443
+ them, honestly, because no site rendered such a page. A term was a filing cabinet with
1444
+ no door.
1445
+
1446
+ - `GET /{taxonomy}/{term-slug}` lists every published entry filed under a term, newest
1447
+ first, across every collection that classifies with it. `?page=N` paginates; page 2
1448
+ and beyond are `noindex, follow` with a canonical of their own.
1449
+ - `@cogenta/theme-kit` gains `TermArchiveInput` and `ThemeModule.renderTermArchive` —
1450
+ **optional**: a theme that does not implement it still serves the page, in its own
1451
+ chrome, through a plain host-rendered list. The five built-in themes each implement
1452
+ it with their own layout, reusing their own `collectionList` card classes so an
1453
+ archive looks like that theme's lists rather than a sixth design.
1454
+ - `resolveMenuTerm` returns a real route, so a taxonomy menu item is a link.
1455
+ - `/sitemap.xml` lists every term that has something published under it.
1456
+
1457
+ Two decisions: the URL pattern is fixed and resolved by the host **after** every real
1458
+ collection route has failed to match — so a `/blog/:slug` route can never be shadowed,
1459
+ and a taxonomy needs no `routing` of its own (which would have been a contract A
1460
+ change ADR-0022 deliberately avoided). And a term archive lists that term only; its
1461
+ sub-terms are offered as links rather than folded in, so what the page shows always
1462
+ matches the term that was asked for.
1463
+ - 9b1dae8: Fiche 43 sub-chantiers A, B, E, F (Cogenta Page Builder — motifs, copier/coller, verrouillage/sélection multiple, import/export) — extends the L16 visual page builder without touching contract A, B, C or D.
1464
+
1465
+ **Sub-chantier A — pattern/model library.** `@cogenta/schema` gains a new
1466
+ one-fixed-table store (`ensurePatternTables`/`createPatternStore`,
1467
+ `cogenta_patterns`), the same "not schema-declared, one fixed pair/table"
1468
+ treatment `menu-tables.ts` already gets — a pattern is a reusable *shape* an
1469
+ editor composes from existing blocks, never a thirteenth block type. Two
1470
+ kinds share the table: a **motif** (a few blocks, added to whatever a page
1471
+ already has) and a **modèle de page complet** (replaces the whole block
1472
+ zone, and only ever behind explicit confirmation in the admin — never
1473
+ silently). `@cogenta/api` gains `createPatternRouter` (`/api/patterns`,
1474
+ admin/editor only on every method, mirroring `redirect-router.ts`'s fixed
1475
+ door) with two new error codes on `@cogenta/core`, `PATTERN_UNKNOWN`/`PATTERN_INVALID`. A
1476
+ pattern's blocks are validated against the site's block registry
1477
+ (`@cogenta/blocks`'s `vocabularyRegistry` by default, overridable) exactly
1478
+ the way a clipboard paste is: one unknown block type refuses the whole
1479
+ pattern, never a partial or best-effort insert. `@cogenta/cli` wires both
1480
+ into `cogenta serve` (`ensurePatternTables` at boot, `/api/patterns` mounted
1481
+ next to `/api/menus`) and into `cogenta backup`/`cogenta restore`
1482
+ (`PATTERN_TABLE` added to the table list `buildBackupTables` already
1483
+ assembles).
1484
+
1485
+ **Sub-chantier B — copy/paste and reusable blocks.** Purely client-side
1486
+ (`@cogenta/admin`, no published package touched): `Ctrl/⌘+C`/`Ctrl/⌘+V` on
1487
+ the builder's block selection, through the browser clipboard as
1488
+ `cogenta/blocks@1`-tagged JSON, validated the same way on paste (unknown
1489
+ block type named and refused). "Blocs réutilisables" is deliberately not a
1490
+ second mechanism — fiche 05 task 3's own recommendation — a single-block
1491
+ pattern already covers it: insertion is always a copy, never a live
1492
+ reference, so there is nothing in contract B to touch.
1493
+
1494
+ **Sub-chantier E — lock and multi-select.** Also admin-only. A lock is a
1495
+ session-only admin flag, never persisted to contract B or the server; a
1496
+ locked block cannot be moved (by its own controls, by a neighbour's move
1497
+ displacing it, or as part of a group move) or removed. Multi-select is
1498
+ scoped to the outline list (`Shift`+click), never the preview — the same
1499
+ `Shift`+click a keyboard/switch user can also drive, with named group
1500
+ buttons doubling every drag, per the lot's own rule. A group move/remove is
1501
+ always one undo step, never one per block.
1502
+
1503
+ **Sub-chantier F — import/export.** A pattern library round-trips through a
1504
+ versioned JSON file (`cogenta/pattern-file@1`), validated block-by-block on
1505
+ import the same way a save is. `provenance`/`provenanceDetail` follow
1506
+ contract A's own values (`human`/`assisted`/`generated`) — a pattern an
1507
+ agent generates is never indistinguishable from one a person authored by
1508
+ hand.
1509
+
1510
+ `cogenta_patterns` has the same one-suite-run-four-times contract test as
1511
+ `taxonomy-store.ts`/`content-store.ts` (`pattern-store.contract.ts`,
1512
+ SQLite as a unit test and Postgres/MySQL/MariaDB as loud-skip integration
1513
+ tests) — deliberately not left SQLite-only the way `menu-store.ts`'s own
1514
+ table predates this discipline and still is.
1515
+
1516
+ No contract touched: A, B, C and D are all unchanged. `PermissionLayer`
1517
+ gains no new method — pattern management is a fixed admin/editor rule, the
1518
+ same shape `redirectRouter`/`menuRouter` already use, and *inserting* a
1519
+ pattern's blocks into an entry still goes through the entry's own existing
1520
+ `update` permission (`POST /api/builder/render`'s `PermissionLayer.assert`),
1521
+ unchanged.
1522
+ - 8a8d873: Add PayPal as a third, independently registered payment driver — proof that the payment
1523
+ gateway a shop uses is not a fixed Stripe/bank-transfer pair but an open registry
1524
+ (`@cogenta/commerce`'s `PaymentGateway` interface, the same `Driver<T, Config>` shape as
1525
+ cache/queue/storage), the concrete answer to "what if I don't want Stripe?".
1526
+
1527
+ - `@cogenta/commerce` gains `payment/paypal.ts` (`paypalPaymentDriver`), written against
1528
+ PayPal's REST Orders v2 / Payments v2 API with `fetch`, no new dependency (R9), the same
1529
+ discipline as `payment/stripe.ts`: OAuth2 client-credentials token caching, a real
1530
+ RSA-SHA256 webhook signature check against a certificate fetched from
1531
+ `paypal-cert-url` (trusted only when its origin matches `apiBaseUrl` or is a genuine
1532
+ `*.paypal.com` host — a forged cert-url header cannot "verify" against its own key), a
1533
+ freshness window, and an explicit event whitelist so an unrecognised PayPal event is
1534
+ refused rather than guessed as `paid`. `fetch()` captures an order the moment it sees
1535
+ `APPROVED` (there is no separate capture verb in this project's narrow
1536
+ `PaymentGateway` interface), tolerating the one real race a concurrent poll can hit
1537
+ (`ORDER_ALREADY_CAPTURED`) by re-reading the order instead of failing. Registered in
1538
+ `payment/registry.ts` alongside Stripe (both `optimal`) ahead of the always-available
1539
+ `manual` driver (`degraded`).
1540
+ - `@cogenta/core`'s `payment` configuration section gains `paypal` as a named driver and
1541
+ three secret fields (`paypalClientId`, `paypalClientSecret`, `paypalWebhookId`), refused
1542
+ in `cogenta.config.mjs` the same way Stripe's are and sourced only from
1543
+ `COGENTA_PAYMENT_PAYPAL_CLIENT_ID` / `COGENTA_PAYMENT_PAYPAL_CLIENT_SECRET` /
1544
+ `COGENTA_PAYMENT_PAYPAL_WEBHOOK_ID`.
1545
+ - `@cogenta/cli`'s `cogenta serve` passes the three PayPal fields through to
1546
+ `createPaymentRegistry` alongside the existing Stripe ones — no other wiring changed.
1547
+ - `@cogenta/admin` (private, no changeset): the payment screen is rebuilt from a
1548
+ two-card grid into a real provider list (WooCommerce's "Payment providers" pattern) —
1549
+ each row shows tier, configured/not-configured, active, and its own test-connection
1550
+ button — so a third driver appears with no change to the component, proving the point
1551
+ visually rather than only in code.
1552
+
1553
+ PayPal's sandbox and live environments are different hostnames (`api-m.sandbox.paypal.com`
1554
+ vs `api-m.paypal.com`), unlike Stripe's single host with a test/live key prefix — an
1555
+ operator testing against the sandbox sets `payment.apiBaseUrl` explicitly, the same escape
1556
+ hatch the driver's own test suite uses to point at a local HTTP stub.
1557
+ - dd9e9a2: Fiche 40 (diagnostics et messages d'erreur de configuration) — the exact bug
1558
+ the user reported: `preview-token.ts` already threw a `CONFIG_INVALID` error
1559
+ with a correct `hint` ("Set COGENTA_PREVIEW_SIGNING_KEY … `openssl rand -hex
1560
+ 32` … never in a configuration file"), but the admin only ever read
1561
+ `caught.message`, throwing the `hint` away every time — so an editor clicking
1562
+ "Prévisualiser" without the key set never saw what to do about it.
1563
+
1564
+ `@cogenta/api` exports `PREVIEW_SIGNING_KEY_MINIMUM_LENGTH` (`preview-token.ts`,
1565
+ was already an internal `32` — additive, no behaviour change) so `cogenta
1566
+ doctor` can check against the exact same number the token service enforces,
1567
+ rather than a second `32` copied by hand that could silently drift.
1568
+
1569
+ `@cogenta/cli`'s `cogenta doctor` gains a proactive check: a missing or
1570
+ too-short `COGENTA_PREVIEW_SIGNING_KEY` is now reported in `notes` (a
1571
+ warning, same tier as the existing `COGENTA_STORAGE_SIGNING_KEY` note) —
1572
+ never in `problems`, since the key is only needed once a draft is actually
1573
+ previewed (`withPreview`, `packages/api/src/rest/router.ts`) and `doctor`
1574
+ must never fail a site over an optional feature.
1575
+
1576
+ No contract touched: `PREVIEW_SIGNING_KEY_MINIMUM_LENGTH` is a plain
1577
+ constant, not a tool, permission, schema field or theme hook.
1578
+ - 3075941: Fiche 45 — Prompt Settings, a shared, editable library for every utility prompt an `assist.*` tool sends the model. Until now, each instruction line (`assist.rewrite`, `assist.proofread`, `assist.summarise`, `assist.translate`, `assist.meta_description`, `assist.titles`, `assist.tags`, `assist.alt_text`, `assist.classify`, `assist.moderate`, `assist.faq_draft`, `assist.schema_org_draft`, `assist.chat`) was a literal string baked into the package.
1579
+
1580
+ `@cogenta/agents` gains a new `prompts/` module: `PromptTemplateStore` (`createFilePromptTemplateStore` — one JSON file per template, same "real but local" tier as the existing agent/skill/provider stores, R1), `renderPromptTemplate`/`resolveInstruction` (`{{field}}` placeholder substitution that throws `PROMPT_TEMPLATE_PLACEHOLDER_UNRESOLVED` rather than sending a literal unresolved placeholder to the model), and `builtinPromptTemplateSeeds`/`ensureBuiltinPromptTemplates` (thirteen templates reproducing every existing `assist.*` instruction verbatim as editable text, plus two new ones — `generate_text_block` for the future page-builder "Générer" button and `generate_agent_system_prompt` for the future agent-creation flow — written with the same care as a built-in agent's `identity.md`).
1581
+
1582
+ Every migrated `assist.*` tool constructor now accepts an optional trailing `PromptTemplateStore` argument (`createWritingTools`, `createClassifyTool`, `createModerateTool`, `createFaqTool`, `createSchemaOrgTool`, `createContentChatTool`'s options). Backward compatible: omitting it (or a site whose store has never been seeded) reproduces the exact pre-existing hard-coded instruction, byte for byte — proven by a dedicated non-regression test comparing the seeded-store path against the original inline construction for every migrated tool. A tool's `role`/objectives and the R8 anti-injection rule stay in code, deliberately not migrated — they are the security boundary, not the prompt text an editor should be able to reword from a settings screen.
1583
+
1584
+ `@cogenta/api` gains `createPromptTemplatesRouter` (`/api/prompt-templates`) — `GET` open to any signed-in actor, `POST`/`PATCH`/`DELETE` restricted to `admin`, mirroring `agent-skills-router.ts`'s shape. New `ErrorCode`s (`@cogenta/core`): `PROMPT_TEMPLATE_UNKNOWN` (404), `PROMPT_TEMPLATE_DUPLICATE` (409), `PROMPT_TEMPLATE_BUILTIN_UNDELETABLE` (409), `PROMPT_TEMPLATE_INVALID` (400), `PROMPT_TEMPLATE_PLACEHOLDER_UNRESOLVED` (400).
1585
+
1586
+ `@cogenta/cli`'s `cogenta serve` now builds a `PromptTemplateStore` under `.cogenta/agents-runtime/prompt-templates` (seeded on first boot, idempotent) and threads it through both `buildAssistant` (so the writing-assistant tools resolve their instruction text from it) and `buildAgentRuntime` (which mounts `/api/prompt-templates`) — the same directory, two file-store instances, safe because neither caches across calls.
1587
+
1588
+ The admin's "Prompt Settings" screen (`packages/admin`, private, no changeset) is a new admin-only entry in the AI nav group: list/create/edit/delete a template, with a builtin always editable but never removable.
1589
+ - e01efae: T09-04 (RGPD, audit 2026-09-01) — `exportPersonalData` (`@cogenta/export`) had zero
1590
+ callers anywhere in the codebase; the legal obligation it exists to satisfy was not
1591
+ exerciseable. `@cogenta/api`'s `users-router.ts` gains `GET /api/users/{id}/personal-data`
1592
+ (self-or-admin, the same rule `GET /{id}` itself already follows) — assembles the
1593
+ account, every collection entry it authored (via the same `storeFor` REST/GraphQL/theme
1594
+ rendering already share, now a `UsersRouterOptions` field), and the honest `gaps` array
1595
+ `exportPersonalData` reports for domains this codebase has no store for yet. The export
1596
+ is itself journalled (`user.personal_data_export`, naming whether it was a self-request
1597
+ or an admin acting on a third party). `cogenta serve` wires `storeFor` into the router;
1598
+ the admin gains an "Export my personal data" button on the profile screen (every role,
1599
+ self only) and an "Export the personal data of {{email}}" action per account row on the
1600
+ Users screen (admin, any account). New direct dependency `@cogenta/api` → `@cogenta/export`
1601
+ (R9: reusing an existing, tested assembly function rather than a second one).
1602
+
1603
+ T09-01 — `AuditLog.prune()` (`@cogenta/auth`) has existed since fiche 21 task 5 with no
1604
+ scheduled caller, so an audit log grew without bound on every site regardless of
1605
+ retention intent. `@cogenta/core` gains `security.audit.retainDays` (optional; absent —
1606
+ the default — changes nothing, `0` is the explicit "never purge" opt-out). `cogenta
1607
+ serve` registers a new daily `audit-prune` scheduled task (`Site.tickAuditPrune`) that
1608
+ purges entries older than the configured window and journals the purge itself
1609
+ (`audit.prune`, naming `retainDays`/`cutoff`/`prunedCount`) — a no-op when unconfigured.
1610
+
1611
+ T09-02 — `errorResponse` (`@cogenta/api`) gains a generic `Retry-After` header for any
1612
+ `CogentaError` whose `details.retryAfterMs` names a concrete backoff (only the derived
1613
+ integer ever reaches the wire, never `details` itself). `AUTH_RATE_LIMITED` — thrown by
1614
+ login and forgot-password rate limiting — is the first beneficiary: a 429 that used to
1615
+ say "try again later" in prose now carries a real, pollable `Retry-After`.
1616
+ - 5de237f: Fiche 63 (ADR-0028) — a role's grant on a collection or taxonomy action can
1617
+ now be overridden in the database, applied on the very next request with no
1618
+ deploy cycle. `cogenta.schema.*`'s `permissions` block stays the source of
1619
+ truth for a site that never writes an override; the database is checked
1620
+ first and falls back to the file, never the other way around.
1621
+
1622
+ `@cogenta/core` gains three error codes: `ROLE_PERMISSION_TARGET_UNKNOWN`
1623
+ (404 — an override names a collection/taxonomy the site does not declare),
1624
+ `ROLE_PERMISSION_INVALID` (400 — a malformed override, including `own` on a
1625
+ taxonomy, which has no author) and `ROLE_PERMISSION_EXPORT_INVALID` (a
1626
+ malformed `cogenta roles export` file being read back).
1627
+
1628
+ `@cogenta/schema` gains `createRolePermissionStore` (validates every write
1629
+ by folding the candidate rule into the real `CollectionDefinition`/
1630
+ `TaxonomyDefinition` and reusing `defineCollection`/`defineTaxonomy`
1631
+ unmodified — no second validation logic), `createRolePermissionOverlay` (the
1632
+ synchronous, refreshable read-through cache `PermissionLayer` consults),
1633
+ `ensureRolePermissionTable`/`ROLE_PERMISSIONS_TABLE`, and
1634
+ `serialiseRolePermissionExport`/`parseRolePermissionExport` for freezing the
1635
+ table's state into a versioned JSON file. All additive; contract A
1636
+ (`CollectionDefinition`, `TaxonomyDefinition`, `CollectionPermissions`) is
1637
+ unchanged — the override table lives entirely outside the contract.
1638
+
1639
+ `@cogenta/api`'s `createPermissionLayer` gains an optional
1640
+ `rolePermissionOverrides` option (a `RolePermissionOverrides` from
1641
+ `@cogenta/schema`) — absent behaves byte-for-byte as before. A new router,
1642
+ `createRolePermissionRouter`, serves `GET`/`PUT /api/role-permissions` and
1643
+ `DELETE /api/role-permissions/{targetType}/{targetName}/{action}`,
1644
+ admin-only. `STATUS_BY_CODE` gains the two new HTTP-mapped error codes above.
1645
+
1646
+ `@cogenta/cli` wires the override store and overlay into `cogenta serve`
1647
+ (mounting `/api/role-permissions`, journaling every successful write to the
1648
+ audit log), `cogenta mcp` and `cogenta channels` (each builds its own
1649
+ `PermissionLayer`, so each needed the same wiring — otherwise a permission
1650
+ revoked in production would stay granted to those processes until restart).
1651
+ A new command, `cogenta roles export [--out <path>]`, freezes the table into
1652
+ a file a site can commit to git.
1653
+ - 745ebd8: Editorial workflow and owner permission (`schema@2.1`, ADR-0027, fiche 37 + fiche 19
1654
+ task 5).
1655
+
1656
+ Strictly additive — a site that never declares `workflow: { enabled: true }` on a
1657
+ collection, and never uses the `{ roles, own }` permission form, behaves identically
1658
+ to before this release. Proved by a compatibility test: a client reading only
1659
+ `status` gets byte-identical values.
1660
+
1661
+ - `reviewState` (`none`/`pending`/`changes-requested`/`approved`) and
1662
+ `assignedReviewer` join the system fields, orthogonal to `status` — the same design
1663
+ ADR-0022 gave `deletedAt`. `approved` is not `published`: approving authorises,
1664
+ `publish` remains the action that makes an entry public.
1665
+ - A closed, server-side transition table (`submit`/`approve`/`requestChanges`), each
1666
+ gated by its own contract A action (`update` for submit, `publish` for the other
1667
+ two) — never duplicated by a client.
1668
+ - New `ContentStore` methods `submitForReview`/`approveReview`/`requestReviewChanges`/
1669
+ `assignReviewer`, and new REST routes `POST .../submit`, `.../approve`,
1670
+ `.../request-changes`, `.../assign-reviewer` — each its own path, never a second
1671
+ meaning for an existing verb (ADR-0022's own lesson for `purge`).
1672
+ - `CollectionPermissionRule` gains the object form `{ roles, own? }` alongside the
1673
+ plain role-name array, which stays valid. `own: true` scopes every listed role to
1674
+ entries the acting account created; `PermissionLayer.can()`/`.assert()` take an
1675
+ optional `ownerId` to check it.
1676
+ - Reversible, non-destructive migration (`schema21Migration`) adding `review_state`
1677
+ (`not null default 'none'`) and a nullable `assigned_reviewer` to every collection.
1678
+ - Admin: a review queue screen (three tabs — assigned to me / all pending / my
1679
+ submissions — aggregated server-side via a new `GET /api/review`), a pending-count
1680
+ nav badge, and an entry editor sidebar showing workflow state, assigned reviewer,
1681
+ and a contextual action button that replaces the absent Publish button with
1682
+ "Submit for review" for an actor without `publish`.
1683
+
1684
+ Postgres/MySQL/MariaDB integration test files are written
1685
+ (`packages/schema/test/integration/schema-2-1-migration.test.ts`) but not executed
1686
+ this session — Docker unavailable; they skip loudly, naming the missing variable.
1687
+ - 4bb6ba3: Fiche 50, tasks 1-5 — direct sitemap/robots.txt links from the Diagnostic tab, Search Console/Bing site verification (meta tag only, no OAuth — R1/R7), a hand-written robots.txt addendum, and wiring the two indexing extras (`indexnow.ts`/`llms-txt.ts`) that were written and unit-tested since L3/L9 but never reachable from any route or setting. Task 6 (RSS/Atom) is explicitly out of scope, per the fiche's own "à confirmer".
1688
+
1689
+ - **`@cogenta/seo`**: `RobotsOptions` gains `customRules` — an admin's own robots.txt lines, merged in verbatim by `renderRobotsTxt` after the derived group(s) and before the `Sitemap:` directive. New export `robotsRuleDisallowsEverything(text)` — true when `text` contains a bare `Disallow: /`, so a caller (the admin's custom-rules editor, in particular) can confirm before saving a rule that would block every crawler.
1690
+ - **`@cogenta/schema`**: `SITE_SETTINGS_REGISTRY`'s `seo` group gains six settings — `seo.googleSiteVerification`/`seo.bingSiteVerification` (meta-tag verification tokens), `seo.robotsCustomRules` (free text, merged into `/robots.txt`), `seo.indexNowEnabled`/`seo.indexNowKey` (off by default), `seo.llmsTxtEnabled` (off by default). All admin-only, all in the existing `SiteSettingsStore` — no new table.
1691
+ - **`@cogenta/api`**: `SeoRouterOptions` gains `robotsCustomRules` (an async getter, same "read live" contract as `titleDefaults`) — the Diagnostics screen's `robots.content` preview now shows the exact document `/robots.txt` serves, custom rules included, and `disallowsEverything` also flags a custom rule that blocks every crawler.
1692
+ - **`@cogenta/cli`**: `seo.ts`'s `SeoRenderDefaults` gains `googleSiteVerification`/`bingSiteVerification`/`robotsCustomRules`; new export `siteVerificationMetaTags` renders the two `<meta>` tags. New export `SeoOperationalSettings`/`readSeoOperationalSettings` for the two off-by-default extras. `RobotsRenderOptions`/`renderRobots` gain `customRules`. `PageChromeOptions` (`theme-render.ts`) gains `seo`, so `/search` and `/forms/{name}` carry the same verification tags every entry page does. `cogenta serve` gains `GET /llms.txt` (404 unless `seo.llmsTxtEnabled`) and IndexNow's ownership-proof key file at `/<key>.txt` (served only when the requested key matches the configured one), and pings IndexNow on a successful publish/unpublish response when `seo.indexNowEnabled` is on — never blocks or fails the response it follows.
1693
+
1694
+ Admin (`@cogenta/admin`, private, no changeset): the SEO screen's Général tab gains a search-engine-verification card and an IndexNow/llms.txt card (with a "Generate a key" button); the Diagnostic tab gains "Open sitemap.xml"/"Open robots.txt" links and an editable robots.txt custom-rules field that asks for confirmation before saving a rule containing `Disallow: /`.
1695
+ - 960757d: Fiche 70 (SEO platform parity — AIOSEO/The SEO Framework/MonsterInsights/Site
1696
+ Kit) — four tasks closing the gaps a real research pass found against those
1697
+ four tools, which the earlier SEO fiches (13, 50) never looked at.
1698
+
1699
+ **Task 1 — real-time content score.** `@cogenta/seo` gains `analyseContent`
1700
+ (`content-analysis.ts`): a pure, synchronous TruSEO-style scorer over
1701
+ contract A's rich text — keyword usage in title/description/first sentence,
1702
+ keyword density, sentence length, subheadings, content length. Returns a
1703
+ closed `'red' | 'orange' | 'green'` score, never a numeric percentage. A new
1704
+ conventional field, `seoFocusKeyword`, joins `seoTitle`/`seoDescription`/etc.
1705
+ (contract A untouched). The admin panel keeps its own mirrored copy of the
1706
+ algorithm rather than depending on `@cogenta/seo`/`@cogenta/schema` — the
1707
+ admin is a browser bundle and never takes that dependency.
1708
+
1709
+ **Task 2 — internal link assistant.** `@cogenta/seo` gains
1710
+ `analyseInternalLinks` (`link-assistant.ts`), reusing `@cogenta/schema`'s
1711
+ existing `extractLinks`: reports entries with no inbound link and, for
1712
+ entries sharing title words, up to five link candidates. `@cogenta/api`'s
1713
+ `createSeoRouter` gains `GET /api/seo/link-suggestions?collection=…`, gated
1714
+ by `update` on the named collection (never `admin`) so an editor can run it
1715
+ on whatever they may already write.
1716
+
1717
+ **Task 3 — SEO feature grid.** Four new `seo.*` boolean settings
1718
+ (`contentScoreEnabled`, `linkAssistantEnabled`, `searchVerificationEnabled`,
1719
+ `robotsCustomRulesEnabled`) in `@cogenta/schema`'s site settings registry,
1720
+ all defaulting to `true` so an upgrading site's behaviour is unchanged. The
1721
+ last two are gated centrally inside `@cogenta/cli`'s `readSeoRenderDefaults`,
1722
+ so every consumer (public `robots.txt`, verification meta tags, the
1723
+ diagnostics scan) honours the toggle with no per-call-site duplication.
1724
+
1725
+ **Task 4 — optional Google Search Console connector (ADR-0032).**
1726
+ `@cogenta/seo` gains `search-console.ts`: a fetch-only OAuth client (no
1727
+ `googleapis` SDK) for the authorization URL, token exchange/refresh, and one
1728
+ read-only `searchAnalytics.query` call — structurally incapable of writing
1729
+ anything on the Google side. `@cogenta/schema` gains
1730
+ `createSearchConsoleConnectionStore`: one site-wide connection row,
1731
+ AES-256-GCM at rest via `COGENTA_AUTH_SIGNING_KEY` (same discipline as the
1732
+ LLM provider store), full SQLite/Postgres/MySQL/MariaDB contract suite.
1733
+ `@cogenta/api` gains `createSearchConsoleRouter`
1734
+ (`/api/seo/search-console/*`): `status`/`authorize`/`metrics`/`disconnect`
1735
+ are admin-only; `callback` (Google's own browser redirect target) carries no
1736
+ bearer token by design, proven legitimate instead by an HMAC-signed,
1737
+ ten-minute `state` token keyed by `COGENTA_AUTH_SIGNING_KEY`. `@cogenta/core`
1738
+ gains the `searchConsole` config section (client id/secret, environment-only,
1739
+ refused in the config file like every other secret) and five new error codes
1740
+ (`SEARCH_CONSOLE_NOT_CONFIGURED`/`_NOT_CONNECTED`/`_STATE_INVALID`/
1741
+ `_TOKEN_EXCHANGE_FAILED`/`_QUERY_FAILED`). Absent without both
1742
+ `COGENTA_SEARCH_CONSOLE_CLIENT_ID`/`_CLIENT_SECRET` set — every other SEO
1743
+ feature, including tasks 1-3 above, works identically with or without it
1744
+ (R1/R2), which was the explicit condition the user set when accepting
1745
+ ADR-0032.
1746
+ - 2d84729: Fiche 21, task 3 — merge SEO + Redirections into one admin screen, and make sitemap/social/title settings real and admin-editable (previously "read-only by design", a scope choice of a previous lot rather than an ADR).
1747
+
1748
+ - **`@cogenta/seo`**: `MetadataOptions` gains `fallbackImage` — a site-wide default Open Graph/Twitter Card image, used by `buildMetaTags` only when neither the caller's own `image` nor the resource's `seoImage`/first `media` field resolves to anything. `SitemapOptions` gains `collectionOverrides` (new exported type `SitemapCollectionOverride`) — per-collection `included`/`changefreq`/`priority`, applied by `sitemapUrlsFor`; `included: false` drops every entry of that collection from the sitemap outright.
1749
+ - **`@cogenta/api`**: `SeoRouterOptions.titleTemplate`/`collectionTitleTemplates` (static, and never actually wired to anything — dead since the fields were added) are replaced by `titleDefaults`, an async getter read fresh on every diagnostic scan and SEO preview, mirroring the "read live, never cached at startup" contract `@cogenta/cli`'s `ThemeRenderOptions.homePath` already uses. **Breaking** for any direct caller of `createSeoRouter` passing the old static fields.
1750
+ - **`@cogenta/schema`**: `SITE_SETTINGS_REGISTRY` gains a `seo` group — `seo.titleTemplate`, `seo.collectionTitleTemplates`, `seo.defaultMetaDescription`, `seo.sitemapCollectionSettings`, `seo.twitterHandle`, `seo.defaultSocialImageUrl` — persisted through the same `SiteSettingsStore` `settings.tsx`'s Général/Reading/Discussion tabs already use, no new table or migration.
1751
+ - **`@cogenta/cli`**: `seo.ts` gains `SeoRenderDefaults`/`readSeoRenderDefaults` (reads the six settings above, live); `seoSiteFor` and `HeadOptions`/`renderSeoHead` take an optional `seo`/`SeoRenderDefaults` to apply the title template, per-collection template override, default meta description, Twitter handle and fallback social image; `buildSitemapFiles` takes an optional `collectionOverrides`. `ThemeRenderOptions` gains `seo?: () => Promise<SeoRenderDefaults>`, wired into every render path in `cogenta serve` (published page, page-builder preview, admin SEO preview redirect check, `/sitemap.xml`) so a saved setting shows up on the very next request, no restart.
1752
+
1753
+ Admin (`@cogenta/admin`, private, no changeset): `/seo` and `/redirects` merge into one nav entry ("SEO") with five tabs — Général, Sitemap, Réseaux sociaux, Redirections (the previous `redirects.tsx` screen, unchanged, now `RedirectsPanel`), Diagnostic (the previous read-only reports, unchanged, now loaded lazily only when that tab is opened). `/redirects` still resolves (redirects to `/seo?tab=redirects`), the same pattern already used for `/site-plan` → `/create-site`.
1754
+ - b50f7bb: Fiche 35 (coquille et navigation): the admin sidebar is now grouped by
1755
+ domain (Contenu, Apparence, Boutique, IA, Comptes, Exploitation,
1756
+ Réglages) and filtered by role, active features (a shop-less site has
1757
+ no Boutique group) and available capabilities (no AI provider reduces
1758
+ the IA group to its explanation page) — a contributor sees six entries
1759
+ instead of twenty-three. Adds a collapsible/responsive sidebar with a
1760
+ mobile drawer, aggregated badges (trash count, orders to process) from
1761
+ one request rather than one per badge, a breadcrumb with a
1762
+ per-navigation `document.title`, and `⌘K`/`Ctrl+K` command-palette
1763
+ actions on top of the existing search. `@cogenta/api` gains
1764
+ `createShellStatusRouter` (the single aggregated status read the
1765
+ badges and feature gates use). `@cogenta/cli`'s `theme-render.ts`
1766
+ renders a thin "edit this page" admin bar on the public site for an
1767
+ authenticated visitor only, never for an anonymous one.
1768
+ - 835d736: L22 task 3: "l'agent qui surveille le site" — the one concrete case the lot's spec asks to ship first, tested end to end against a real `cogenta serve`. A superagent-shaped agent, disabled by default like the other two examples, that reads the public 404 log (never source code, never a request body or an IP — the log itself carries neither), picks a genuinely related, routed page, and proposes or creates a redirect depending on the site's configured autonomy — reusing the runtime `withAutonomyForManifest` already built for L22 task 1, not a bespoke gate.
1769
+
1770
+ `@cogenta/agents` gains a fourth built-in agent, "Site Monitor" (`SITE_MONITOR_AGENT_NAME`, `builtins.ts`), disabled by default with a daily cron trigger, autonomy `propose` by default — raising it to `autonomous` (autopilot) is what the lot names as the condition for an *applied*, not merely *suggested*, redirect. Four new contract-C tools back it: `logs.read_not_found` (new permission `logs.read`, read-only over `@cogenta/schema`'s `NotFoundLogStore`), `content.collections`/`content.list` (both under the existing `content.read` permission — browsing is the same access as reading one entry, not a wider grant), and `redirects.create` (new permission `redirects.write`, `sideEffects: true`, `reversible: true` — its `revert` removes exactly the redirect it created). Contract C moves to `tools@1.2` (`docs/04-contrats.md`): two permissions added by the bottom to an open taxonomy, no existing tool signature touched — the same kind of change `document.extract` was in `tools@1.1`.
1771
+
1772
+ `@cogenta/schema`'s `RedirectReason` gains a fourth value, `'agent'` — `redirects.create` always writes it, never `'manual'`, so an admin looking at the Redirections screen can tell which rows a human typed and which one an agent proposed and had applied. Additive to a stored, open list (not a versioned contract enum); a row written by an older build still reads back fine (`toRecord`'s existing fallback to `'manual'`).
1773
+
1774
+ `@cogenta/api` gains `createMonitoringRedirectSuggestionSource` (`notices/monitoring-redirect-suggestion.ts`) — the dashboard half: a redirect an agent proposed under `co-pilot` autonomy surfaces as an admin notice (from/to, which agent), linking straight to the *existing* Redirections screen rather than a second confirmation UI, and disappears on its own once the redirect exists (created by hand, or later applied under `autopilot`) — never because the underlying `ApprovalQueue` request was "decided" (L22 task 1's queue still has no admin surface to decide anything from).
1775
+
1776
+ `@cogenta/cli`'s `agent-runtime.ts` wires all four new tools into the site's real tool registry (the real `NotFoundLogStore`/`RedirectStore`/`CollectionDefinition[]` `serve.ts` already builds, never a second instance) and now exposes the runtime's `ApprovalQueue` on `AgentRuntimeAssembly` so `serve.ts` can build the notice source over the exact same queue `co-pilot` autonomy files into. `serve.ts` adds one more entry to the notices sources array — the seam fiche 38 designed this mechanism around — and threads `collections`/`notFoundLog`/`redirects` into `buildAgentRuntime`.
1777
+
1778
+ R2 holds throughout: with no LLM provider configured, the Site Monitor exists in configuration (seeded, listable, editable) and attempts zero network calls — `AgentRunner.run()`'s existing `AGENT_NO_PROVIDER` guarantee, unchanged, covers this agent the same as every other one.
1779
+
1780
+ **Deliberately out of scope, named honestly rather than silently promised**: server-error and downtime detection (the lot's own other two example anomalies) are not built — this task ships the one case the spec asks to land first, tested end to end; the other two stay documented ideas for a future lot.
1781
+
1782
+ No new dependency (R9): every new tool wraps a store or a route this project already had (`NotFoundLogStore`, `RedirectStore`, `ContentService.summary`/`list`, `buildPath`), and `@cogenta/agents` already depended on `@cogenta/schema`.
1783
+ - cf005d4: Fiche 60: site plan generation gains conscience of the site it would join. Before this, a plan proposed from the admin on a site with two hundred articles and a live shop looked exactly like a plan proposed on an empty database — the only contact with reality was `site-plan.ts`'s late, defensive "this collection name is already taken" refusal at *apply* time, never an entry of the agent's own reasoning.
1784
+
1785
+ `@cogenta/agents` gains `describeExistingSite`/`ExistingSiteSnapshot` (`site-plan/site-context.ts`): a plain-data snapshot of a site's declared collections (name, fields, entry/published counts), taxonomies, active theme and configured integrations — built by the caller (no database dependency inside this package), and rendered to text only for `assembleContext`'s tagged `data` channel (R8: the whole rendering goes through escaping uniformly, since a collection's own `labels` are free text an operator or an earlier agent chose).
1786
+
1787
+ `analyseBrief`, `proposeContentModel` and `generateSkinCandidates` gain an optional `existingSite` parameter, threaded through `proposeSitePlan`. Absent (the installer's own path, on a fresh site) or an empty snapshot, every request stays byte-for-byte what it always was — proven by tests comparing the two paths' requests directly. `generateSkin` (`skin/generate.ts`) gains an optional `context` (tagged data items) it did not have before, used only when `generateSkinCandidates` is given a populated `existingSite`; every existing caller keeps its exact single-message request.
1788
+
1789
+ Given a populated `existingSite`, `proposeContentModel` switches to "évolution plutôt que premier jet": the prompt asks for complements rather than a redefinition, and — never trusting the model alone, the same discipline `enforce.ts` already applies to explicit constraints — any collection proposed anyway under a name the site already declares is dropped structurally and reported in the new `skippedExisting` result field, surfaced as a plan warning.
1790
+
1791
+ New deterministic pass `detectStructuralGaps` (`site-plan/structural-gaps.ts`): compares the proposed pages and the existing site against a closed list of pages most sites need (contact, legal notice, privacy policy) and suggests only what neither already covers — never generated automatically (R6). `SitePlanDraft` gains `structuralGaps`, and `summarisePlan`/`resolveApprovedPlan` (`site-plan/approval.ts`) gain a new `structuralGaps` review section between `pages` and `skin`; an accepted suggestion joins the approved plan's `pages`, exactly as reviewable and exactly as unapplied-by-itself as every other item.
1792
+
1793
+ `@cogenta/cli`'s `site-plan.ts` gains `buildExistingSiteSnapshot`/`ExistingSiteContext`/`detectActiveIntegrations` (all exported for testing) and wires the snapshot into `createPlanner`, read fresh on every proposal — never cached across the process's lifetime, the same discipline `theme-wiring.ts` documents for its own token overlay. `SitePlanningOptions` gains an optional `taxonomies` field; `serve.ts` passes the taxonomies it already loads. The installer entry point (`create-cogenta`) is untouched — on a new site, `existingSite` is empty by construction, so its behaviour is unchanged.
1794
+
1795
+ Admin: "Créer un site" is renamed "Générer le site" (`nav.createSite`, `sitePlan.heading`, the onboarding guide's step 4) in both locales — the review screen itself needed no code change, since it already renders whatever sections the server returns.
1796
+
1797
+ No new dependency (R9). No contract touched: this is read access already covered by `PermissionLayer` (R2/R4), consistent with ADR-0023.
1798
+ - a6530f6: `cogenta serve` now serves `/feed.xml` (RSS 2.0) and `/atom.xml` (Atom 1.0), and every
1799
+ page advertises them from its `<head>`.
1800
+
1801
+ `@cogenta/seo`'s `feedItemsFor`/`renderRssFeed`/`renderAtomFeed` were written and
1802
+ unit-tested in L3 and never reached a route: a Cogenta site simply had no feed, which
1803
+ is parity Ghost, WordPress and Hugo all ship out of the box. Both are read the same
1804
+ `ANONYMOUS` way `sitemap.xml` and `robots.txt` are, and on by default — a feed
1805
+ publishes only what is already published, at URLs already in the sitemap, so there is
1806
+ nothing here for an operator to consent to.
1807
+
1808
+ Also fixes the public admin bar (fiche 35 task 6), which had **never rendered**: its
1809
+ renderer existed and the one dispatch meant to enable it never set the flag. Now that
1810
+ it appears, its three labels are translated (fr/en, following the page's own language)
1811
+ and its first one follows the same white-label switch as the footer credit, instead of
1812
+ saying "Cogenta Admin" on every site.
1813
+ - 19fe157: Fiche L24 tâche 5 (aperçu visuel des thèmes) — the appearance screen's theme
1814
+ picker gains a real visual preview per theme, not the five text-only cards it
1815
+ had since fiche L23.
1816
+
1817
+ `@cogenta/cli` gains `renderThemeGalleryPreview` (`theme-render.ts`) and the
1818
+ new admin-only route `POST /api/theme/gallery-preview`: it renders one fixed,
1819
+ database-free demo page (hero + collectionList + featureGrid, the same shape
1820
+ `create-cogenta`'s "blog" blueprint seeds a real home page with) through
1821
+ whichever installed theme package the request names, and returns the
1822
+ resulting HTML for the admin to show in an iframe — the same "iframe on the
1823
+ real server render, never a screenshot or a second React reimplementation of
1824
+ the twelve blocks" principle the visual page builder (fiche L16) already
1825
+ established. The route never touches `ContentGateway`: it cannot leak draft
1826
+ or private content, and it works identically on a site with zero content
1827
+ seeded yet, which is exactly when an admin is most likely to be comparing
1828
+ themes.
1829
+
1830
+ The demo content is fixed and identical across every theme on purpose —
1831
+ letting one theme's card look richer than another's because *this site's*
1832
+ real home page happens to use more blocks would make the comparison
1833
+ meaningless.
1834
+
1835
+ `Site` gains `themeGalleryStyles?: (themeName: string) => Promise<string |
1836
+ null>`, resolving the combined skin + *that theme's own* stylesheet by name
1837
+ — distinct from `resolveStyles`/`previewStyles`, which both resolve against
1838
+ the currently active theme only. Absent under the same condition every other
1839
+ theme field on `Site` is (no theme wiring — a test harness that does not
1840
+ care about appearance).
1841
+
1842
+ No contract touched: this is server-side rendering wiring, not a change to
1843
+ contract B, D or the theme registry's resolution rules.
1844
+ - 17727db: Add `@cogenta/theme-portfolio`, an ultra-modern creative-portfolio theme built on the
1845
+ `@cogenta/theme-kit` contract every theme now implements against (fiche L23). It ships
1846
+ alongside `@cogenta/theme-canonical` in `theme-registry.ts` and `cogenta serve`'s
1847
+ appearance screen, selectable per site without a restart.
1848
+
1849
+ All twelve contract-B blocks get their own distinctive DOM and layout — a numbered
1850
+ "plate" caption on `mediaFigure` (CSS counters, not stored data), an editorial index
1851
+ list for `collectionList`, an inverted full-bleed panel for `cta`, a hairline-ruled
1852
+ "selected clients" ledger for `logos` — rather than a recolour of the reference theme's
1853
+ markup. Its own default skin (`tokens.json`) picks a near-black-on-near-white palette
1854
+ with a single electric-violet accent, Bricolage Grotesque/Fraunces/JetBrains Mono via
1855
+ Google Fonts (each with a real system fallback stack), and its own dark-mode derivation
1856
+ in `tokens.css`: light mode expresses elevation as a hard offset shadow (from the skin's
1857
+ own `shadow.sm`/`shadow.md`), dark mode replaces that mechanism entirely with an
1858
+ accent-tinted glow ring rather than merely dimming the shadow — a distinct design
1859
+ decision from the reference theme's own dark palette, computed and AA-verified in both
1860
+ schemes by a real contrast test against the rendered stylesheet.
1861
+
1862
+ Zero client JavaScript (asserted: no `<script>`, no `on*` handler, no `client:*`
1863
+ directive), zero literal colour in any stylesheet, and no new dependency beyond what
1864
+ `@cogenta/theme-canonical` already uses.
1865
+ - 421cf33: `cogenta serve` now actually sweeps the trash. `purgeExpired()` has existed on
1866
+ every `ContentStore` since ADR-0022, but nothing called it — a site's trash
1867
+ grew forever despite `trash.retainDays` implying otherwise. `runServe` now
1868
+ ticks it once at startup and then on a daily `setInterval` (override with
1869
+ `trashPurgeTickMs`, mirroring `scheduledPublishTickMs`), one collection's
1870
+ expired rows at a time, never fatal per collection.
1871
+
1872
+ `createOpsStatusRouter` gains an optional `trash` provider and a third route,
1873
+ `GET /api/trash-status` (admin-only, same as `/api/security-status` and
1874
+ `/api/webhooks-status`): `{ retainDaysByCollection, lastRunAt, lastPurged }`,
1875
+ so an admin screen can say when the sweep last ran instead of only that it is
1876
+ configured to happen. A caller that does not wire `trash` gets an honest
1877
+ all-empty answer instead of a crash.
1878
+
1879
+ Fixes a real gap in the audit log: `POST .../untrash`, `POST .../purge`,
1880
+ `POST .../unpublish` and `POST .../duplicate` were silently unaudited —
1881
+ `recordContentAudit` only ever recognised `publish` and `restore` among
1882
+ sub-actions, treating every other one as a read. All four now record
1883
+ `content.untrash`, `content.purge`, `content.unpublish` and
1884
+ `content.duplicate` respectively.
1885
+
1886
+ ### Patch Changes
1887
+
1888
+ - 08e394b: A real, persisted conversation with an agent, and two robustness fixes found by using it live against DeepSeek.
1889
+
1890
+ **The conversation.** Two chat surfaces — the agent detail page and the floating widget — used to keep their own local transcript, so starting a conversation on one and reopening the other never "loaded" it: there was nothing server-side to load. `@cogenta/agents` gains `AgentConversationStore` (memory + file implementations, one per `(agentName, actorId)` thread) and `RunAgentOptions.history` (real prior turns threaded into the model call, not folded into the instruction text); `@cogenta/api`'s `agents-router.ts` gains `GET/DELETE /api/agents/:name/conversation` and `POST .../conversation/messages`; `@cogenta/cli` wires a file-backed store under `.cogenta/agents-runtime/conversations`. Both admin chat surfaces now read and write through the same thread.
1891
+
1892
+ **Found while testing it for real:**
1893
+ - A content-generation reply came back empty (`stopReason: 'max_tokens'`) — the default per-call budget (2000 tokens) was tuned for a short reply, not a real draft with a rich-text body. Raised to 8000 (6000 for a sub-agent hop).
1894
+ - A stalled DeepSeek response left the request — and the browser tab awaiting it — hanging for minutes with nothing logged and no way to recover short of killing the process. None of the three provider adapters (OpenAI-compatible, Anthropic, Google) ever bounded a call on their own. Each now falls back to a 180s timeout when the caller supplies no cancellation signal of its own, and reports a named "did not answer in time" error rather than hanging forever.
1895
+
1896
+ Also: the `content.schema` tool (introspects a collection's field shape and the block vocabulary — closes the gap where the superagent could only guess field names when asked to draft content) is now visible in the admin's own permission checkboxes, and the superagent detail page opens straight on the chat, with every configuration field moved behind a "Réglages" button, and the technical log truncated with a "show all" toggle.
1897
+ - 08e394b: Gives an agent a way to learn a collection's actual field shape before writing to it. A live run asked the "Cogenta Agent" superagent "peux-tu générer un template ?" and it answered by asking the human to specify every field itself — `content.write_draft`'s `values` input is deliberately schema-blind (`z.record(z.string(), z.unknown())`), so nothing let the model discover a collection's real field keys short of guessing or reverse-engineering an existing entry, and a fresh collection with zero entries left it nothing to reverse-engineer at all.
1898
+
1899
+ `@cogenta/agents` gains a new contract-C tool, `content.schema` (`createContentSchemaTool`), read-only under the same `content.read` permission as the existing browse pair (`content.collections`/`content.list`) — describing a collection's shape is not a wider grant than reading one of its entries. It answers two things: one or every readable collection's field shape (key, kind, required, label, kind-specific options), and this site's fixed block vocabulary (contract B's seventeen blocks, each with its own name/version/field shape) — the block half needs no site data at all, it is always present so an agent building a `blocks`-kind field's value never has to guess what a `hero` or `prose` block actually holds. The "Cogenta Agent" seed gains it alongside the existing browse pair, and `ensureBuiltinAgents` grants it to an already-seeded built-in that holds `content.read`, exactly like `content.collections`/`content.list` before it.
1900
+
1901
+ `@cogenta/agents` gains a new direct dependency, `@cogenta/blocks` (workspace-internal, zero transitive cost) — the same package `@cogenta/theme-canonical`/`@cogenta/theme-kit` already depend on to read the same fixed vocabulary.
1902
+
1903
+ `@cogenta/cli`'s `agent-runtime.ts` wires the new tool into the site's real tool registry with a `contentSchemaServiceLikeOf` adapter that reuses the exact same `ContentService.summary()` permission check `content.collections` already goes through, so `content.schema` never describes a collection the calling actor could not otherwise read.
1904
+ - 168ee37: Wires the new `@cogenta/theme-association` package into `cogenta serve`'s
1905
+ theme registry and dependency list (L25, Phase 1) — a site can now select
1906
+ "Association" from the theme gallery, and the `association` blueprint's
1907
+ `defaultTheme` resolves to a real, installed theme instead of falling back
1908
+ to canonical. `create-cogenta` gains the `association` starting skin (a
1909
+ warm off-white ground with a deep-green accent), matching the theme's own
1910
+ default look before any AI-generated skin is chosen.
1911
+
1912
+ Also fixes a real privacy bug found while verifying this theme end to end:
1913
+ a public entry byline (`PageContent.entry.author`, contract D `theme@1.4`)
1914
+ used to fall back to an author's login email when their account had no
1915
+ display name — exactly the `displayName ?? email` fallback the
1916
+ authenticated `admin-*` screens already use safely in a private context.
1917
+ `create-cogenta` only ever asks for an email, so a freshly scaffolded
1918
+ site's admin account has no display name by default, meaning **every**
1919
+ themed site with author bylines enabled was publishing its own admin's
1920
+ login email on the very first page a visitor could open. The byline is now
1921
+ omitted rather than naming an email; a real display name still shows once
1922
+ one is set.
1923
+ - 0c7ecef: Audit A1-commerce (P2) — open carts nobody touches are now actually marked
1924
+ abandoned, automatically.
1925
+
1926
+ `CartStore.abandon()` has existed since fiche 32 with no automatic caller: a shop's
1927
+ open carts stayed `status: 'open'` forever, even weeks after a shopper vanished.
1928
+ `CartStore` gains a new method, `abandonInactive(options?: { olderThanMs?: number })`
1929
+ (default 24h, `DEFAULT_CART_ABANDON_MS`, also exported), which marks every open cart
1930
+ past its staleness threshold abandoned in one guarded `UPDATE` — idempotent on its own,
1931
+ same discipline as this package's other bulk sweeps.
1932
+
1933
+ `cogenta serve` schedules it as a new `commerce-carts` task (hourly by default,
1934
+ `cartAbandonTickMs`/`cartAbandonAfterMs` test seams), always registered — like
1935
+ `commerce-subscriptions`, it needs no e-mail transport, only this site's own
1936
+ unconditionally-created commerce tables.
1937
+
1938
+ No breaking change: `abandonInactive` is a new, additive method on `CartStore`.
1939
+ - e01efae: T09-05 (audit 2026-09-01, partial) — account creation, password change and session
1940
+ revocation now write their audit entry directly at the point of mutation in
1941
+ `users-router.ts` (the same discipline fiche 61 task 1 already applied to
1942
+ `applyUserChange`/`bulkRoute`/`inviteRoute`/`anonymizeRoute`), instead of `cogenta
1943
+ serve` sniffing the HTTP path afterwards. `recordUserAudit`'s path-shape guesswork is
1944
+ removed rather than kept as a redundant second writer — a caller that reaches account
1945
+ creation, a password change or a session revoke through any future non-HTTP entry point
1946
+ now produces the same audit entry a browser request always did, which sniffing a URL
1947
+ could never guarantee. No route, request or response shape changes; `UsersRouterOptions`
1948
+ gains an optional `storeFor` (see the RGPD export changeset in this same wave).
1949
+ `api-keys-router.ts` and `role-permissions-router.ts` still use `cogenta serve`'s
1950
+ sniffing (`recordApiKeyAudit`/`recordRolePermissionAudit`) — left for a follow-up, out
1951
+ of this wave's budget.
1952
+ - 5463fd0: Audit T-COM-01 (P0) — `cogenta serve` now actually bills subscriptions.
1953
+
1954
+ `SubscriptionStore.runBilling`/`runDunning`/`sendRenewalNotices` (fiche 53 tasks 3 and
1955
+ 5, `@cogenta/commerce`) had no caller anywhere in `cogenta serve`: a subscription whose
1956
+ renewal date came and went was never billed on a real site, a failed renewal payment was
1957
+ never retried, and a renewal reminder was never sent — despite all three being fully
1958
+ tested at `@cogenta/commerce`'s own level.
1959
+
1960
+ `runServe` now registers a new `commerce-subscriptions` scheduled task (daily by
1961
+ default, overridable with the new `commerceBillingTickMs` test seam, same pattern as
1962
+ `commerce-order-emails`) that runs all three in sequence. Unlike the order-email task,
1963
+ this one is always registered — commerce tables and stores exist unconditionally
1964
+ (contract E, ADR-0024), and only `sendRenewalNotices` itself needs an e-mail transport
1965
+ to do anything, degrading to a safe no-op (R2) without one. `runServe` also now wires
1966
+ `createEmailRenewalNotifier` (`@cogenta/commerce`, already exported since fiche 53 but
1967
+ never called) as the subscription store's `notifyRenewal`, using the same degraded
1968
+ `FileEmailTransport`/real transport every other transactional sender in this file
1969
+ already has.
1970
+
1971
+ A real bug was caught and fixed while wiring this in: the new task's interval override
1972
+ was missing from `scheduledTasksHeartbeatMs`'s `Math.min(...)` — the heartbeat itself is
1973
+ what actually drives every scheduled task, and forgetting an override there means the
1974
+ task's own `intervalMs` is irrelevant, since the heartbeat never runs often enough to
1975
+ notice it is due. A comment already on that line names this exact failure mode as
1976
+ something fiche 52's own commerce task once found and fixed for itself; T-COM-01 found
1977
+ it again for its own task, caught this time by a real end-to-end test (an overdue
1978
+ subscription billed within 5s of a 20ms tick) rather than a code read.
1979
+
1980
+ No contract change: `runBilling`/`runDunning`/`sendRenewalNotices`/
1981
+ `createEmailRenewalNotifier` are all pre-existing `@cogenta/commerce` exports, unchanged
1982
+ by this patch.
1983
+ - b3ce406: Fiche 61 task 1 — every account mutation now lands in the audit log, not just
1984
+ anonymization. `applyUserChange` (`users-router.ts`, shared by the single
1985
+ `PATCH /api/users/{id}` route and `POST /api/users/bulk`) now records a
1986
+ `user.update` entry, naming exactly which roles and/or status changed, for
1987
+ every account it actually mutates — a bulk action that used to leave no
1988
+ audit trail at all now writes one entry per account it touched, and none for
1989
+ an account it refused (the last-admin guard, an anonymized row). Resending
1990
+ or cancelling an invitation (`POST`/`DELETE /api/users/{id}/invite`) now
1991
+ records `user.invite_resend`/`user.invite_cancel`.
1992
+
1993
+ `cogenta serve`'s `recordUserAudit` no longer re-derives a `user.update`
1994
+ entry by sniffing the HTTP path shape — that was the mechanism `/api/users/
1995
+ bulk` never matched in the first place, which is how bulk actions went
1996
+ unaudited. Single-account role/status changes are still recorded exactly
1997
+ once, now from inside the router that actually makes the change.
1998
+ - 39d4be1: Fixes a real bug found while verifying the `saas` blueprint (L25): a routed
1999
+ entry page's `<title>`/`<h1>` fell back to the entry's raw id whenever its
2000
+ collection named its title field anything other than `title` — `vitrine`'s
2001
+ `service`, `restaurant`'s `menu_item`, `store`'s `product` and `saas`'s
2002
+ `feature` all use `name`. `theme-render.ts`'s own `entryTitle` now follows
2003
+ the same `title`/`name`/`label` fallback chain `@cogenta/theme-kit`'s
2004
+ `entryTitle` already used for cards and lists, so a feature page reads
2005
+ "Workflow automation" instead of a UUID.
2006
+ - 33163e6: L25 Phase 1 — registers the new `@cogenta/theme-docs` package in the built-in theme
2007
+ registry (`theme-registry.ts`) and as a real npm dependency, so a site can select it from
2008
+ the Appearance screen and `cogenta serve`/`cogenta dev` can actually load it.
2009
+ - 39d4be1: Registers `@cogenta/theme-saas` (L25) in the built-in theme registry
2010
+ (`theme-registry.ts`'s `BUILTIN_THEMES`) and adds it as a real dependency, so
2011
+ a site can select it from the Appearance screen and `saas`-blueprint sites
2012
+ activate it by default.
2013
+ - 5d72083: Register `@cogenta/theme-restaurant` in `theme-registry.ts`'s `BUILTIN_THEMES` and as a
2014
+ declared dependency, so it is selectable from the admin's appearance screen alongside the
2015
+ other built-in themes, with no change to any existing site's active rendering.
2016
+ - b60b198: Fixed the public icon still looking wrong after the earlier icon-only crop (`272b606`):
2017
+ the root cause was never the CSS size, it was the source pixels. That crop pulled the
2018
+ icon out of a combined icon+wordmark lockup, which left the hexagon mark occupying only
2019
+ a fraction of an already-small 64×64 canvas — no `block-size` on the `<img>` could fix
2020
+ that, since the mark itself was tiny inside its own image.
2021
+
2022
+ `DEFAULT_LOGO_BASE64` (`packages/cli/src/commands/default-logo.ts`) now embeds a
2023
+ properly composed, generously padded icon-only source (`docs/logo/logo-cogenta-icon.png`
2024
+ and the matching admin/branding assets, all regenerated together) at 128×128 instead of
2025
+ 64×64 — sharp enough for the 32–40px footer/login contexts that actually display it.
2026
+ Verified by `curl`, bypassing the browser cache, against the real served bytes.
2027
+ - 3e22108: Audit fiche 05/15 (2026-09-01), correction A3 — `cogenta doctor` now
2028
+ reports on the two driver-backed needs it previously said nothing about:
2029
+
2030
+ - **`images`**: which image transformer is active — `sharp` (optimal,
2031
+ native libvips) or the WebAssembly fallback (degraded) — the same
2032
+ driver-tier reporting `database`/`cache`/`storage`/`rateLimit` already
2033
+ get (rule R1, the `new-driver` skill's "doctor reporting" requirement).
2034
+ An operator on a host where `sharp` cannot install previously had no way
2035
+ to learn that short of a slow first media upload.
2036
+ - **`vector`** (L18 semantic search): reports the active vector store
2037
+ driver (`pgvector`/`file`/`memory`) the same way. A site with
2038
+ `vector.driver: 'pgvector'` pinned but no real Postgres connection now
2039
+ fails `doctor` with a named, actionable `DRIVER_UNAVAILABLE` problem
2040
+ instead of only surfacing the first time the assistant needs it; a site
2041
+ that leaves `vector` unconfigured reports no problem, since a
2042
+ service-free default (`file`, or `memory` as a last resort) always
2043
+ exists (R1).
2044
+
2045
+ Also reports **image generation** (L18 task 4) as a note, when configured
2046
+ — the provider, model, and whether an API key is present in the
2047
+ environment — mirroring the existing LLM-provider note. This is a note
2048
+ rather than a `checks` entry: `createImageProviderRegistry` has no
2049
+ driver-tier/health concept (there is no service-free way to generate an
2050
+ image, R2's own reason this section has no default), unlike every real
2051
+ driver need above.
2052
+ - e75b23e: Add global search: the ⌘K/Ctrl+K palette with shortcuts and "go to"/"create" actions, a full `/search?q=…` results page, highlighted excerpts, widened sources (orders, media, users, menus, extensions, taxonomy terms), typed inline filters (`status:draft`) and recent-search history (fiche 36).
2053
+
2054
+ - `@cogenta/schema`'s search indexing (`extract.ts`) gains `buildExcerpt` — a window of prose
2055
+ around the first query term found, with match offsets scoped to that window, never the
2056
+ full text. Built from the *display* text (`SearchDocument.body`, never folded), so an
2057
+ excerpt keeps real casing and accents while still matching a folded, prefix-matching
2058
+ query.
2059
+ - `@cogenta/api`'s `search-router.ts` enriches each `SearchHit` with an excerpt built
2060
+ server-side, never reconstructed from HTML on the client (R3/R8: the excerpt is data,
2061
+ escaped at render).
2062
+ - `@cogenta/commerce`'s order store and admin router gain a search-by-number/email lookup,
2063
+ gated on the caller's own `commerce.read` permission — a source in the global search
2064
+ widens only what its own permission already allows, never more.
2065
+ - Admin: `shell/global-search.tsx` (palette, shortcuts, recent searches, inline-filter
2066
+ parsing), `routes/search.tsx` (the full results page, one tab per source with its own
2067
+ permission gate), `search/` (excerpt highlighting, inline-filter parser, recent-search
2068
+ `localStorage` store — never server-side, these are one person's own queries).
2069
+ - 9bd3042: Fix: a `collectionList` block linking to an entry whose routed collection has
2070
+ no slug (a `slug`-kind field is not `required` by contract A — a draft
2071
+ published without one is real, reachable content) used to make `buildPath`
2072
+ throw straight through `renderPage`, turning one incomplete entry into a 500
2073
+ for every visitor of the page listing it. `link()` now degrades an
2074
+ unresolvable route to `href="#"`, the same fallback already used when the
2075
+ target was never fetched at all — one broken linked entry no longer takes
2076
+ down the whole page.
2077
+
2078
+ Also: a refused `POST /api/auth/login` (wrong password or unknown account)
2079
+ is now journalled as `auth.login_failed` (actor `null`, attempted email kept
2080
+ on the entry), matching the intrusion-detection signal every other CMS's
2081
+ security tooling logs and that this audit trail was missing until now. Only
2082
+ the password step records this — TOTP, recovery-code and passkey completion
2083
+ reuse the same error codes for a different meaning each time, so recording
2084
+ those under the same generic action would misname what actually failed.
2085
+ - 272b606: Fix: the default Cogenta mark served at `/_cogenta/logo-cogenta.png` (public
2086
+ site footer credit, and now the public site's favicon too) was a 64×64
2087
+ raster containing the icon *and* the "COGENTA" wordmark baked together —
2088
+ shrinking it to a footer-credit size made the text illegible and left the
2089
+ hexagon mark itself occupying only a fraction of an already-tiny canvas. No
2090
+ CSS `block-size` could fix that; the problem was in the source pixels. The
2091
+ asset is now cropped to the icon alone, trimmed and re-exported at the same
2092
+ 64×64, with the full pixel budget spent on the mark instead of shared with
2093
+ text nothing at that size could read anyway. Also: the public site now
2094
+ serves a favicon (`<link rel="icon">`) — there wasn't one before.
2095
+ - 68f5485: A public page whose collection opted out of comments, and that holds none, no longer ends
2096
+ on a "Comments (0) — comments are closed" section: closed and empty means there is no
2097
+ discussion on this page, not a discussion the visitor may not join. A closed thread that
2098
+ already holds approved comments still shows them read-only. `@cogenta/theme-saas` caps its
2099
+ feature grid at three columns so six features read as a 3×2 grid rather than four plus
2100
+ two orphans.
2101
+ - fe789cf: Fiche L21 task 8 — Cogenta's own logo and credit, and a white-label override.
2102
+
2103
+ Nothing branding-related existed before this: the admin's topbar carried a
2104
+ plain `//` text mark, and the public footer showed only the site's own name
2105
+ and its footer nav. `@cogenta/schema`'s `SITE_SETTINGS_REGISTRY` gains a new
2106
+ `branding` group — `branding.showCogentaBranding` (boolean, `true` by
2107
+ default) and `branding.customLogoMediaId` (a media id, or unset) — persisted
2108
+ through the same generic key/value settings table every other editorial
2109
+ setting already uses, so no migration was needed for it.
2110
+
2111
+ `@cogenta/cli`'s public theme render (`theme-render.ts`, both `renderPageChrome`
2112
+ and `renderEntryPage`) now renders a small branding block in the site
2113
+ footer: Cogenta's own logo and a link back to the project by default, the
2114
+ site's uploaded replacement once Cogenta's credit is turned off (served
2115
+ through the same public `/_image` endpoint every other image on the page
2116
+ already uses), or nothing once it's off with no replacement. Cogenta's own
2117
+ logo is served at a new, permanently cacheable `/_cogenta/logo-cogenta.png`
2118
+ route — a 64×64 PNG resized from the vendored source with the project's own
2119
+ WASM image driver (zero new dependency, R9/R10), the same degraded-tier
2120
+ codec `/_image` already relies on. Read live per request off the same
2121
+ settings store `reading.homePath` already reads, so turning branding off
2122
+ shows up on the very next page view, not the next restart — verified end to
2123
+ end (`test/serve-branding.test.ts`) on the home page, `/search`, and the
2124
+ page builder's own preview (whose fidelity test asserts its `<body>` stays
2125
+ byte-identical to the published page's — the branding block had to be wired
2126
+ identically on both paths for that to still hold).
2127
+
2128
+ `@cogenta/theme-canonical`'s `base.css` gains the `.cg-site-footer__branding`
2129
+ rules the new markup needs.
2130
+ - 86fc9cf: Fixed the media cache-busting bug audit 05-mediatheque §6 T02 found: replacing an
2131
+ uploaded file (`MediaStore.replace()`, fiche 11 task 4) has recorded a fresh
2132
+ `contentHash` since `theme@1.2`, and `RenderMediaAsset`/`MediaAsset.version` has existed
2133
+ on the type since then too — but `variantUrl()` never actually read it, and
2134
+ `loadRenderMedia()` (`cogenta serve`) never actually set it. The result: a replaced
2135
+ logo kept serving under the exact same `/_image?id=…` query string a year-long
2136
+ `Cache-Control: immutable` response had already cached, everywhere that image was
2137
+ already rendered.
2138
+
2139
+ `@cogenta/render`'s `variantUrl()` now folds `media.version` into every candidate URL
2140
+ as `&v=` when present, unchanged (no `&v=`) when absent — fully backward compatible for
2141
+ a media entity built without this field. `@cogenta/cli`'s `loadRenderMedia()` now sets
2142
+ `version: asset.contentHash`, so `og:image`, JSON-LD's `image`, and every `srcset`
2143
+ candidate all change URL the moment a file is replaced, which is what actually protects
2144
+ a page a browser or CDN has already cached: an `immutable` response is never
2145
+ revalidated, so the origin's own stored bytes changing is not what breaks the cache —
2146
+ only the URL changing is.
2147
+
2148
+ Also documents the `version` field on contract D's `MediaReference` (`docs/04-contrats.md`
2149
+ § Contrat D), additive to `theme@1.2`.
2150
+ - 46572ba: Add the admin notification center (fiche 38): a bell with an unread count, filterable
2151
+ by severity/period, bulk mark-as-read; new notice sources (plugin auto-disabled,
2152
+ scheduled publication failed); channel-bridged notices reusing `@cogenta/channels`'
2153
+ existing message formats, grouping and identity-linking (no second mechanism); and a
2154
+ per-severity channel routing settings screen.
2155
+
2156
+ `@cogenta/schema` gains `scheduled-publish-failures` store used by the new notice
2157
+ source. `@cogenta/api` gains a real `@cogenta/channels` dependency, new notice-router
2158
+ routes for channel settings and notice history, and a `plugin-disabled`/
2159
+ `scheduled-publish-failed` notice source pair. `@cogenta/plugins` exposes disabled-state
2160
+ data the new notice source reads. `@cogenta/channels`' preference types gain the field
2161
+ the settings screen needs.
2162
+ - 8c98093: Fix rich text (`richText` field) rendering when it carries a `media` node or an
2163
+ `internalLink` mark (ADR-0013): `cogenta serve` now resolves both before rendering, the
2164
+ same way it already did for a `collectionList` block's entries. Previously, an image
2165
+ placed inside a paragraph could make the whole page throw (`THEME_IMAGE_UNSUPPORTED`,
2166
+ the asset was never fetched), and an internal link inside prose always rendered a dead
2167
+ `<a href="#">` since its target was never looked up.
2168
+
2169
+ An internal link whose target cannot be resolved — trashed, still a draft, or renamed
2170
+ away and gone — now renders as plain text instead of a dead anchor, on `@cogenta/theme-canonical`'s
2171
+ own recommendation for a stale link: never a 404, never a link to nowhere.
2172
+ - 2299569: L20 audit, two real bugs in public-facing pages.
2173
+
2174
+ **`/search` found nothing, even for words plainly on a freshly scaffolded
2175
+ site's own seeded demo content.** Every blueprint's `seedDemoContent`
2176
+ (`create-cogenta`) and `resetPlaygroundData`'s reseed write straight through
2177
+ `createContentStore`, never through the `withSearchIndexing`-wrapped store
2178
+ `cogenta serve` builds at startup — so the seeded rows existed in the content
2179
+ tables but never reached the search index table. Both now reindex every
2180
+ seeded collection against the site's real search index (`createSearchIndex` +
2181
+ `reindexAll`, the same pair `cogenta`'s own "Reindex search" tool uses)
2182
+ immediately after seeding, so the physical index and the content it describes
2183
+ are never out of step from the moment a site exists.
2184
+
2185
+ **`/search` and `/forms/{name}` rendered with none of the site's visual
2186
+ chrome**, even though both already linked the site's stylesheet: they built
2187
+ their own thin `<html>` shell rather than the frame every collection page
2188
+ gets (skip link, `color-scheme` meta, header with primary nav, footer with
2189
+ footer nav) — the stylesheet loaded, but the markup its selectors target was
2190
+ never on the page. `@cogenta/cli` extracts that frame into a new
2191
+ `renderPageChrome` (`theme-render.ts`) and both pages now call it, menu
2192
+ wiring included. `renderFormPage`/`renderFormNotFoundPage` are now async and
2193
+ take an `AccessContext`, to match. The comment thread appended after an entry
2194
+ page shared the same gap — `@cogenta/theme-canonical`'s `base.css` gains the
2195
+ missing `.cg-search__*`, `.cg-form__*` and `.cg-comment__*` rules, at the same
2196
+ page-width measure `.cg-page__title` already sets.
2197
+ - 07c0f0a: Fiche 57 (Compétences : dossiers de référence standard) — a skill's
2198
+ `references/`, `scripts/` and `assets/` sub-folders, the standard layout a
2199
+ real Claude Code/Anthropic skill uses, are now created automatically and
2200
+ manageable from the admin. No contract A/B/C/D touched; no ADR required
2201
+ (that would only apply to a future `skill.read_resource` tool, which this
2202
+ fiche deliberately does not add).
2203
+
2204
+ **`@cogenta/agents`**: `AgentSkillStore` gains `listResources`,
2205
+ `addResource` and `removeResource`, plus the exported `SKILL_RESOURCE_DIRS`
2206
+ constant and `SkillResource`/`SkillResourceDir` types.
2207
+ `createFileAgentSkillStore`'s `create()` now also creates the three standard
2208
+ sub-folders, empty, alongside `SKILL.md`/`.meta.json`. Writing or removing a
2209
+ path outside `references/`, `scripts/` or `assets/` — or one that tries to
2210
+ escape the skill's own directory — is refused
2211
+ (`AGENT_SKILL_RESOURCE_INVALID`); a skill created before this fiche, with no
2212
+ sub-folders on disk, lists an empty resource set rather than erroring.
2213
+
2214
+ **`@cogenta/core`**: two new error codes, `AGENT_SKILL_RESOURCE_INVALID` and
2215
+ `AGENT_SKILL_RESOURCE_UNKNOWN`.
2216
+
2217
+ **`@cogenta/api`**: `agent-skills-router.ts` gains `GET`/`POST
2218
+ /api/agent-skills/:id/resources` and `DELETE
2219
+ /api/agent-skills/:id/resources/<path>`, all admin-only like the rest of the
2220
+ router. An upload accepts either a real `multipart/form-data` body (`path`
2221
+ field, `file` part — no base64 inflation for a binary asset) or a JSON body
2222
+ `{ path, content }` with `content` as plain UTF-8 text.
2223
+ `AgentSkillRegistryLike` gains the three matching methods; any other
2224
+ implementer of this interface needs to add them.
2225
+
2226
+ **`@cogenta/cli`**: `agent-runtime.ts`'s `createSkillRegistryAdapter` wires
2227
+ the three new methods straight through to `AgentSkillStore` — no new CLI
2228
+ command or flag.
2229
+
2230
+ **Admin** (not published, `@cogenta/admin`): the Compétences screen's edit
2231
+ row gains a "Fichiers de référence" panel — three lists (Références,
2232
+ Scripts, Gabarits) with upload and remove, using `FormData` uploads directly
2233
+ rather than the `fileToBase64` path `media-client.ts` still uses, since a
2234
+ resource file (an asset image, in particular) should not pay a ~33% base64
2235
+ inflation when a real `multipart/form-data` transport is already wired on
2236
+ the server side.
2237
+
2238
+ Nothing here is loaded into an agent's context automatically — deliberately
2239
+ so, per the fiche's own warning against uncontrolled context growth (R7).
2240
+ - 06d7c1d: Registers `@cogenta/theme-blog` in the built-in theme registry (`theme-registry.ts`)
2241
+ and as a real workspace dependency — selectable from the appearance screen's theme
2242
+ gallery and by `cogenta_theme.active_theme`, without a restart, exactly like the four
2243
+ existing built-in themes.
2244
+ - Updated dependencies [1df80de]
2245
+ - Updated dependencies [684d743]
2246
+ - Updated dependencies [154a751]
2247
+ - Updated dependencies [5c5ffbd]
2248
+ - Updated dependencies [a2516aa]
2249
+ - Updated dependencies [08e394b]
2250
+ - Updated dependencies [d0a3250]
2251
+ - Updated dependencies [0e88f30]
2252
+ - Updated dependencies [750a10b]
2253
+ - Updated dependencies [08e394b]
2254
+ - Updated dependencies [deece35]
2255
+ - Updated dependencies [edd0787]
2256
+ - Updated dependencies [2c4de46]
2257
+ - Updated dependencies [39d4be1]
2258
+ - Updated dependencies [c489fde]
2259
+ - Updated dependencies [54ca689]
2260
+ - Updated dependencies [7d80741]
2261
+ - Updated dependencies [23299e9]
2262
+ - Updated dependencies [0692713]
2263
+ - Updated dependencies [8a13e08]
2264
+ - Updated dependencies [0c7ecef]
2265
+ - Updated dependencies [e01efae]
2266
+ - Updated dependencies [36744d3]
2267
+ - Updated dependencies [39807ed]
2268
+ - Updated dependencies [b3ce406]
2269
+ - Updated dependencies [4335296]
2270
+ - Updated dependencies [722fc6b]
2271
+ - Updated dependencies [ca9d74c]
2272
+ - Updated dependencies [916ef34]
2273
+ - Updated dependencies [af57fa2]
2274
+ - Updated dependencies [322d1a3]
2275
+ - Updated dependencies [2211d4b]
2276
+ - Updated dependencies [c476861]
2277
+ - Updated dependencies [7b7ec0b]
2278
+ - Updated dependencies [7a59646]
2279
+ - Updated dependencies [0ca8a79]
2280
+ - Updated dependencies [c392e24]
2281
+ - Updated dependencies [967ec5a]
2282
+ - Updated dependencies [562c9c1]
2283
+ - Updated dependencies [edf5623]
2284
+ - Updated dependencies [db307e0]
2285
+ - Updated dependencies [49815b9]
2286
+ - Updated dependencies [122da7a]
2287
+ - Updated dependencies [2fb2101]
2288
+ - Updated dependencies [0e90b32]
2289
+ - Updated dependencies [d0bfa1d]
2290
+ - Updated dependencies [95acedf]
2291
+ - Updated dependencies [6e5df34]
2292
+ - Updated dependencies [bebbab8]
2293
+ - Updated dependencies [e75b23e]
2294
+ - Updated dependencies [a8199ea]
2295
+ - Updated dependencies [16f63f6]
2296
+ - Updated dependencies [a15b1ae]
2297
+ - Updated dependencies [1dd9e6f]
2298
+ - Updated dependencies [656163e]
2299
+ - Updated dependencies [c555723]
2300
+ - Updated dependencies [4513a71]
2301
+ - Updated dependencies [bdcb563]
2302
+ - Updated dependencies [0dceff3]
2303
+ - Updated dependencies [3cbd6d7]
2304
+ - Updated dependencies [249eb6f]
2305
+ - Updated dependencies [dda55d6]
2306
+ - Updated dependencies [befad6d]
2307
+ - Updated dependencies [befad6d]
2308
+ - Updated dependencies [befad6d]
2309
+ - Updated dependencies [68f5485]
2310
+ - Updated dependencies [a915e1a]
2311
+ - Updated dependencies [4d3f3c7]
2312
+ - Updated dependencies [e8061e2]
2313
+ - Updated dependencies [fe789cf]
2314
+ - Updated dependencies [cb62917]
2315
+ - Updated dependencies [5e43b20]
2316
+ - Updated dependencies [b8d307a]
2317
+ - Updated dependencies [77c680d]
2318
+ - Updated dependencies [86fc9cf]
2319
+ - Updated dependencies [3fb9e11]
2320
+ - Updated dependencies [54409f3]
2321
+ - Updated dependencies [f47e893]
2322
+ - Updated dependencies [2285720]
2323
+ - Updated dependencies [46572ba]
2324
+ - Updated dependencies [a6530f6]
2325
+ - Updated dependencies [9b1dae8]
2326
+ - Updated dependencies [8a8d873]
2327
+ - Updated dependencies [4856972]
2328
+ - Updated dependencies [dd9e9a2]
2329
+ - Updated dependencies [3075941]
2330
+ - Updated dependencies [e01efae]
2331
+ - Updated dependencies [8c98093]
2332
+ - Updated dependencies [1995d35]
2333
+ - Updated dependencies [5de237f]
2334
+ - Updated dependencies [2c1af5d]
2335
+ - Updated dependencies [1cdf7d7]
2336
+ - Updated dependencies [745ebd8]
2337
+ - Updated dependencies [2299569]
2338
+ - Updated dependencies [4bb6ba3]
2339
+ - Updated dependencies [960757d]
2340
+ - Updated dependencies [2d84729]
2341
+ - Updated dependencies [b50f7bb]
2342
+ - Updated dependencies [8a8d873]
2343
+ - Updated dependencies [835d736]
2344
+ - Updated dependencies [cf005d4]
2345
+ - Updated dependencies [07c0f0a]
2346
+ - Updated dependencies [9e67928]
2347
+ - Updated dependencies [06d7c1d]
2348
+ - Updated dependencies [eb21099]
2349
+ - Updated dependencies [70c7306]
2350
+ - Updated dependencies [4f74c57]
2351
+ - Updated dependencies [4335296]
2352
+ - Updated dependencies [05f9e29]
2353
+ - Updated dependencies [795ad62]
2354
+ - Updated dependencies [5d72083]
2355
+ - Updated dependencies [39d4be1]
2356
+ - Updated dependencies [954460e]
2357
+ - Updated dependencies [421cf33]
2358
+ - Updated dependencies [3824e8e]
2359
+ - @cogenta/theme-saas@0.2.0
2360
+ - @cogenta/theme-blog@0.2.0
2361
+ - @cogenta/theme-restaurant@0.2.0
2362
+ - @cogenta/theme-association@0.2.0
2363
+ - @cogenta/theme-docs@0.2.0
2364
+ - @cogenta/theme-canonical@1.0.0
2365
+ - @cogenta/theme-ecommerce@1.0.0
2366
+ - @cogenta/theme-entreprise@1.0.0
2367
+ - @cogenta/theme-magazine@1.0.0
2368
+ - @cogenta/theme-portfolio@1.0.0
2369
+ - @cogenta/core@0.5.0
2370
+ - @cogenta/schema@0.4.0
2371
+ - @cogenta/api@2.0.0
2372
+ - @cogenta/agents@0.3.0
2373
+ - @cogenta/plugins@0.3.0
2374
+ - @cogenta/analytics@0.3.0
2375
+ - @cogenta/auth@0.4.0
2376
+ - @cogenta/commerce@0.3.0
2377
+ - @cogenta/blocks@1.0.0
2378
+ - @cogenta/theme-kit@0.2.0
2379
+ - @cogenta/channels@0.3.0
2380
+ - @cogenta/render@0.2.0
2381
+ - @cogenta/seo@0.3.0
2382
+ - @cogenta/comments@0.2.0
2383
+ - @cogenta/import@0.2.0
2384
+ - @cogenta/forms@0.2.0
2385
+ - @cogenta/export@0.2.0
2386
+ - @cogenta/observability@0.2.0
2387
+ - @cogenta/mcp@0.2.0
2388
+
2389
+ ## 0.4.0
2390
+
2391
+ ### Minor Changes
2392
+
2393
+ - [`d72b40f`](https://github.com/cogenta-cms/cogenta/commit/d72b40f64ab5b98985a22d9daae34796a4638f45) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Self-hosted, cookie-free page-view analytics — the one CMS feature category
2394
+ the audit found completely missing. No third party, no cookie, no personal
2395
+ data ever stored, consistent with R1 (no dure dependency on external
2396
+ infrastructure) and the project's privacy stance.
2397
+
2398
+ **New package `@cogenta/analytics`.** One table (`cogenta_analytics_events`):
2399
+ timestamp, page path, referring **domain only** (never the full referrer
2400
+ URL), a device category reduced from the User-Agent (`desktop`/`mobile`/
2401
+ `tablet`/`other`, never the raw string), and a **daily-salted session hash**
2402
+ — never an IP address, never a cookie. The salt (`cogenta_analytics_daily_salts`)
2403
+ is minted once per UTC day and rotates every day, so
2404
+ `sha256(salt|ip|device)` for the same real visitor is a *different*, unrelated
2405
+ value on every new day: nothing in the stored data can link two days of the
2406
+ same visitor's traffic, even with full database access, because reproducing
2407
+ yesterday's hash needs yesterday's IP, which was never written down. The IP
2408
+ address and the full User-Agent are used only as transient inputs to that
2409
+ hash and to the device classifier — neither is ever persisted. A dedicated
2410
+ privacy test suite (`test/privacy.test.ts`) inspects the actual stored
2411
+ columns, not just the public types, to prove this. `createAnalyticsStore`
2412
+ aggregates views by day, top pages, top referring domains and device
2413
+ breakdown; a same-session rate limit (60 events/minute) drops abusive
2414
+ traffic silently rather than erroring.
2415
+
2416
+ **`@cogenta/core`** gains one error code, `ANALYTICS_SALT_UNAVAILABLE`
2417
+ (an internal race-recovery failure, not expected in normal operation).
2418
+
2419
+ **`@cogenta/api`** gains `createAnalyticsRouter`: `GET /api/analytics/beacon`
2420
+ (public, records one event, always answers `204` even on a malformed or
2421
+ rate-limited request — a public collection endpoint must never break page
2422
+ rendering) and `GET /api/analytics/summary` (`admin`-only, `?days=` window).
2423
+
2424
+ **`@cogenta/cli`** wires both into `cogenta serve` and injects the collection
2425
+ tag into every rendered page. The tag is an invisible `<img>` pixel, not a
2426
+ `<script>`: the theme's rendered output already carries a hard "zero
2427
+ executable client JavaScript" property (enforced by a `serve.test.ts`
2428
+ assertion), so a script reading `document.referrer` was not an option. The
2429
+ referrer is instead read **server-side**, from the `Referer` header of the
2430
+ request that is rendering the page, and baked straight into the pixel's URL
2431
+ — no client code needed to capture it. The page builder's live-preview
2432
+ render includes the same pixel (rather than omitting it) specifically to
2433
+ keep its `<body>` byte-identical to the published page's, the invariant
2434
+ `theme-render-fidelity` depends on.
2435
+
2436
+ The admin gains a full `/analytics` dashboard (hand-built SVG bar chart, no
2437
+ charting dependency — R9) and a "views this week" widget on the main
2438
+ dashboard, both `admin`-only like every other traffic-shaped view in the
2439
+ admin.
2440
+
2441
+ - [`4eda357`](https://github.com/cogenta-cms/cogenta/commit/4eda35754f55484e12028707e4f54aaaccc188d2) Thanks [@georgesmomo](https://github.com/georgesmomo)! - API keys, wired to the transport (L13 task 8, companion to the
2442
+ `@cogenta/auth` changeset that adds the store).
2443
+
2444
+ `resolveActor` now recognises two bearer-token shapes instead of one: a
2445
+ session (unchanged) and an API key, told apart by the key's `cogenta_sk_`
2446
+ prefix before any database lookup runs. A key resolves to an actor whose
2447
+ `roles` are exactly its granted `scope` — never more, and never derived from
2448
+ whoever created it — with an id prefixed `apikey:` so it can never collide
2449
+ with, or be mistaken for, a real user id in the audit log or a `me` route.
2450
+ Repeated attempts with an invalid key are rate-limited the same way a wrong
2451
+ password is, keyed on a hash of the attempted key since an unrecognised key
2452
+ carries no other identity to limit by.
2453
+
2454
+ `@cogenta/api` gains `createApiKeysRouter` — `GET`/`POST /api/api-keys` and
2455
+ `DELETE /api/api-keys/{id}`, admin-only. The raw key is present in exactly
2456
+ one response body, `POST`'s, and never again: `list()` only ever returns the
2457
+ 12-character prefix a key was minted with.
2458
+
2459
+ `@cogenta/cli` mounts the router in `cogenta serve` under `/api/api-keys`
2460
+ and records `apikey.create`/`apikey.revoke` in the audit log, the same
2461
+ transport-boundary pattern `recordUserAudit` already uses — the raw key
2462
+ never reaches the audit entry, only the key's id.
2463
+
2464
+ **The admin screen for managing keys lands in the same session**
2465
+ (`@cogenta/admin`, unpublished/private, no changeset needed) — a new
2466
+ `/api-keys` route, admin-only, that shows the raw key exactly once in a
2467
+ dismissable notice right after creation and never again afterwards.
2468
+
2469
+ Compromise taken under time pressure, noted rather than hidden: scope is a
2470
+ flat list of role names rather than a collection-by-collection permission
2471
+ matrix. A key's actor is checked by the same `PermissionLayer` every other
2472
+ actor is, so a key can never do more than the roles it was granted allow —
2473
+ the simplification is in how finely a grant can be sliced, not in whether it
2474
+ is enforced.
2475
+
2476
+ - [`206b4cd`](https://github.com/cogenta-cms/cogenta/commit/206b4cd12df7d3a2a5831029b5f0ef726e7fd84d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Completes the admin surface of contract E (ADR-0024) beyond its MVP: multiple
2477
+ variants per product, coupons, invoices and subscriptions are now all
2478
+ reachable from a real HTTP admin, not just the backend that already carried
2479
+ them.
2480
+
2481
+ `@cogenta/commerce`'s `createCommerceAdminRouter` gains: `DELETE
2482
+ /variants/{id}` (a product's variant list was previously append-only from the
2483
+ admin's point of view); `GET`/`POST /coupons` and `POST
2484
+ /coupons/{code}/deactivate`; `GET`/`POST /subscriptions` and the
2485
+ `pause`/`resume`/`cancel` actions (absent when the caller does not wire a
2486
+ `SubscriptionStore` — a site with no `commerceSubscriptions` store answers
2487
+ 404, never a crash); and `GET /orders/{id}/invoice` plus `GET
2488
+ /orders/{id}/invoice/pdf`, the read side of an invoice-issuing route that
2489
+ existed but could previously only be written to, never read back. The PDF
2490
+ route answers with a raw `Uint8Array` body — the one response in this router
2491
+ that is not JSON — and the Node transport (`cogenta serve`) now checks for
2492
+ that shape before deciding whether to `JSON.stringify` or stream bytes with
2493
+ `content-type: application/pdf`.
2494
+
2495
+ `@cogenta/core` gains an optional `billing` config section (legal name,
2496
+ address, tax id, footer) — nothing here is a secret, rule R7 does not apply,
2497
+ a legal name is meant to be printed. Its absence is a real, first-class state:
2498
+ `cogenta serve` only builds an `InvoiceStore` and only accepts `POST
2499
+ /orders/{id}/invoice` once a site has filled this in, because an invoice with
2500
+ a made-up seller address is worse than no invoicing feature at all.
2501
+
2502
+ `@cogenta/cli` wires `createSubscriptionStore` and the conditional
2503
+ `createInvoiceStore` into `assembleSite`, passes `coupons`/`subscriptions`/
2504
+ `invoices` into the admin router (previously only `catalog`/`orders`/
2505
+ `customers`/`payments` were threaded through, silently dropping the coupon
2506
+ store `cogenta serve` already built), and adds the PDF passthrough above.
2507
+
2508
+ The admin (`@cogenta/admin`, private, no changeset) gets the screens this
2509
+ backend work makes possible: a real variant list per product (add, edit,
2510
+ remove, price and stock each independently, `commerce.catalog.write`-gated)
2511
+ replacing the one-variant-per-product MVP; `/commerce/coupons` (create by
2512
+ code/kind/value/validity window/redemption limit, deactivate); `/commerce/
2513
+ subscriptions` (list by status, cancel — creation is deliberately absent,
2514
+ since a subscription is created at checkout, not from the back office); and
2515
+ an "issue invoice" / download-PDF pair on the order detail screen. All money
2516
+ is entered and displayed through the existing `commerce/money.ts` conversion
2517
+ at the edges — every request on the wire still carries `priceMinor`, never a
2518
+ float.
2519
+
2520
+ Proven end to end in `packages/cli/test/serve-commerce.test.ts`, against a
2521
+ real HTTP server and a real SQLite file: a second variant added and removed
2522
+ through the router; a coupon created, listed and deactivated, and refused for
2523
+ a role with only `commerce.read`; a paid order invoiced, the invoice read
2524
+ back by the same route the admin polls, and its PDF downloaded and checked
2525
+ for the format's own magic bytes (`%PDF-`) rather than merely a 200 status; a
2526
+ site with no `billing` configured answering `COMMERCE_INVOICE_NOT_FOUND`
2527
+ instead of issuing a document with a fabricated seller address; and a
2528
+ subscription seeded the way checkout would seed one, listed and cancelled
2529
+ through the real admin API.
2530
+
2531
+ - [`8e33d60`](https://github.com/cogenta-cms/cogenta/commit/8e33d60882a7194c1f329e8974d39575c1f45d3d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now mounts contract E's back office at `/api/commerce/*`.
2532
+
2533
+ `@cogenta/commerce` had a complete, tested backend (products, variants, stock,
2534
+ carts, orders, payments, coupons, taxes, shipping) since L15, but its router
2535
+ was never actually reachable from a running site — the same "written, tested,
2536
+ never called" gap L10 closed for search, SEO, images and security. This
2537
+ closes it for commerce: `ensureCommerceTables` runs once at startup (a site
2538
+ that sells nothing pays only for a handful of idempotent `create table if not
2539
+ exists` statements it never queries), the catalogue/customer/order/payment
2540
+ stores are built the same way the taxonomy stores already are, and
2541
+ `createCommerceAdminRouter` is gated by contract E's own permission
2542
+ vocabulary (`commerce.read`, `commerce.catalog.write`, `commerce.order.write`,
2543
+ `commerce.payment.settle`, `commerce.order.refund`, `commerce.invoice.issue`)
2544
+ — never contract A's five actions, which do not stretch to "refund an order".
2545
+
2546
+ The payment gateway wired in today is the manual/bank-transfer driver only
2547
+ (no provider keys required, so a shop is sellable out of the box); a site
2548
+ that wants Stripe configures it itself once `@cogenta/commerce`'s driver
2549
+ registry grows a way to do so from `cogenta.config`. Invoicing needed seller
2550
+ details this file had no source for — see the follow-up changeset that adds
2551
+ `cogenta.config`'s `billing` section and mounts it.
2552
+
2553
+ Proven end to end in `packages/cli/test/serve-commerce.test.ts`: a real HTTP
2554
+ server, a real SQLite file, a real session — a product and its variant
2555
+ created through `/api/commerce` are immediately listable and carry the stock
2556
+ and price the write set.
2557
+
2558
+ - [`03d1327`](https://github.com/cogenta-cms/cogenta/commit/03d13277224c5abd011d15e19c8f9ec67ef40c27) Thanks [@georgesmomo](https://github.com/georgesmomo)! - The other half of password reset (`.changeset/auth-password-reset.md`,
2559
+ L13 task 6): that changeset built the store and the terminal command and
2560
+ said plainly "no admin route can receive a reset click yet". This is that
2561
+ route, and the screen behind it.
2562
+
2563
+ `@cogenta/auth`'s `AuthStore` gains a `resets` field — the
2564
+ `PasswordResetStore` `createPasswordResetStore` already built, now wired
2565
+ into the object every caller already holds, the same way `rateLimit` and
2566
+ `sessions` are.
2567
+
2568
+ `@cogenta/api`'s `createAuthRouter` gains two routes. `POST
2569
+ /api/auth/forgot-password` accepts an email and answers with the **exact
2570
+ same response** whether or not an account exists for it — the line this
2571
+ route exists to never cross is account enumeration, and every branch of its
2572
+ handler (an existing account, a disabled one, a non-existent one) returns
2573
+ byte-identical bodies. It rate-limits by the submitted email, before the
2574
+ account lookup, on the same subject either way, the same posture
2575
+ `loginAttempts` already applies to a wrong password. Only a real, active
2576
+ account gets a token issued, delivered through a new optional
2577
+ `onForgotPassword` callback rather than a hard dependency on
2578
+ `@cogenta/channels` (R9) — the router itself never sends mail. `POST
2579
+ /api/auth/reset-password` redeems the token, sets the new password (same
2580
+ 12-character floor as the self-service password-change route, now shared
2581
+ from a new `password-policy.ts` instead of duplicated), and revokes every
2582
+ existing session, exactly like `cogenta users reset-password --token`
2583
+ already does. A new error code, `AUTH_RESET_TOKEN_INVALID` (400), names an
2584
+ invalid, expired or already-used token — unlike `forgot-password`, this
2585
+ route's refusal is allowed to say why, since the secret here is the token
2586
+ itself, not whether an email exists.
2587
+
2588
+ `@cogenta/cli` factors the mail-sending half of `cogenta users
2589
+ reset-password --email` out of `commands/users.ts` into a new shared
2590
+ `reset-mail.ts`, so `cogenta serve` can wire the identical wording (now with
2591
+ an optional link to the admin's reset screen instead of the terminal
2592
+ command) into `onForgotPassword` without a second copy of it. `runServe`
2593
+ passes it to `createAuthRouter` unconditionally: the token is still issued
2594
+ and thrown away unsent when no site's mail is configured to go anywhere
2595
+ useful, since the HTTP response must never depend on whether the mail could
2596
+ be delivered.
2597
+
2598
+ `@cogenta/admin` (private, no changeset) gains the two screens this needed:
2599
+ "forgot password" on `/forgot-password`, linked from the sign-in screen, and
2600
+ "reset password" on `/reset-password?token=…`, the link the mail sends. Both
2601
+ are public routes, like `/login`. The user-management screen's role editor
2602
+ also moves off a raw comma-separated text field: four standard role names
2603
+ (`admin`/`editor`/`author`/`contributor`) are now offered as checkboxes,
2604
+ alongside any role a site's accounts already use, plus a free-text field for
2605
+ a role of the site's own — a UX convention only, not a contract A change
2606
+ (a role is still an arbitrary string as far as the server and the five
2607
+ permission actions are concerned).
2608
+
2609
+ - [`b61ff68`](https://github.com/cogenta-cms/cogenta/commit/b61ff68620644fbff48fb244178d1ad733035729) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Mounts L17's marketplace router (`@cogenta/api`'s `createMarketplaceRouter`,
2610
+ `@cogenta/plugins`' catalog and installer) into `cogenta serve` at
2611
+ `/api/marketplace/*`. Admin-only, same as every other route that installs or
2612
+ runs code. The catalog is local/embedded and empty by default — no site
2613
+ configures a distant registry yet, since that would need L13's API keys,
2614
+ which were never built.
2615
+
2616
+ `@cogenta/cli` gains a new dependency on `@cogenta/plugins` (workspace).
2617
+
2618
+ - [`3c73e58`](https://github.com/cogenta-cms/cogenta/commit/3c73e58ff0a54782a58ef1bf2d70e84819ff8944) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Navigation menus reach the public theme. `@cogenta/schema`'s menu store, `@cogenta/api`'s
2619
+ `createMenuRouter` and the admin's `/menus` screen were complete and tested (see the
2620
+ `menus-navigation` changeset), but nothing ever rendered one — the changeset that added
2621
+ them named the exact gap and where to close it, and this closes it.
2622
+
2623
+ Convention (undeclared by contract A or D — navigation is not content, and `Base.astro`'s
2624
+ real header/footer slots are not reachable from `cogenta serve`'s render pipeline, which
2625
+ builds its own minimal frame): a menu named `main` renders in the header, one named
2626
+ `footer` renders in the footer. Neither existing is unchanged behaviour — the same empty
2627
+ slots as before this was wired.
2628
+
2629
+ Rendering is a flat list of links (the documented MVP): every item of the menu, in the
2630
+ order the store returns them, regardless of `parent`/`depth`. The hierarchy the store
2631
+ already carries is not thrown away — a real sub-menu render only needs a new
2632
+ `renderMenuLinks`, not a data change — it is simply not built yet, for time.
2633
+
2634
+ The lookup itself is `GET /api/menus/by-name/{name}` called in-process through the exact
2635
+ same `MenuRouter` `/api/menus/*` is mounted with (`RestRequest` in, `RestResponse` out) —
2636
+ never a second lookup path, and never a real HTTP round trip to itself.
2637
+
2638
+ - [`029da6b`](https://github.com/cogenta-cms/cogenta/commit/029da6b238ad438b77375e389de57d83fb7f3a4e) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Navigation menus, missing entirely until now — no backend, no admin, no theme wiring — and
2639
+ a P0 gap for a CMS meant to compete with WordPress/Strapi/Drupal.
2640
+
2641
+ `@cogenta/schema` gains `createMenuStore`/`ensureMenuTables`: a menu is a named tree of
2642
+ items (`entry` — a link to a real collection entry, `url` — an external link, or
2643
+ `submenu-placeholder` — a heading with no target of its own), structurally close to a
2644
+ taxonomy term tree (materialised path, reusing `taxonomy-path.ts`'s helpers as-is) but
2645
+ **not** a `TaxonomyStore`: a menu is created and edited entirely at runtime from the admin,
2646
+ never declared in a site's schema module, so it gets one fixed pair of tables
2647
+ (`cogenta_menus`/`cogenta_menu_items`) rather than one table per declared name. A menu
2648
+ belongs to a locale the same way a localised collection does (ADR-0014) — two menus named
2649
+ `main` can coexist, one per locale, never one row trying to carry both. New error codes:
2650
+ `MENU_UNKNOWN`, `MENU_NAME_TAKEN`, `MENU_ITEM_NOT_FOUND`, `MENU_ITEM_INVALID`,
2651
+ `MENU_CYCLE`.
2652
+
2653
+ **One real bug found and fixed while building this**: a materialised path is id-based, so
2654
+ two siblings' paths diverge at their own id — sorting a listing by `path asc, position asc`
2655
+ (`taxonomy-store.ts`'s own pattern) therefore sorts siblings by *creation order*, never by
2656
+ `position`, silently defeating any "move up/down" a caller might build on top of it. The
2657
+ menu store walks the tree in application code instead (group by parent, sort each group by
2658
+ `position`, depth-first from the roots) — cheap for something the size of a navigation
2659
+ menu, and it is what makes `reorderItem` (swap with the sibling before/after) actually work.
2660
+
2661
+ `@cogenta/api` gains `createMenuRouter`: `GET /api/menus` and `GET /api/menus/{id}` are
2662
+ public (a menu serves the public theme's navigation, same as a published entry); every
2663
+ write requires `admin` or `editor` — a fixed rule, not a per-site permission
2664
+ configuration, since a menu is neither a collection nor a taxonomy and giving it a third
2665
+ `PermissionLayer` method for one rule that never varies would be new surface for nothing.
2666
+ `GET /api/menus/by-name/{name}?locale=` resolves a menu the way a theme will want to
2667
+ (refusing ambiguity across locales without `?locale=`, rather than guessing). An `entry`
2668
+ item is optionally resolved to a display label and public route via an injected
2669
+ `resolveEntry` callback, kept out of the router itself so it stays decoupled from content
2670
+ resolution.
2671
+
2672
+ `cogenta serve` mounts `/api/menus/*`, resolving `entry` items through the same
2673
+ permission-checked `ContentGateway` and `buildPath` the theme renderer uses, as `ANONYMOUS`
2674
+ (a menu is public navigation — an item never resolves to more than an anonymous visitor
2675
+ could see). The admin gains a `/menus` screen (menu selector, item list with up/down
2676
+ reorder buttons and delete, add-item form for a URL or a collection+entry), kept plain like
2677
+ `taxonomies.tsx` — L11 owns how the admin looks; every action goes through the real API and
2678
+ write controls only render for `admin`/`editor` (the server refuses the rest regardless,
2679
+ R4).
2680
+
2681
+ **What is not done, and why**: theme rendering (a public page actually showing a menu) is
2682
+ out of scope for this change — see `BLOCKERS.md` for the exact point to wire it in
2683
+ (`packages/theme-canonical/src/Base.astro`'s header/footer slots, fed by
2684
+ `GET /api/menus/by-name/{name}`). Nothing here touches contract A or B: a menu is
2685
+ deliberately not content and not a block.
2686
+
2687
+ - [`b37e51c`](https://github.com/cogenta-cms/cogenta/commit/b37e51cea79fc8d3070d5c741a8415192985d9ff) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Give the redirect table, HTTP security and outbound webhooks a real admin
2688
+ screen (audit follow-up to L10 tasks 2/6 and L14 task 1)
2689
+
2690
+ Three backend pieces existed and were fully wired into `cogenta serve` with
2691
+ no way to reach them from a browser.
2692
+
2693
+ - `@cogenta/core` gains the `REDIRECT_UNKNOWN` error code, for a `DELETE` on a
2694
+ redirect that does not exist.
2695
+ - `@cogenta/api` gains `createRedirectRouter` (`GET`/`POST`/`DELETE
2696
+ /api/redirects`) and `createOpsStatusRouter` (`GET /api/security-status`,
2697
+ `GET /api/webhooks-status`). Both are admin-only on every method, including
2698
+ `GET`: a redirect table and a site's CORS/CSP/HSTS configuration are
2699
+ routing and hardening decisions, not content, so neither has a reader role
2700
+ the way a taxonomy or a menu does. Loop and self-redirect refusal is
2701
+ entirely `RedirectStore`'s own job (`CONTENT_REDIRECT_LOOP`,
2702
+ `CONTENT_ROUTE_INVALID`), surfaced here as a proper 409/400 instead of a
2703
+ 500.
2704
+ - `cogenta serve` mounts all three at `/api/redirects`, `/api/security-status`
2705
+ and `/api/webhooks-status`, and `@cogenta/admin` gains three screens:
2706
+ `/redirects` (full CRUD) and `/ops-settings` (`security` and `webhooks`,
2707
+ **read-only**).
2708
+
2709
+ The security and webhooks screens are read-only by design, not by omission.
2710
+ Both settings live in the site's `cogenta.config.mjs` — versioned in git,
2711
+ deployed with the code that depends on it (a CSP that allows a script host
2712
+ has to travel with the deploy that added the script). Letting the admin edit
2713
+ them would create a second source of truth that disagrees with the file the
2714
+ moment either one changes without the other, which is a bigger architecture
2715
+ change than this audit's scope. The screens instead mirror exactly what the
2716
+ running process is enforcing on every request.
2717
+
2718
+ No delivery history is shown for webhooks: none is persisted anywhere today
2719
+ (`WebhookEventSender.send` only ever returns a per-call result to log). The
2720
+ screen says so rather than inventing one.
2721
+
2722
+ - [`3c73e58`](https://github.com/cogenta-cms/cogenta/commit/3c73e58ff0a54782a58ef1bf2d70e84819ff8944) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Scheduled publication, written and tested since L1 (`@cogenta/schema`'s
2723
+ `schedulePublication`/`registerScheduledPublishing`, a `QueueDriver`-based mechanism with
2724
+ a real degraded `database` driver) but never wired to anything: an editor could set an
2725
+ entry to "Scheduled" with a future date and nothing would ever happen — the admin showed
2726
+ it as a read-only badge, honest about the gap rather than lying about it.
2727
+
2728
+ **The missing link was the write path, not the queue.** `ContentStore.update()` never
2729
+ changes `status` (contract A keeps that transition to `publish`/`unpublish`), so there was
2730
+ no way to move an *existing* entry into `scheduled` at all — only `create({status:
2731
+ 'scheduled', ...})` worked. `unpublish()` now also accepts `status: 'scheduled'` with a
2732
+ required `publishedAt` (a `Date`, an ISO string, or epoch milliseconds), writing it as an
2733
+ ordinary value of the collection's own `publishedAt` field the same way `publish()`
2734
+ already does. A collection that never declared `publishedAt` refuses with
2735
+ `CONTENT_SCHEDULE_INVALID` rather than accepting a schedule with nowhere to put the date.
2736
+
2737
+ `@cogenta/schema` gains `withScheduledPublishEnqueue`, a `ContentStore` decorator in the
2738
+ same family as `withSearchIndexing`/`withLifecycleEvents`: wrapping `create`/`update`/
2739
+ `unpublish`/`restore`, it calls `schedulePublication` whenever the result is
2740
+ `status: 'scheduled'`. It re-enqueues on every save rather than tracking a previous job
2741
+ id — safe, because the handler re-reads the entry before publishing and skips anything no
2742
+ longer `scheduled` (an edit back to `draft`, or a manual publish that already happened).
2743
+
2744
+ `@cogenta/api`'s `POST /{collection}/{id}/unpublish` accepts
2745
+ `{"status": "scheduled", "publishedAt": "…"}` alongside the existing `draft`/`archived`.
2746
+
2747
+ `cogenta serve` creates a `database`-backed `QueueDriver` per site (R1: no external
2748
+ worker, no Redis — a table in the site's own database, drained in-process) and registers
2749
+ the publish handler once, at `assembleSite`. `runServe` drains it on a `setInterval` —
2750
+ once immediately at startup to catch up on anything overdue, then every 60 seconds for as
2751
+ long as the process runs. The trade this makes, and the one worth knowing: a page
2752
+ scheduled for 09:00 goes live between 09:00 and 09:01, and if the process is down when
2753
+ 09:00 comes, nothing is lost — the job is still in the table — it simply runs late, on
2754
+ the first tick after the next start.
2755
+
2756
+ Not a CLI flag: `ServeOptions.scheduledPublishTickMs` overrides the cadence for tests
2757
+ only (proving the loop really drains the queue without waiting a real minute for it); an
2758
+ operator has no reason to touch it.
2759
+
2760
+ The admin's status control gains a real `datetime-local` picker (never free text),
2761
+ offered whenever the collection declares `publishedAt`: "Programmer"/"Reprogrammer" call
2762
+ the new `unpublish` shape, and "Annuler la programmation" moves a scheduled entry back to
2763
+ draft.
2764
+
2765
+ - [`71e1dcd`](https://github.com/cogenta-cms/cogenta/commit/71e1dcd3f8204dca3b05cfd8558e7cf39aedc9e8) Thanks [@georgesmomo](https://github.com/georgesmomo)! - WordPress import from the admin, not only `cogenta import wordpress` on a
2766
+ terminal. `@cogenta/api` gains `createImportRouter` (`POST
2767
+ /api/import/wordpress`), and `cogenta serve` mounts it — admin-only, checked
2768
+ before the (potentially multi-megabyte) upload body is even read, the same
2769
+ defensive order `/api/site-plans` already uses for the same reason.
2770
+
2771
+ The import logic itself is not duplicated: the router takes an injected
2772
+ `runWordPressImport` function, and `cogenta serve` wires it to
2773
+ `@cogenta/import`'s real `importWordPress`, unchanged — `@cogenta/api` gains
2774
+ no new dependency, the same shape rule `MediaRouterOptions.images` already
2775
+ follows. A successful import is recorded in the audit log
2776
+ (`import.wordpress`) with the counts, never the document itself.
2777
+
2778
+ The admin gets a screen at `/import`: choose a WordPress "Export All Content"
2779
+ file, and see the same report `cogenta import wordpress` already prints — what
2780
+ was imported, what was skipped, and what could not be converted to a block.
2781
+
2782
+ ### Patch Changes
2783
+
2784
+ - Updated dependencies [[`fa3d13b`](https://github.com/cogenta-cms/cogenta/commit/fa3d13beb1d7394010dcb77e6bab0efbb07e3f6d), [`3b04c56`](https://github.com/cogenta-cms/cogenta/commit/3b04c56ca17291732a1e3f61cfa3b07248708a19), [`d72b40f`](https://github.com/cogenta-cms/cogenta/commit/d72b40f64ab5b98985a22d9daae34796a4638f45), [`4eda357`](https://github.com/cogenta-cms/cogenta/commit/4eda35754f55484e12028707e4f54aaaccc188d2), [`4eda357`](https://github.com/cogenta-cms/cogenta/commit/4eda35754f55484e12028707e4f54aaaccc188d2), [`206b4cd`](https://github.com/cogenta-cms/cogenta/commit/206b4cd12df7d3a2a5831029b5f0ef726e7fd84d), [`03d1327`](https://github.com/cogenta-cms/cogenta/commit/03d13277224c5abd011d15e19c8f9ec67ef40c27), [`174b521`](https://github.com/cogenta-cms/cogenta/commit/174b521e9bca3b783e06ac8aa3dff6e0ded58aa5), [`029da6b`](https://github.com/cogenta-cms/cogenta/commit/029da6b238ad438b77375e389de57d83fb7f3a4e), [`b37e51c`](https://github.com/cogenta-cms/cogenta/commit/b37e51cea79fc8d3070d5c741a8415192985d9ff), [`3c73e58`](https://github.com/cogenta-cms/cogenta/commit/3c73e58ff0a54782a58ef1bf2d70e84819ff8944), [`71e1dcd`](https://github.com/cogenta-cms/cogenta/commit/71e1dcd3f8204dca3b05cfd8558e7cf39aedc9e8)]:
2785
+ - @cogenta/api@1.1.0
2786
+ - @cogenta/analytics@0.2.0
2787
+ - @cogenta/core@0.4.0
2788
+ - @cogenta/auth@0.3.0
2789
+ - @cogenta/commerce@0.2.0
2790
+ - @cogenta/plugins@0.2.0
2791
+ - @cogenta/schema@0.3.0
2792
+ - @cogenta/agents@0.2.1
2793
+ - @cogenta/blocks@0.1.4
2794
+ - @cogenta/channels@0.2.1
2795
+ - @cogenta/import@0.1.4
2796
+ - @cogenta/render@0.1.4
2797
+ - @cogenta/seo@0.2.1
2798
+ - @cogenta/theme-canonical@0.2.1
2799
+
2800
+ ## 0.3.0
2801
+
2802
+ ### Minor Changes
2803
+
2804
+ - [`552645e`](https://github.com/cogenta-cms/cogenta/commit/552645e039b8c8c4f5340d065ea2f4a552950815) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Advanced AI (L18): a writing assistant, a `vector` driver, semantic search,
2805
+ RAG chat with citations, classification/duplicate detection/moderation, and
2806
+ FAQ/Schema.org drafting. **Nothing here is on a required path** — a site with
2807
+ no AI provider configured behaves exactly as before, and the whole feature set
2808
+ disappears from the UI rather than failing (R2).
2809
+
2810
+ - **`@cogenta/agents`** gains the `vector` driver need the architecture
2811
+ document has named since L0 and nothing implemented: `VectorStore` with three
2812
+ drivers behind the existing `createDriverRegistry` — `pgvector` (optimal),
2813
+ `file` (degraded, survives a restart) and `memory` (degraded, always
2814
+ available). One contract suite runs against all three; pgvector's run is an
2815
+ integration test that skips loudly without `COGENTA_TEST_POSTGRES_URL`.
2816
+ Nothing re-implements cosine similarity: L4's `vectorRank` does the ranking
2817
+ everywhere, and all three drivers return the same number.
2818
+
2819
+ `createSemanticSearch` fuses the vector half with L10's full-text index by
2820
+ RRF — **beside it, never instead of it**: pure vector search misses
2821
+ exact-keyword queries, which is the failure the architecture document warns
2822
+ about at line 190.
2823
+
2824
+ Fifteen Contract C tools, all `sideEffects: false`, every output carrying
2825
+ `applied: false` as a **literal** so an assistant tool's type cannot say it
2826
+ changed anything (R6). Eight writing tools (rewrite, proofread, summarise,
2827
+ translate, meta description, titles, tags, alt text), `assist.generate_image`
2828
+ behind a two-vendor image provider driver (OpenAI, Stability), `assist.chat`
2829
+ (RAG with citations), `assist.classify`/`assist.find_duplicates`/
2830
+ `assist.moderate`, and `assist.faq_draft`/`assist.schema_org_draft`.
2831
+
2832
+ Three properties worth knowing:
2833
+ - **Citations come from retrieval, not from the model.** The model names
2834
+ 1-based indices into the passages it was shown; this code maps them back to
2835
+ what the retriever returned, and an invented index resolves to nothing. A
2836
+ chat answer can never cite a page that was not retrieved.
2837
+ - **Moderation and duplicate detection can recommend `none` or `review`, and
2838
+ nothing else.** The union has no destructive member, so no answer —
2839
+ however jailbroken — describes a deletion.
2840
+ - **`assist.find_duplicates` needs no AI provider at all.** It embeds with
2841
+ the site's `EmbeddingProvider`, which by default is the local hashing one:
2842
+ no key, no service, no model download.
2843
+
2844
+ - **`@cogenta/core`** gains an `imageGeneration` config section
2845
+ (`COGENTA_IMAGE_PROVIDER`/`_MODEL`/`_BASE_URL`, key in `COGENTA_IMAGE_API_KEY`
2846
+ and refused in the config file like every other secret), a `vector` section
2847
+ (`driver`/`path`/`table` — dimensions stay on `embeddings`, never duplicated),
2848
+ and the error codes `VECTOR_DIMENSION_MISMATCH`, `VECTOR_STORE_FAILED`,
2849
+ `ASSIST_UNAVAILABLE`, `ASSIST_RESPONSE_INVALID`.
2850
+
2851
+ - **`@cogenta/api`** gains `createAssistantRouter` — `GET /api/assistant` and
2852
+ `POST /api/assistant/run`. The `GET` answers **200 with
2853
+ `{available: false, tools: []}`** on a site with no provider, which is what
2854
+ lets a client render nothing instead of handling an error. The permission
2855
+ gate is the route's, not the tools' (R4): an actor may use the assistant when
2856
+ they may edit content somewhere, and an anonymous caller is refused before any
2857
+ provider is contacted, so an unauthenticated request can never spend the
2858
+ site's AI budget. The route also refuses any tool declaring a side effect,
2859
+ even though none does.
2860
+
2861
+ - **`@cogenta/cli`** wires all of it into `cogenta serve`: providers built from
2862
+ the config, the vector store selected through the registry, the content stores
2863
+ wrapped so a publish updates the embedding index the same way it already
2864
+ updates the full-text one, and `/api/assistant` mounted on every site. Every
2865
+ piece degrades to "off" with a log line rather than stopping the site: an
2866
+ unknown provider name, a missing API key, an unavailable vector store and an
2867
+ embeddings provider with no adapter yet are four warnings, not four crashes.
2868
+
2869
+ **Migration**: none. Every new configuration section is optional, and a site
2870
+ that adds none behaves exactly as it did before.
2871
+
2872
+ - [`cc3ea98`](https://github.com/cogenta-cms/cogenta/commit/cc3ea981188f16efa17352370251374b62709060) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Password reset, absent until now (L13 task 6). A person who forgot their
2873
+ password had no way back: `users create` was the only account command, so
2874
+ the recovery procedure was "have an administrator make you a second
2875
+ account".
2876
+
2877
+ `@cogenta/auth` gains `createPasswordResetStore`, backed by a new
2878
+ `cogenta_password_resets` table that `ensureAuthTables` creates like the
2879
+ others. A token is 32 random bytes stored only as a SHA-256 hash — a leaked
2880
+ table hands out nothing live, the same posture as a session token — bound to
2881
+ one user, valid 30 minutes, and usable exactly once. Single use is enforced
2882
+ by `update ... where used_at is null` reporting `rowsAffected`, so two
2883
+ simultaneous redemptions produce one `ready` and one `used`, not two
2884
+ successes. Issuing a second reset deletes the first: a person who asks again
2885
+ because the mail never arrived must not leave two working links behind.
2886
+
2887
+ The token is deliberately **not** a signed payload. A signature can be
2888
+ checked without touching the database, and that is precisely what must not
2889
+ happen — single use and revocation are properties of a row, and an
2890
+ already-used token still carries a perfectly valid signature.
2891
+
2892
+ `@cogenta/cli` gains `cogenta users reset-password`, in two halves:
2893
+ `--email <address>` issues a token and mails it; `--token <token>
2894
+ [--password <text>]` redeems it, replaces the password, and revokes every
2895
+ session the user had. That last step is why the CLI composes the stores
2896
+ rather than calling one: a reset that leaves whoever knew the old password
2897
+ signed in has reset nothing.
2898
+
2899
+ The mail goes through `@cogenta/channels`'s existing email adapter — a new
2900
+ workspace dependency of `@cogenta/cli`, and the project's one way out for
2901
+ mail rather than a second mailer. Its only transport is the local file one
2902
+ (a real SMTP transport remains a documented gap in that package), so the
2903
+ command writes a real message to `.cogenta/mail` and says so in as many
2904
+ words instead of pretending anything was posted. Because the token never
2905
+ appears on the terminal, the mail is the only place it exists.
2906
+
2907
+ Since no admin route can receive a reset click yet (that lands with L11),
2908
+ the message carries the token and the exact command rather than a link that
2909
+ would 404 today.
2910
+
2911
+ - [`1c9b114`](https://github.com/cogenta-cms/cogenta/commit/1c9b114d7bde96ea00e8f75b75129f109e5c34ae) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Render an unsaved draft through the real page pipeline, so the visual page builder can
2912
+ show the published page instead of a lookalike.
2913
+
2914
+ `theme-render.ts` gains `renderDraftPage(draft, options, context)`. It reads the stored
2915
+ entry through the same permission-checked `ContentGateway` as everything else, overlays
2916
+ the block list and values the editor has on screen but has not saved, resolves the entry's
2917
+ real path with the same `buildPath` the public route uses, and hands all of it to the one
2918
+ page renderer `renderRequestedPage` already used. There is no second renderer: both
2919
+ exports now differ only in how they got hold of an entry.
2920
+
2921
+ `cogenta serve` exposes it as `POST /api/builder/render`, behind three gates in order — an
2922
+ authenticated actor, `update` on the collection asked of the same `PermissionLayer` every
2923
+ write path asks, and the gateway's own read check inside the render. A refusal answers 403
2924
+ through `errorResponse`, not 500. The response is `no-store`: a draft is cacheable by
2925
+ nobody.
2926
+
2927
+ `Site` now carries `permissions`, so a route this file serves itself can ask the one
2928
+ authority rather than re-deciding who may edit.
2929
+
2930
+ **What the fidelity test found.** The preview's `<body>` is byte-for-byte the public
2931
+ page's — asserted, not assumed. Its `<head>` is not, and should not be: a preview reads the
2932
+ *working* face of the entry, so `@cogenta/seo` refuses it `isPublished` and the document
2933
+ carries `noindex, nofollow` and drops the canonical link. The test asserts the difference
2934
+ is exactly those two tags and nothing else, which is a stronger statement than equality
2935
+ would have been.
2936
+
2937
+ - [`45d2815`](https://github.com/cogenta-cms/cogenta/commit/45d281560017abde1a069b01458a709293c1613b) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now serves a real SEO surface instead of a bare `<title>`
2938
+ (L10 tasks 1-2). `@cogenta/seo` existed, was tested and was imported by
2939
+ nothing; it is now wired to the running server.
2940
+
2941
+ Every rendered page carries a title, a meta description, a canonical URL,
2942
+ `hreflang` alternates for its linked translations (ADR-0014), Open Graph
2943
+ and Twitter Card tags, and a JSON-LD block — all derived from the real
2944
+ entry and the real collection through `buildMetaTags`/`buildJsonLd`, never
2945
+ hand-written here. An unpublished entry rendered through a preview token
2946
+ still carries `noindex`, because the gate is the package's own.
2947
+
2948
+ Three new routes, all built from the live content:
2949
+
2950
+ - `GET /sitemap.xml` (and `/sitemap-N.xml` once a site outgrows the
2951
+ 50 000-URL protocol limit), listing published, routed entries only.
2952
+ - `GET /robots.txt`, naming the sitemap and keeping crawlers out of
2953
+ `/admin` and `/api/`.
2954
+ - The redirect table is applied to **every** public GET before route
2955
+ matching, so a page renamed last month answers its old URL with the 301
2956
+ the rename recorded, query string preserved. It was previously reachable
2957
+ only through `/api/content/-/by-path`, which a browser never calls.
2958
+
2959
+ `hreflang` lookup is skipped entirely on a single-locale site, so a
2960
+ monolingual install pays nothing for it.
2961
+
2962
+ - [`ad18e0e`](https://github.com/cogenta-cms/cogenta/commit/ad18e0ed335d06ad861958e74bbfd2318e2509b8) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Images are processed at upload and served with a real `srcset` (L10 task 5).
2963
+ `@cogenta/render`'s image pipeline, `srcset.ts` and its two driver tiers
2964
+ (sharp, WebAssembly libvips) had existed since L3 and were called by nothing:
2965
+ an uploaded image recorded no dimensions, produced no renditions, and
2966
+ `ctx.image()` in the rendered page threw `THEME_IMAGE_UNSUPPORTED`.
2967
+
2968
+ - **`@cogenta/api`** — `createMediaRouter` takes an optional
2969
+ `MediaImageProcessor`. On an image upload it probes the intrinsic size into
2970
+ the asset's existing `width`/`height` columns (no schema change) and writes
2971
+ the renditions beside the original under `media/{id}/variants/`. Deleting
2972
+ the asset deletes them, by recomputing their names — `StorageDriver` has no
2973
+ `list`, which is why the ladder is fixed and `variantNames` exists. The
2974
+ interface is injected rather than imported: a REST transport has no business
2975
+ pulling a 12 MB WebAssembly dependency into its tree.
2976
+ - **`@cogenta/cli`** — builds that processor from the real driver registry and
2977
+ serves the renditions at a new **public** `GET /_image?id=…&w=…`. Public and
2978
+ image-only on purpose: a published page's `<img>` is fetched by a browser
2979
+ with no session, so it cannot sit behind the same gate as
2980
+ `/api/media/{id}/file`, which is unchanged and still covers every other kind.
2981
+ `/_image` never renders on demand — an unstored width falls back to the
2982
+ original — so a public URL cannot be turned into CPU.
2983
+ - The rendered page now carries a real `srcset`, and `og:image` and JSON-LD's
2984
+ `image` come from the same asset, absolute. Which media a page needs is
2985
+ answered by `collectDependencies`, the walk `/api/content` already uses,
2986
+ rather than by a new heuristic over block JSON.
2987
+
2988
+ Variants are produced at upload rather than lazily because `cogenta serve`
2989
+ has no durable variant cache: a lazy pipeline behind an in-memory store
2990
+ re-decodes every image after every restart, which is the worst answer on the
2991
+ shared hosting R10 names. WebP only, for now, because AVIF's encode cost on
2992
+ the WASM tier — the tier that always exists — would make an upload of a
2993
+ handful of images take minutes.
2994
+
2995
+ Also fixes a real shutdown hang: `server.close()` waits for every open
2996
+ connection, so one client that fetched a large response and never read the
2997
+ body kept `cogenta serve` alive forever. Shutdown now cuts remaining
2998
+ connections after a short grace period.
2999
+
3000
+ - [`17aa538`](https://github.com/cogenta-cms/cogenta/commit/17aa538e94da132ce1ca48d2213d2b84df231c78) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Detect broken links across published content (L14 task 3)
3001
+
3002
+ `@cogenta/schema` gains `extractLinks` and `checkLinks`, and `@cogenta/cli`
3003
+ gains `cogenta links check` to run them over a real site.
3004
+
3005
+ The crawl walks every published entry, collects every link it holds — a
3006
+ rich-text `markDefs` href, a contract B action `target`, a plain `url` field —
3007
+ and reports the ones that lead nowhere, telling apart a target that was
3008
+ deleted, one that exists but is not published, a path no route can serve, and
3009
+ a reference to a collection the site does not have. Each distinct target is
3010
+ resolved once however many entries point at it.
3011
+
3012
+ Two deliberate limits, both documented in the code:
3013
+
3014
+ - **External URLs are opt-in** (`--external` / `checkExternal`). A HEAD that
3015
+ comes back 403 or 405 is retried as a GET, because plenty of hosts refuse
3016
+ HEAD on pages they serve happily.
3017
+ - **Nothing schedules itself.** Rule R1 guarantees no durable worker, so
3018
+ "periodically" is a cron entry calling the command, not a scheduler
3019
+ pretending to exist inside the site. `cogenta links check` exits 1 when it
3020
+ finds something, so it works as a CI or cron check.
3021
+
3022
+ Note: the full-text index is not reused for this, as the lot suggested it
3023
+ might be — `search/extract.ts` deliberately strips `href`, `url` and
3024
+ `markDefs` before indexing, so it holds no URL at all.
3025
+
3026
+ - [`755201d`](https://github.com/cogenta-cms/cogenta/commit/755201d55fd8c04ba2794a03797696769b59f6cc) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Send a real signed webhook when content is published (L14 task 1)
3027
+
3028
+ The signed outbound webhook channel has existed since L6 and nothing ever
3029
+ called it. It is now connected to the content lifecycle.
3030
+
3031
+ - `@cogenta/channels` gains `createWebhookEventSender`, which POSTs a
3032
+ structured `{ event, occurredAt, data }` envelope to every configured
3033
+ endpoint. It reuses `signOutgoingWebhook` and the existing
3034
+ `X-Cogenta-Timestamp` / `X-Cogenta-Signature` headers **verbatim**, so a
3035
+ receiver verifies an event with `verifyIncomingWebhook` exactly as it
3036
+ verifies a message — there is no second signing path. It never throws: a
3037
+ failed delivery comes back as a result to log, so an editor's publish is
3038
+ never lost to somebody else's downtime.
3039
+ - `@cogenta/schema` gains `withLifecycleEvents`, a `ContentStore` decorator in
3040
+ the same shape as `withSearchIndexing`. It emits `content.publish` (from
3041
+ `publish()`, and from `create()` with a published status),
3042
+ `content.unpublish` and `content.delete`, each carrying the entry's
3043
+ identity, status, timestamps and its real route path from `buildPath`.
3044
+ Draft edits emit nothing. The event body never carries the content itself.
3045
+ - `@cogenta/core` gains a `webhooks.endpoints` config section. The signing
3046
+ secret is environment-only (`COGENTA_WEBHOOK_SECRET`, rule R7); endpoints
3047
+ configured without it disable delivery with a startup warning rather than
3048
+ falling back to unsigned requests.
3049
+ - `cogenta serve` wires the two together, outermost of all store decorators so
3050
+ an event only describes a write that really landed.
3051
+
3052
+ Proven end to end by a suite that publishes over real HTTP and verifies the
3053
+ signature on the bytes a real `node:http` receiver got off the socket.
3054
+
3055
+ - [`551a06c`](https://github.com/cogenta-cms/cogenta/commit/551a06c2e58bb4119618e5502dfcae4bb024b7d4) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Serve a site's own page for an unmatched URL (L14 task 2)
3056
+
3057
+ `cogenta serve` answered every unmatched public URL with a bare JSON error.
3058
+ It now renders the site's own 404 page instead, with a real 404 status.
3059
+
3060
+ The 404 body is an ordinary published entry at `site.notFoundPath` (`/404` by
3061
+ default, overridable in `cogenta.config` or via `COGENTA_SITE_NOT_FOUND_PATH`)
3062
+ — editable in the admin like any other page, and rendered by exactly the same
3063
+ function, through exactly the same permission-checked gateway, as every other
3064
+ page. So a draft 404 page is not shown to the public, and a site that has not
3065
+ written one still gets the plain refusal it got before. The lookup happens at
3066
+ most once per request: the 404 path itself is never re-resolved.
3067
+
3068
+ - [`8ebd276`](https://github.com/cogenta-cms/cogenta/commit/8ebd2768190f34d9ba1d67878e9024f19edb6f0f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Surface repeated failed sign-ins instead of only slowing them down (L14 task 4)
3069
+
3070
+ `cogenta_login_attempts` has been written to on every failed sign-in since L2
3071
+ and read by nothing but the rate limiter's own counter. A site being
3072
+ brute-forced knew it and told nobody. It now says so, in two places.
3073
+
3074
+ - `@cogenta/auth`'s `RateLimiter` gains `recentFailures()`, which groups the
3075
+ attempts still inside the backoff window by subject, worst first. It also
3076
+ **prunes** what has fallen out of the window — a real leak, since `clear()`
3077
+ only runs after a *successful* sign-in, so a subject that never succeeds
3078
+ accumulated rows for ever, which is exactly the case that grows fastest.
3079
+ - `@cogenta/api` gains `createSuspiciousActivitySource`, one more `NoticeSource`
3080
+ in the array `serve.ts` already builds. It shows an admin — and only an
3081
+ admin — how many failures across how many accounts, and is not dismissible
3082
+ because it disappears on its own within the limiter's fifteen-minute window.
3083
+ - `cogenta serve` also sends a `security.suspicious_activity` alert through the
3084
+ signed webhook channel L14 task 1 connected, built with `@cogenta/channels`'s
3085
+ own `buildAlert` — no second notification path and no second signature. At
3086
+ most one alert per five minutes, so a script making hundreds of attempts does
3087
+ not become hundreds of outbound requests.
3088
+
3089
+ **Counts only, never the accounts.** Neither the notice nor the outbound alert
3090
+ names an email: that would turn an admin screen into an account-enumeration
3091
+ surface, and the numbers are what a decision is made on. Per-subject detail
3092
+ stays in the audit log, behind its own permission.
3093
+
3094
+ The rate limiter itself was audited before anything was added and needed
3095
+ nothing: password sign-in, TOTP sign-in and TOTP enrolment all go through it,
3096
+ WebAuthn is deliberately exempt (there is no guessable secret), and password
3097
+ reset has no HTTP route at all.
3098
+
3099
+ - [`7ed521e`](https://github.com/cogenta-cms/cogenta/commit/7ed521edc6f8affb11020a7012e858411d40699d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - MFA is no longer a gate at sign-in, and the admin gains a generic notices
3100
+ mechanism that recommends it instead (ADR-0021).
3101
+
3102
+ **Breaking for anyone driving the auth API directly**, although both packages are
3103
+ still pre-1.0 and this is released as a minor:
3104
+
3105
+ - `LoginResult` has two members, not three. `totp_setup_required` is gone.
3106
+ `passwordLogin` now issues a session for any role that has no second factor
3107
+ enrolled — including `admin` — and challenges only an account that actually
3108
+ enrolled one. Previously a role that could `publish` on any collection, and
3109
+ `admin` unconditionally, was refused a session until it completed a TOTP
3110
+ ceremony, which meant the first admin of a brand-new site could not reach a
3111
+ single screen without an authenticator app to hand.
3112
+ - An unconfirmed TOTP secret no longer counts as a factor. Someone who opened
3113
+ the enrolment screen and walked away used to be challenged for a code their
3114
+ authenticator app had never received, with no way back.
3115
+ - `AuthService.beginTotpSetup(ticket)` / `confirmTotpSetup(ticket, code)` are
3116
+ replaced by `beginTotpEnrolment(userId)`, `confirmTotpEnrolment(userId, code)`
3117
+ and `disableTotp(userId)`. Enrolment is self-service from an existing session
3118
+ rather than a step in the sign-in flow.
3119
+ - `POST /api/auth/totp-setup` and `POST /api/auth/totp-setup-confirm` are
3120
+ replaced by `POST /api/auth/totp/enrol`, `POST /api/auth/totp/enrol/confirm`
3121
+ and `DELETE /api/auth/totp`. All three require a session, and the account they
3122
+ touch is the one the bearer token resolves to — no route takes a user id, so
3123
+ no request shape can enrol or disable a factor on somebody else's account.
3124
+
3125
+ `requiresMfa()` and `sensitiveRoles()` are unchanged and still exported. They now
3126
+ answer "who is shown the recommendation" instead of "who is blocked".
3127
+
3128
+ New in `@cogenta/api`: `createNoticeRouter`, `createNoticeDismissalStore` and
3129
+ `createMfaRecommendationSource` — a generic admin-notice mechanism serving
3130
+ `GET /api/notices` and `POST /api/notices/{id}/dismiss`. Notices are per-account,
3131
+ persist until the thing they report is fixed or the person dismisses them, and
3132
+ carry a stable code plus substitutions rather than prose, so the admin translates
3133
+ them. A dismissal is stored server-side (new table `cogenta_notice_dismissals`,
3134
+ created on startup), so the answer follows an account across browsers instead of
3135
+ living in one `localStorage`. Adding a future recommendation is one more
3136
+ `NoticeSource` in an array, with no change to the router, the store or the admin.
3137
+
3138
+ `cogenta serve` mounts `/api/notices` and registers the MFA recommendation.
3139
+
3140
+ - [`62c2898`](https://github.com/cogenta-cms/cogenta/commit/62c28982ab130aafdb8b3aed04821b039e9e03ff) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Full-text search is reachable for the first time (L10 task 3). The engine
3141
+ (`packages/schema/src/search/`, one driver per database) has existed and
3142
+ been tested since L1, but nothing anywhere in the repository ever called
3143
+ `index()` and no route ever called `search()` — so every search returned
3144
+ nothing, however the query was written.
3145
+
3146
+ - **`@cogenta/schema`** gains `withSearchIndexing(store, { collection,
3147
+ index, onError })`, a `ContentStore` decorator in the same shape as
3148
+ `withReadOnlyStore`. Wrapping the store rather than hooking a router is
3149
+ what makes REST and GraphQL both covered by one guard instead of two.
3150
+ Its central safety property: after any mutation the **published** face is
3151
+ read back first and indexed when it exists, so an unpublished edit to a
3152
+ published entry can never be filed under a status a public search reaches.
3153
+ A failing index write never fails the content write — the index is derived
3154
+ data — and surfaces through `onError` rather than silently.
3155
+ - **`@cogenta/api`** gains `createSearchRouter` — `GET /api/search?q=…`,
3156
+ with `collections`, `status`, `locale`, `limit` and `offset`. Naming a
3157
+ collection you may not read is a 403, not a quieter answer; the default
3158
+ scope is the readable collections only, and every hit is filtered against
3159
+ that same set on the way out. `status` other than `published` requires
3160
+ `canReadUnpublished` on every collection in scope.
3161
+ - **`@cogenta/cli`** creates the index at startup, wraps every collection's
3162
+ store with it, mounts `/api/search`, and serves a public `/search?q=…`
3163
+ page with a real form and real links (`noindex`, as a search results page
3164
+ must be). The public page is a **route, not a contract B block**: contract
3165
+ B is frozen and adding a block needs an RFC, which does not belong in a
3166
+ lot whose premise is "wiring only".
3167
+
3168
+ - [`ca71b3b`](https://github.com/cogenta-cms/cogenta/commit/ca71b3bbd5d5d7371923d0521444fc94a525de06) Thanks [@georgesmomo](https://github.com/georgesmomo)! - CORS, security headers and a coherent cache-control on `cogenta serve`
3169
+ (L10 task 6).
3170
+
3171
+ `@cogenta/core`'s configuration gains a `security` section:
3172
+
3173
+ ```ts
3174
+ security: {
3175
+ cors: { origins: ['https://app.example.com'], credentials: false },
3176
+ csp: "default-src 'self'",
3177
+ hstsMaxAge: 31536000,
3178
+ pageMaxAge: 60,
3179
+ }
3180
+ ```
3181
+
3182
+ Every field is off or permissive-by-omission by default, and that is a
3183
+ decision rather than timidity. CORS is off unless a site names an origin —
3184
+ the origin list *is* the switch, so "CORS is on" and "these origins may read
3185
+ it" cannot drift apart. HSTS is off unless asked and is never sent over plain
3186
+ HTTP: on a host that is not fully HTTPS it locks browsers out for `maxAge`
3187
+ seconds with no server-side undo, and it is the one header a wrong default can
3188
+ take a site offline with. Credentials together with the `*` origin is refused
3189
+ at startup, because every browser refuses that pair and a server that accepted
3190
+ it would look configured while granting nothing.
3191
+
3192
+ `cogenta serve` applies all of it in one place, before any route runs, so a
3193
+ route added later cannot opt out by forgetting:
3194
+
3195
+ - `X-Content-Type-Options: nosniff`, `X-Frame-Options: SAMEORIGIN` and
3196
+ `Referrer-Policy: strict-origin-when-cross-origin` on every response.
3197
+ - The configured CSP verbatim — a string, not a builder, because a CSP depends
3198
+ on which analytics, fonts and embeds a site actually uses.
3199
+ - CORS with an echoed (never blindly reflected) origin and `Vary: Origin`,
3200
+ plus a real preflight answer.
3201
+ - Cache-control by path class: `no-store` for `/api/*` and for the admin,
3202
+ `public, max-age=0, s-maxage=<pageMaxAge>, must-revalidate` for a public
3203
+ page, and the long immutable value image variants already set for
3204
+ themselves.
3205
+
3206
+ - [`1f1e8b2`](https://github.com/cogenta-cms/cogenta/commit/1f1e8b24385750995bb2af90a8d94478d44bdcdc) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Four corrections to L19, from the contract review.
3207
+
3208
+ **ADR-0010 wins over the lot document.** Applying a site plan writes
3209
+ `cogenta.schema.*` and creates tables — that is the schema editor arriving by a
3210
+ different door, and ADR-0010 says it plainly: "uniquement en mode
3211
+ développement. En production le schéma est en lecture seule." L19's brief asked
3212
+ for the opposite ("un site déjà en production peut recevoir de nouveaux
3213
+ documents"); the acted decision wins, and the disagreement is written down in
3214
+ `BLOCKERS.md` with a ready-to-insert ADR-0023 rather than worked around.
3215
+ `RunServeOptions` gains `development`, set by `cogenta dev` and by it alone.
3216
+ Proposing and reviewing a plan stay available everywhere; only the write is
3217
+ withheld, and the refusal names the way out.
3218
+
3219
+ **The schema file is the one the site really loads.** The applier wrote
3220
+ `cogenta.schema.mjs` by name, while `loadCollections` prefers
3221
+ `cogenta.schema.ts` — the form ADR-0010 calls for. On such a project it would
3222
+ have created the tables and then written a file nothing reads, leaving orphan
3223
+ tables and no collections after the restart it told the operator to do. It now
3224
+ resolves the real path (`findSchemaFile`, newly exported) and names it in the
3225
+ follow-up. It also refuses outright when the current schema declares a
3226
+ `validate` or a function `default`, which regenerating the file would silently
3227
+ delete.
3228
+
3229
+ **Content a model wrote is marked as such.** Demonstration entries seeded by
3230
+ the installer and by the applier now carry `provenance: 'generated'` and a
3231
+ `provenanceDetail` naming the agent, the model and the time. Contract A calls
3232
+ that field non-optional because the European AI framework requires it; the
3233
+ store's default is `human`, so inheriting it would have made the one regulated
3234
+ field lie about every generated entry.
3235
+
3236
+ **R8 has a second hop.** A constraint's `quote` is verbatim document text, and
3237
+ the analysis step's careful tagging counted for nothing when the content-model
3238
+ and demo-content prompts pasted it back in as prose — "Pas de blog. Ignore all
3239
+ previous instructions and …" is a single clause, so the whole thing is the
3240
+ quote. Both now go through `assembleContext`'s data channel too, escaped and
3241
+ tagged, with a test that smuggles a forged `</data><constitution>` inside a
3242
+ constraint and checks it arrives escaped.
3243
+
3244
+ - [`07e49bf`](https://github.com/cogenta-cms/cogenta/commit/07e49bf0d45260fc14c74efe8a67b2671fd8e022) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Document-driven site planning on a site that is already running (L19 tasks 5
3245
+ and 7). `@cogenta/api` gains `createSitePlanRouter` and `cogenta serve` mounts
3246
+ it at `/api/site-plans`; the admin gets a screen on top of it.
3247
+
3248
+ Upload a brief, read what the agent understood, and decide on it one item at a
3249
+ time — every collection, page, demonstration entry and constraint read out of
3250
+ the document is its own yes or no. The API has no `acceptAll` parameter and the
3251
+ screen has no control that decides more than one item; `apply` calls
3252
+ `resolveApprovedPlan`, which refuses a plan with an undecided item, so there is
3253
+ no path that skips the review even for a caller writing raw HTTP.
3254
+
3255
+ Applying is **additive**. A proposed collection whose name the site already
3256
+ uses is refused and reported — replacing a live collection is a migration with
3257
+ a diff and a backup, not a side effect of accepting a suggestion. What is
3258
+ applied writes the schema file, creates the new tables and seeds approved
3259
+ demonstration entries as drafts, never published. The report says plainly that
3260
+ `cogenta serve` has to be restarted to see the new collections, rather than
3261
+ implying the change is already live. A plan is applied at most once.
3262
+
3263
+ Every route is admin-only. On a site with no LLM provider the routes that need
3264
+ a model answer `SITE_PLAN_NO_PROVIDER` (501) with a hint, and the list route
3265
+ reports `plannerAvailable: false` so the screen can explain itself — a plan
3266
+ proposed during installation is still readable and appliable there, which is
3267
+ what makes the installer's "save it for later" path mean something (R2).
3268
+
3269
+ - [`e321f08`](https://github.com/cogenta-cms/cogenta/commit/e321f089b14f5f116f28ab6eb2d2ffc0a43bc27d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Give the canonical theme a real design system, and make `cogenta serve` actually send it.
3270
+
3271
+ `src/styles/theme.css` is now three layers — `tokens.css` (the design system),
3272
+ `base.css` (document, accessibility, page frame, actions) and `blocks.css` (the twelve
3273
+ vocabulary blocks). Every value in all three is still derived from contract D's closed
3274
+ skin token set: spacing from `--cogenta-space-unit` and `--cogenta-space-scale`, type from
3275
+ the seven `--cogenta-font-size-*` steps `renderSkin` emits, colour from the seven colour
3276
+ tokens through `color-mix()` and relative `oklch()`. No token was added to the contract and
3277
+ no colour literal was added to the theme.
3278
+
3279
+ **Two real bugs fixed on the way.** The stylesheet referenced `--cogenta-color-accentFg`,
3280
+ `--cogenta-color-mutedFg` and `--cogenta-font-baseSize`, but `renderSkin` kebab-cases token
3281
+ names, so the real properties are `--cogenta-color-accent-fg`, `--cogenta-color-muted-fg`
3282
+ and `--cogenta-font-base-size`. Every muted text colour, every primary button label and the
3283
+ entire typographic scale therefore resolved to nothing. A new test derives the expected
3284
+ property names from the skin and fails on any future misspelling.
3285
+
3286
+ And `cogenta serve` sent the skin's generated custom properties but never the stylesheet
3287
+ that uses them, so every served page was styled by the browser's defaults with a skin
3288
+ defined and unused. It now inlines the theme's sheet — `@import`s flattened, comments
3289
+ dropped, whitespace squeezed — next to the skin's, and renders the same page frame
3290
+ `Base.astro` builds: a skip link, a site header with a home link, and a footer.
3291
+
3292
+ **Dark mode, designed rather than inverted.** A `light-dark()` palette behind an
3293
+ `@supports` guard, with `color-scheme` declared so native controls follow. Elevation is
3294
+ expressed as lightness rather than shadow, the accent is lifted and its foreground
3295
+ consequently flipped to ink, borders become lighter overlays, and text stays off pure
3296
+ white. `design-system.test.ts` computes every one of those colours from the real stylesheet
3297
+ — resolving `var()`, `light-dark()`, `color-mix(in oklab, …)` and `oklch(from …)` — and
3298
+ asserts AA body contrast on fourteen pairs in both schemes.
3299
+
3300
+ `Site.skinCss` is now `Site.styles`, and `assembleSite`'s last parameter with it.
3301
+
3302
+ - [`87bae8d`](https://github.com/cogenta-cms/cogenta/commit/87bae8dd4cc08261f3d5ba83947fa2ad77b0b826) Thanks [@georgesmomo](https://github.com/georgesmomo)! - **Breaking: `DELETE /api/content/{collection}/{id}` now means "move to the
3303
+ trash"**, not "destroy" (`schema@2.0`, ADR-0022). Two routes complete it:
3304
+
3305
+ - `POST /{collection}/{id}/untrash` — take it back out;
3306
+ - `POST /{collection}/{id}/purge` — destroy it for good.
3307
+
3308
+ Purge is a POST on its own path rather than a second meaning for `DELETE`,
3309
+ because two verbs on one path with two very different consequences is how
3310
+ someone destroys content by reflex. A client that used `DELETE` to really
3311
+ remove an entry must now follow it with `/purge`.
3312
+
3313
+ `?trashed=include|only` on a list opens the trash; without it a pre-2.0 client
3314
+ sees exactly what it saw before. All four operations — including *seeing* the
3315
+ trash — require the `delete` permission on the collection: contract A freezes
3316
+ the five actions, so the trash borrows the one that fills it.
3317
+
3318
+ Serialised entries gain `deletedAt`, orthogonal to `status`: an entry in the
3319
+ trash still reports the status it had, which is what restoring gives back.
3320
+
3321
+ ### Taxonomy terms over HTTP
3322
+
3323
+ `createTaxonomyRouter` mounts `/api/taxonomies`:
3324
+
3325
+ ```
3326
+ GET /{taxonomy} the tree, in tree order
3327
+ POST /{taxonomy} create a term
3328
+ GET /{taxonomy}/{id} one term
3329
+ PATCH /{taxonomy}/{id} rename, relabel, reorder
3330
+ DELETE /{taxonomy}/{id} delete (?cascade=true for the whole branch)
3331
+ POST /{taxonomy}/{id}/move re-parent it
3332
+ ```
3333
+
3334
+ Mounted apart from `/api/content` because a taxonomy is not a collection and a
3335
+ site may legitimately name both the same thing. The materialised path is
3336
+ deliberately **not** serialised — it is a storage decision, and `parent` plus
3337
+ `depth` are what a tree renderer needs.
3338
+
3339
+ `PermissionLayer` gains `canTerm`/`assertTerm` rather than a widened `can`:
3340
+ same role rules, no preview path. A preview token names a collection and an
3341
+ entry, so with a `category` collection beside a `category` taxonomy, sharing
3342
+ the code path would let a token minted for one unlock the other. Custom
3343
+ `PermissionLayer` implementations must add the two methods.
3344
+
3345
+ ### In `cogenta serve`
3346
+
3347
+ A project declares its taxonomies as a named `taxonomies` export beside the
3348
+ default one in `cogenta.schema.*`; a schema file written before 2.0 keeps
3349
+ loading unchanged and declares none. The server creates the terms tables before
3350
+ the collections, mounts `/api/taxonomies`, and passes `siblings` to every
3351
+ content store so `restrict` is still enforced when an entry is trashed.
3352
+
3353
+ - [`89ec072`](https://github.com/cogenta-cms/cogenta/commit/89ec0724be1dcc50b8fa5f7a14ca026c40e0de89) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Account management moves out of the terminal: `@cogenta/api` gains
3354
+ `createUsersRouter`, mounted by `cogenta serve` at `/api/users`.
3355
+
3356
+ Until now `cogenta users create` was the only way to make an account. The new
3357
+ routes are:
3358
+
3359
+ - `GET /api/users` (admin) — every account, optionally filtered by `?role=`,
3360
+ each with a summary of the second factors it holds
3361
+ - `POST /api/users` (admin) — creates the account and returns a server-generated
3362
+ password exactly once, the same rule the CLI already follows. The admin never
3363
+ chooses it.
3364
+ - `PATCH /api/users/{id}` (admin) — roles and status. Disabling an account
3365
+ revokes its live sessions in the same move.
3366
+ - `GET /api/users/{id|me}` and `GET /api/users/{id|me}/sessions` — yours, or
3367
+ anyone's with `admin`
3368
+ - `DELETE /api/users/{id|me}/sessions/{sessionId}` — revoke one session
3369
+ - `POST /api/users/me/password` — change your own password, current one
3370
+ required, rate-limited on the same store as sign-in
3371
+
3372
+ Two deliberate absences. There is no delete: accounts are disabled, never
3373
+ removed, because an account that wrote content still has to be nameable in the
3374
+ audit log. And there is no route for an admin to set somebody else's password —
3375
+ that is a reset, it needs a delivery channel and a single-use token to be
3376
+ anything but a back door, and it is L13's task.
3377
+
3378
+ Two safety properties worth naming, both covered by tests:
3379
+
3380
+ - The last active `admin` cannot be demoted or disabled. Not a permission
3381
+ question — the person doing it is allowed to — but with no password reset yet
3382
+ there is no way back into a site with no administrator.
3383
+ - `DELETE /api/users/me/sessions/{id}` checks the session actually belongs to
3384
+ the caller before revoking it, so passing someone else's session id under
3385
+ `me` is a 404 rather than a successful revocation.
3386
+
3387
+ `cogenta serve` records `user.create`, `user.update`, `user.password_change` and
3388
+ `user.session_revoke` in the audit log, naming the actor and the subject and
3389
+ nothing that could sign anyone in.
3390
+
3391
+ `cogenta users create`'s closing hint and `create-cogenta`'s install recap no
3392
+ longer tell people they will be asked to set up a second factor at first
3393
+ sign-in: since ADR-0021 they will not be.
3394
+
3395
+ ### Patch Changes
3396
+
3397
+ - [`6ad0f3a`](https://github.com/cogenta-cms/cogenta/commit/6ad0f3a495176169fe95f4955dfef30a6af376fd) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Closes four denial-of-service and permission-escalation gaps a security review found in L19's document-upload pipeline and site-plan review screen, all reachable from a single uploaded file or a proposed content model — no LLM provider required to trigger them.
3398
+
3399
+ - `.docx` extraction (`packages/agents/src/documents/docx.ts`): the regex scanning `word/document.xml` for `<w:t>…</w:t>` runs backtracked quadratically on unclosed tags (measured 21.8 s for 400 KB). Replaced with a single linear `indexOf`-based scan, and `word/document.xml`/footnotes/endnotes are now capped at 8 MiB each (`zip.ts`'s `read()` gained a per-call `maxBytes`) instead of the shared 200 MiB decompression-bomb ceiling, since a highly repetitive XML payload can deflate at several hundred to one.
3400
+ - PDF stream collection (`packages/agents/src/documents/pdf.ts`): `collectStreams` used an unbounded `lastIndexOf` to find each stream's dictionary, which re-scans the entire prefix of the file for every stream found — a file that is mostly fake `stream`/`endstream` markers with no real PDF structure could cost minutes of CPU with no decompression involved. The search window is now bounded to 2 KiB behind each `stream` keyword, and the number of streams processed is capped at 10 000.
3401
+ - PDF text accumulation (`packages/agents/src/documents/pdf.ts`, `extract-text.ts`): `MAX_TEXT_CHARACTERS` was only enforced after every content stream had already been decoded and joined, so a PDF with many individually-small-enough, highly compressible streams could accumulate many times that budget in memory before truncation ever ran. The reader now stops pulling in further pages once the accumulated text already exceeds the cap, moved to a shared `limits.ts` so both `pdf.ts` and `extract-text.ts` read the same number.
3402
+ - Site plan review (`packages/agents/src/site-plan/content-model.ts`, `approval.ts`): a proposed content model's `permissions` is entirely the model's own choice, so a hallucinated or prompt-injected proposal granting `public` the `create`/`update`/`delete` actions would have let any anonymous visitor write to that collection once the plan was applied. `buildCollection` now refuses such a proposal outright (`CONTENT_MODEL_PROPOSAL_PERMISSIONS_UNSAFE`, fed back as the next attempt's correction like any other invalid proposal); separately, the human review screen (`summarisePlan`) now always shows a collection's proposed permissions and routing pattern, not only its fields and rationale, so a legitimate-but-surprising grant is visible before acceptance.
3403
+ - `cogenta serve` (`packages/cli/src/commands/serve.ts`): `readBody` had no byte limit, and the one route inviting multi-megabyte bodies by design (`/api/site-plans`) only checked the admin role after the body was fully buffered. `readBody` now caps every request body at 64 MiB, rejecting with a new `REQUEST_BODY_TOO_LARGE` error code (HTTP 413); `/api/site-plans` now checks the admin role before reading the body at all, so a non-admin caller — anonymous or not — is turned away before the server reads anything they sent.
3404
+
3405
+ - [`b8ed3cf`](https://github.com/cogenta-cms/cogenta/commit/b8ed3cfca3f7b84e5454ffeb357edbe970afa065) Thanks [@georgesmomo](https://github.com/georgesmomo)! - **Breaking:** `GET /api/media` and `GET /api/media/{id}` now require an
3406
+ authenticated actor, like every other route on that router. They never did,
3407
+ despite the file's own doc comment claiming otherwise since L2 — so an
3408
+ anonymous request returned every asset's id, filename, alt text, storage key
3409
+ and uploader.
3410
+
3411
+ That gap became a real exfiltration path the moment L10 added a public
3412
+ `/_image?id=…` delivery endpoint: the ids that endpoint is keyed on are
3413
+ unguessable UUIDs, but they were *listable*, so every uploaded image —
3414
+ including the ones attached to nothing published — was downloadable without a
3415
+ session. Found by the security review of this lot.
3416
+
3417
+ Any client reading the media library must now send its bearer token. The
3418
+ admin already did on every call.
3419
+
3420
+ Two related fixes in the same area:
3421
+
3422
+ - An uploaded image is stored with the content type its **bytes** earn, never
3423
+ the one the uploader declared. Sniffing already decided whether the file is
3424
+ an image; repeating the declared type afterwards let a genuine PNG announced
3425
+ as `text/html` be served as a document on the site's own origin, publicly
3426
+ and cached for a year. `/_image` also whitelists the type it puts on the
3427
+ wire, so an asset stored before this fix serves as an opaque download rather
3428
+ than executing.
3429
+ - `cogenta serve` no longer marks a page rendered for a signed-in actor as
3430
+ cacheable by a shared cache. A page render is per-actor — an editor sees the
3431
+ draft at the same URL — and `public, s-maxage=…` is precisely what RFC 9111
3432
+ §3.5 says re-authorises a CDN to store the answer to a request carrying
3433
+ `Authorization`. Anything sent with credentials is now `private, no-store`.
3434
+ - `/sitemap.xml` no longer 500s when the site has a routed collection the
3435
+ `public` role may not read: such a collection is skipped, since it has no
3436
+ public URLs to list.
3437
+ - Updated dependencies [[`552645e`](https://github.com/cogenta-cms/cogenta/commit/552645e039b8c8c4f5340d065ea2f4a552950815), [`cc3ea98`](https://github.com/cogenta-cms/cogenta/commit/cc3ea981188f16efa17352370251374b62709060), [`8b561d1`](https://github.com/cogenta-cms/cogenta/commit/8b561d1ba735eb2b42c27725f67faf64e53866e5), [`182ef48`](https://github.com/cogenta-cms/cogenta/commit/182ef48d97e2757e7b1404dc407327f53ed377dd), [`6ad0f3a`](https://github.com/cogenta-cms/cogenta/commit/6ad0f3a495176169fe95f4955dfef30a6af376fd), [`ad18e0e`](https://github.com/cogenta-cms/cogenta/commit/ad18e0ed335d06ad861958e74bbfd2318e2509b8), [`17aa538`](https://github.com/cogenta-cms/cogenta/commit/17aa538e94da132ce1ca48d2213d2b84df231c78), [`755201d`](https://github.com/cogenta-cms/cogenta/commit/755201d55fd8c04ba2794a03797696769b59f6cc), [`551a06c`](https://github.com/cogenta-cms/cogenta/commit/551a06c2e58bb4119618e5502dfcae4bb024b7d4), [`8ebd276`](https://github.com/cogenta-cms/cogenta/commit/8ebd2768190f34d9ba1d67878e9024f19edb6f0f), [`b8ed3cf`](https://github.com/cogenta-cms/cogenta/commit/b8ed3cfca3f7b84e5454ffeb357edbe970afa065), [`7ed521e`](https://github.com/cogenta-cms/cogenta/commit/7ed521edc6f8affb11020a7012e858411d40699d), [`809baee`](https://github.com/cogenta-cms/cogenta/commit/809baee0b47e48aea06235a97c0da29c7ba4b06c), [`87bae8d`](https://github.com/cogenta-cms/cogenta/commit/87bae8dd4cc08261f3d5ba83947fa2ad77b0b826), [`b4e7deb`](https://github.com/cogenta-cms/cogenta/commit/b4e7deb11cb56f514da8533ffd9296a809bd45f0), [`62c2898`](https://github.com/cogenta-cms/cogenta/commit/62c28982ab130aafdb8b3aed04821b039e9e03ff), [`ca71b3b`](https://github.com/cogenta-cms/cogenta/commit/ca71b3bbd5d5d7371923d0521444fc94a525de06), [`45d2815`](https://github.com/cogenta-cms/cogenta/commit/45d281560017abde1a069b01458a709293c1613b), [`a332e41`](https://github.com/cogenta-cms/cogenta/commit/a332e416bfe08a226756451624b6344e7c6b7516), [`1f1e8b2`](https://github.com/cogenta-cms/cogenta/commit/1f1e8b24385750995bb2af90a8d94478d44bdcdc), [`ade7b38`](https://github.com/cogenta-cms/cogenta/commit/ade7b3807fd273e56bcbe7499eb83374a592d35f), [`07e49bf`](https://github.com/cogenta-cms/cogenta/commit/07e49bf0d45260fc14c74efe8a67b2671fd8e022), [`32f5db9`](https://github.com/cogenta-cms/cogenta/commit/32f5db932454aa35e586a4ffe144f909b0b773af), [`e321f08`](https://github.com/cogenta-cms/cogenta/commit/e321f089b14f5f116f28ab6eb2d2ffc0a43bc27d), [`87bae8d`](https://github.com/cogenta-cms/cogenta/commit/87bae8dd4cc08261f3d5ba83947fa2ad77b0b826), [`89ec072`](https://github.com/cogenta-cms/cogenta/commit/89ec0724be1dcc50b8fa5f7a14ca026c40e0de89)]:
3438
+ - @cogenta/core@0.3.0
3439
+ - @cogenta/agents@0.2.0
3440
+ - @cogenta/api@1.0.0
3441
+ - @cogenta/auth@0.2.0
3442
+ - @cogenta/schema@0.2.0
3443
+ - @cogenta/channels@0.2.0
3444
+ - @cogenta/seo@0.2.0
3445
+ - @cogenta/theme-canonical@0.2.0
3446
+ - @cogenta/blocks@0.1.3
3447
+ - @cogenta/import@0.1.3
3448
+ - @cogenta/render@0.1.3
3449
+
3450
+ ## 0.2.2
3451
+
3452
+ ### Patch Changes
3453
+
3454
+ - [`82d7b1d`](https://github.com/cogenta-cms/cogenta/commit/82d7b1de151888df1623262ff6fe104232b4c46e) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Fix `/admin` rendering a blank page. Vite always trails its build `base`
3455
+ with `/` ("/admin/"), and react-router's `basename` match is a literal
3456
+ string prefix — a request for exactly `/admin` (no trailing slash, the URL
3457
+ a real user actually types or gets redirected to first) does not start
3458
+ with "/admin/", so the router silently rendered nothing. Confirmed via the
3459
+ browser console: `<Router basename="/admin/"> is not able to match the URL
3460
+ "/admin"...`. `/admin/` (with the slash) always worked, which is why this
3461
+ was easy to miss testing via curl/HTTP status codes alone — a 200 response
3462
+ doesn't mean the page actually rendered.
3463
+
3464
+ Fixed by stripping the trailing slash from the basename `@cogenta/admin`'s
3465
+ `app.tsx` passes to `BrowserRouter` — "/admin" still matches
3466
+ "/admin/collections" (still starts with "/admin"), so nothing about deep
3467
+ links changes. Verified with a real browser: login → TOTP setup → a
3468
+ working dashboard with real site health and audit-log data, both starting
3469
+ from `/admin` with no trailing slash.
3470
+
3471
+ ## 0.2.0
3472
+
3473
+ ### Minor Changes
3474
+
3475
+ - [`7ff79a2`](https://github.com/cogenta-cms/cogenta/commit/7ff79a260f97c79192553e88e2e7e4d22e0d8965) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now serves the real admin SPA (`@cogenta/admin`) under
3476
+ `/admin/*`, alongside the public theme render at `/` — there was previously
3477
+ no way to reach the admin UI from a scaffolded site at all (`/admin` 404'd,
3478
+ and nothing in the installer's recap explained how to get there). The
3479
+ admin's own `vite build` is copied into `@cogenta/cli`'s `dist/admin-assets`
3480
+ at build time (a plain file copy, not a real npm dependency — `@cogenta/admin`
3481
+ stays `private` and unpublished); a request for a real built asset gets that
3482
+ exact file (still a real 404 if missing, never silently swapped for HTML),
3483
+ and any other path under `/admin` gets `index.html` so the SPA's own
3484
+ client-side router (now mounted with `basename="/admin"`, matching the
3485
+ build's `base: '/admin/'`) resolves deep links. The API the SPA talks to is
3486
+ same-origin (`fetch('/api/...')`), so no CORS or separate-origin auth
3487
+ wiring was needed — that boundary was already designed into
3488
+ `@cogenta/admin`'s `http.ts`, just never connected to a real server.
3489
+
3490
+ Found while answering "how do I log into the admin UI" — the admin app
3491
+ itself was real and complete (auth, schema-driven editing, media, audit,
3492
+ agents, fleet), it had simply never been wired to anything a scaffolded
3493
+ site's `cogenta serve` could reach.
3494
+
3495
+ - [`cb69cab`](https://github.com/cogenta-cms/cogenta/commit/cb69cab09b89d3cc5b8d15f5887ec93f82e32599) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now renders real HTML pages, not just the `/api/*` REST and
3496
+ GraphQL surface. Until a real Astro build exists (`cogenta build`/`theme` are
3497
+ still honestly deferred — no static site generation, no theme dev server),
3498
+ this is a scoped in-process stand-in: a GET request that doesn't match
3499
+ `/api/*` is resolved against the site's real collection routes
3500
+ (`matchPath`/`buildPath`, `@cogenta/schema`), the matching published entry is
3501
+ fetched through the exact same permission-checked `ContentGateway` every
3502
+ REST and GraphQL request already goes through, and rendered with
3503
+ `@cogenta/theme-canonical`'s real `renderPage` — the same function the
3504
+ `create-cogenta` blueprint tests already exercise. A collection with a
3505
+ `blocks` field renders its real block zone; a `richText`-only collection
3506
+ (e.g. `post`) gets its body wrapped in a single real `prose` block rather
3507
+ than a second hand-rolled serialiser. Styling comes from
3508
+ `@cogenta/render`'s already-tested `renderSkin` against the site's real
3509
+ `theme.tokens.json`, never a second token-to-CSS mapping.
3510
+
3511
+ No secret, database handle or config value ever reaches theme code — only
3512
+ the same `ContentEntry` shape a real HTTP client would receive through
3513
+ `@cogenta/theme-canonical`'s own, deliberately separate `ContentEntry`/
3514
+ `QueryRequest` contract (ADR-0016's boundary holds even in-process).
3515
+
3516
+ Scoped deliberately: no image pipeline is wired in yet (a theme asking for
3517
+ one gets `THEME_IMAGE_UNSUPPORTED`, not a broken `<img>`), and a
3518
+ cross-reference to an entry this render didn't already fetch resolves to
3519
+ `#` rather than a guessed URL — a real Astro site would build a full
3520
+ link-graph ahead of render; this stand-in doesn't.
3521
+
3522
+ Found and built while investigating why a scaffolded site had nothing to
3523
+ show a browser: `cogenta serve` had never rendered a page, only the API.
3524
+
3525
+ Building it against a real seeded site surfaced a real, separate bug in
3526
+ `assembleSite`: the `ContentGateway`'s store map was only ever populated
3527
+ lazily, by REST's own `storeFor` — a collection no REST request had touched
3528
+ yet had no store at all, so the very first GraphQL (or now theme-render)
3529
+ query against it failed with `INTERNAL`/"has no store" instead of a real
3530
+ answer. `assembleSite` now populates every collection's store eagerly, once,
3531
+ so REST, GraphQL and the theme-render fallback all see the same complete
3532
+ map from the first request.
3533
+
3534
+ ### Patch Changes
3535
+
3536
+ - [`fd0a52e`](https://github.com/cogenta-cms/cogenta/commit/fd0a52e155d802b102ac9012b3ed2d650b271c3f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve`'s theme-render fallback (added in a previous, unreleased
3537
+ change on this package) 404'd on `/` itself: every `page` collection's route
3538
+ pattern is `/:slug`, which structurally cannot match an empty segment. `/`
3539
+ now retries once as `/home` — the real, consistent slug every
3540
+ `create-cogenta` blueprint seeds its home page at — before giving up. A site
3541
+ with no page at that slug still 404s honestly, exactly like any other
3542
+ unmatched path; this is not a magic redirect.
3543
+
3544
+ Also fixes `runServe` passing its resolved `env` object down to `loadConfig`
3545
+ in a way that always looked "explicitly supplied" (see `@cogenta/core`'s
3546
+ `env-file-autoload` changeset) — without this, `@cogenta/core`'s new `.env`
3547
+ auto-loading could never actually fire from a real `cogenta serve` run.
3548
+
3549
+ Both found via the user's own real end-to-end test against a freshly
3550
+ scaffolded Portfolio-blueprint site: `/` returned `CONTENT_NOT_FOUND`, and
3551
+ `cogenta serve` still demanded a manually exported signing key despite a
3552
+ `.env` file sitting right next to the config.
3553
+ - Updated dependencies [[`fd0a52e`](https://github.com/cogenta-cms/cogenta/commit/fd0a52e155d802b102ac9012b3ed2d650b271c3f), [`4c95475`](https://github.com/cogenta-cms/cogenta/commit/4c9547543ec9a4464d8c9a05d1967dd15b7953aa)]:
3554
+ - @cogenta/core@0.2.0
3555
+ - @cogenta/agents@0.1.2
3556
+ - @cogenta/api@0.1.2
3557
+ - @cogenta/auth@0.1.2
3558
+ - @cogenta/blocks@0.1.2
3559
+ - @cogenta/import@0.1.2
3560
+ - @cogenta/render@0.1.2
3561
+ - @cogenta/schema@0.1.2
3562
+ - @cogenta/theme-canonical@0.1.2
3563
+
3564
+ ## 0.1.0
3565
+
3566
+ ### Minor Changes
3567
+
3568
+ - [`f323580`](https://github.com/cogenta-cms/cogenta/commit/f3235809422e16a4e9d34f16e1171d2ebcfaf01a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `@cogenta/cli` and its first command, `cogenta doctor`.
3569
+
3570
+ `doctor` reports which driver is running for each need, **why that one**, and what it
3571
+ costs. The "why" is the point: the registry can fall back from Redis to the filesystem
3572
+ without anyone noticing, and an operator who cannot see that has a site that is slower
3573
+ than they think for a reason nothing told them. Skipped drivers are listed with their
3574
+ reason too.
3575
+
3576
+ It also states out loud what would otherwise be discovered later — that a site with no
3577
+ LLM provider works apart from the agents, that SQLite is one machine with no vector
3578
+ index, and that signed media URLs will not survive a restart without
3579
+ `COGENTA_STORAGE_SIGNING_KEY`. An invalid configuration is reported as the offending
3580
+ fields rather than a stack trace, and exits non-zero so a deployment script notices.
3581
+
3582
+ Core gains `loadConfig` and `findConfigFile`, which walk up from the working directory
3583
+ the way a package manager looks for a lockfile. A missing config file is not an error: a
3584
+ container configured entirely through `COGENTA_*` and `DATABASE_URL` is a legitimate way
3585
+ to run.
3586
+
3587
+ - [`bcf646e`](https://github.com/cogenta-cms/cogenta/commit/bcf646edfd9810a977161075e639bce760b91864) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add the agent administration interface (L5 task 9): "état, autonomie,
3588
+ budget, historique, traces".
3589
+
3590
+ `@cogenta/agents`: `BudgetTracker` gains `usage(): BudgetUsage` — a
3591
+ read-only snapshot of the same three calendar-bucketed counters
3592
+ `checkCall`/`recordCall` already track, needed so an admin can show
3593
+ real spend against budget.
3594
+
3595
+ `@cogenta/api`: a new `/api/agents` router (`createAgentsRouter`),
3596
+ structural against `AgentRegistryLike`/`TraceStoreLike`/`AuditLogLike`
3597
+ — no hard dependency on `@cogenta/agents`. Lists agents with their
3598
+ state/autonomy/budget/usage, enables/disables one, and reads its
3599
+ traces/history (empty list, not an error, when a trace store or audit
3600
+ log was not wired in).
3601
+
3602
+ `@cogenta/cli`: `assembleSite` accepts an optional `agents` option;
3603
+ `/api/agents` is only mounted when it is supplied — no site constructs
3604
+ one today, so every existing deployment is unaffected (R2).
3605
+
3606
+ `@cogenta/admin`: a new "Agents" screen — a list with enable/disable
3607
+ per row, and a detail panel showing recent traces and history for the
3608
+ selected agent.
3609
+
3610
+ - [`5d64afd`](https://github.com/cogenta-cms/cogenta/commit/5d64afdb47dd5bfdbe06cb7895391b726fb22277) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `GET /api/audit` (filterable by `actorId`/`action`/`collection`/`since`,
3611
+ paginated by `limit`) and `GET /api/audit/verify` (recomputes the hash
3612
+ chain, `AUDIT_CHAIN_BROKEN` naming the first mismatch on tampering) — both
3613
+ restricted to the `admin` role.
3614
+
3615
+ `@cogenta/auth`'s hash-chained audit log (`createAuditLog`) existed since it
3616
+ was built as generic core infrastructure, but nothing wrote to it and no
3617
+ route read from it. `cogenta serve` is now its first writer: every
3618
+ successful login, logout, content create/update/delete/publish/restore and
3619
+ media upload/update/delete records an entry, at the transport layer rather
3620
+ than inside each service — one place, so no future write path has to
3621
+ remember to call it separately. Recording never blocks or fails the
3622
+ response it is auditing.
3623
+
3624
+ - [`a9a7553`](https://github.com/cogenta-cms/cogenta/commit/a9a75531fe0b52fd9b55a3940c4abc337446cdc1) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add passkey registration and passkey login (WebAuthn), completing L2 task 3's second
3625
+ factor: TOTP with self-service enrolment, and now passkeys — the spec's primary sign-in
3626
+ method.
3627
+
3628
+ `@cogenta/auth`'s `AuthService` gains four methods: `beginWebAuthnRegistration`/
3629
+ `completeWebAuthnRegistration` for adding a passkey to an already-signed-in account, and
3630
+ `beginWebAuthnLogin`/`completeWebAuthnLogin` for a usernameless sign-in — no account is
3631
+ named up front; the assertion's own credential id decides which one it is. The challenge
3632
+ each ceremony needs between its two requests rides in the same short-lived signed ticket
3633
+ the rest of this package already uses, extended with an optional `challenge` field and a
3634
+ nullable `userId` (unknown until login resolves it) — never a server-side store for
3635
+ something single-use that lives seconds. `AuthStoreOptions` gains `webauthn` (relying
3636
+ party config) and `issuer`, both previously accepted by `createAuthService` but silently
3637
+ dropped by the store-level factory.
3638
+
3639
+ `@cogenta/api`'s `createAuthRouter` exposes this as
3640
+ `POST /api/auth/webauthn/{register|login}/{begin|complete}`. `cogenta serve` derives the
3641
+ relying party id and origin from `site.url` and the name from `site.name` — one more
3642
+ config field to keep, not a new one to add.
3643
+
3644
+ `@cogenta/admin`'s login screen leads with "Se connecter avec une clé d'accès" over
3645
+ `@simplewebauthn/browser`'s `startAuthentication`, with password-then-TOTP as the
3646
+ fallback underneath. Passkey *registration* — adding one to an account — needs a
3647
+ settings surface that does not exist yet in the admin and is deferred to when that
3648
+ surface is built; the backend and API routes for it are already in place.
3649
+
3650
+ - [`b18a02c`](https://github.com/cogenta-cms/cogenta/commit/b18a02c3f5638520794db83bd1adfdb246a4f839) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `cogenta migrate` — `status`, `up` and `down` — over the existing migration engine.
3651
+
3652
+ `status` lists every migration with the date and duration of its run, and marks the ones
3653
+ that changed after they were applied here. That last case exits non-zero: two
3654
+ environments that ran different SQL under the same id is the worst state to debug, and a
3655
+ deployment script has to notice it rather than read it.
3656
+
3657
+ Migrations are plain ESM files in a `migrations/` directory next to the configuration
3658
+ file, default-exporting an object with `up(tx)` and `down(tx)`. They are ordered by file
3659
+ name, the id defaults to the file name, and the checksum is a hash of the file itself —
3660
+ so a migration edited after it ran is detected without anyone maintaining a second
3661
+ number. A project with no `migrations/` directory has zero migrations, which is not an
3662
+ error: L0 ships no business schema at all.
3663
+
3664
+ A destructive migration still needs `--confirm-destructive` **and** `--backup-verified`.
3665
+ The engine already refused without both; the CLI now makes the refusal actionable by
3666
+ naming each destructive migration and printing its declared impact, instead of asking
3667
+ the operator to go and read the files.
3668
+
3669
+ Core fix, found by running the command from a subdirectory: a relative path in a config
3670
+ file is now resolved against **that file**, not against the shell's working directory.
3671
+ `cogenta migrate status` run from `src/` used to open an empty `./site.db` next to `src/`
3672
+ and report an already-migrated database as entirely pending. The same applies to
3673
+ `cache.path` and `storage.path`. Absolute paths, server URLs and `:memory:` are
3674
+ untouched, and configuration that comes from the environment alone is unaffected.
3675
+
3676
+ - [`67d188f`](https://github.com/cogenta-cms/cogenta/commit/67d188fb8d3cc7525299f462b2308e9e24e3c12f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - L9 task 9: real CLI surface for `generate types` and `skin list/validate/apply/generate`, plus `cogenta dev` as an alias for `cogenta serve`.
3677
+
3678
+ `generate types` is a thin wrapper around `@cogenta/schema`'s existing `renderTypeDeclarations`, writing to `.cogenta/types/schema.d.ts` by default. `skin list/validate/apply` are thin wrappers around `@cogenta/render`'s existing `validateSkin`/contract-D token groups — `apply` never writes a skin that fails validation.
3679
+
3680
+ `skin generate`'s underlying logic (`generateSkin`, the LLM→JSON→validate→retry-on-hint loop built for `create-cogenta`'s L9 task 7) is relocated from `create-cogenta` to `@cogenta/agents` (`@cogenta/agents`'s `generateSkin`/`GenerateSkinOptions`/`GenerateSkinResult`) so both the installer and `@cogenta/cli` can call the same implementation without either depending on the other — `@cogenta/agents` gains a dependency on `@cogenta/render` (the schema/validation it generates against), not the other way around. `create-cogenta`'s `skin-flow.ts` now imports `generateSkin` from `@cogenta/agents`; no behavior change.
3681
+
3682
+ `build`, `backup`, `upgrade`, `deploy`, `theme`, `agent`, and `generate schema`/`generate migrations` remain unbuilt — none has a real underlying capability to wrap yet (no Astro build wiring, no backup/restore mechanism, no deploy-target concept, no theme registry, no live `AgentRegistry` anywhere in the codebase, no schema-diff-to-migration generator). `cogenta <command>` for any of these falls through to the existing unknown-command usage message rather than a stub — see CLAUDE.md for the per-command reasoning.
3683
+
3684
+ - [`1b54335`](https://github.com/cogenta-cms/cogenta/commit/1b5433577617c1c3a50d123ba1a4e81c7c5c9d97) Thanks [@georgesmomo](https://github.com/georgesmomo)! - `cogenta serve` now streams the file behind a media asset at
3685
+ `GET /api/media/{id}/file`. It sits outside `@cogenta/api`'s `mediaRouter`
3686
+ because a binary body has no shape in that router's JSON-only `RestResponse`
3687
+ — the same treatment `/api/schema` already gets — so it reads the object
3688
+ through the storage driver and pipes it straight to the response, gated by
3689
+ the same "signed in at all" rule every other `/api/media` route uses.
3690
+
3691
+ - [`24b1745`](https://github.com/cogenta-cms/cogenta/commit/24b174536c79a7b0f505e1ba4e70d5070fb14f6d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - L9 task 12 ("Site du projet et playground"), the buildable slice the lot itself calls out: "commencer par une démo en lecture seule réinitialisée périodiquement."
3692
+
3693
+ - `@cogenta/schema`: new `withReadOnlyStore(store)` — wraps any `ContentStore` so `create`/`update`/`delete`/`publish`/`unpublish`/`restore` refuse with a real `CONTENT_READ_ONLY` error while every read passes through unchanged.
3694
+ - `@cogenta/cli`: `runServe`'s `ServeOptions` gained a `readOnly` flag. Wrapped once, at the single point `serve.ts` constructs every `ContentStore` — both REST's `ContentService` and GraphQL's gateway share it, so neither transport can bypass the guard.
3695
+ - `@cogenta/api`: `CONTENT_READ_ONLY` maps to HTTP 403.
3696
+ - `@cogenta/core`: two new error codes — `CONTENT_READ_ONLY`, `PLAYGROUND_BLUEPRINT_UNKNOWN`.
3697
+ - `create-cogenta`: new `resetPlaygroundData()` — wipes and reseeds a blueprint's tables back to its own real demo content (`BLUEPRINT_CONTENT_PACKS`, unchanged, not a second parallel demo dataset). A real, tested, callable unit; scheduling it periodically is an operational decision for whoever deploys a read-only instance, not made here. `BLUEPRINT_CONTENT_PACKS`/`BlueprintContentPack` are now part of the package's public exports.
3698
+
3699
+ Actual public deployment of a playground or the project site is explicitly out of scope: it is an irreversible action toward the outside world requiring resources only a human holds, per this project's standing autonomy rule.
3700
+
3701
+ Also new: `@cogenta/project-site` (private, unpublished) — a small, real presentation site for the Cogenta project itself, built through the same content model and `renderPage`/`renderBlock` pipeline any installed site uses, with real content drawn from `docs/00-vision.md` and this session's own documentation.
3702
+
3703
+ - [`3bc0872`](https://github.com/cogenta-cms/cogenta/commit/3bc0872800001aace498f331abbd903c66f750e5) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `/api/media` — upload, list, read, edit and delete media assets — over
3704
+ the `MediaStore` `@cogenta/core` shipped previously. `cogenta serve` now
3705
+ selects a storage driver (S3 or local, same registry the rest of the config
3706
+ already uses) and mounts the route alongside `/api/content` and `/api/auth`.
3707
+
3708
+ Uploads travel as JSON with the file base64-encoded rather than multipart:
3709
+ the REST transport's own contract is "a body already parsed by the
3710
+ transport", and staying inside it avoids a multipart-parsing dependency for
3711
+ an admin-only upload path. The real file type is read from the bytes, never
3712
+ from the declared `Content-Type` or filename — the same check the image
3713
+ pipeline already used, moved into `@cogenta/core` in the previous release
3714
+ so this route can share it. An image whose bytes are not one of AVIF/WebP/
3715
+ JPEG/PNG is refused, naming what it actually is; an SVG upload is refused
3716
+ outright, per ADR-0017.
3717
+
3718
+ Every route requires an authenticated actor — there is no per-collection
3719
+ permission model for media the way there is for content yet, so today's
3720
+ gate is "signed in at all," tightened once L4's agent tool permissions
3721
+ (contract C's `media.read`/`media.write`) land.
3722
+
3723
+ - [`ccfb4e1`](https://github.com/cogenta-cms/cogenta/commit/ccfb4e1c2ff2ccf528ebf4a8656c8f34f2da45ff) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `cogenta users create` — the bootstrap for the very first admin account.
3724
+
3725
+ An admin panel nobody can sign into is not usable, and until now there was no way to
3726
+ create the first user at all. `cogenta users create --email <email> --admin` generates a
3727
+ random password, prints it once, and stores only its hash — the same path any later
3728
+ account goes through, just run from the command line before the admin UI exists to do it
3729
+ for you.
3730
+
3731
+ - [`b939bf4`](https://github.com/cogenta-cms/cogenta/commit/b939bf4957bceccf01c86775a32acbf32d0925f8) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `GET /api/schema` and wire the admin's collection list to it — L2 task 4, "rôles et
3732
+ affichage conditionnel selon permissions".
3733
+
3734
+ `cogenta serve` computes the schema document once at startup (collections do not change
3735
+ while the process runs) and serves it read-only, unauthenticated: it describes shapes and
3736
+ which role names an action needs, never content. `@cogenta/admin` fetches it once per
3737
+ session through a new `SchemaProvider`, and a small `canPerform`/`readableCollections`
3738
+ pair — independently re-implemented rather than imported from `@cogenta/api`, which pulls
3739
+ in the database and GraphQL layers that do not belong in a browser bundle — decides what
3740
+ to show. The collections page lists only what the signed-in actor may read; the rest are
3741
+ not merely disabled, they are absent, matching the acceptance criterion that a hidden
3742
+ action is also refused by the API rather than just hidden by convention.
3743
+
3744
+ - [`764344a`](https://github.com/cogenta-cms/cogenta/commit/764344abe6869f855b87ff80a2cb6b1b4711c01d) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `GET /api/health`, restricted to the `admin` role: the same database and
3745
+ storage driver/tier/latency report `cogenta doctor` prints from a terminal,
3746
+ now queryable from the running server. Backs the admin dashboard's site
3747
+ health widget.
3748
+
3749
+ - [`aa878ea`](https://github.com/cogenta-cms/cogenta/commit/aa878ea6766361219fe218e17741ce1d9d9ffd2f) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `cogenta serve` — a real HTTP server over `@cogenta/api` and `@cogenta/auth`, and
3750
+ the `/api/auth/*` REST routes (`login`, `totp`, `session`) those two now share through
3751
+ `@cogenta/api`'s new `createAuthRouter`.
3752
+
3753
+ The actor a request authenticates as comes from one function, `resolveActor` — a bearer
3754
+ token resolved through `@cogenta/auth`'s sessions, never trusted further than that — and
3755
+ both `/api/content/*` and `/api/graphql` call it, so there is exactly one answer to "who
3756
+ is asking", not a REST answer and a GraphQL answer that could drift apart.
3757
+
3758
+ Collections load from `cogenta.schema.ts` next to the config file, the same
3759
+ dynamic-import convention `migrate.ts` already used for migrations. `serve` refuses to
3760
+ start without `COGENTA_AUTH_SIGNING_KEY` rather than inventing one, since a signing key
3761
+ that changes on every restart would silently invalidate every in-flight MFA ticket.
3762
+
3763
+ Passkey ceremonies and TOTP enrolment are not in this router yet — both need a challenge
3764
+ held between two requests, which is deliberately out of scope for this pass and tracked
3765
+ for L2 task 3.
3766
+
3767
+ - [`d5896bb`](https://github.com/cogenta-cms/cogenta/commit/d5896bb8bbabb43873e82deb1acfdb818def201b) Thanks [@georgesmomo](https://github.com/georgesmomo)! - New package `@cogenta/import`: `cogenta import wordpress <file.xml>` (L9 task
3768
+ 6). Imports a WordPress "Export All Content" WXR file — posts, pages,
3769
+ categories, tags, media (downloaded and re-stored through `MediaStore`/
3770
+ `StorageDriver`), authors (as real, credential-less users), approved comments,
3771
+ postmeta (carried as opaque `f.json()` `customFields`, contract A has no
3772
+ free-form field kind), Gutenberg blocks converted to the block vocabulary
3773
+ (`prose`/`mediaFigure`/`quote`/`gallery`/`embed`) where a mapping exists, and
3774
+ 301 redirects from each entry's old permalink (`reason: 'import'`, the
3775
+ `@cogenta/schema` redirect store's own case for this). Every WXR reader is a
3776
+ zero-dependency, WXR-scoped XML tokenizer (`deps-auditor` rejected
3777
+ `fast-xml-parser`: a single-maintainer seven-package split published the same
3778
+ day, and a general parser's DTD support is an unnecessary XXE surface for a
3779
+ file of unknown provenance) — a document declaring `<!DOCTYPE ... ENTITY` is
3780
+ rejected outright.
3781
+
3782
+ Nothing that cannot be converted is silently dropped: an unmappable Gutenberg
3783
+ block, a dead media URL, an author with no email, a trashed post — every one
3784
+ of them lands in the returned `ConversionReport` (`imported`/`skipped`/
3785
+ `unconvertedBlocks`/`warnings`), which `cogenta import wordpress` prints. The
3786
+ command exits `0` even with items reported as unconverted — a reported
3787
+ partial import is the intended outcome for a real-world export, not a
3788
+ failure — and only exits non-zero when the file cannot be read or parsed at
3789
+ all.
3790
+
3791
+ Two new `@cogenta/core` error codes: `IMPORT_WXR_PARSE_FAILED`,
3792
+ `IMPORT_WXR_UNSAFE_DOCUMENT`.
3793
+
3794
+ ### Patch Changes
3795
+
3796
+ - [`ec2529b`](https://github.com/cogenta-cms/cogenta/commit/ec2529b7c7cb70c0c91d8275fdac4811b2d1073a) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Fix `cogenta serve` crashing on Windows the moment `cogenta.schema.ts`
3797
+ doesn't exist, instead of falling through to the next candidate filename
3798
+ (`.mts`, `.mjs`, `.js`).
3799
+
3800
+ `loadCollections`'s `isModuleNotFound` decided whether a missing candidate
3801
+ was safe to skip by checking that the thrown error's message contained the
3802
+ candidate's `file://` URL. On Windows, Node's own `ERR_MODULE_NOT_FOUND`
3803
+ message embeds the raw OS path (`C:\...`) instead of the URL form, so the
3804
+ check never matched — the first missing extension in the candidate list
3805
+ (typically `.ts`, since most real sites use `.mjs`) surfaced as a hard
3806
+ `SCHEMA_INVALID` failure rather than being silently skipped.
3807
+
3808
+ Now matches either form. Found via the same end-to-end local-registry test
3809
+ that surfaced the `create-cogenta` blank-schema bug (see that changeset) —
3810
+ after fixing the schema file itself, `cogenta serve` still failed on
3811
+ Windows specifically, for this unrelated reason.
3812
+
3813
+ - [`7a16841`](https://github.com/cogenta-cms/cogenta/commit/7a168415e2fce628d4a835eb778be396104a2590) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add preview links: `POST /{collection}/{id}/preview` mints a one-hour,
3814
+ one-entry `PreviewGrant` token and returns the entry's real page path/URL
3815
+ alongside it (`site.url` + the collection's routing pattern). Any read of
3816
+ that one entry — `GET /{collection}/{id}` or `GET /-/by-path` — now accepts
3817
+ `?preview=<token>` together with `?state=working` to unlock exactly that
3818
+ entry's draft for whoever holds the link, and nothing else; a token for one
3819
+ entry never covers another, and a request with no token behaves exactly as
3820
+ it did before this change.
3821
+
3822
+ The token is verified lazily, only when a `preview` query parameter is
3823
+ actually present, so an ordinary request never needs
3824
+ `COGENTA_PREVIEW_SIGNING_KEY` to be set at all — only minting and consuming
3825
+ a preview link do.
3826
+
3827
+ `cogenta serve` passes `site.url` through to the REST router so a minted
3828
+ link is a ready-to-open absolute URL, not just a token the caller has to
3829
+ build a path for by hand.
3830
+
3831
+ - [`2aa38b4`](https://github.com/cogenta-cms/cogenta/commit/2aa38b4d466126c16afd0ac55febd35c7d163b00) Thanks [@georgesmomo](https://github.com/georgesmomo)! - Add `GET /{collection}/{id}/translations`, listing every live entry of the
3832
+ translation family an id belongs to (ADR-0014: one entry per language,
3833
+ linked by `translationOf`) — itself included, gated the same way `history`
3834
+ already is (only an actor who may read this entry's working state may
3835
+ enumerate its family).
3836
+
3837
+ `buildSchemaDocument` accepts an optional second `site` argument
3838
+ (`{locales, defaultLocale}`), included in the document only when given —
3839
+ `.cogenta/schema.json`'s own build-time call is unaffected. `cogenta serve`
3840
+ now passes it through to `/api/schema`, so the admin can render a locale
3841
+ switcher without hardcoding assumptions about which locales a site has.
3842
+
3843
+ Fixed along the way: `cogenta serve` was hardcoding `locales: ['en']`,
3844
+ `defaultLocale: 'en'` into the content service's routing options instead of
3845
+ reading `config.site.locales`/`defaultLocale` — a site configured for more
3846
+ than English silently only ever routed English. `translationOf` on create
3847
+ was already fully wired end to end (REST body → `ContentStore.create`); no
3848
+ change was needed there.
3849
+ - Updated dependencies [[`f323580`](https://github.com/cogenta-cms/cogenta/commit/f3235809422e16a4e9d34f16e1171d2ebcfaf01a), [`ea82de1`](https://github.com/cogenta-cms/cogenta/commit/ea82de10eba12d520e586b69e1bce733339da26d), [`bcf646e`](https://github.com/cogenta-cms/cogenta/commit/bcf646edfd9810a977161075e639bce760b91864), [`8ae3456`](https://github.com/cogenta-cms/cogenta/commit/8ae3456d346ee2e169fceaa45c3cbaef1df01982), [`0877503`](https://github.com/cogenta-cms/cogenta/commit/0877503bf4a999543d51ce6dda2126471a4852c0), [`5d64afd`](https://github.com/cogenta-cms/cogenta/commit/5d64afdb47dd5bfdbe06cb7895391b726fb22277), [`a9a7553`](https://github.com/cogenta-cms/cogenta/commit/a9a75531fe0b52fd9b55a3940c4abc337446cdc1), [`dc674b2`](https://github.com/cogenta-cms/cogenta/commit/dc674b2dc8a375b8ace5881a3fb8601855888500), [`b18a02c`](https://github.com/cogenta-cms/cogenta/commit/b18a02c3f5638520794db83bd1adfdb246a4f839), [`6f0b7bd`](https://github.com/cogenta-cms/cogenta/commit/6f0b7bdd457ba8d81e0aa18d0bde9b583bf810af), [`f870177`](https://github.com/cogenta-cms/cogenta/commit/f8701772440a4b3a7d0726b0836b94b7c1b57344), [`fd5ada9`](https://github.com/cogenta-cms/cogenta/commit/fd5ada927327946603a05349c2f87686ef8f003c), [`22bb5b2`](https://github.com/cogenta-cms/cogenta/commit/22bb5b2903b35c79d80a7df0bb99bead1533ba55), [`50d3b40`](https://github.com/cogenta-cms/cogenta/commit/50d3b4041fb5392502711c2bf20f4ec92d2ce76d), [`27e32b5`](https://github.com/cogenta-cms/cogenta/commit/27e32b52ed11e97969e2b319b2e74345bbc1f213), [`962073f`](https://github.com/cogenta-cms/cogenta/commit/962073f3aa5e56e68869c7d14a4b2937e506cfbd), [`59aced9`](https://github.com/cogenta-cms/cogenta/commit/59aced90e97d3aa2a98ab5e7aa067f50e2ceb611), [`b26dd9f`](https://github.com/cogenta-cms/cogenta/commit/b26dd9f636095b126ceb78e69bda50f7f5f8cb52), [`f52f97f`](https://github.com/cogenta-cms/cogenta/commit/f52f97ff8c553ab44f715b55f37ac726ea335160), [`39b6d33`](https://github.com/cogenta-cms/cogenta/commit/39b6d339a52ba97a1437167c15910971eee02383), [`67d188f`](https://github.com/cogenta-cms/cogenta/commit/67d188fb8d3cc7525299f462b2308e9e24e3c12f), [`6322731`](https://github.com/cogenta-cms/cogenta/commit/632273109648e850e415bb179bea6e5ea027c500), [`4921407`](https://github.com/cogenta-cms/cogenta/commit/4921407b4dbd283bdd76cf74d288a79c2ebcab64), [`7d9ed38`](https://github.com/cogenta-cms/cogenta/commit/7d9ed3878de61d54e58a4aa027c72447c118761c), [`046ffa8`](https://github.com/cogenta-cms/cogenta/commit/046ffa85769066150a0d0e8443d0d257ef72239c), [`d10724c`](https://github.com/cogenta-cms/cogenta/commit/d10724cb238399bf7203fff0bc151a832c555ad4), [`a958ee1`](https://github.com/cogenta-cms/cogenta/commit/a958ee12cee1130effb97e95d58fda219e153a4c), [`39fc7a4`](https://github.com/cogenta-cms/cogenta/commit/39fc7a4d490f0a1683ef69dd5495e0ff6494ca72), [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819), [`c522dda`](https://github.com/cogenta-cms/cogenta/commit/c522dda594169b5148643726fbd41dbbf1c9a308), [`2a044a1`](https://github.com/cogenta-cms/cogenta/commit/2a044a1689f98a25258b6f45d9baf0b325194c95), [`5ae4e24`](https://github.com/cogenta-cms/cogenta/commit/5ae4e24e59cf807ef7aca5839623fd8a24798435), [`77ff957`](https://github.com/cogenta-cms/cogenta/commit/77ff95771e3fc415d9581e8d51ccae200167703d), [`22ec8de`](https://github.com/cogenta-cms/cogenta/commit/22ec8deec494a2925a943550fcf3c5e1689eb40e), [`7a16841`](https://github.com/cogenta-cms/cogenta/commit/7a168415e2fce628d4a835eb778be396104a2590), [`3021aa1`](https://github.com/cogenta-cms/cogenta/commit/3021aa1c65d708b1267c662ce925d560f735d7d0), [`b2ecf93`](https://github.com/cogenta-cms/cogenta/commit/b2ecf9310366fcbaf18fbbf2c71bc45fccc577da), [`8d3b27c`](https://github.com/cogenta-cms/cogenta/commit/8d3b27ce2334c7ea6e75182707aa6d6e78688b31), [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a), [`8d3b27c`](https://github.com/cogenta-cms/cogenta/commit/8d3b27ce2334c7ea6e75182707aa6d6e78688b31), [`40539fc`](https://github.com/cogenta-cms/cogenta/commit/40539fcd48da958dba69f9a32f0b440f868d539f), [`24b1745`](https://github.com/cogenta-cms/cogenta/commit/24b174536c79a7b0f505e1ba4e70d5070fb14f6d), [`e8692eb`](https://github.com/cogenta-cms/cogenta/commit/e8692eba9f47a7a7eee176058f4638abec71dce0), [`3184163`](https://github.com/cogenta-cms/cogenta/commit/318416355a83d88828786344e1ff80e1b113c564), [`1f2eecc`](https://github.com/cogenta-cms/cogenta/commit/1f2eecc754286c9e140511634b465a6536f99f25), [`6ce944f`](https://github.com/cogenta-cms/cogenta/commit/6ce944ffac8e947a979b8dc46a64ee3699b0b402), [`a958ee1`](https://github.com/cogenta-cms/cogenta/commit/a958ee12cee1130effb97e95d58fda219e153a4c), [`fc13c44`](https://github.com/cogenta-cms/cogenta/commit/fc13c4484c1c01a64b23941622e8308731fd937e), [`f5b0d4c`](https://github.com/cogenta-cms/cogenta/commit/f5b0d4cd8b7a81b36f8c539b38a412b893cb125c), [`1041c9f`](https://github.com/cogenta-cms/cogenta/commit/1041c9fb8c39872350786e5dc5b8a4f84e2b3ff7), [`ed7e7d1`](https://github.com/cogenta-cms/cogenta/commit/ed7e7d1cd73eedff8877c974938b7134bd24ac3b), [`fe1e7b6`](https://github.com/cogenta-cms/cogenta/commit/fe1e7b693d3a5eb8635e783a75863f5613712fb4), [`a609efa`](https://github.com/cogenta-cms/cogenta/commit/a609efa46060a35b048a24e7d03b7bbde414b7a4), [`32dc81a`](https://github.com/cogenta-cms/cogenta/commit/32dc81adac441ecc0b105c4da02e9064ead09b99), [`ff45fb3`](https://github.com/cogenta-cms/cogenta/commit/ff45fb3fef9b076e0550e09601912ad759831476), [`f0915d5`](https://github.com/cogenta-cms/cogenta/commit/f0915d5b3040512560477cfbb95729a6e69a3f3c), [`269c38b`](https://github.com/cogenta-cms/cogenta/commit/269c38b4df5bae381cadbfa85d5c6fe12353e177), [`3bc0872`](https://github.com/cogenta-cms/cogenta/commit/3bc0872800001aace498f331abbd903c66f750e5), [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a), [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a), [`2aa38b4`](https://github.com/cogenta-cms/cogenta/commit/2aa38b4d466126c16afd0ac55febd35c7d163b00), [`11d592b`](https://github.com/cogenta-cms/cogenta/commit/11d592bbca9cea415c95aa0edb4a85aef8b05174), [`6a84427`](https://github.com/cogenta-cms/cogenta/commit/6a84427da789abdce1f61feeef7c1ff5bc7fb9f5), [`64b43fb`](https://github.com/cogenta-cms/cogenta/commit/64b43fb661784c855c1375dfcf995999198e93d3), [`c93a5f7`](https://github.com/cogenta-cms/cogenta/commit/c93a5f709bce8b380c270a1b4ef31dac86293535), [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819), [`163d88b`](https://github.com/cogenta-cms/cogenta/commit/163d88bc594b457a06e19ce39e4fbe9e4693e4d8), [`73acd6f`](https://github.com/cogenta-cms/cogenta/commit/73acd6f40a6c1904fde717891f04079d930a0e43), [`99aa9b2`](https://github.com/cogenta-cms/cogenta/commit/99aa9b2fb2bbedeacf658b57008a863f6af81d45), [`1c5efd2`](https://github.com/cogenta-cms/cogenta/commit/1c5efd24572d6295e5e21f476637adf8ebc92819), [`696c163`](https://github.com/cogenta-cms/cogenta/commit/696c163c05bb981413e52af74d63dcbcbe72c99e), [`aa878ea`](https://github.com/cogenta-cms/cogenta/commit/aa878ea6766361219fe218e17741ce1d9d9ffd2f), [`d5896bb`](https://github.com/cogenta-cms/cogenta/commit/d5896bb8bbabb43873e82deb1acfdb818def201b), [`693697e`](https://github.com/cogenta-cms/cogenta/commit/693697ed41174c027c5acaa43abb3a9c0e41bbab), [`ee839be`](https://github.com/cogenta-cms/cogenta/commit/ee839be0c862bea209acd080b6a44bcd41738d5a)]:
3850
+ - @cogenta/core@0.1.0
3851
+ - @cogenta/agents@0.1.0
3852
+ - @cogenta/api@0.1.0
3853
+ - @cogenta/auth@0.1.0
3854
+ - @cogenta/schema@0.1.0
3855
+ - @cogenta/render@0.1.0
3856
+ - @cogenta/import@0.1.0