@cogenta/cli 0.4.0 → 0.7.0

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