@cogenta/cli 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CHANGELOG.md +3856 -0
  2. package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -0
  3. package/dist/admin-assets/assets/index-fYXyHpn8.js +1090 -0
  4. package/dist/admin-assets/assets/vips-_dmTUXFO.wasm +0 -0
  5. package/dist/admin-assets/assets/vips-es6-DrQTTS8Q.js +7 -0
  6. package/dist/admin-assets/branding/logo-cogenta-dark.png +0 -0
  7. package/dist/admin-assets/branding/logo-cogenta-icon.png +0 -0
  8. package/dist/admin-assets/branding/logo-cogenta-light.png +0 -0
  9. package/dist/admin-assets/branding/logo-cogenta-small.png +0 -0
  10. package/dist/admin-assets/branding/logo-cogenta-transparent.png +0 -0
  11. package/dist/admin-assets/branding/logo-cogenta.png +0 -0
  12. package/dist/admin-assets/favicon.png +0 -0
  13. package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
  14. package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
  15. package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
  16. package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
  17. package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
  18. package/dist/admin-assets/fonts/jetbrains-mono-var.woff2 +0 -0
  19. package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
  20. package/dist/admin-assets/index.html +3 -2
  21. package/dist/bin.js +6 -0
  22. package/dist/bin.js.map +1 -1
  23. package/dist/commands/admin-assets.d.ts +1 -0
  24. package/dist/commands/admin-assets.d.ts.map +1 -1
  25. package/dist/commands/admin-assets.js +19 -1
  26. package/dist/commands/admin-assets.js.map +1 -1
  27. package/dist/commands/agent-runtime.d.ts +151 -0
  28. package/dist/commands/agent-runtime.d.ts.map +1 -0
  29. package/dist/commands/agent-runtime.js +519 -0
  30. package/dist/commands/agent-runtime.js.map +1 -0
  31. package/dist/commands/assistant.d.ts +89 -3
  32. package/dist/commands/assistant.d.ts.map +1 -1
  33. package/dist/commands/assistant.js +206 -39
  34. package/dist/commands/assistant.js.map +1 -1
  35. package/dist/commands/audit-integrity-alert.d.ts +23 -0
  36. package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
  37. package/dist/commands/audit-integrity-alert.js +33 -0
  38. package/dist/commands/audit-integrity-alert.js.map +1 -0
  39. package/dist/commands/backup.d.ts +78 -0
  40. package/dist/commands/backup.d.ts.map +1 -0
  41. package/dist/commands/backup.js +238 -0
  42. package/dist/commands/backup.js.map +1 -0
  43. package/dist/commands/channels.d.ts +47 -0
  44. package/dist/commands/channels.d.ts.map +1 -0
  45. package/dist/commands/channels.js +277 -0
  46. package/dist/commands/channels.js.map +1 -0
  47. package/dist/commands/default-logo.d.ts +27 -0
  48. package/dist/commands/default-logo.d.ts.map +1 -0
  49. package/dist/commands/default-logo.js +30 -0
  50. package/dist/commands/default-logo.js.map +1 -0
  51. package/dist/commands/doctor.d.ts +5 -1
  52. package/dist/commands/doctor.d.ts.map +1 -1
  53. package/dist/commands/doctor.js +74 -2
  54. package/dist/commands/doctor.js.map +1 -1
  55. package/dist/commands/export.d.ts +22 -0
  56. package/dist/commands/export.d.ts.map +1 -0
  57. package/dist/commands/export.js +182 -0
  58. package/dist/commands/export.js.map +1 -0
  59. package/dist/commands/forms-page.d.ts +75 -0
  60. package/dist/commands/forms-page.d.ts.map +1 -0
  61. package/dist/commands/forms-page.js +191 -0
  62. package/dist/commands/forms-page.js.map +1 -0
  63. package/dist/commands/import.d.ts.map +1 -1
  64. package/dist/commands/import.js +13 -1
  65. package/dist/commands/import.js.map +1 -1
  66. package/dist/commands/mcp.d.ts +35 -0
  67. package/dist/commands/mcp.d.ts.map +1 -0
  68. package/dist/commands/mcp.js +277 -0
  69. package/dist/commands/mcp.js.map +1 -0
  70. package/dist/commands/roles.d.ts +22 -0
  71. package/dist/commands/roles.d.ts.map +1 -0
  72. package/dist/commands/roles.js +79 -0
  73. package/dist/commands/roles.js.map +1 -0
  74. package/dist/commands/search-page.d.ts +15 -0
  75. package/dist/commands/search-page.d.ts.map +1 -1
  76. package/dist/commands/search-page.js +22 -16
  77. package/dist/commands/search-page.js.map +1 -1
  78. package/dist/commands/seo.d.ts +92 -4
  79. package/dist/commands/seo.d.ts.map +1 -1
  80. package/dist/commands/seo.js +168 -4
  81. package/dist/commands/seo.js.map +1 -1
  82. package/dist/commands/serve.d.ts +489 -6
  83. package/dist/commands/serve.d.ts.map +1 -1
  84. package/dist/commands/serve.js +3883 -164
  85. package/dist/commands/serve.js.map +1 -1
  86. package/dist/commands/site-plan.d.ts +27 -1
  87. package/dist/commands/site-plan.d.ts.map +1 -1
  88. package/dist/commands/site-plan.js +71 -4
  89. package/dist/commands/site-plan.js.map +1 -1
  90. package/dist/commands/term-archive-page.d.ts +94 -0
  91. package/dist/commands/term-archive-page.d.ts.map +1 -0
  92. package/dist/commands/term-archive-page.js +195 -0
  93. package/dist/commands/term-archive-page.js.map +1 -0
  94. package/dist/commands/theme-css.d.ts +22 -7
  95. package/dist/commands/theme-css.d.ts.map +1 -1
  96. package/dist/commands/theme-css.js +33 -8
  97. package/dist/commands/theme-css.js.map +1 -1
  98. package/dist/commands/theme-registry.d.ts +94 -0
  99. package/dist/commands/theme-registry.d.ts.map +1 -0
  100. package/dist/commands/theme-registry.js +130 -0
  101. package/dist/commands/theme-registry.js.map +1 -0
  102. package/dist/commands/theme-render.d.ts +360 -2
  103. package/dist/commands/theme-render.d.ts.map +1 -1
  104. package/dist/commands/theme-render.js +984 -29
  105. package/dist/commands/theme-render.js.map +1 -1
  106. package/dist/commands/theme-wiring.d.ts +107 -0
  107. package/dist/commands/theme-wiring.d.ts.map +1 -0
  108. package/dist/commands/theme-wiring.js +295 -0
  109. package/dist/commands/theme-wiring.js.map +1 -0
  110. package/dist/commands/tools.d.ts +71 -0
  111. package/dist/commands/tools.d.ts.map +1 -0
  112. package/dist/commands/tools.js +330 -0
  113. package/dist/commands/tools.js.map +1 -0
  114. package/dist/commands/update.d.ts +25 -0
  115. package/dist/commands/update.d.ts.map +1 -0
  116. package/dist/commands/update.js +193 -0
  117. package/dist/commands/update.js.map +1 -0
  118. package/dist/commands/users.d.ts.map +1 -1
  119. package/dist/commands/users.js +6 -36
  120. package/dist/commands/users.js.map +1 -1
  121. package/dist/index.d.ts +15 -0
  122. package/dist/index.d.ts.map +1 -1
  123. package/dist/index.js +146 -4
  124. package/dist/index.js.map +1 -1
  125. package/dist/invite-mail.d.ts +39 -0
  126. package/dist/invite-mail.d.ts.map +1 -0
  127. package/dist/invite-mail.js +39 -0
  128. package/dist/invite-mail.js.map +1 -0
  129. package/dist/reset-mail.d.ts +39 -0
  130. package/dist/reset-mail.d.ts.map +1 -0
  131. package/dist/reset-mail.js +45 -0
  132. package/dist/reset-mail.js.map +1 -0
  133. package/dist/update/apply.d.ts +54 -0
  134. package/dist/update/apply.d.ts.map +1 -0
  135. package/dist/update/apply.js +77 -0
  136. package/dist/update/apply.js.map +1 -0
  137. package/dist/update/changelog-risk.d.ts +20 -0
  138. package/dist/update/changelog-risk.d.ts.map +1 -0
  139. package/dist/update/changelog-risk.js +55 -0
  140. package/dist/update/changelog-risk.js.map +1 -0
  141. package/dist/update/check.d.ts +60 -0
  142. package/dist/update/check.d.ts.map +1 -0
  143. package/dist/update/check.js +56 -0
  144. package/dist/update/check.js.map +1 -0
  145. package/dist/update/contract-risk.d.ts +55 -0
  146. package/dist/update/contract-risk.d.ts.map +1 -0
  147. package/dist/update/contract-risk.js +75 -0
  148. package/dist/update/contract-risk.js.map +1 -0
  149. package/dist/update/history.d.ts +30 -0
  150. package/dist/update/history.d.ts.map +1 -0
  151. package/dist/update/history.js +38 -0
  152. package/dist/update/history.js.map +1 -0
  153. package/dist/update/index.d.ts +28 -0
  154. package/dist/update/index.d.ts.map +1 -0
  155. package/dist/update/index.js +11 -0
  156. package/dist/update/index.js.map +1 -0
  157. package/dist/update/list-restore-points.d.ts +20 -0
  158. package/dist/update/list-restore-points.d.ts.map +1 -0
  159. package/dist/update/list-restore-points.js +35 -0
  160. package/dist/update/list-restore-points.js.map +1 -0
  161. package/dist/update/npm-registry.d.ts +11 -0
  162. package/dist/update/npm-registry.d.ts.map +1 -0
  163. package/dist/update/npm-registry.js +74 -0
  164. package/dist/update/npm-registry.js.map +1 -0
  165. package/dist/update/restore-point.d.ts +26 -0
  166. package/dist/update/restore-point.d.ts.map +1 -0
  167. package/dist/update/restore-point.js +23 -0
  168. package/dist/update/restore-point.js.map +1 -0
  169. package/dist/update/tar.d.ts +27 -0
  170. package/dist/update/tar.d.ts.map +1 -0
  171. package/dist/update/tar.js +56 -0
  172. package/dist/update/tar.js.map +1 -0
  173. package/dist/update/version-compare.d.ts +17 -0
  174. package/dist/update/version-compare.d.ts.map +1 -0
  175. package/dist/update/version-compare.js +42 -0
  176. package/dist/update/version-compare.js.map +1 -0
  177. package/dist/version.d.ts +13 -0
  178. package/dist/version.d.ts.map +1 -0
  179. package/dist/version.js +18 -0
  180. package/dist/version.js.map +1 -0
  181. package/package.json +33 -13
  182. package/dist/admin-assets/assets/index-Buwdj1V2.js +0 -71
  183. package/dist/admin-assets/assets/index-Csef0SiA.css +0 -1
@@ -1,25 +1,53 @@
1
- import { readFile, stat } from 'node:fs/promises';
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile, stat, statfs } from 'node:fs/promises';
2
3
  import { createServer } from 'node:http';
3
4
  import { dirname, join } from 'node:path';
4
5
  import process from 'node:process';
5
6
  import { pathToFileURL } from 'node:url';
6
- import { buildContentSchema, createAgentsRouter, createAssistantRouter, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createMediaRouter, createMfaRecommendationSource, createNoticeDismissalStore, createNoticeRouter, createPermissionLayer, createRestRouter, createSearchRouter, createSitePlanRouter, createSuspiciousActivitySource, createTaxonomyRouter, createUsersRouter, errorResponse, executeGraphQL, resolveActor, variantKeyFor, } from '@cogenta/api';
7
- import { createAuthStore } from '@cogenta/auth';
8
- import { CogentaError, createDatabaseMediaStore, createDatabaseRegistry, createLogger, createStorageRegistry, isCogentaError, loadConfig, } from '@cogenta/core';
9
- import { buildSchemaDocument, createContentStore, createRedirectStore, createSchemaTables, createSearchIndex, createTaxonomyStore, withLifecycleEvents, withReadOnlyStore, withSearchIndexing, } from '@cogenta/schema';
7
+ import { createFileAgentDeclarationStore, createFilePromptTemplateStore, createFileProviderConfigStore, ensureBuiltinPromptTemplates, } from '@cogenta/agents';
8
+ import { createAnalyticsStore, ensureAnalyticsTables, } from '@cogenta/analytics';
9
+ import { buildContentSchema, createAdminThemeRouter, createAgentSkillsRouter, createAgentsRouter, createAnalyticsRouter, createApiKeyExpiryNoticeSource, createApiKeysRouter, createAssistantRouter, createAuditIntegritySource, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createFormsRouter, createHealthRouter, createImportRouter, createMarketplaceRouter, createMcpConnectionsRouter, createMediaRouter, createMenuRouter, createMfaRecommendationSource, createMonitoringRedirectSuggestionSource, createNotFoundRouter, createNoticeChannelBridge, createNoticeChannelSettingsRouter, createNoticeDismissalStore, createNoticeHistoryStore, createNoticeRouter, createObservabilityRouter, createOpsStatusRouter, createPatternRouter, createPendingMigrationsSource, createPermissionLayer, createPluginDisabledSource, createPreviewTokens, createPromptTemplatesRouter, createProvidersRouter, createRecoveryCodeUsedNoticeSource, createRedirectRouter, createRestRouter, createReviewRouter, createRolePermissionRouter, createScheduledPublishFailedSource, createScheduledTasksRouter, createSearchConsoleRouter, createSearchRouter, createSeoRouter, createShellStatusRouter, createSitePlanRouter, createSiteSettingsRouter, createSuspiciousActivitySource, createTaxonomyRouter, createThemeRouter, createToolsRouter, createUpdateRouter, createUsersRouter, errorResponse, executeGraphQL, isMultipartFormData, parseMultipartFormData, resolveActor, roleState, streamSubmissionsCsv, variantKeyFor, } from '@cogenta/api';
10
+ import { createAuditLog, createAuthStore } from '@cogenta/auth';
11
+ import { createChannelLinkStore, createChannelRegistry, createFileEmailTransport, createNotificationDispatcher, createPreferenceStore, ensureChannelTables, ensurePreferenceTables, } from '@cogenta/channels';
12
+ import { createCommentPermissions, createCommentRateLimiter, createCommentSettingsStore, createCommentStore, createCommentsRouter, effectiveEnabled, ensureCommentsTables, } from '@cogenta/comments';
13
+ import { createCartStore, createCatalogStore, createCommerceAdminRouter, createCommercePermissions, createCouponStore, createCreditNoteStore, createCustomerStore, createEmailRenewalNotifier, createInvoiceStore, createOrderEmailQueue, createOrderStore, createPaymentRegistry, createPaymentStore, createShippingStore, createSubscriptionStore, createTaxStore, ensureCommerceTables, } from '@cogenta/commerce';
14
+ import { CogentaError, createCacheRegistry, createDatabaseMediaFolderStore, createDatabaseMediaStore, createDatabaseQueue, createDatabaseRegistry, createErrorLog, createLogger, createMemoryRateLimiter, createMigrator, createRateLimitRegistry, createStorageRegistry, getCoreVersion, identifier, isCogentaError, loadConfig, sql, } from '@cogenta/core';
15
+ import { createFormStore, ensureFormsTables } from '@cogenta/forms';
16
+ import { analyzeGeneric, analyzeJson, analyzeWordPress, applyGeneric, applyJson, createImportTrackingStore, csvToRecords, feedToRecords, importWordPress, parseJsonImport, undoImport, } from '@cogenta/import';
17
+ import { createMcpConnectionStore, ensureMcpConnectionTables } from '@cogenta/mcp';
18
+ import { createObservabilityRuntime, withRequestTracing, } from '@cogenta/observability';
19
+ import { createMarketplaceCatalog, createMarketplaceInstaller, createPluginDisableStore, createPluginGrantStore, createPluginUsageStore, describeCapability, ensureMarketplaceTables, ensurePluginTables, } from '@cogenta/plugins';
20
+ import { buildPath, buildSchemaDocument, columnFor, createAdminThemeStore, createContentStore, createMaintenanceStore, createMenuStore, createNotFoundLogStore, createPatternStore, createRedirectPatternStore, createRedirectStore, createRolePermissionOverlay, createRolePermissionStore, createScheduledPublishFailureStore, createScheduledTaskRegistry, createSchemaTables, createSearchConsoleConnectionStore, createSearchIndex, createSiteSettingsStore, createTaxonomyStore, DEFAULT_TRASH_RETAIN_DAYS, ensureAdminThemeTable, ensureMaintenanceTable, ensureMenuTables, ensurePatternTables, ensureSearchConsoleConnectionTable, ensureSiteSettingsTables, entriesTable, registerScheduledPublishing, relationsOf, relationTable, SITE_SETTINGS_SITE_SCOPE, withLifecycleEvents, withReadOnlyStore, withRedirectTracking, withScheduledPublishEnqueue, withSearchIndexing, } from '@cogenta/schema';
21
+ import { canonicalUrl, feedItemsFor, indexNowKeyFile, llmsTxtSectionsFor, pingIndexNow, renderAtomFeed, renderLlmsTxt, renderRssFeed, } from '@cogenta/seo';
22
+ import { sendInviteMail } from '../invite-mail.js';
23
+ import { sendResetMail } from '../reset-mail.js';
24
+ import { AUTO_UPDATE_POLICIES, applyUpdate, checkForUpdates, listRestorePoints, listUpdateHistory, policyAllows, recordUpdateHistory, UPDATE_APPLIED_ACTION, UPDATE_APPLY_FAILED_ACTION, } from '../update/index.js';
25
+ import { getCliVersion } from '../version.js';
10
26
  import { serveAdminAsset } from './admin-assets.js';
27
+ import { AGENTS_SUBDIR, buildAgentRuntime, PROVIDERS_SUBDIR, } from './agent-runtime.js';
11
28
  import { buildAssistant, withVectorIndexing } from './assistant.js';
29
+ import { sendAuditIntegrityAlert } from './audit-integrity-alert.js';
12
30
  import { createContentWebhookEmitter } from './content-webhooks.js';
31
+ import { DEFAULT_LOGO_CONTENT_TYPE, DEFAULT_LOGO_PATH, defaultLogoBytes } from './default-logo.js';
32
+ import { runDoctor } from './doctor.js';
33
+ import { renderFormNotFoundPage, renderFormPage } from './forms-page.js';
13
34
  import { applySecurity } from './http-security.js';
14
35
  import { selectMediaImageProcessor } from './media-images.js';
36
+ import { loadMigrations, MIGRATIONS_DIRECTORY } from './migrate.js';
15
37
  import { renderSearchPage } from './search-page.js';
16
38
  import { createSecurityAlertWatch } from './security-alerts.js';
17
- import { buildSitemapFiles, collectRoutedResources, renderRobots, seoSiteFor } from './seo.js';
39
+ import { buildSitemapFiles, collectRoutedResources, readSeoOperationalSettings, readSeoRenderDefaults, renderRobots, seoSiteFor, } from './seo.js';
18
40
  import { createSitePlanning } from './site-plan.js';
19
- import { cssEtag, loadThemeCss } from './theme-css.js';
20
- import { DEFAULT_IMAGE_ENDPOINT, joinStyles, loadSkinCss, renderDraftPage, renderRequestedPage, STYLESHEET_PATH, } from './theme-render.js';
41
+ import { renderTermArchivePage } from './term-archive-page.js';
42
+ import { createThemeCssResolver, cssEtag } from './theme-css.js';
43
+ import { availableThemes, DEFAULT_THEME_NAME } from './theme-registry.js';
44
+ import { DEFAULT_IMAGE_ENDPOINT, EMPTY_SITE_IDENTITY, entryTitle, joinStyles, loadSkinCss, renderDraftPage, renderMaintenancePage, renderRequestedPage, renderThemeGalleryPreview, resolveEntry, STYLESHEET_PATH, } from './theme-render.js';
45
+ import { computeCandidateGalleryStyles, computeEffectiveStyles, computePreviewStyles, createThemeCreatorToolWiring, createThemeWiring, } from './theme-wiring.js';
46
+ import { buildToolBodies, createToolRunner, TOOL_DEFINITIONS } from './tools.js';
21
47
  /** `/sitemap.xml` and the `/sitemap-N.xml` chunks a large site splits into. */
22
48
  const SITEMAP_PATH = /^\/sitemap(?:-\d+)?\.xml$/u;
49
+ /** IndexNow's own key-file path — matches `indexNowKeyFile`'s `path` (`@cogenta/seo`) for any hex key, whether or not it is the one currently configured (fiche 50 task 3). */
50
+ const INDEXNOW_KEY_FILE_PATTERN = /^\/([a-fA-F0-9]{8,128})\.txt$/u;
23
51
  /** The only `Content-Type` values `/_image` will ever put on the wire. */
24
52
  const SERVABLE_IMAGE_TYPES = new Set([
25
53
  'image/avif',
@@ -154,11 +182,39 @@ function webauthnConfigFor(site) {
154
182
  const host = new URL(site.url).hostname;
155
183
  return { relyingPartyName: site.name, relyingPartyId: host, origin: site.url };
156
184
  }
185
+ /**
186
+ * The active theme's own stylesheet, resolved live (fiche L23): reads the
187
+ * currently saved `activeTheme` off the same overrides row `resolveStyles`
188
+ * already reads, then resolves that theme's CSS through the memoised
189
+ * `themeCssFor` (real file I/O happens at most once per theme name, not per
190
+ * request). Falls back to the static `themeCss` snapshot when this instance
191
+ * built no `themeCssFor` — a test harness that only ever renders the default
192
+ * theme, mainly.
193
+ */
194
+ async function themeCssForActive(options) {
195
+ if (options.themeCssFor === undefined)
196
+ return options.themeCss ?? null;
197
+ const overrides = await options.theme.store.get();
198
+ return options.themeCssFor(overrides.activeTheme ?? DEFAULT_THEME_NAME);
199
+ }
157
200
  async function assembleSite(options) {
158
201
  const { db, collections, site, storage, logger } = options;
159
202
  const readOnly = options.readOnly ?? false;
160
203
  const styles = options.styles ?? null;
161
204
  const taxonomies = options.taxonomies ?? [];
205
+ // Fiche 22 tâche 8, part 4: the one version number shown in the admin
206
+ // footer/topbar and, if branding stays on, the public site footer.
207
+ // Resolved once per site assembly (cached after the first real read across
208
+ // every site a multi-site process serves) and never allowed to fail
209
+ // startup over a cosmetic label — a broken resolution falls back to an
210
+ // honest placeholder instead of refusing to serve the site at all.
211
+ let cogentaVersion;
212
+ try {
213
+ cogentaVersion = getCoreVersion();
214
+ }
215
+ catch {
216
+ cogentaVersion = '0.0.0';
217
+ }
162
218
  // Taxonomies first: a `f.taxonomy()` field carries a real foreign key into
163
219
  // the terms table, which therefore has to exist before the collection does.
164
220
  await createSchemaTables(db, collections, taxonomies);
@@ -170,6 +226,75 @@ async function assembleSite(options) {
170
226
  // assistant so the semantic half can be fused with *this* index rather than
171
227
  // with a second one over the same table.
172
228
  const searchIndex = options.searchIndex ?? (await createSearchIndex({ db }));
229
+ // Scheduled publication (L1's `schedulePublication`/`registerScheduledPublishing`,
230
+ // written and tested from the start but never wired to anything — the admin
231
+ // showed "Scheduled" as a read-only badge). The `database` queue driver is
232
+ // the R1-honest choice: no Redis, no external worker, just a table in the
233
+ // site's own database, drained by `runServe`'s own `setInterval` tick — see
234
+ // the comment there for the lateness this trades for not requiring a
235
+ // persistent process.
236
+ const scheduledPublishQueue = createDatabaseQueue({ db, logger });
237
+ // Built before `storeFor` below so a collection's store can be wrapped with
238
+ // `withRedirectTracking` (fiche 12 task 3): renaming the slug of a
239
+ // published entry must write its 301 in the very same place every other
240
+ // derived write — the search index, the vector index — already happens.
241
+ const redirects = createRedirectStore({ db });
242
+ await redirects.ensureTable();
243
+ // Prefix redirects (fiche 12 task 4) — a second, simpler table checked
244
+ // only when `redirects.resolve()` finds nothing. See `@cogenta/schema`'s
245
+ // `redirect-patterns.ts` for why this is not a merged into `redirects`.
246
+ const redirectPatterns = createRedirectPatternStore({ db });
247
+ await redirectPatterns.ensureTable();
248
+ // Fiche 47 task 4 — moved ahead of its previous position (originally built
249
+ // alongside the notice-to-channel bridge, further down this function) so
250
+ // the forms router below can reuse the very same registry rather than a
251
+ // second one: one live Slack/Discord/Telegram/webhook adapter set per
252
+ // site, not two independently configured ones for two different features.
253
+ const channelRegistry = options.channels?.registry ?? createChannelRegistry([]);
254
+ // Forms (contract G, ADR-0026 + fiche 47). Always mounted — see `Site.formStore`'s
255
+ // own comment for why this, unlike commerce, is not opt-in.
256
+ await ensureFormsTables(db);
257
+ const formStore = createFormStore(db);
258
+ // Derived, never the raw signing key itself — same discipline
259
+ // `commentsIpHashSecret` already follows a little further down this
260
+ // function: a leak of this one purpose-specific value must not also be a
261
+ // leak of the JWT signing key. Found necessary by a security review of
262
+ // fiche 47 task 2/3: a `file` field's value carried across a multi-step
263
+ // form's pages must be signed, or a client could forge one (claim any
264
+ // `storageKey` exists) without ever uploading a real byte.
265
+ const formFileSigningSecret = createHash('sha256')
266
+ .update(`${options.signingKey}:form-file-token`)
267
+ .digest('hex');
268
+ const formsRouter = createFormsRouter({
269
+ forms: formStore,
270
+ // Falls back to an in-process limiter rather than leaving the public
271
+ // submit route unprotected when no shared driver was configured (R1) —
272
+ // the same fallback `resolveActor`'s own `requestQuota` parameter takes.
273
+ rateLimit: options.requestQuota ?? createMemoryRateLimiter(),
274
+ ...(options.emailTransport === undefined ? {} : { emailTransport: options.emailTransport }),
275
+ // Fiche 47 task 3 — the same storage driver media uploads already use;
276
+ // a `file` field answers `FORM_FILE_REJECTED` rather than silently
277
+ // accepting bytes when a site somehow has none (never true in practice,
278
+ // `storage` is always resolved by `runServe`, but the router itself
279
+ // stays honest about the dependency rather than assuming it).
280
+ storage,
281
+ fileSigningSecret: formFileSigningSecret,
282
+ // Fiche 47 task 4 — absent channels simply mean no `notifyChannels`
283
+ // entry ever fires (R1), the same shape `emailTransport` already has.
284
+ channelRegistry,
285
+ adminUrl: new URL('/admin', site.url).toString(),
286
+ });
287
+ // The 404 log (fiche 12 task 1) — bounded and purged, never carrying an
288
+ // IP or a user agent. See `@cogenta/schema`'s `not-found-log.ts` for the
289
+ // anti-abuse reasoning `maxPaths` exists for.
290
+ const notFoundLog = createNotFoundLogStore({ db, maxPaths: options.notFoundLog.maxPaths });
291
+ await notFoundLog.ensureTable();
292
+ // Fiche 38 task 1's last named source: a scheduled publication that throws
293
+ // (the handler below) is recorded here rather than only living in the
294
+ // queue driver's own retry bookkeeping, which nothing surfaces to an
295
+ // admin (`ScheduledPublishFailureStore`'s own doc comment says why).
296
+ const scheduledPublishFailures = createScheduledPublishFailureStore(db);
297
+ await scheduledPublishFailures.ensureTable();
173
298
  const stores = new Map();
174
299
  const storeFor = (collection) => {
175
300
  const existing = stores.get(collection.name);
@@ -180,9 +305,32 @@ async function assembleSite(options) {
180
305
  // left to refuse at that moment.
181
306
  const created = createContentStore({ db, collection, siblings: collections });
182
307
  const guarded = readOnly ? withReadOnlyStore(created) : created;
308
+ // Writes the redirect a slug rename on a *published* entry owes (fiche 12
309
+ // task 3). Placed right after the read-only guard, for the same reason
310
+ // scheduling is: a write the guard refused must never leave a redirect
311
+ // behind either.
312
+ const tracked = withRedirectTracking(guarded, {
313
+ collection,
314
+ redirects,
315
+ onError: (error) => logger.error('redirect tracking failed', {
316
+ collection: collection.name,
317
+ error: String(error),
318
+ }),
319
+ });
320
+ // Queues the real publish job for a save that lands as `status:
321
+ // 'scheduled'`. Placed right after the read-only guard so a write that
322
+ // guard refused never reaches the queue either.
323
+ const schedulable = withScheduledPublishEnqueue(tracked, {
324
+ collection,
325
+ queue: scheduledPublishQueue,
326
+ onError: (error) => logger.error('scheduled publish enqueue failed', {
327
+ collection: collection.name,
328
+ error: String(error),
329
+ }),
330
+ });
183
331
  // Outermost, so a read-only refusal happens *before* anything is indexed:
184
332
  // a write that never landed must not change the index either.
185
- const indexed = withSearchIndexing(guarded, {
333
+ const indexed = withSearchIndexing(schedulable, {
186
334
  collection,
187
335
  index: searchIndex,
188
336
  onError: (error) => logger.error('search index write failed', {
@@ -201,10 +349,13 @@ async function assembleSite(options) {
201
349
  siteId: site.url,
202
350
  store: options.assistant.vectors.store,
203
351
  embeddings: options.assistant.vectors.embeddings,
352
+ // L22 task 4's per-collection toggle, read live on every write.
353
+ isEnabled: options.assistant.vectors.isEnabled,
204
354
  onError: (error) => logger.error('vector index write failed', {
205
355
  collection: collection.name,
206
356
  error: String(error),
207
357
  }),
358
+ onIndexed: () => options.assistant?.vectorInfo?.noteIndexed(),
208
359
  });
209
360
  // Outermost of all: an event must describe a write that really landed, so
210
361
  // it fires after the read-only guard has had its chance to refuse and
@@ -232,9 +383,261 @@ async function assembleSite(options) {
232
383
  // same, already-complete map.
233
384
  for (const collection of collections)
234
385
  storeFor(collection);
235
- const redirects = createRedirectStore({ db });
236
- await redirects.ensureTable();
237
- const permissions = createPermissionLayer({ collections });
386
+ // Trash auto-purge (fiche 07 task 5): the collections that actually have a
387
+ // trash, and the window each one keeps it for — computed once, since
388
+ // `collection.trash` cannot change without a restart.
389
+ const trashRetainDaysByCollection = {};
390
+ for (const collection of collections) {
391
+ if (collection.trash === false)
392
+ continue;
393
+ trashRetainDaysByCollection[collection.name] =
394
+ collection.trash?.retainDays ?? DEFAULT_TRASH_RETAIN_DAYS;
395
+ }
396
+ // `null` until the first tick completes — see `TrashStatus` for why that is
397
+ // the honest answer for the brief window right after startup, rather than
398
+ // claiming a sweep that has not run yet.
399
+ let lastTrashPurgeAt = null;
400
+ let lastTrashPurgeCount = null;
401
+ const tickTrashPurge = async () => {
402
+ const perCollection = [];
403
+ for (const collection of collections) {
404
+ if (collection.trash === false)
405
+ continue;
406
+ try {
407
+ const report = await storeFor(collection).purgeExpired();
408
+ perCollection.push({ collection: collection.name, purged: report.purged });
409
+ }
410
+ catch (error) {
411
+ // One collection's sweep failing (a database hiccup, not the
412
+ // `restrict` case `purgeExpired()` already swallows per row) must
413
+ // not stop the rest of the site's collections from being swept.
414
+ logger.error('trash purge failed', { collection: collection.name, error: String(error) });
415
+ }
416
+ }
417
+ const purged = perCollection.reduce((sum, entry) => sum + entry.purged, 0);
418
+ lastTrashPurgeAt = new Date().toISOString();
419
+ lastTrashPurgeCount = purged;
420
+ return { purged, perCollection };
421
+ };
422
+ // ---- Import: preview/apply/status/undo (fiche 25) ---------------------
423
+ //
424
+ // `storeFor` above is reused unchanged: an imported entry goes through the
425
+ // exact same read-only guard, search index and lifecycle event wiring as
426
+ // one typed by hand in the admin. `importTracking` owns two tables of its
427
+ // own (`cogenta_import_runs`/`cogenta_import_items`, never a field on
428
+ // contract A — see `@cogenta/import`'s `tracking.ts`), which is what makes
429
+ // a resumed `apply` skip what an earlier, interrupted attempt already
430
+ // wrote, and what `undoImport` reads to trash exactly what one run
431
+ // created.
432
+ const importTracking = createImportTrackingStore({ db });
433
+ /** The raw uploaded text, kept in the site's own storage driver (never the database — a WXR export can be tens of megabytes, well past what a portable `text` column promises across all three dialects) so `apply` can read it back after `analyze`, possibly in a different request. */
434
+ async function storeImportSource(runId, text) {
435
+ await storage.put(`imports/${runId}/source.txt`, Buffer.from(text, 'utf8'), {
436
+ contentType: 'text/plain; charset=utf-8',
437
+ });
438
+ }
439
+ async function readImportSource(runId) {
440
+ const stream = await storage.get(`imports/${runId}/source.txt`);
441
+ const chunks = [];
442
+ for await (const chunk of stream)
443
+ chunks.push(chunk);
444
+ return Buffer.concat(chunks).toString('utf8');
445
+ }
446
+ function storeForName(name) {
447
+ const collection = collections.find((c) => c.name === name);
448
+ return collection === undefined ? undefined : storeFor(collection);
449
+ }
450
+ async function analyzeImportSource(input) {
451
+ if (input.source === 'wordpress') {
452
+ const analysis = analyzeWordPress(input.text);
453
+ const run = await importTracking.createRun({
454
+ source: 'wordpress',
455
+ createdBy: input.createdBy,
456
+ analysis,
457
+ });
458
+ await storeImportSource(run.id, input.text);
459
+ return run;
460
+ }
461
+ if (input.source === 'json') {
462
+ const records = parseJsonImport(input.text);
463
+ const analysis = analyzeJson(records, collections);
464
+ const run = await importTracking.createRun({
465
+ source: 'json',
466
+ createdBy: input.createdBy,
467
+ analysis,
468
+ total: records.length,
469
+ });
470
+ await storeImportSource(run.id, input.text);
471
+ return run;
472
+ }
473
+ // CSV and RSS/Atom share the generic engine and need one target
474
+ // collection to propose a mapping against — the caller's choice if
475
+ // given, the site's first declared collection otherwise, so a preview
476
+ // is never blocked on a decision the mapping screen can still change.
477
+ const target = (input.targetCollection === undefined
478
+ ? undefined
479
+ : collections.find((c) => c.name === input.targetCollection)) ?? collections[0];
480
+ if (target === undefined) {
481
+ throw new CogentaError({
482
+ code: 'IMPORT_MAPPING_INVALID',
483
+ message: 'This site declares no collection to import into.',
484
+ hint: 'Add a collection to the schema before importing.',
485
+ });
486
+ }
487
+ const records = input.source === 'csv' ? csvToRecords(input.text) : feedToRecords(input.text);
488
+ const analysis = analyzeGeneric(records, target);
489
+ const run = await importTracking.createRun({
490
+ source: input.source,
491
+ createdBy: input.createdBy,
492
+ analysis,
493
+ mapping: analysis.proposedMapping,
494
+ total: records.length,
495
+ });
496
+ await storeImportSource(run.id, input.text);
497
+ return run;
498
+ }
499
+ async function applyImportRun(input) {
500
+ const run = await importTracking.getRun(input.runId);
501
+ if (run === null) {
502
+ throw new CogentaError({
503
+ code: 'IMPORT_RUN_NOT_FOUND',
504
+ message: `No import run "${input.runId}" exists.`,
505
+ hint: 'Analyze a source first — the response names the runId to apply.',
506
+ details: { id: input.runId },
507
+ });
508
+ }
509
+ const text = await readImportSource(input.runId);
510
+ await importTracking.updateRun(input.runId, { status: 'running' });
511
+ try {
512
+ if (run.source === 'wordpress') {
513
+ const report = await importWordPress(text, {
514
+ db,
515
+ storage,
516
+ tracking: importTracking,
517
+ runId: input.runId,
518
+ // Contract F (ADR-0025) — real status and threading, on posts and
519
+ // pages alike, the same store `/api/comments` itself writes
520
+ // through.
521
+ comments: commentsStore,
522
+ });
523
+ return await importTracking.updateRun(input.runId, {
524
+ status: 'done',
525
+ report,
526
+ progress: { processed: run.progress.total, total: run.progress.total },
527
+ });
528
+ }
529
+ if (run.source === 'json') {
530
+ const records = parseJsonImport(text);
531
+ const report = await applyJson({
532
+ records,
533
+ collections,
534
+ storeFor: (collection) => storeFor(collection),
535
+ tracking: importTracking,
536
+ runId: input.runId,
537
+ createdBy: run.createdBy,
538
+ });
539
+ return await importTracking.updateRun(input.runId, {
540
+ status: 'done',
541
+ report,
542
+ progress: { processed: report.imported + report.resumedSkips, total: run.progress.total },
543
+ });
544
+ }
545
+ // csv / rss
546
+ const records = run.source === 'csv' ? csvToRecords(text) : feedToRecords(text);
547
+ const mapping = (input.mapping ?? run.mapping);
548
+ if (mapping === null) {
549
+ throw new CogentaError({
550
+ code: 'IMPORT_MAPPING_INVALID',
551
+ message: 'This run has no field mapping — analyze proposed one, but it was never confirmed.',
552
+ hint: 'Send { "mapping": { "targetCollection": "...", "fields": { ... } } } to apply.',
553
+ });
554
+ }
555
+ const report = await applyGeneric({
556
+ records,
557
+ mapping,
558
+ collections,
559
+ storeFor: (collection) => storeFor(collection),
560
+ tracking: importTracking,
561
+ runId: input.runId,
562
+ createdBy: run.createdBy,
563
+ });
564
+ return await importTracking.updateRun(input.runId, {
565
+ status: 'done',
566
+ report,
567
+ mapping,
568
+ progress: { processed: report.imported + report.resumedSkips, total: run.progress.total },
569
+ });
570
+ }
571
+ catch (error) {
572
+ await importTracking.updateRun(input.runId, {
573
+ status: 'failed',
574
+ error: isCogentaError(error) ? error.message : String(error),
575
+ });
576
+ throw error;
577
+ }
578
+ }
579
+ async function cancelImportRun(runId) {
580
+ await undoImport({ tracking: importTracking, runId, storeFor: storeForName });
581
+ const run = await importTracking.getRun(runId);
582
+ if (run === null) {
583
+ throw new CogentaError({
584
+ code: 'IMPORT_RUN_NOT_FOUND',
585
+ message: `No import run "${runId}" exists.`,
586
+ hint: 'Only a run that has been analyzed can be cancelled.',
587
+ details: { id: runId },
588
+ });
589
+ }
590
+ return run;
591
+ }
592
+ // The publish half of scheduling: re-reads the entry before acting, so an
593
+ // entry edited back to `draft` — or already published by hand — before its
594
+ // hour comes is left alone rather than redone by a job still sitting in
595
+ // the queue (see `withScheduledPublishEnqueue`, which enqueues again on
596
+ // every save rather than tracking a previous job id).
597
+ registerScheduledPublishing(scheduledPublishQueue, async (publication) => {
598
+ try {
599
+ const target = stores.get(publication.collection);
600
+ if (target === undefined)
601
+ return;
602
+ const entry = await target.read(publication.entryId, { state: 'working' });
603
+ if (entry?.status === 'scheduled')
604
+ await target.publish(publication.entryId);
605
+ // A retry that finally lands must make the earlier attempts' failure
606
+ // disappear the same way a fixed migration or a re-enabled plugin
607
+ // does for their own notices — nothing left over to dismiss by hand.
608
+ await scheduledPublishFailures.clear(publication.collection, publication.entryId, publication.locale);
609
+ }
610
+ catch (error) {
611
+ // Recorded, then re-thrown: the queue's own retry/backoff (up to
612
+ // `maxAttempts`) must still run exactly as before — this notice
613
+ // source is a second, admin-visible witness of the same failure,
614
+ // never a replacement for the queue's own bookkeeping.
615
+ await scheduledPublishFailures
616
+ .record({
617
+ collection: publication.collection,
618
+ entryId: publication.entryId,
619
+ locale: publication.locale,
620
+ error: error instanceof Error ? error.message : String(error),
621
+ })
622
+ .catch((recordError) => logger.error('failed to record a scheduled-publish failure', {
623
+ error: String(recordError),
624
+ }));
625
+ throw error;
626
+ }
627
+ }, { logger });
628
+ // Fiche 63, ADR-0028: a role's grant on a collection or taxonomy action can
629
+ // live in the database, checked by `PermissionLayer` *before* falling back
630
+ // to this site's `cogenta.schema.*` — never the other way around. The
631
+ // overlay's first `list()` happens here, once, before the layer that
632
+ // consults it is ever built; `rolePermissionRouter` (mounted below) calls
633
+ // `refresh()` after every write so the very next request already sees it,
634
+ // with no restart.
635
+ const rolePermissionStore = createRolePermissionStore({ db, collections, taxonomies });
636
+ const rolePermissionOverlay = await createRolePermissionOverlay(rolePermissionStore);
637
+ const permissions = createPermissionLayer({
638
+ collections,
639
+ rolePermissionOverrides: rolePermissionOverlay,
640
+ });
238
641
  const service = createContentService({
239
642
  collections,
240
643
  permissions,
@@ -260,32 +663,756 @@ async function assembleSite(options) {
260
663
  return created;
261
664
  };
262
665
  const mediaStore = createDatabaseMediaStore({ db });
666
+ // Fiche 46: the media library's folder tree. Bootstraps a default
667
+ // `contents` root once, idempotently — a fresh site gets it on its very
668
+ // first `cogenta serve`, and an already-provisioned one no-ops here on
669
+ // every subsequent restart (`ensureRoot` finds the existing row rather
670
+ // than creating a second one). Nothing here ever files a pre-existing
671
+ // asset into it: `folder_id` stays `null` (unclassified) for everything
672
+ // uploaded before this fiche, exactly as `MediaAsset.folderId`'s own doc
673
+ // comment promises.
674
+ const mediaFolderStore = createDatabaseMediaFolderStore({ db });
675
+ await mediaFolderStore.ensureRoot('contents');
676
+ // Fiche 58 tasks 2/3/4 — the external MCP connection registry. Table and
677
+ // store exist unconditionally (an admin can wire up a connection and
678
+ // check its tools regardless of whether `agentsRuntimeConfig` is set,
679
+ // same posture as `apiKeysRouter`), but the connections only ever become
680
+ // real `ToolDefinition`s an agent can call when `agentsRuntime` itself is
681
+ // built below — see `mcpConnections` passed into `buildAgentRuntime`.
682
+ await ensureMcpConnectionTables(db);
683
+ const mcpConnections = createMcpConnectionStore(db, { signingKey: options.signingKey });
684
+ // L22 task 1/1bis: the real agent runtime, built here — the one place
685
+ // `service` (this site's real `ContentService`) and `mediaStore` are both
686
+ // already in scope, exactly the way `content.*`/`media.*` contract-C
687
+ // tools need them (mirrors `packages/cli/src/commands/mcp.ts`'s own
688
+ // `buildSiteManifest`). `agentsRuntimeConfig` is optional so a caller
689
+ // that builds a bare `Site` by hand (tests included) is unaffected —
690
+ // `runServe` always supplies it.
691
+ const agentsRuntime = options.agentsRuntimeConfig === undefined
692
+ ? undefined
693
+ : await buildAgentRuntime({
694
+ dataDir: options.agentsRuntimeConfig.dataDir,
695
+ projectRoot: options.agentsRuntimeConfig.projectRoot,
696
+ // Fiche 58 task 4 — every enabled connection's checked tools are
697
+ // merged into this site's real tool registry, wrapped by the same
698
+ // sandboxed `McpClient` a "test connection" probe uses
699
+ // (`@cogenta/mcp`'s `buildMcpToolDefinitions`, called inside
700
+ // `buildAgentRuntime`'s own `buildToolRegistry`).
701
+ mcpConnections,
702
+ signingKey: options.signingKey,
703
+ site: {
704
+ name: site.name,
705
+ url: site.url,
706
+ locales: site.locales,
707
+ defaultLocale: site.defaultLocale,
708
+ },
709
+ contentService: service,
710
+ mediaStore,
711
+ auditLog: auth.audit,
712
+ logger,
713
+ // L22 task 3: the Site Monitor's own tools — the same `redirects`/
714
+ // `notFoundLog` stores and `collections` this function already
715
+ // built above, never a second instance.
716
+ collections,
717
+ notFoundLog,
718
+ redirects,
719
+ // L26 task 5 — "Cogenta Theme Creator"'s tool. Absent, exactly
720
+ // like `options.theme?.generator`, whenever no LLM provider is
721
+ // configured.
722
+ ...(options.themeCreatorTools === undefined
723
+ ? {}
724
+ : { themeCreator: options.themeCreatorTools }),
725
+ });
726
+ if (agentsRuntime !== undefined)
727
+ logger.info(agentsRuntime.summary);
263
728
  const noticeDismissals = createNoticeDismissalStore(db);
264
729
  await noticeDismissals.ensureTable();
730
+ // L17: a local/embedded catalog, not a distant service — L13's API keys,
731
+ // which the lot names as that dependency, were never built. Empty until a
732
+ // site configures one; a marketplace router that always answers is what
733
+ // lets the admin screen render instead of guessing whether one exists.
734
+ await ensurePluginTables(db);
735
+ await ensureMarketplaceTables(db);
736
+ const marketplaceGrants = createPluginGrantStore(db);
737
+ // `ensurePluginTables` above already creates the disabled-plugins table —
738
+ // this is the first thing that ever reads it back (fiche 38 task 1's
739
+ // `plugin-disabled` notice source).
740
+ const pluginDisabled = createPluginDisableStore(db);
741
+ // Fiche 29 task 3 — accumulated real per-run duration/outcome. Nothing in
742
+ // `cogenta serve` actually calls `runPlugin` yet (no live `AgentRegistry`
743
+ // exists anywhere in this repo, the same R2-honest gap already noted for
744
+ // L5/L7/L9/L8) — this store exists and is wired into the marketplace
745
+ // router regardless, so the "installed extensions" screen has a real,
746
+ // testable place to read from the moment a real execution pipeline lands,
747
+ // rather than a second wiring pass.
748
+ const pluginUsage = createPluginUsageStore(db);
749
+ const marketplaceCatalog = createMarketplaceCatalog(options.marketplace?.catalog ?? []);
750
+ const marketplaceInstaller = createMarketplaceInstaller(db, {
751
+ grantStore: marketplaceGrants,
752
+ disableStore: pluginDisabled,
753
+ usageStore: pluginUsage,
754
+ ...(options.marketplace?.trustedPublicKeys === undefined
755
+ ? {}
756
+ : { trustedPublicKeys: options.marketplace.trustedPublicKeys }),
757
+ });
758
+ // Fiche 38 task 2: what has ever been shown to each person, resolved or
759
+ // not — the half of the notice mechanism `NoticeDismissalStore` was never
760
+ // meant to be.
761
+ const noticeHistory = createNoticeHistoryStore(db);
762
+ await noticeHistory.ensureTable();
763
+ // Fiche 38 tasks 3-4: linking a channel to receive notices, and
764
+ // per-(person, channel) preferences. Both tables are database-only — they
765
+ // work with zero live channel adapters configured, which is the R1-honest
766
+ // default (`options.channels?.registry` is where a deployer plugs real
767
+ // ones in).
768
+ await ensureChannelTables(db);
769
+ await ensurePreferenceTables(db);
770
+ const channelLinks = createChannelLinkStore(db);
771
+ const channelPreferences = createPreferenceStore(db);
772
+ // `channelRegistry` itself is built earlier in this function, right before
773
+ // the forms router, and reused here rather than rebuilt.
774
+ const channelDispatcher = createNotificationDispatcher({
775
+ db,
776
+ registry: channelRegistry,
777
+ linkStore: channelLinks,
778
+ preferenceStore: channelPreferences,
779
+ buildAdminUrl: () => `${site.url}/admin/notifications`,
780
+ });
781
+ const noticeChannelBridge = createNoticeChannelBridge({
782
+ dispatcher: channelDispatcher,
783
+ linkedChannelNames: async (userId) => (await channelLinks.listLinkedChannels(userId)).map((link) => link.channelName),
784
+ // Server-side wording for a channel message. The on-screen board
785
+ // translates the same `code`/`params` through i18next (ADR-0019); a
786
+ // channel message has no browser locale to read, so it renders in
787
+ // English — the same honest simplification `formats/report.ts`'s own
788
+ // callers already accept for anything built off-screen.
789
+ render: (entry) => ({
790
+ title: entry.code,
791
+ summary: Object.keys(entry.params).length === 0
792
+ ? entry.code
793
+ : `${entry.code} (${Object.entries(entry.params)
794
+ .map(([key, value]) => `${key}: ${value}`)
795
+ .join(', ')})`,
796
+ }),
797
+ });
798
+ // Menus (navigation). Not schema-declared, so one fixed pair of tables
799
+ // rather than one per taxonomy — see `menu-tables.ts`.
800
+ await ensureMenuTables(db);
801
+ const menuStore = createMenuStore({ db });
802
+ // The page builder's motif/model library (fiche 43 sub-chantier A). Same
803
+ // one-fixed-table treatment as menus — a pattern is not schema-declared
804
+ // content either.
805
+ await ensurePatternTables(db);
806
+ const patternStore = createPatternStore({ db });
807
+ // Google Search Console's one stored connection (fiche 70 task 4,
808
+ // ADR-0032) — same one-fixed-table treatment, and the table is always
809
+ // created regardless of whether an OAuth app is configured, so enabling
810
+ // the connector later needs no migration.
811
+ await ensureSearchConsoleConnectionTable(db);
812
+ const searchConsoleStore = createSearchConsoleConnectionStore({
813
+ db,
814
+ signingKey: options.signingKey,
815
+ });
816
+ // Editorial site settings (fiche 23, ADR-0025): not schema-declared either
817
+ // — a rédacteur's tagline or homepage choice is not part of the content
818
+ // model — so this gets the same one-fixed-table treatment as menus.
819
+ await ensureSiteSettingsTables(db);
820
+ const siteSettingsStore = createSiteSettingsStore({ db });
821
+ // The admin's own runtime theme (L21 task 2) — same one-fixed-table
822
+ // treatment, admin-role-only to write, public to read (the login screen
823
+ // needs it before a session exists).
824
+ await ensureAdminThemeTable(db);
825
+ const adminThemeStore = createAdminThemeStore({ db });
826
+ const gateway = createContentGateway({ collections, stores, permissions });
827
+ // Resolves an `entry`-kind menu item to a display label, public route and
828
+ // — for an actor entitled to see it — a health status (fiche 09, task 4).
829
+ //
830
+ // The label/route half always reads through the same permission-checked
831
+ // gateway everything else reads through, as `ANONYMOUS`: a menu is public
832
+ // navigation, so an item's *link* is only ever resolved to what an
833
+ // anonymous visitor could also reach — an unpublished target resolves to
834
+ // `null` rather than leaking a draft's title into a public nav response.
835
+ //
836
+ // `health` is the one piece computed differently, and only sometimes: it
837
+ // is read straight from the collection's own store (bypassing the
838
+ // gateway's published-only default, `trashed: 'include'` so a trashed
839
+ // target reads back rather than looking merely deleted) — but only for an
840
+ // actor whose *role* already has draft access to this collection, the
841
+ // same `roleState` gate every other read of unpublished content goes
842
+ // through (`draft-access.ts`). A public visitor, or an actor without that
843
+ // role, gets exactly the pre-task-4 behaviour: an unresolved item, never a
844
+ // status field announcing that a draft exists.
845
+ const resolveMenuEntry = async (collectionName, entryId, context) => {
846
+ const collection = collections.find((candidate) => candidate.name === collectionName);
847
+ if (collection === undefined)
848
+ return null;
849
+ const canSeeDrafts = roleState(permissions, collection, context) === 'working';
850
+ const collectionStore = stores.get(collectionName);
851
+ const privileged = canSeeDrafts && collectionStore !== undefined
852
+ ? await collectionStore.read(entryId, { state: 'working', trashed: 'include' })
853
+ : null;
854
+ const entry = privileged ??
855
+ (await gateway.read(collectionName, entryId, { actor: { id: null, roles: ['public'] } }));
856
+ if (entry === null)
857
+ return null;
858
+ const stringValues = Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string'));
859
+ const label = typeof entry.values.title === 'string'
860
+ ? entry.values.title
861
+ : typeof entry.values.name === 'string'
862
+ ? entry.values.name
863
+ : entryId;
864
+ let route = null;
865
+ if (collection.routing !== undefined) {
866
+ try {
867
+ route = buildPath(collection, stringValues, entry.locale ?? undefined);
868
+ }
869
+ catch {
870
+ // A route field is missing on this entry (e.g. an empty slug on a
871
+ // draft). The item still resolves — with a label, no link — rather
872
+ // than failing the whole menu response over one broken reference.
873
+ route = null;
874
+ }
875
+ }
876
+ return {
877
+ label,
878
+ route,
879
+ ...(privileged === null
880
+ ? {}
881
+ : { health: privileged.deletedAt !== null ? 'trashed' : privileged.status }),
882
+ };
883
+ };
884
+ /** A term's own label in the site's default locale — a term carries no request context of its own the way an entry's `locale` field does. */
885
+ const termLabel = (term) => term.labels[site.defaultLocale] ?? Object.values(term.labels)[0] ?? term.slug;
886
+ // Resolves a `taxonomy`-kind menu item to a display label and a real route
887
+ // (fiche 09 task 4; audit 2026-09-01, 04-taxonomies-menus.md T01).
888
+ //
889
+ // `route` was `null` here for as long as the archive page did not exist —
890
+ // honestly so, with a comment saying no site rendered one. It does now
891
+ // (`term-archive-page.ts`), so a menu item pointing at a term is finally a
892
+ // link rather than a dead `<span>`.
893
+ const resolveMenuTerm = async (taxonomyName, termId) => {
894
+ const taxonomy = taxonomies.find((candidate) => candidate.name === taxonomyName);
895
+ if (taxonomy === undefined)
896
+ return null;
897
+ const term = await taxonomyStoreFor(taxonomy).read(termId);
898
+ if (term === null)
899
+ return null;
900
+ return {
901
+ label: termLabel(term),
902
+ route: `/${encodeURIComponent(taxonomy.name)}/${encodeURIComponent(term.slug)}`,
903
+ };
904
+ };
905
+ /**
906
+ * Every published entry filed under one term, newest first, as
907
+ * `(collection, id)` pairs — the query behind the public term archive.
908
+ *
909
+ * Modelled on `countTaxonomyUsage` (`@cogenta/schema`), which asks the same
910
+ * question one aggregate at a time: one statement per taxonomy field found
911
+ * across the site's collections, never one per entry. A to-many taxonomy
912
+ * field lives in its own join table and simply cannot be reached through
913
+ * `ListOptions.where` (which refuses columnless fields by design), which is
914
+ * why this is SQL here rather than a gateway filter.
915
+ *
916
+ * It deliberately answers **ids only**: every one of them is read back
917
+ * through the permission-checked gateway before it reaches a page, so this
918
+ * query never has to know anything about roles (R4 — the check belongs to
919
+ * the runtime, never to the statement that found the row).
920
+ *
921
+ * The result is capped: an archive is a browsing surface, and merging tens
922
+ * of thousands of rows in memory to show twelve of them would be a
923
+ * different feature with a different design.
924
+ */
925
+ const ARCHIVE_SCAN_CAP = 600;
926
+ const entriesForTerm = async (taxonomyName, termId) => {
927
+ const dialect = db.dialect;
928
+ const idColumn = identifier('id', dialect);
929
+ const deletedAtColumn = identifier('deleted_at', dialect);
930
+ const statusColumn = identifier('status', dialect);
931
+ const createdAtColumn = identifier('created_at', dialect);
932
+ const found = [];
933
+ for (const collection of collections) {
934
+ const relevant = relationsOf(collection).filter((relation) => relation.kind === 'taxonomy' && relation.to === taxonomyName);
935
+ if (relevant.length === 0)
936
+ continue;
937
+ const entriesTableName = identifier(entriesTable(collection.name), dialect);
938
+ for (const relation of relevant) {
939
+ if (relation.many) {
940
+ const join = identifier(relationTable(collection.name, relation.field), dialect);
941
+ const rows = await db.query(sql `select e.${idColumn} as ${idColumn}, e.${createdAtColumn} as ${createdAtColumn}
942
+ from ${join} jt
943
+ join ${entriesTableName} e on e.${idColumn} = jt.${identifier('entry_id', dialect)}
944
+ where jt.${identifier('target_id', dialect)} = ${termId}
945
+ and e.${deletedAtColumn} is null
946
+ and e.${statusColumn} = ${'published'}`);
947
+ for (const row of rows.rows) {
948
+ found.push({
949
+ collection: collection.name,
950
+ id: String(row.id),
951
+ createdAt: String(row.created_at),
952
+ });
953
+ }
954
+ continue;
955
+ }
956
+ const column = identifier(columnFor(relation.field), dialect);
957
+ const rows = await db.query(sql `select ${idColumn}, ${createdAtColumn}
958
+ from ${entriesTableName}
959
+ where ${column} = ${termId}
960
+ and ${deletedAtColumn} is null
961
+ and ${statusColumn} = ${'published'}`);
962
+ for (const row of rows.rows) {
963
+ found.push({
964
+ collection: collection.name,
965
+ id: String(row.id),
966
+ createdAt: String(row.created_at),
967
+ });
968
+ }
969
+ }
970
+ }
971
+ // Newest first, across every collection at once — an archive of a term
972
+ // that classifies both articles and case studies is one list, not two.
973
+ // `createdAt` rather than `publishedAt`: the latter is nullable on a
974
+ // contract A entry, which is exactly why `SortField` excludes it.
975
+ found.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
976
+ const unique = new Map();
977
+ for (const row of found) {
978
+ if (unique.size >= ARCHIVE_SCAN_CAP)
979
+ break;
980
+ // Two taxonomy fields of one collection can name the same term; the
981
+ // entry is listed once.
982
+ if (!unique.has(row.id))
983
+ unique.set(row.id, { collection: row.collection, id: row.id });
984
+ }
985
+ return [...unique.values()];
986
+ };
987
+ /**
988
+ * `/{taxonomy}/{term-slug}` → everything the archive renderer needs, or
989
+ * `null` when either half of the path names nothing. Tried only after every
990
+ * real collection route has failed, so it can never shadow one.
991
+ */
992
+ const resolveTermArchive = async (taxonomyName, termSlug) => {
993
+ const taxonomy = taxonomies.find((candidate) => candidate.name === taxonomyName);
994
+ if (taxonomy === undefined)
995
+ return null;
996
+ const store = taxonomyStoreFor(taxonomy);
997
+ const term = await store.bySlug(termSlug);
998
+ if (term === null)
999
+ return null;
1000
+ // `ancestors` is inclusive of the term itself (`taxonomy-store.ts`), and
1001
+ // a breadcrumb must not repeat the heading right below it.
1002
+ const lineage = await store.ancestors(term.id);
1003
+ const children = await store.list({ parent: term.id });
1004
+ return {
1005
+ taxonomyName: taxonomy.name,
1006
+ term: { slug: term.slug, label: termLabel(term) },
1007
+ ancestors: lineage
1008
+ .filter((ancestor) => ancestor.id !== term.id)
1009
+ .map((ancestor) => ({ slug: ancestor.slug, label: termLabel(ancestor) })),
1010
+ children: children.map((child) => ({ slug: child.slug, label: termLabel(child) })),
1011
+ entries: await entriesForTerm(taxonomy.name, term.id),
1012
+ };
1013
+ };
1014
+ /**
1015
+ * Every term archive URL a crawler should know about, for `/sitemap.xml`.
1016
+ * A term with nothing published under it is left out: the page answers 200,
1017
+ * but advertising an empty list to a crawler is asking it to spend a
1018
+ * request on nothing.
1019
+ */
1020
+ const termSitemapUrls = async () => {
1021
+ const urls = [];
1022
+ for (const taxonomy of taxonomies) {
1023
+ for (const term of await taxonomyStoreFor(taxonomy).list()) {
1024
+ const entries = await entriesForTerm(taxonomy.name, term.id);
1025
+ if (entries.length === 0)
1026
+ continue;
1027
+ urls.push({
1028
+ loc: new URL(`/${encodeURIComponent(taxonomy.name)}/${encodeURIComponent(term.slug)}`, site.url).toString(),
1029
+ changefreq: 'weekly',
1030
+ priority: 0.5,
1031
+ });
1032
+ }
1033
+ }
1034
+ return urls;
1035
+ };
1036
+ // Contract E (ADR-0024): a whole separate domain, wired the same way the
1037
+ // taxonomy tables are — created idempotently, once, here, so a site that
1038
+ // never sells anything pays nothing beyond a handful of `create table if
1039
+ // not exists` statements it never queries.
1040
+ await ensureCommerceTables(db);
1041
+ const commerceCatalog = createCatalogStore(db);
1042
+ const commerceCustomers = createCustomerStore(db);
1043
+ const commerceTax = createTaxStore(db);
1044
+ const commerceShipping = createShippingStore(db);
1045
+ const commerceCoupons = createCouponStore(db);
1046
+ const commerceCarts = createCartStore(db, {
1047
+ catalog: commerceCatalog,
1048
+ tax: commerceTax,
1049
+ shipping: commerceShipping,
1050
+ coupons: commerceCoupons,
1051
+ });
1052
+ const commerceOrders = createOrderStore(db, {
1053
+ catalog: commerceCatalog,
1054
+ carts: commerceCarts,
1055
+ customers: commerceCustomers,
1056
+ coupons: commerceCoupons,
1057
+ });
1058
+ // Contract E's payment gateway (fiche 34 task 3) — the same registry
1059
+ // pattern as cache/queue/storage (R1): Stripe and PayPal are both `optimal`
1060
+ // and answer only with real credentials the gateway itself accepts, bank
1061
+ // transfer is `degraded` and always answers, so a shop is sellable before
1062
+ // anyone configures either. `select()` never throws here (`payment.driver`
1063
+ // defaults to `'auto'`, and the degraded driver always resolves), unlike
1064
+ // database or storage where a named-but-unreachable driver is fatal on
1065
+ // purpose.
1066
+ const paymentConfig = {
1067
+ driver: options.payment?.driver ?? 'auto',
1068
+ ...(options.payment?.stripeSecretKey === undefined
1069
+ ? {}
1070
+ : { secretKey: options.payment.stripeSecretKey }),
1071
+ ...(options.payment?.stripeWebhookSecret === undefined
1072
+ ? {}
1073
+ : { webhookSecret: options.payment.stripeWebhookSecret }),
1074
+ ...(options.payment?.paypalClientId === undefined
1075
+ ? {}
1076
+ : { clientId: options.payment.paypalClientId }),
1077
+ ...(options.payment?.paypalClientSecret === undefined
1078
+ ? {}
1079
+ : { clientSecret: options.payment.paypalClientSecret }),
1080
+ ...(options.payment?.paypalWebhookId === undefined
1081
+ ? {}
1082
+ : { webhookId: options.payment.paypalWebhookId }),
1083
+ ...(options.payment?.manualInstructions === undefined
1084
+ ? {}
1085
+ : { transferInstructions: options.payment.manualInstructions }),
1086
+ };
1087
+ const paymentRegistry = createPaymentRegistry({ logger });
1088
+ const paymentSelection = await paymentRegistry.select(paymentConfig);
1089
+ const commercePayments = createPaymentStore(db, {
1090
+ gateway: paymentSelection.instance,
1091
+ orders: commerceOrders,
1092
+ });
1093
+ const commercePermissions = createCommercePermissions();
1094
+ const commerceSubscriptions = createSubscriptionStore(db, {
1095
+ catalog: commerceCatalog,
1096
+ customers: commerceCustomers,
1097
+ orders: commerceOrders,
1098
+ payments: commercePayments,
1099
+ // Absent on a site with no e-mail transport configured (R1/R2) — same
1100
+ // degrade-to-no-op gate `commerceOrderEmails` uses below, reusing
1101
+ // `@cogenta/channels` via `createEmailRenewalNotifier` rather than a
1102
+ // second renderer (fiche 53 task 5).
1103
+ ...(options.emailTransport === undefined
1104
+ ? {}
1105
+ : { notifyRenewal: createEmailRenewalNotifier(options.emailTransport) }),
1106
+ });
1107
+ // Absent until the site fills in `billing` (contract E, ADR-0024): an
1108
+ // invoice with a made-up seller address is worse than no invoicing at all,
1109
+ // so the route stays unreachable rather than issuing one anyway.
1110
+ const billing = options.billing;
1111
+ const commerceSeller = billing === undefined
1112
+ ? undefined
1113
+ : {
1114
+ address: [billing.legalName, ...billing.address],
1115
+ ...(() => {
1116
+ const footer = [billing.taxId, billing.footer]
1117
+ .filter((part) => part !== undefined)
1118
+ .join(' — ');
1119
+ return footer === '' ? {} : { footer };
1120
+ })(),
1121
+ };
1122
+ const commerceInvoices = commerceSeller === undefined
1123
+ ? undefined
1124
+ : createInvoiceStore(db, { orders: commerceOrders, seller: commerceSeller });
1125
+ // A credit note per refund (fiche 52 task 6) needs the same seller details
1126
+ // as an invoice — the same gate, deliberately: a credit note without a
1127
+ // real seller address is not a usable accounting document either.
1128
+ const commerceCreditNotes = commerceSeller === undefined
1129
+ ? undefined
1130
+ : createCreditNoteStore(db, { orders: commerceOrders, seller: commerceSeller });
1131
+ // Transactional order e-mails (fiche 52 task 2) — the same degraded-by-default
1132
+ // `EmailTransport` every other transactional sender in this file reuses
1133
+ // (R1/R2): absent, orders still place and ship, nobody is ever notified.
1134
+ const commerceOrderEmails = options.emailTransport === undefined
1135
+ ? undefined
1136
+ : createOrderEmailQueue(db, { orders: commerceOrders, transport: options.emailTransport });
1137
+ // Contract F (ADR-0025) — a comment is not a collection entry, so its
1138
+ // tables are created idempotently here the same way commerce's are above:
1139
+ // a site that never receives a comment never pays for them.
1140
+ await ensureCommentsTables(db);
1141
+ const commentsStore = createCommentStore({ db });
1142
+ const commentsSettingsStore = createCommentSettingsStore(db);
1143
+ const commentsRateLimiter = createCommentRateLimiter(db);
1144
+ const commentsPermissions = createCommentPermissions();
1145
+ // Derived, never the raw signing key itself: `hashIp` mixes this secret
1146
+ // into every hash, and a comment's IP hash living in the database is a
1147
+ // different exposure than the JWT signing key living in the process
1148
+ // environment — deriving keeps a leak of one from being a leak of both
1149
+ // (R7: read once here, never re-read from the environment by the library).
1150
+ const commentsIpHashSecret = createHash('sha256')
1151
+ .update(`${options.signingKey}:comments-ip-hash`)
1152
+ .digest('hex');
1153
+ const commentsRouter = createCommentsRouter({
1154
+ store: commentsStore,
1155
+ settings: commentsSettingsStore,
1156
+ rateLimiter: commentsRateLimiter,
1157
+ permissions: commentsPermissions,
1158
+ ipHashSecret: commentsIpHashSecret,
1159
+ siteDefaults: async () => {
1160
+ const [enabledSetting, moderationSetting] = await Promise.all([
1161
+ siteSettingsStore.get('discussion.enabled', SITE_SETTINGS_SITE_SCOPE),
1162
+ siteSettingsStore.get('discussion.moderationRequired', SITE_SETTINGS_SITE_SCOPE),
1163
+ ]);
1164
+ return {
1165
+ enabled: typeof enabledSetting?.value === 'boolean' ? enabledSetting.value : true,
1166
+ moderationRequired: typeof moderationSetting?.value === 'boolean' ? moderationSetting.value : true,
1167
+ };
1168
+ },
1169
+ });
1170
+ await ensureAnalyticsTables(db);
1171
+ const analyticsStore = createAnalyticsStore(db);
1172
+ const siteHost = new URL(site.url).hostname;
1173
+ // Mirrors `@cogenta/core`'s `analyticsSchema` default: every real caller
1174
+ // passes `loaded.config.analytics`, this only covers a test harness that
1175
+ // builds a `Site` directly without going through config resolution.
1176
+ const analyticsRetainDays = options.analytics?.retainDays ?? 400;
1177
+ /**
1178
+ * Resolves a stored analytics path to the entry that lives there (fiche 27
1179
+ * task 1) — the seam `analytics-router.ts` documents: `@cogenta/analytics`
1180
+ * itself knows nothing about collections or routes, so this is where a
1181
+ * bare path becomes a title and an admin link, through the exact same
1182
+ * `resolveEntry`/permission-checked `gateway` the public page render uses.
1183
+ * `undefined` for "no route matches" or "nothing published there any
1184
+ * more" — the top-pages table then falls back to the bare path.
1185
+ */
1186
+ async function resolveAnalyticsPage(path, actor) {
1187
+ const resolved = await resolveEntry(path, { collections, gateway, site, styles }, { actor });
1188
+ if (resolved === null)
1189
+ return undefined;
1190
+ return {
1191
+ title: entryTitle(resolved.entry),
1192
+ editHref: `/admin/collections/${encodeURIComponent(resolved.collection.name)}/${encodeURIComponent(resolved.entry.id)}`,
1193
+ };
1194
+ }
1195
+ const reviewRouter = createReviewRouter({ collections, permissions, storeFor });
265
1196
  return {
266
1197
  db,
267
1198
  auth,
1199
+ cogentaVersion,
268
1200
  restRouter: createRestRouter({ service, siteUrl: site.url }),
269
- authRouter: createAuthRouter({ auth }),
1201
+ authRouter: createAuthRouter({
1202
+ auth,
1203
+ ...(options.onForgotPassword == null ? {} : { onForgotPassword: options.onForgotPassword }),
1204
+ }),
1205
+ analyticsStore,
1206
+ analyticsRouter: createAnalyticsRouter({
1207
+ store: analyticsStore,
1208
+ siteHost,
1209
+ resolvePage: resolveAnalyticsPage,
1210
+ retainDays: analyticsRetainDays,
1211
+ }),
1212
+ tickAnalyticsPurge: async () => {
1213
+ const purged = await analyticsStore.purgeEvents(analyticsRetainDays);
1214
+ // Not load-bearing for the purge count a test asserts against — see
1215
+ // `AnalyticsStore.purgeSalts`'s own doc — so its own count is not
1216
+ // reported here.
1217
+ await analyticsStore.purgeSalts(analyticsRetainDays);
1218
+ return purged;
1219
+ },
270
1220
  mediaRouter: createMediaRouter({
271
1221
  store: mediaStore,
272
1222
  storage,
1223
+ folders: mediaFolderStore,
273
1224
  ...(options.images === undefined || options.images === null
274
1225
  ? {}
275
1226
  : { images: options.images }),
1227
+ // Fiche 46 task 7's own critère ("panneau détail enrichi... usage,
1228
+ // déjà en API") needs this real: `findMediaUsage` (fiche 11 task 3)
1229
+ // was written and tested but never actually wired here, so
1230
+ // `GET /api/media/{id}/usage` always answered "nothing found" on a
1231
+ // real server — a gap fiche 46's own admin work would otherwise have
1232
+ // silently reproduced (an always-empty usage panel next to a real
1233
+ // scan nobody ever asked for). `storeFor`/`collections` are the exact
1234
+ // instances every other reader (REST, GraphQL, theme rendering)
1235
+ // already shares.
1236
+ usage: { collections, storeFor },
1237
+ // fiche 23 task 2's "Médias" tab — read fresh on every upload so a
1238
+ // changed ceiling applies immediately, no restart needed.
1239
+ maxUploadBytes: async () => {
1240
+ const setting = await siteSettingsStore.get('media.maxUploadSizeMb', SITE_SETTINGS_SITE_SCOPE);
1241
+ const mb = typeof setting?.value === 'number' ? setting.value : 15;
1242
+ return mb * 1024 * 1024;
1243
+ },
1244
+ }),
1245
+ auditRouter: createAuditRouter({
1246
+ audit: auth.audit,
1247
+ // Reuses `ContentService.diff` — the same function
1248
+ // `GET /{collection}/{id}/diff` already calls — rather than the audit
1249
+ // router re-deriving a structural diff of its own (fiche 21 task 1).
1250
+ diff: (actor, name, id, from, to) => service.diff({ actor }, name, id, from, to),
1251
+ users: auth.users,
1252
+ apiKeys: auth.apiKeys,
1253
+ integrity: auth.auditIntegrity,
276
1254
  }),
277
- auditRouter: createAuditRouter({ audit: auth.audit }),
278
1255
  taxonomyRouter: createTaxonomyRouter({
279
1256
  taxonomies,
280
1257
  permissions,
281
1258
  storeFor: (taxonomy) => taxonomyStoreFor(taxonomy),
1259
+ // Wires `?counts=1`/`?unused=1` (08-taxonomies.md, task 3): every real
1260
+ // server has a database and a collection set, so this is never left out
1261
+ // here — only the router's own tests exercise the "no usage source"
1262
+ // degradation.
1263
+ usage: { db, collections },
1264
+ }),
1265
+ marketplaceRouter: createMarketplaceRouter({
1266
+ catalog: marketplaceCatalog,
1267
+ installer: marketplaceInstaller,
1268
+ disableStore: pluginDisabled,
1269
+ usageStore: pluginUsage,
1270
+ grantStore: marketplaceGrants,
1271
+ describeCapability,
1272
+ }),
1273
+ menuRouter: createMenuRouter({
1274
+ store: menuStore,
1275
+ resolveEntry: resolveMenuEntry,
1276
+ resolveTerm: resolveMenuTerm,
1277
+ }),
1278
+ patternRouter: createPatternRouter({ store: patternStore }),
1279
+ siteSettingsRouter: createSiteSettingsRouter({
1280
+ store: siteSettingsStore,
1281
+ defaultLocale: site.defaultLocale,
1282
+ }),
1283
+ siteSettingsStore,
1284
+ adminThemeRouter: createAdminThemeRouter({ store: adminThemeStore }),
1285
+ commerceRouter: createCommerceAdminRouter({
1286
+ catalog: commerceCatalog,
1287
+ orders: commerceOrders,
1288
+ customers: commerceCustomers,
1289
+ payments: commercePayments,
1290
+ coupons: commerceCoupons,
1291
+ subscriptions: commerceSubscriptions,
1292
+ tax: commerceTax,
1293
+ shipping: commerceShipping,
1294
+ ...(commerceInvoices === undefined ? {} : { invoices: commerceInvoices }),
1295
+ ...(commerceCreditNotes === undefined ? {} : { creditNotes: commerceCreditNotes }),
1296
+ ...(commerceOrderEmails === undefined ? {} : { orderEmails: commerceOrderEmails }),
1297
+ payment: {
1298
+ registry: paymentRegistry,
1299
+ config: paymentConfig,
1300
+ testMode: options.payment?.testMode ?? true,
1301
+ // Informational only — see `router.ts`'s own comment: no inbound
1302
+ // route answers this path yet (deferred, `BLOCKERS.md`).
1303
+ webhookUrl: `${site.url.replace(/\/+$/u, '')}/api/commerce/payments/webhook`,
1304
+ },
1305
+ permissions: commercePermissions,
1306
+ }),
1307
+ commentsRouter,
1308
+ commentsStore,
1309
+ commentsSettingsStore,
1310
+ redirectRouter: createRedirectRouter({ store: redirects, patterns: redirectPatterns }),
1311
+ redirectPatterns,
1312
+ rolePermissionStore,
1313
+ rolePermissionRouter: createRolePermissionRouter({
1314
+ store: rolePermissionStore,
1315
+ overlay: rolePermissionOverlay,
1316
+ }),
1317
+ formStore,
1318
+ formsRouter,
1319
+ tickFormsPurge: async () => (await formStore.submissions.purgeExpired()).purged,
1320
+ tickCommerceEmails: commerceOrderEmails === undefined ? null : () => commerceOrderEmails.flushDue(),
1321
+ tickCommerceSubscriptions: async () => {
1322
+ // Sequential, not `Promise.all`: `runDunning` and `sendRenewalNotices`
1323
+ // both read subscription state `runBilling` may just have changed
1324
+ // (a subscription billed this tick is no longer the one dunning
1325
+ // should be retrying, and a subscription just billed has a fresh
1326
+ // `nextBillingAt` a renewal notice should measure against).
1327
+ const billing = await commerceSubscriptions.runBilling();
1328
+ const dunning = await commerceSubscriptions.runDunning();
1329
+ const renewal = await commerceSubscriptions.sendRenewalNotices();
1330
+ return {
1331
+ billed: billing.billed.length,
1332
+ dunningRetried: dunning.retried.length,
1333
+ dunningSuspended: dunning.suspended.length,
1334
+ renewalNoticesSent: renewal.notified.length,
1335
+ };
1336
+ },
1337
+ tickCartAbandon: async (tickOptions) => commerceCarts.abandonInactive(tickOptions ?? {}),
1338
+ notFoundLog,
1339
+ notFoundLogEnabled: options.notFoundLog.enabled,
1340
+ notFoundRouter: createNotFoundRouter({ store: notFoundLog }),
1341
+ tickNotFoundPurge: () => notFoundLog.purge(options.notFoundLog.retainDays),
1342
+ opsStatusRouter: createOpsStatusRouter({
1343
+ security: options.security,
1344
+ webhooks: options.webhooks,
1345
+ trash: () => ({
1346
+ retainDaysByCollection: trashRetainDaysByCollection,
1347
+ lastRunAt: lastTrashPurgeAt,
1348
+ lastPurged: lastTrashPurgeCount,
1349
+ }),
1350
+ config: options.configStatus,
1351
+ }),
1352
+ shellStatusRouter: createShellStatusRouter({
1353
+ content: service,
1354
+ trashableCollections: collections
1355
+ .filter((collection) => collection.trash !== false)
1356
+ .map((collection) => collection.name),
1357
+ commerceOrders,
1358
+ commerceCatalog,
1359
+ marketplaceCatalog,
1360
+ marketplaceInstaller,
1361
+ ...(collections.some((collection) => collection.workflow?.enabled === true)
1362
+ ? { reviewQueue: reviewRouter }
1363
+ : {}),
1364
+ comments: commentsStore,
1365
+ forms: { countUnread: () => formStore.submissions.unreadCount() },
1366
+ cogentaVersion,
282
1367
  }),
283
1368
  searchRouter: createSearchRouter({
284
1369
  index: searchIndex,
285
1370
  collections,
286
1371
  permissions,
287
1372
  defaultLocale: site.defaultLocale,
1373
+ // Real excerpts (fiche 36 task 3): the gateway is already built above
1374
+ // with the same stores and permissions REST/GraphQL use.
1375
+ gateway,
1376
+ }),
1377
+ seoRouter: createSeoRouter({
1378
+ collections,
1379
+ gateway,
1380
+ permissions,
1381
+ site: seoSiteFor(site),
1382
+ // Read fresh on every diagnostic scan / preview (fiche 21 task 3) —
1383
+ // see `SeoRouterOptions.titleDefaults`'s own doc comment for why this
1384
+ // is a getter rather than a value captured once at server startup.
1385
+ titleDefaults: async () => {
1386
+ const defaults = await readSeoRenderDefaults(siteSettingsStore);
1387
+ return {
1388
+ titleTemplate: defaults.titleTemplate,
1389
+ collectionTitleTemplates: defaults.collectionTitleTemplates,
1390
+ };
1391
+ },
1392
+ // Fiche 50 task 4 — read fresh, same reasoning as `titleDefaults`: the
1393
+ // Diagnostics screen's robots.txt preview must show the exact document
1394
+ // `/robots.txt` serves, not a stale one from before a custom rule was
1395
+ // saved.
1396
+ robotsCustomRules: async () => (await readSeoRenderDefaults(siteSettingsStore)).robotsCustomRules,
1397
+ }),
1398
+ searchConsoleRouter: createSearchConsoleRouter({
1399
+ store: searchConsoleStore,
1400
+ signingKey: options.signingKey,
1401
+ // A URL-prefix property (the form GSC's own UI defaults to) — always
1402
+ // exactly one trailing slash, whatever `site.url` was written as.
1403
+ siteUrl: `${site.url.replace(/\/+$/u, '')}/`,
1404
+ ...(options.searchConsole === undefined
1405
+ ? {}
1406
+ : {
1407
+ oauth: {
1408
+ clientId: options.searchConsole.clientId,
1409
+ clientSecret: options.searchConsole.clientSecret,
1410
+ redirectUri: `${site.url.replace(/\/+$/u, '')}/api/seo/search-console/callback`,
1411
+ },
1412
+ }),
288
1413
  }),
1414
+ // The review queue (`schema@2.1`, ADR-0027, fiche 37 task 3).
1415
+ reviewRouter,
289
1416
  securityAlerts: options.onSecurityEvent == null
290
1417
  ? null
291
1418
  : createSecurityAlertWatch({
@@ -295,9 +1422,9 @@ async function assembleSite(options) {
295
1422
  logger,
296
1423
  }),
297
1424
  noticeRouter: createNoticeRouter({
298
- // One source today, and the seam is the array: a future recommendation
299
- // (a plugin update waiting, a certificate about to expire) is one more
300
- // entry here and nothing else anywhere.
1425
+ // The seam is the array (fiche 38 task 1): a new recommendation is one
1426
+ // more entry here and nothing else anywhere the router, the store
1427
+ // and the admin board are all unaware how many there are.
301
1428
  sources: [
302
1429
  createMfaRecommendationSource({ collections, credentials: auth.credentials }),
303
1430
  // The failed-sign-in table has been written to since L2 and read by
@@ -305,26 +1432,131 @@ async function assembleSite(options) {
305
1432
  // in this array is the whole wiring — the seam the notice mechanism was
306
1433
  // designed around.
307
1434
  createSuspiciousActivitySource({ rateLimit: auth.rateLimit }),
1435
+ // "Une clé qui expire sans prévenir casse une intégration en
1436
+ // production" (fiche 20 task 1) — one more source, no change to the
1437
+ // router, the store or the admin's notice board.
1438
+ createApiKeyExpiryNoticeSource({ apiKeys: auth.apiKeys, href: '/api-keys' }),
1439
+ // Fiche 21 task 3's on-screen half: recomputed from the scheduled
1440
+ // check's persisted status on every load, so it disappears on its
1441
+ // own once a forced full check reports the chain intact again.
1442
+ createAuditIntegritySource({ integrity: auth.auditIntegrity }),
1443
+ // Fiche 18 task 1: signing in with a recovery code is exactly what a
1444
+ // stolen batch of codes would also produce, so the account that just
1445
+ // did it is told, and can look at its own sessions and remaining codes.
1446
+ createRecoveryCodeUsedNoticeSource({ audit: auth.audit }),
1447
+ // Fiche 24 task 2's second bullet. Absent (a caller with no migrator)
1448
+ // means the array simply does not grow.
1449
+ ...(options.pendingMigrations === undefined
1450
+ ? []
1451
+ : [createPendingMigrationsSource(options.pendingMigrations)]),
1452
+ // A plugin `@cogenta/plugins` killed for a timeout/memory/crash
1453
+ // violation (L7 task 6) stayed disabled with nothing on screen
1454
+ // saying so, until fiche 38.
1455
+ createPluginDisabledSource({
1456
+ listDisabled: () => pluginDisabled.listDisabled(),
1457
+ pluginsHref: '/marketplace',
1458
+ }),
1459
+ // "Contenu programmé dont la publication a échoué" — fiche 38 task 1.
1460
+ createScheduledPublishFailedSource({
1461
+ listFailed: () => scheduledPublishFailures.list(),
1462
+ entryHref: (record) => `/collections/${record.collection}/${record.entryId}`,
1463
+ }),
1464
+ // L22 task 3: a redirect the Site Monitor agent proposed under
1465
+ // `co-pilot` autonomy, still pending — absent (no agent runtime
1466
+ // configured) means the array simply does not grow, same as the
1467
+ // pending-migrations source above.
1468
+ ...(agentsRuntime === undefined
1469
+ ? []
1470
+ : [
1471
+ createMonitoringRedirectSuggestionSource({
1472
+ approvalQueue: agentsRuntime.approvalQueue,
1473
+ redirects,
1474
+ }),
1475
+ ]),
308
1476
  ],
309
1477
  dismissals: noticeDismissals,
1478
+ // Fiche 38 tasks 2-3: history for the notification centre, and the
1479
+ // channel bridge that notifies whatever this person has linked —
1480
+ // both optional on the router itself, both always supplied here.
1481
+ history: noticeHistory,
1482
+ channelBridge: noticeChannelBridge,
1483
+ }),
1484
+ noticeChannelSettingsRouter: createNoticeChannelSettingsRouter({
1485
+ linkStore: channelLinks,
1486
+ preferenceStore: channelPreferences,
310
1487
  }),
311
- usersRouter: createUsersRouter({ auth }),
1488
+ usersRouter: createUsersRouter({
1489
+ auth,
1490
+ collections,
1491
+ // T09-04/RGPD: `GET /{id}/personal-data` walks every collection for
1492
+ // entries this account authored — the same `storeFor` REST, GraphQL
1493
+ // and theme rendering already share.
1494
+ storeFor,
1495
+ ...(options.onInvite == null ? {} : { onInvite: options.onInvite }),
1496
+ }),
1497
+ apiKeysRouter: createApiKeysRouter({ auth }),
1498
+ ...(options.requestQuota === undefined ? {} : { requestQuota: options.requestQuota }),
312
1499
  assistantRouter: createAssistantRouter({
313
1500
  toolset: (options.assistant?.toolset ?? EMPTY_TOOLSET),
314
1501
  collections,
315
1502
  permissions,
316
1503
  site,
317
1504
  logger,
1505
+ ...(options.assistant?.vectorInfo === undefined
1506
+ ? {}
1507
+ : { vectorInfo: options.assistant.vectorInfo }),
1508
+ ...(options.assistant?.documents === undefined
1509
+ ? {}
1510
+ : { documents: options.assistant.documents }),
1511
+ }),
1512
+ ...(agentsRuntime === undefined
1513
+ ? {}
1514
+ : {
1515
+ agentsRouter: createAgentsRouter({
1516
+ agents: agentsRuntime.agentRegistry,
1517
+ audit: auth.audit,
1518
+ runner: agentsRuntime.agentRunner,
1519
+ conversations: agentsRuntime.conversations,
1520
+ }),
1521
+ providersRouter: createProvidersRouter({ providers: agentsRuntime.providerRegistry }),
1522
+ agentSkillsRouter: createAgentSkillsRouter({ skills: agentsRuntime.skillRegistry }),
1523
+ promptTemplatesRouter: createPromptTemplatesRouter({
1524
+ templates: agentsRuntime.promptTemplateRegistry,
1525
+ }),
1526
+ }),
1527
+ mcpConnectionsRouter: createMcpConnectionsRouter({
1528
+ connections: mcpConnections,
1529
+ logger,
1530
+ // Fiche 58 task 4 — a connection created/tested/toggled/exposed here
1531
+ // takes effect on this runtime's very next tool lookup, no restart
1532
+ // (see `AgentRuntimeAssembly.refreshMcpTools`'s own comment). A no-op
1533
+ // when `agentsRuntimeConfig` was never given (no agent runtime to
1534
+ // refresh) — the registry itself still works standalone either way.
1535
+ onMutated: async () => {
1536
+ await agentsRuntime?.refreshMcpTools();
1537
+ },
318
1538
  }),
319
- ...(options.agents === undefined ? {} : { agentsRouter: createAgentsRouter(options.agents) }),
320
1539
  ...(options.sitePlans === undefined
321
1540
  ? {}
322
1541
  : { sitePlanRouter: createSitePlanRouter(options.sitePlans) }),
1542
+ updatesRouter: options.updatesRouter,
1543
+ importRouter: createImportRouter({
1544
+ // `db`/`storage` are the very ones already in scope for the rest of
1545
+ // this function — `@cogenta/import`'s real importer, unchanged, never
1546
+ // reimplemented here (R9: this package gains no dependency on it, only
1547
+ // `@cogenta/cli` does, which already had one for the terminal command).
1548
+ runWordPressImport: (xml) => importWordPress(xml, { db, storage, comments: commentsStore }),
1549
+ analyze: analyzeImportSource,
1550
+ apply: applyImportRun,
1551
+ getRun: (id) => importTracking.getRun(id),
1552
+ listRuns: () => importTracking.listRuns(),
1553
+ cancel: cancelImportRun,
1554
+ }),
323
1555
  mediaStore,
324
1556
  storage,
325
1557
  images: options.images ?? null,
326
1558
  graphqlSchema: buildContentSchema({ collections }),
327
- gateway: createContentGateway({ collections, stores, permissions }),
1559
+ gateway,
328
1560
  permissions,
329
1561
  schemaDocument: buildSchemaDocument(collections, { locales: site.locales, defaultLocale: site.defaultLocale }, taxonomies),
330
1562
  redirects,
@@ -332,9 +1564,97 @@ async function assembleSite(options) {
332
1564
  taxonomies,
333
1565
  site,
334
1566
  styles,
1567
+ resolveStyles: options.theme === undefined
1568
+ ? async () => styles
1569
+ : async () => computeEffectiveStyles(options.theme, await themeCssForActive(options)),
1570
+ ...(options.theme === undefined
1571
+ ? {}
1572
+ : {
1573
+ previewStyles: async (candidate) => computePreviewStyles(options.theme, await themeCssForActive(options), candidate),
1574
+ }),
1575
+ ...(options.theme === undefined ? {} : { themeRouter: createThemeRouter(options.theme) }),
1576
+ ...(options.theme === undefined
1577
+ ? {}
1578
+ : {
1579
+ themeGalleryStyles: async (themeName, tokens) => {
1580
+ const themeCss = options.themeCssFor === undefined
1581
+ ? (options.themeCss ?? null)
1582
+ : await options.themeCssFor(themeName);
1583
+ return tokens === undefined
1584
+ ? computeEffectiveStyles(options.theme, themeCss)
1585
+ : computeCandidateGalleryStyles(options.theme, themeCss, tokens);
1586
+ },
1587
+ }),
1588
+ ...(options.theme === undefined
1589
+ ? {}
1590
+ : {
1591
+ activeTheme: async () => (await options.theme.store.get()).activeTheme,
1592
+ }),
1593
+ ...(options.theme === undefined
1594
+ ? {}
1595
+ : {
1596
+ siteIdentity: async () => {
1597
+ const overrides = await options.theme.store.get();
1598
+ return {
1599
+ logoMediaId: overrides.logoMediaId,
1600
+ logoDarkMediaId: overrides.logoDarkMediaId,
1601
+ faviconMediaId: overrides.faviconMediaId,
1602
+ shareImageMediaId: overrides.shareImageMediaId,
1603
+ };
1604
+ },
1605
+ }),
1606
+ resolveTermArchive,
1607
+ resolveTaxonomyTerm: resolveMenuTerm,
1608
+ termSitemapUrls,
335
1609
  security: options.security,
336
1610
  health: options.health,
1611
+ tickScheduledPublishing: () => scheduledPublishQueue.tick(),
1612
+ checkAuditIntegrity: async () => {
1613
+ const result = await auth.auditIntegrity.check();
1614
+ if (!result.newlyBroken)
1615
+ return;
1616
+ await sendAuditIntegrityAlert(result.status, {
1617
+ send: options.onSecurityEvent ?? null,
1618
+ siteUrl: site.url,
1619
+ logger,
1620
+ });
1621
+ },
1622
+ tickAuditPrune: async () => {
1623
+ const retainDays = options.security.audit.retainDays;
1624
+ // `undefined` (never configured) and `0` (the explicit "never purge"
1625
+ // opt-out) are the same instruction here — see `tickAuditPrune`'s own
1626
+ // interface comment and `auditRetentionSchema` (`@cogenta/core`).
1627
+ if (retainDays === undefined || retainDays === 0)
1628
+ return { pruned: 0 };
1629
+ const cutoff = new Date(Date.now() - retainDays * 24 * 60 * 60 * 1000).toISOString();
1630
+ const result = await auth.audit.prune(cutoff);
1631
+ // The purge is itself an event the audit log has to carry — the same
1632
+ // rule the RGPD export follows (`users-router.ts`'s `personalDataRoute`):
1633
+ // a retention sweep that deletes rows without a trace of having run
1634
+ // would defeat the very audit trail it is trimming.
1635
+ await auth.audit.record({
1636
+ actorId: null,
1637
+ actorRoles: [],
1638
+ action: 'audit.prune',
1639
+ diff: { retainDays, cutoff, prunedCount: result.prunedCount },
1640
+ });
1641
+ return { pruned: result.prunedCount };
1642
+ },
1643
+ tickTrashPurge,
1644
+ tickChannelNotifications: () => channelDispatcher.flushDue(),
337
1645
  dispose: async () => {
1646
+ // Fiche 58 task 4 — kills every spawned MCP server process and
1647
+ // removes every sandbox working directory before the database (and
1648
+ // everything else) goes away. A no-op when no connection was wired.
1649
+ // `.catch()` matches this same function's own posture just below
1650
+ // (`site.dispose().catch(...)` at the call site) — a failure tearing
1651
+ // down one resource must never skip closing the rest.
1652
+ await agentsRuntime?.mcpDispose().catch((error) => {
1653
+ logger.error('mcp dispose failed', {
1654
+ error: error instanceof Error ? error.message : String(error),
1655
+ });
1656
+ });
1657
+ await scheduledPublishQueue.close();
338
1658
  await db.close();
339
1659
  },
340
1660
  };
@@ -349,7 +1669,7 @@ async function assembleSite(options) {
349
1669
  * arbitrarily large body before ever being told no.
350
1670
  */
351
1671
  const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
352
- async function readBody(req) {
1672
+ async function readRawBodyBuffer(req) {
353
1673
  const chunks = [];
354
1674
  let total = 0;
355
1675
  let tooLarge = false;
@@ -376,9 +1696,44 @@ async function readBody(req) {
376
1696
  }
377
1697
  if (chunks.length === 0)
378
1698
  return undefined;
379
- const text = Buffer.concat(chunks).toString('utf8');
1699
+ return Buffer.concat(chunks);
1700
+ }
1701
+ /**
1702
+ * `application/x-www-form-urlencoded` — the plain no-JS `<form>` shape
1703
+ * (public comment form, fiche 15 task 6; public form submissions, fiche 16
1704
+ * task 3: "sans JavaScript, le formulaire doit fonctionner") — and, since
1705
+ * fiche 47 task 3, real `multipart/form-data` too: the one shape a browser's
1706
+ * own `<input type="file">` forces its enclosing `<form>` into, with no
1707
+ * JavaScript involved. Parsed with the exact same zero-dependency parser
1708
+ * `@cogenta/api`'s media route already relies on
1709
+ * (`parseMultipartFormData`/`isMultipartFormData`), read here as raw bytes
1710
+ * rather than as UTF-8 text — decoding a binary upload as UTF-8 first would
1711
+ * corrupt it before the parser ever saw it. Every other route on this server
1712
+ * still only ever sends JSON, so neither branch fires for them. A repeated
1713
+ * urlencoded key (a `choiceMulti` field's checkboxes) collects into an array
1714
+ * rather than keeping only the last value, which is what a naive
1715
+ * `Object.fromEntries` would silently do.
1716
+ */
1717
+ async function readBody(req) {
1718
+ const buffer = await readRawBodyBuffer(req);
1719
+ if (buffer === undefined || buffer.length === 0)
1720
+ return undefined;
1721
+ const contentType = req.headers['content-type'] ?? '';
1722
+ if (contentType.includes('multipart/form-data')) {
1723
+ return parseMultipartFormData(buffer, contentType);
1724
+ }
1725
+ const text = buffer.toString('utf8');
380
1726
  if (text.trim().length === 0)
381
1727
  return undefined;
1728
+ if (contentType.includes('application/x-www-form-urlencoded')) {
1729
+ const params = new URLSearchParams(text);
1730
+ const body = {};
1731
+ for (const key of params.keys()) {
1732
+ const values = params.getAll(key);
1733
+ body[key] = values.length > 1 ? values : (values[0] ?? '');
1734
+ }
1735
+ return body;
1736
+ }
382
1737
  try {
383
1738
  return JSON.parse(text);
384
1739
  }
@@ -408,17 +1763,246 @@ function toRestRequest(req, url, body) {
408
1763
  ...(body === undefined ? {} : { body }),
409
1764
  };
410
1765
  }
1766
+ /**
1767
+ * `CommerceRequest`'s `query` is single-valued (contract E has no route that
1768
+ * takes a repeated key), unlike `RestRequest`'s — so this is its own small
1769
+ * adapter rather than a cast of `toRestRequest`'s output.
1770
+ */
1771
+ function toCommerceRequest(req, url, body) {
1772
+ const query = {};
1773
+ for (const key of url.searchParams.keys()) {
1774
+ query[key] = url.searchParams.get(key) ?? undefined;
1775
+ }
1776
+ return {
1777
+ method: req.method ?? 'GET',
1778
+ path: url.pathname,
1779
+ query,
1780
+ ...(body === undefined ? {} : { body }),
1781
+ };
1782
+ }
1783
+ /**
1784
+ * `CommentsRequest`'s own adapter, carrying the two fields the public write
1785
+ * route needs that no other router does: the caller's IP (hashed inside
1786
+ * `@cogenta/comments`, never here — R7-adjacent: this layer reads it once
1787
+ * off the socket and hands it to the one function allowed to hash it) and
1788
+ * the user agent, both purely informational for the moderation queue.
1789
+ */
1790
+ /**
1791
+ * The public thread for one entry (fiche 15 task 6) — resolves the same
1792
+ * inheritance chain (`effectiveEnabled`) the public POST route enforces, so
1793
+ * "may I comment here" never disagrees between the form and the page that
1794
+ * renders it.
1795
+ */
1796
+ async function commentsForEntry(site, collection, entryId,
1797
+ // A comment is not localised (ADR-0025) — every comment on an entry shows
1798
+ // regardless of which translation a visitor is reading, so this parameter
1799
+ // exists only to match `ThemeRenderOptions['comments']['forEntry']`'s
1800
+ // signature, not because it is read here.
1801
+ _locale) {
1802
+ const [entrySettings, collectionSettings, enabledSetting, comments] = await Promise.all([
1803
+ site.commentsSettingsStore.getEntry(collection, entryId),
1804
+ site.commentsSettingsStore.getCollection(collection),
1805
+ site.siteSettingsStore.get('discussion.enabled', SITE_SETTINGS_SITE_SCOPE),
1806
+ site.commentsStore.listApprovedForEntry(collection, entryId),
1807
+ ]);
1808
+ const siteDefault = typeof enabledSetting?.value === 'boolean' ? enabledSetting.value : true;
1809
+ const open = effectiveEnabled(entrySettings, collectionSettings, siteDefault);
1810
+ return {
1811
+ open,
1812
+ items: comments.map((comment) => ({
1813
+ id: comment.id,
1814
+ parentId: comment.parentId,
1815
+ authorName: comment.authorName,
1816
+ authorUrl: comment.authorUrl,
1817
+ body: comment.body,
1818
+ createdAt: comment.createdAt,
1819
+ })),
1820
+ };
1821
+ }
1822
+ /**
1823
+ * `branding.showCogentaBranding` / `branding.customLogoMediaId` (fiche L21
1824
+ * task 8), read live off the same `SiteSettingsStore` every other public
1825
+ * settings read already uses — never cached at startup, for the same reason
1826
+ * `homePath` above is not: turning Cogenta's credit off has to show up on
1827
+ * the very next page view, not the next restart.
1828
+ */
1829
+ async function brandingForSite(site) {
1830
+ const [showSetting, logoSetting] = await Promise.all([
1831
+ site.siteSettingsStore.get('branding.showCogentaBranding', SITE_SETTINGS_SITE_SCOPE),
1832
+ site.siteSettingsStore.get('branding.customLogoMediaId', SITE_SETTINGS_SITE_SCOPE),
1833
+ ]);
1834
+ const showCogentaBranding = typeof showSetting?.value === 'boolean' ? showSetting.value : true;
1835
+ const customLogoMediaId = typeof logoSetting?.value === 'string' && logoSetting.value !== '' ? logoSetting.value : null;
1836
+ return { showCogentaBranding, customLogoMediaId, cogentaVersion: site.cogentaVersion };
1837
+ }
1838
+ /**
1839
+ * The active theme *package* name (fiche L23), or `null` for the built-in
1840
+ * default — `site.activeTheme` is absent only when this instance built no
1841
+ * theme wiring (a test harness that does not care about appearance), which
1842
+ * `theme-render.ts`'s own `themeFor` already treats identically to `null`.
1843
+ */
1844
+ async function activeThemeForSite(site) {
1845
+ return site.activeTheme === undefined ? null : site.activeTheme();
1846
+ }
1847
+ /**
1848
+ * The site's identity media, or "nothing chosen" for an instance with no
1849
+ * theme wiring at all — the same shape `activeThemeForSite` above has, for
1850
+ * the same reason: `theme-render.ts` must not have to know which of its
1851
+ * callers built a theme store.
1852
+ */
1853
+ async function identityForSite(site) {
1854
+ return site.siteIdentity === undefined ? EMPTY_SITE_IDENTITY : site.siteIdentity();
1855
+ }
1856
+ /** A `general.socialLinks` row's value, narrowed — the registry's own schema already refused anything else at write time. */
1857
+ function isStoredSocialLinkList(value) {
1858
+ return (Array.isArray(value) &&
1859
+ value.every((entry) => typeof entry === 'object' &&
1860
+ entry !== null &&
1861
+ typeof entry.label === 'string' &&
1862
+ typeof entry.url === 'string'));
1863
+ }
1864
+ /**
1865
+ * `general.tagline`/`general.socialLinks`/`general.footerNote` (contract D
1866
+ * `theme@1.4`, L25 D2), read live off the same `SiteSettingsStore` every
1867
+ * other public settings read already uses — same "no restart" contract as
1868
+ * `brandingForSite` above. `tagline`/`footerNote` are locale-scoped, like
1869
+ * `general.tagline` always has been; `socialLinks` is site-scoped, since a
1870
+ * social handle does not change per locale.
1871
+ */
1872
+ async function chromeExtrasForSite(site, locale) {
1873
+ const [taglineRow, socialRow, footerNoteRow] = await Promise.all([
1874
+ site.siteSettingsStore.get('general.tagline', locale),
1875
+ site.siteSettingsStore.get('general.socialLinks', SITE_SETTINGS_SITE_SCOPE),
1876
+ site.siteSettingsStore.get('general.footerNote', locale),
1877
+ ]);
1878
+ const tagline = typeof taglineRow?.value === 'string' ? taglineRow.value : '';
1879
+ const footerNote = typeof footerNoteRow?.value === 'string' ? footerNoteRow.value : '';
1880
+ const social = isStoredSocialLinkList(socialRow?.value)
1881
+ ? socialRow.value.map((entry) => ({ label: entry.label, href: entry.url }))
1882
+ : [];
1883
+ return { tagline, social, footerNote };
1884
+ }
1885
+ /**
1886
+ * The display name of an entry's author (contract D `theme@1.4`'s
1887
+ * `PageContent.entry.author`) — the same `users.byId(id)` lookup
1888
+ * `audit-router.ts`'s own `resolveActorLabel` already makes, preferring the
1889
+ * public profile name an account chose for itself.
1890
+ *
1891
+ * Unlike `admin-*` screens' own `displayName ?? email` fallback — a private,
1892
+ * authenticated view where showing an email is a reasonable identifier of
1893
+ * last resort — this is a **public** byline, rendered on every visited page
1894
+ * for anyone to read. A scaffolded site's admin account has no display name
1895
+ * by default (`create-cogenta` only ever asks for an email), so reusing that
1896
+ * fallback here would publish the site owner's login email on the very
1897
+ * first page a visitor opens. `null` both for an id nobody can find (an
1898
+ * account since deleted) and for one with no display name set: a byline
1899
+ * that names nobody, or that would have to name an email, is worse than no
1900
+ * byline at all.
1901
+ */
1902
+ async function authorForSite(site, userId) {
1903
+ const user = await site.auth.users.byId(userId);
1904
+ return user === null || user.displayName === null ? null : { name: user.displayName };
1905
+ }
1906
+ function toCommentsRequest(req, url, body) {
1907
+ const query = {};
1908
+ for (const key of url.searchParams.keys()) {
1909
+ query[key] = url.searchParams.get(key) ?? undefined;
1910
+ }
1911
+ // The connecting socket's address only — never `x-forwarded-for`, a
1912
+ // client-supplied header this server has no trusted-proxy list to
1913
+ // validate. Trusting it here would let a single attacker rotate a
1914
+ // fake IP per request to defeat the per-IP rate limit and, worse, the
1915
+ // "already has an approved comment from this IP" auto-approve rule
1916
+ // (`clientIpOf`'s own comment explains why analytics can afford to be
1917
+ // wrong here and moderation cannot).
1918
+ const ip = clientIpOf(req);
1919
+ const userAgent = req.headers['user-agent'];
1920
+ return {
1921
+ method: req.method ?? 'GET',
1922
+ path: url.pathname,
1923
+ query,
1924
+ ip,
1925
+ userAgent: Array.isArray(userAgent) ? (userAgent[0] ?? null) : (userAgent ?? null),
1926
+ ...(body === undefined ? {} : { body }),
1927
+ };
1928
+ }
1929
+ /**
1930
+ * The connecting socket's address — never trusted as anything more than an
1931
+ * input to the daily session hash (`@cogenta/analytics`'s `hashSession`).
1932
+ * No `x-forwarded-for` handling: trusting a client-supplied header for
1933
+ * anything security- or privacy-relevant needs a configured trusted-proxy
1934
+ * list this server does not have, and a wrong guess here would only ever
1935
+ * make analytics *less* accurate, never leak anything (the header is never
1936
+ * stored, only hashed).
1937
+ */
1938
+ function clientIpOf(req) {
1939
+ return req.socket.remoteAddress ?? 'unknown';
1940
+ }
411
1941
  function responseId(response) {
412
1942
  const data = response.body?.data;
413
1943
  return typeof data?.id === 'string' ? data.id : undefined;
414
1944
  }
415
1945
  /**
416
- * Every mutation lands in `@cogenta/auth`'s hash-chained audit log
417
- * (`packages/auth/src/audit.ts`), which existed since L2's own `AuthStore`
418
- * was built but had no writer until now. Recording here, at the transport
419
- * boundary, rather than inside `ContentService`/`MediaRouter`, means every
420
- * route that mutates something is covered by one place instead of every
421
- * write path remembering to call it — the same reasoning that keeps actor
1946
+ * The content version a create/update/restore/publish response just
1947
+ * produced — `SerialisedEntry.version`, a system field on every serialised
1948
+ * entry. Feeds `RecordAuditInput.version` (fiche 21 task 1), which is what
1949
+ * lets the audit detail view ask `GET .../diff?from={version-1}&to={version}`
1950
+ * instead of re-deriving one.
1951
+ */
1952
+ function responseVersion(response) {
1953
+ const data = response.body?.data;
1954
+ return typeof data?.version === 'number' ? data.version : undefined;
1955
+ }
1956
+ /**
1957
+ * The audit action a `/api/content/*` request stands for, or `null` for a
1958
+ * read (`history`/`diff`/`preview`/`translations`, or an unrecognised
1959
+ * sub-route this layer should not guess about).
1960
+ *
1961
+ * Found while wiring the trash screen's "deleted by" column (fiche 07 task
1962
+ * 3): `untrash` and `purge` fell through to `null` here, alongside
1963
+ * `unpublish` and `duplicate` — four real mutations silently missing from
1964
+ * the audit log despite the header comment above claiming every mutation
1965
+ * lands in it. All four are real writes and are named the same way
1966
+ * `publish`/`restore` already were.
1967
+ */
1968
+ function contentAuditAction(method, subAction) {
1969
+ switch (subAction) {
1970
+ case 'publish':
1971
+ return 'content.publish';
1972
+ case 'unpublish':
1973
+ return 'content.unpublish';
1974
+ case 'duplicate':
1975
+ return 'content.duplicate';
1976
+ case 'restore':
1977
+ return 'content.restore';
1978
+ case 'untrash':
1979
+ return 'content.untrash';
1980
+ case 'purge':
1981
+ return 'content.purge';
1982
+ case 'history':
1983
+ case 'diff':
1984
+ case 'preview':
1985
+ case 'translations':
1986
+ return null;
1987
+ case undefined:
1988
+ if (method === 'POST')
1989
+ return 'content.create';
1990
+ if (method === 'PATCH' || method === 'PUT')
1991
+ return 'content.update';
1992
+ if (method === 'DELETE')
1993
+ return 'content.delete';
1994
+ return null;
1995
+ default:
1996
+ return null;
1997
+ }
1998
+ }
1999
+ /**
2000
+ * Every mutation lands in `@cogenta/auth`'s hash-chained audit log
2001
+ * (`packages/auth/src/audit.ts`), which existed since L2's own `AuthStore`
2002
+ * was built but had no writer until now. Recording here, at the transport
2003
+ * boundary, rather than inside `ContentService`/`MediaRouter`, means every
2004
+ * route that mutates something is covered by one place instead of every
2005
+ * write path remembering to call it — the same reasoning that keeps actor
422
2006
  * resolution itself at this layer rather than duplicated per route.
423
2007
  *
424
2008
  * Never blocks or fails the response it is auditing: a write that succeeded
@@ -435,25 +2019,31 @@ async function recordContentAudit(site, actor, method, pathname, body, response,
435
2019
  const [collection, id, subAction] = segments;
436
2020
  if (collection === undefined || collection === '-')
437
2021
  return;
438
- const action = subAction === 'publish'
439
- ? 'content.publish'
440
- : subAction === 'restore'
441
- ? 'content.restore'
442
- : subAction !== undefined
443
- ? null // history/diff/preview/translations are reads
444
- : method === 'POST'
445
- ? 'content.create'
446
- : method === 'PATCH' || method === 'PUT'
447
- ? 'content.update'
448
- : method === 'DELETE'
449
- ? 'content.delete'
450
- : null;
2022
+ const action = contentAuditAction(method, subAction);
451
2023
  if (action === null)
452
2024
  return;
453
2025
  const entryId = id ?? responseId(response);
454
2026
  const values = typeof body === 'object' && body !== null && 'values' in body
455
2027
  ? body.values
456
2028
  : undefined;
2029
+ const version = responseVersion(response);
2030
+ // Fiche 30 task 5: which fields (if any) were filled by an accepted
2031
+ // assistant suggestion since the last save. Not a contract A field — this
2032
+ // never reaches `store.update`, `parseUpdateBody` strips it as an unknown
2033
+ // key — only the audit trail, so "a paragraph written" and "a paragraph
2034
+ // accepted from a suggestion" read differently in the log even though both
2035
+ // produce the same `content.update`.
2036
+ const assistApplied = typeof body === 'object' && body !== null && 'assistApplied' in body
2037
+ ? body.assistApplied
2038
+ : undefined;
2039
+ const diff = values === undefined && (assistApplied === undefined || assistApplied.length === 0)
2040
+ ? undefined
2041
+ : {
2042
+ ...(values === undefined ? {} : values),
2043
+ ...(assistApplied === undefined || assistApplied.length === 0
2044
+ ? {}
2045
+ : { _assistApplied: assistApplied }),
2046
+ };
457
2047
  await site.auth.audit
458
2048
  .record({
459
2049
  actorId: actor.id,
@@ -461,24 +2051,122 @@ async function recordContentAudit(site, actor, method, pathname, body, response,
461
2051
  action,
462
2052
  collection,
463
2053
  ...(entryId === undefined ? {} : { entryId }),
464
- ...(values === undefined ? {} : { diff: values }),
2054
+ ...(diff === undefined ? {} : { diff }),
2055
+ ...(version === undefined ? {} : { version }),
465
2056
  })
466
2057
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
467
2058
  }
2059
+ /**
2060
+ * Pings IndexNow the moment a publish or unpublish response succeeds (fiche
2061
+ * 50 task 3) — off by default (`seo.indexNowEnabled`), a no-op with no key
2062
+ * configured, and a no-op for a collection with no public route (nothing to
2063
+ * tell a crawler about). Deliberately narrower than `recordContentAudit`
2064
+ * above: a plain `content.update` is not a URL changing visibility the way a
2065
+ * publish/unpublish is, and pinging on every keystroke-triggered autosave
2066
+ * would spend the batch IndexNow itself documents as unnecessary.
2067
+ *
2068
+ * Never blocks or fails the response it follows: `pingIndexNow` already
2069
+ * turns a network failure or a non-2xx answer into a logged result rather
2070
+ * than a throw, and the `try`/`catch` here also covers the gateway read and
2071
+ * URL computation around it, so a publish always reaches its caller whether
2072
+ * or not IndexNow could be reached.
2073
+ */
2074
+ async function notifyIndexNowOnContentChange(site, actor, pathname, response, logger) {
2075
+ if (response.status < 200 || response.status >= 300)
2076
+ return;
2077
+ const segments = pathname
2078
+ .replace(/^\/api\/content\/?/u, '')
2079
+ .split('/')
2080
+ .filter((segment) => segment.length > 0);
2081
+ const [collectionName, id, subAction] = segments;
2082
+ if (collectionName === undefined || collectionName === '-')
2083
+ return;
2084
+ if (subAction !== 'publish' && subAction !== 'unpublish')
2085
+ return;
2086
+ try {
2087
+ const operational = await readSeoOperationalSettings(site.siteSettingsStore);
2088
+ if (!operational.indexNowEnabled || operational.indexNowKey === '')
2089
+ return;
2090
+ const collection = site.collections.find((candidate) => candidate.name === collectionName);
2091
+ if (collection === undefined || collection.routing === undefined)
2092
+ return;
2093
+ const entryId = id ?? responseId(response);
2094
+ if (entryId === undefined)
2095
+ return;
2096
+ // The acting editor's own context, not `ANONYMOUS`: they just
2097
+ // published or unpublished this exact entry, so they can always read
2098
+ // it straight back, published or not — an entry just unpublished would
2099
+ // no longer be visible to `ANONYMOUS`, and the URL still needs telling.
2100
+ const entry = await site.gateway.read(collectionName, entryId, { actor });
2101
+ if (entry === null)
2102
+ return;
2103
+ const seoSite = seoSiteFor(site.site, await readSeoRenderDefaults(site.siteSettingsStore));
2104
+ const url = canonicalUrl(seoSite, { collection, entry });
2105
+ if (url === null)
2106
+ return;
2107
+ const result = await pingIndexNow({
2108
+ host: new URL(site.site.url).host,
2109
+ key: operational.indexNowKey,
2110
+ urls: [url],
2111
+ });
2112
+ if (result.outcome === 'failed') {
2113
+ logger.warn('IndexNow ping failed', { reason: result.reason, message: result.message });
2114
+ }
2115
+ }
2116
+ catch (error) {
2117
+ logger.warn('IndexNow ping skipped after an unexpected error', { error: String(error) });
2118
+ }
2119
+ }
468
2120
  async function recordMediaAudit(site, actor, method, pathname, response, logger) {
469
2121
  if (response.status < 200 || response.status >= 300)
470
2122
  return;
471
- const [id] = pathname
2123
+ const segments = pathname
472
2124
  .replace(/^\/api\/media\/?/u, '')
473
2125
  .split('/')
474
2126
  .filter((segment) => segment.length > 0);
475
- const action = method === 'POST'
476
- ? 'media.upload'
477
- : method === 'PATCH' || method === 'PUT'
478
- ? 'media.update'
479
- : method === 'DELETE'
480
- ? 'media.delete'
481
- : null;
2127
+ const [first, second] = segments;
2128
+ // Fiche 46: the folder tree and the two "move" routes get their own
2129
+ // action names and, for a folder, the *folder's* id rather than the
2130
+ // literal segment `folders` — the same care `id` already got for a plain
2131
+ // asset write below.
2132
+ let action;
2133
+ let id;
2134
+ if (first === 'folders') {
2135
+ id = second;
2136
+ action =
2137
+ second === undefined
2138
+ ? method === 'POST'
2139
+ ? 'media_folder.create'
2140
+ : null
2141
+ : segments[2] === 'move'
2142
+ ? method === 'POST'
2143
+ ? 'media_folder.move'
2144
+ : null
2145
+ : method === 'PATCH' || method === 'PUT'
2146
+ ? 'media_folder.update'
2147
+ : method === 'DELETE'
2148
+ ? 'media_folder.delete'
2149
+ : null;
2150
+ }
2151
+ else if (second === 'move') {
2152
+ id = first;
2153
+ action = method === 'POST' ? 'media.move' : null;
2154
+ }
2155
+ else if (first === '-' && second === 'bulk-move') {
2156
+ id = undefined;
2157
+ action = method === 'POST' ? 'media.bulk_move' : null;
2158
+ }
2159
+ else {
2160
+ id = first;
2161
+ action =
2162
+ method === 'POST'
2163
+ ? 'media.upload'
2164
+ : method === 'PATCH' || method === 'PUT'
2165
+ ? 'media.update'
2166
+ : method === 'DELETE'
2167
+ ? 'media.delete'
2168
+ : null;
2169
+ }
482
2170
  if (action === null)
483
2171
  return;
484
2172
  const entryId = id ?? responseId(response);
@@ -491,9 +2179,94 @@ async function recordMediaAudit(site, actor, method, pathname, response, logger)
491
2179
  })
492
2180
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
493
2181
  }
494
- async function recordAuthAudit(site, actor, method, pathname, response, logger) {
2182
+ /**
2183
+ * Fiche 63, ADR-0028 — "aucun changement de permission sans... entrée
2184
+ * d'audit systématique": every successful `PUT`/`DELETE` on
2185
+ * `/api/role-permissions` is journaled unconditionally, never gated on
2186
+ * whether the admin's confirmation dialog was shown (that lives entirely in
2187
+ * `packages/admin`; the server side of "systematic" is that a write cannot
2188
+ * land without also producing this entry).
2189
+ */
2190
+ async function recordRolePermissionAudit(site, actor, method, pathname, response, logger) {
495
2191
  if (response.status < 200 || response.status >= 300)
496
2192
  return;
2193
+ if (method === 'PUT') {
2194
+ const data = response.body?.data;
2195
+ await site.auth.audit
2196
+ .record({
2197
+ actorId: actor.id,
2198
+ actorRoles: actor.roles,
2199
+ action: 'role_permission.set',
2200
+ ...(data === undefined ? {} : { diff: data }),
2201
+ })
2202
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2203
+ return;
2204
+ }
2205
+ if (method === 'DELETE') {
2206
+ const [targetType, targetName, permAction] = pathname
2207
+ .replace(/^\/api\/role-permissions\/?/u, '')
2208
+ .split('/')
2209
+ .filter((segment) => segment.length > 0);
2210
+ await site.auth.audit
2211
+ .record({
2212
+ actorId: actor.id,
2213
+ actorRoles: actor.roles,
2214
+ action: 'role_permission.remove',
2215
+ ...(targetType === undefined || targetName === undefined || permAction === undefined
2216
+ ? {}
2217
+ : { diff: { targetType, targetName, action: permAction } }),
2218
+ })
2219
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2220
+ }
2221
+ }
2222
+ /**
2223
+ * One entry per successful `POST /api/import/wordpress` — who ran it and how
2224
+ * much it brought in, the same field the terminal command prints as
2225
+ * `formatConversionReport`'s opening line. Never the document itself: a WXR
2226
+ * export can carry a whole site's content, and the audit log is not a backup.
2227
+ */
2228
+ async function recordImportAudit(site, actor, method, pathname, response, logger) {
2229
+ if (method !== 'POST' || response.status < 200 || response.status >= 300)
2230
+ return;
2231
+ if (!pathname.startsWith('/api/import/'))
2232
+ return;
2233
+ const report = response.body?.data
2234
+ ?.imported;
2235
+ await site.auth.audit
2236
+ .record({
2237
+ actorId: actor.id,
2238
+ actorRoles: actor.roles,
2239
+ action: 'import.wordpress',
2240
+ ...(report === undefined ? {} : { diff: report }),
2241
+ })
2242
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2243
+ }
2244
+ async function recordAuthAudit(site, actor, method, pathname, response, logger, body) {
2245
+ if (response.status < 200 || response.status >= 300) {
2246
+ // A refused password attempt is the "who is trying to get in" signal a
2247
+ // security-conscious admin expects an audit log to carry (WordPress and
2248
+ // its security plugins log these too) — only the first step, though:
2249
+ // TOTP, recovery-code and passkey completion reuse the same
2250
+ // `AUTH_INVALID_CREDENTIALS`-family codes for a different meaning each
2251
+ // time, so recording those here under one generic action would misname
2252
+ // what actually failed.
2253
+ if (pathname.endsWith('/api/auth/login') && method === 'POST') {
2254
+ const email = body !== null &&
2255
+ typeof body === 'object' &&
2256
+ typeof body.email === 'string'
2257
+ ? body.email
2258
+ : null;
2259
+ await site.auth.audit
2260
+ .record({
2261
+ actorId: null,
2262
+ actorRoles: [],
2263
+ action: 'auth.login_failed',
2264
+ ...(email === null ? {} : { diff: { email } }),
2265
+ })
2266
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2267
+ }
2268
+ return;
2269
+ }
497
2270
  if (pathname.endsWith('/api/auth/session') && method === 'DELETE') {
498
2271
  await site.auth.audit
499
2272
  .record({ actorId: actor.id, actorRoles: actor.roles, action: 'auth.logout' })
@@ -510,63 +2283,188 @@ async function recordAuthAudit(site, actor, method, pathname, response, logger)
510
2283
  .user;
511
2284
  const userId = typeof user?.id === 'string' ? user.id : null;
512
2285
  const roles = Array.isArray(user?.roles) ? user.roles : [];
2286
+ // A recovery-code sign-in is its own, more specific event (fiche 18 task
2287
+ // 1): "a code was consumed" is worth remarking on in a way an ordinary
2288
+ // login is not — it is what `createRecoveryCodeUsedNoticeSource` looks
2289
+ // for — so it is recorded instead of the generic `auth.login`, not in
2290
+ // addition to it.
2291
+ const action = pathname.endsWith('/api/auth/recovery-code')
2292
+ ? 'auth.recovery_code_used'
2293
+ : 'auth.login';
513
2294
  await site.auth.audit
514
- .record({ actorId: userId, actorRoles: roles, action: 'auth.login' })
2295
+ .record({ actorId: userId, actorRoles: roles, action })
515
2296
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
516
2297
  }
517
2298
  /**
518
- * Account management, in the audit log.
519
- *
520
- * Who created an account, who changed a role, who disabled someone and who cut
521
- * a session short are exactly the events an append-only, hash-chained log
522
- * exists for and they were previously invisible, since the only way to do any
523
- * of it was a terminal.
524
- *
525
- * Recorded here, at the transport boundary, for the same reason the content and
526
- * media audits are: the router stays a pure request-in/response-out value, and
527
- * only a response that actually succeeded is written down.
2299
+ * Who minted, rotated, revoked, purged or recovered a machine credential, in
2300
+ * the same append-only log as every other account action (L13 task 8;
2301
+ * rotation added by fiche 20 task 2 "vérifier que c'est déjà le cas" for
2302
+ * create/revoke found it already was, so rotation is the one lifecycle event
2303
+ * that fiche actually added here; purge and recover added by fiche 62 tasks
2304
+ * 2-3). The raw key itself never reaches this function — a `POST`'s response
2305
+ * carries it once, but the audit entry only ever names the key's id, exactly
2306
+ * like `users-router.ts`'s own direct writes (T09-05) never log a password.
528
2307
  */
529
- async function recordUserAudit(site, actor, method, pathname, response, logger) {
2308
+ async function recordApiKeyAudit(site, actor, method, pathname, response, logger) {
530
2309
  if (response.status < 200 || response.status >= 300)
531
2310
  return;
532
2311
  const segments = pathname.split('/').filter((segment) => segment.length > 0);
533
- // ['api', 'users', <id?>, <'sessions' | 'password'>?, <sessionId?>]
2312
+ // ['api', 'api-keys', <id?>, <'rotate' | 'purge' | 'recover'?>]
534
2313
  const target = segments[2];
535
2314
  const sub = segments[3];
536
2315
  const action = method === 'POST' && target === undefined
537
- ? 'user.create'
538
- : method === 'PATCH' && target !== undefined && sub === undefined
539
- ? 'user.update'
540
- : method === 'POST' && sub === 'password'
541
- ? 'user.password_change'
542
- : method === 'DELETE' && sub === 'sessions'
543
- ? 'user.session_revoke'
544
- : null;
2316
+ ? 'apikey.create'
2317
+ : method === 'POST' && target !== undefined && sub === 'rotate'
2318
+ ? 'apikey.rotate'
2319
+ : method === 'POST' && target !== undefined && sub === 'recover'
2320
+ ? 'apikey.recover'
2321
+ : method === 'DELETE' && target !== undefined && sub === 'purge'
2322
+ ? 'apikey.purge'
2323
+ : method === 'DELETE' && target !== undefined && sub === undefined
2324
+ ? 'apikey.revoke'
2325
+ : null;
545
2326
  if (action === null)
546
2327
  return;
547
- // The subject is named, never anything that could sign anyone in: no
548
- // password, no token, not even the new roles' provenance beyond the id.
549
- const created = response.body?.data?.user;
550
- const subjectId = typeof created?.id === 'string' ? created.id : target === 'me' ? actor.id : (target ?? null);
2328
+ const body = response.body;
2329
+ const subjectId = action === 'apikey.rotate'
2330
+ ? typeof body?.data?.issued?.id === 'string'
2331
+ ? body.data.issued.id
2332
+ : null
2333
+ : typeof body?.data?.id === 'string'
2334
+ ? body.data.id
2335
+ : (target ?? null);
2336
+ // For a rotation or a recovery the diff names what was replaced — the id
2337
+ // alone, never any key material, the same restraint every other field in
2338
+ // this function already keeps.
2339
+ const diff = action === 'apikey.rotate' && target !== undefined
2340
+ ? { rotatedFrom: target }
2341
+ : action === 'apikey.recover' && target !== undefined
2342
+ ? { recoveredFrom: target }
2343
+ : undefined;
551
2344
  await site.auth.audit
552
2345
  .record({
553
2346
  actorId: actor.id,
554
2347
  actorRoles: actor.roles,
555
2348
  action,
556
2349
  ...(subjectId === null ? {} : { entryId: subjectId }),
2350
+ ...(diff === undefined ? {} : { diff }),
2351
+ })
2352
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2353
+ }
2354
+ /**
2355
+ * Fiche 21 task 2: "l'export d'un journal d'audit est lui-même un événement à
2356
+ * journaliser" — the log names emails and nominative actions, so pulling a
2357
+ * copy of it out is a personal-data export, and who did it is worth knowing.
2358
+ * The count is recorded, never the exported rows themselves: the audit log
2359
+ * is not where a second copy of everyone's activity belongs.
2360
+ */
2361
+ async function recordAuditExportAudit(site, actor, method, pathname, query, response, logger) {
2362
+ if (method !== 'GET' || response.status < 200 || response.status >= 300)
2363
+ return;
2364
+ if (!pathname.endsWith('/export'))
2365
+ return;
2366
+ const format = typeof query.format === 'string' ? query.format : 'json';
2367
+ const count = format === 'csv'
2368
+ ? typeof response.body === 'string'
2369
+ ? Math.max(response.body.trim().split(/\r\n/u).length - 1, 0) // minus the header row
2370
+ : 0
2371
+ : (response.body?.data?.length ?? 0);
2372
+ await site.auth.audit
2373
+ .record({
2374
+ actorId: actor.id,
2375
+ actorRoles: actor.roles,
2376
+ action: 'audit.export',
2377
+ diff: { format, count },
2378
+ })
2379
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2380
+ }
2381
+ /**
2382
+ * Every editorial setting change, in the same hash-chained log as every
2383
+ * other write (fiche 23 task 1: "toute écriture produit une entrée
2384
+ * d'audit"). Only `PATCH` writes anything down — `GET` is a plain read, the
2385
+ * same restraint `recordMediaAudit`/`recordApiKeyAudit` already apply.
2386
+ *
2387
+ * The written key and value are read back from the router's own response
2388
+ * body (`{ data: { key, value, … } }`) rather than the request body: it is
2389
+ * the value that actually landed, after the store's own validation, which
2390
+ * is the more honest thing for an audit entry to name.
2391
+ */
2392
+ async function recordSiteSettingsAudit(site, actor, method, response, logger) {
2393
+ if (method !== 'PATCH')
2394
+ return;
2395
+ if (response.status < 200 || response.status >= 300)
2396
+ return;
2397
+ const data = response.body?.data;
2398
+ const key = typeof data?.key === 'string' ? data.key : null;
2399
+ if (key === null)
2400
+ return;
2401
+ await site.auth.audit
2402
+ .record({
2403
+ actorId: actor.id,
2404
+ actorRoles: actor.roles,
2405
+ action: 'site_setting.update',
2406
+ diff: {
2407
+ key,
2408
+ value: data?.value ?? null,
2409
+ ...(data?.locale == null ? {} : { locale: data.locale }),
2410
+ },
2411
+ })
2412
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
2413
+ }
2414
+ async function recordAdminThemeAudit(site, actor, method, response, logger) {
2415
+ if (method !== 'PUT')
2416
+ return;
2417
+ if (response.status < 200 || response.status >= 300)
2418
+ return;
2419
+ const active = response.body?.data?.active;
2420
+ const templateId = typeof active?.templateId === 'string' ? active.templateId : null;
2421
+ if (templateId === null)
2422
+ return;
2423
+ await site.auth.audit
2424
+ .record({
2425
+ actorId: actor.id,
2426
+ actorRoles: actor.roles,
2427
+ action: 'admin_theme.update',
2428
+ diff: { templateId },
557
2429
  })
558
2430
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
559
2431
  }
560
2432
  function writeRestResponse(res, response) {
561
2433
  res.writeHead(response.status, response.headers);
2434
+ // A string body (the audit log's CSV export, fiche 21 task 2) is written
2435
+ // as-is: every other route's body is a plain object or `null`, and
2436
+ // `JSON.stringify`ing a string would wrap it in quotes and escape it,
2437
+ // corrupting the file a browser downloads.
562
2438
  res.end(response.body === null || response.body === undefined
563
2439
  ? undefined
564
- : JSON.stringify(response.body));
2440
+ : typeof response.body === 'string'
2441
+ ? response.body
2442
+ : JSON.stringify(response.body));
565
2443
  }
566
2444
  function jsonError(res, status, code, message) {
567
2445
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
568
2446
  res.end(JSON.stringify({ error: { code, message } }));
569
2447
  }
2448
+ /**
2449
+ * `Retry-After` plus the `RateLimit-*` draft headers (fiche 20 task 3), built
2450
+ * from the `{ limit, remaining, resetAt }` `resolveApiKeyActor` (`@cogenta/api`)
2451
+ * put on `API_KEY_RATE_LIMITED`'s `details`. `resetAt` is an absolute instant;
2452
+ * every header here wants a delta, computed once against the real clock so a
2453
+ * response that took a moment to reach this point still reports correctly.
2454
+ */
2455
+ function rateLimitHeaders(details) {
2456
+ const limit = typeof details?.limit === 'number' ? details.limit : undefined;
2457
+ const remaining = typeof details?.remaining === 'number' ? details.remaining : undefined;
2458
+ const resetAt = typeof details?.resetAt === 'number' ? details.resetAt : undefined;
2459
+ const retryAfterSeconds = resetAt === undefined ? 60 : Math.max(1, Math.ceil((resetAt - Date.now()) / 1000));
2460
+ return {
2461
+ 'content-type': 'application/json; charset=utf-8',
2462
+ 'retry-after': String(retryAfterSeconds),
2463
+ ...(limit === undefined ? {} : { 'ratelimit-limit': String(limit) }),
2464
+ ...(remaining === undefined ? {} : { 'ratelimit-remaining': String(remaining) }),
2465
+ 'ratelimit-reset': String(retryAfterSeconds),
2466
+ };
2467
+ }
570
2468
  /** Same authentication gate as every other `/api/media` route — the file itself is not public. */
571
2469
  async function serveMediaFile(site, actor, id, req, res) {
572
2470
  if (req.method !== 'GET') {
@@ -590,6 +2488,49 @@ async function serveMediaFile(site, actor, id, req, res) {
590
2488
  stream.on('error', () => res.destroy());
591
2489
  stream.pipe(res);
592
2490
  }
2491
+ /**
2492
+ * `GET /api/forms/submissions/export.csv` — fiche 47 task 9's server-streamed
2493
+ * export. Admin-only (the same role `forms-router.ts`'s own `requireAdmin`
2494
+ * checks for every other submissions route), handled directly for the same
2495
+ * reason `serveMediaFile` is: a streamed body has no shape `RestResponse`'s
2496
+ * JSON contract can carry. Rows are written to the response as
2497
+ * `streamSubmissionsCsv` (`@cogenta/api`) produces them — the whole export
2498
+ * is never held in memory at once, which is the point of this task over the
2499
+ * client-side, 200-row-capped export `form-submissions.tsx` already has.
2500
+ */
2501
+ async function serveFormsSubmissionsExport(site, actor, url, res) {
2502
+ if (!actor.roles.includes('admin')) {
2503
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may export submissions.');
2504
+ return;
2505
+ }
2506
+ const formId = url.searchParams.get('formId') ?? undefined;
2507
+ const status = url.searchParams.get('status') ?? undefined;
2508
+ const from = url.searchParams.get('from') ?? undefined;
2509
+ const to = url.searchParams.get('to') ?? undefined;
2510
+ const query = url.searchParams.get('q') ?? undefined;
2511
+ res.writeHead(200, {
2512
+ 'content-type': 'text/csv; charset=utf-8',
2513
+ 'content-disposition': 'attachment; filename="form-submissions.csv"',
2514
+ 'cache-control': 'no-store',
2515
+ });
2516
+ // UTF-8 BOM — same reason `admin/src/lib/csv.ts`'s `downloadCsv` prepends
2517
+ // one: without it, Excel guesses the wrong codepage for accented text.
2518
+ res.write('');
2519
+ try {
2520
+ for await (const chunk of streamSubmissionsCsv(site.formStore, {
2521
+ ...(formId === undefined ? {} : { formId }),
2522
+ ...(status === undefined ? {} : { status }),
2523
+ ...(from === undefined ? {} : { from }),
2524
+ ...(to === undefined ? {} : { to }),
2525
+ ...(query === undefined ? {} : { query }),
2526
+ })) {
2527
+ res.write(chunk);
2528
+ }
2529
+ }
2530
+ finally {
2531
+ res.end();
2532
+ }
2533
+ }
593
2534
  /**
594
2535
  * `GET /_image?id=…&w=…` — the public delivery endpoint for images.
595
2536
  *
@@ -684,6 +2625,14 @@ async function loadRenderMedia(site, ids) {
684
2625
  ...(asset.width === null ? {} : { width: asset.width }),
685
2626
  ...(asset.height === null ? {} : { height: asset.height }),
686
2627
  focal: asset.focal,
2628
+ // Fiche 05 task 2: the field `ImageSource`/`variantUrl` have carried
2629
+ // since `theme@1.2` (`packages/render/src/images/types.ts`'s own
2630
+ // `version` doc comment) but nobody ever set — a replaced original
2631
+ // kept serving under the exact same `/_image?id=…` query string a
2632
+ // year-long `immutable` cache had already stored. `contentHash`
2633
+ // changes on every `MediaStore.replace()`, so folding it in here is
2634
+ // what actually breaks that cache.
2635
+ version: asset.contentHash,
687
2636
  });
688
2637
  }
689
2638
  return found;
@@ -697,7 +2646,7 @@ async function loadRenderMedia(site, ids) {
697
2646
  * to that shape and back, so a serverless adapter later is the same kind of
698
2647
  * thin layer rather than a second implementation of any of it.
699
2648
  */
700
- export function createRequestListener(site, logger) {
2649
+ export function createRequestListener(site, logger, extras) {
701
2650
  return async (req, res) => {
702
2651
  const url = new URL(req.url ?? '/', 'http://localhost');
703
2652
  // Before anything else, and once: CORS, the security headers and the
@@ -709,8 +2658,52 @@ export function createRequestListener(site, logger) {
709
2658
  const actor = await resolveActor(site.auth, Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
710
2659
  key,
711
2660
  Array.isArray(value) ? value.join(', ') : value,
712
- ])));
713
- const context = { actor };
2661
+ ])), site.requestQuota === undefined ? {} : { requestQuota: site.requestQuota });
2662
+ // The `?preview=` token the admin's "Prévisualiser" button issues
2663
+ // (`POST /{collection}/{id}/preview`, `router.ts`) is only ever
2664
+ // consumed here for the public *page* route below — `createContentGateway`'s
2665
+ // own `list()` already has the preview overlay built in (it merges the
2666
+ // one granted entry into an otherwise published-only page), so folding
2667
+ // the grant into `context` here is the entire integration: nothing in
2668
+ // `theme-render.ts` needs to know a preview even happened. A missing or
2669
+ // invalid token is never a 500 — it just means this request proceeds as
2670
+ // an ordinary anonymous visitor, and an unpublished page 404s exactly
2671
+ // as it always did.
2672
+ const previewToken = url.searchParams.get('preview');
2673
+ let preview;
2674
+ if (previewToken !== null) {
2675
+ try {
2676
+ preview = createPreviewTokens().verify(previewToken);
2677
+ }
2678
+ catch (error) {
2679
+ logger.warn('preview token rejected', { error: String(error) });
2680
+ }
2681
+ }
2682
+ const context = { actor, ...(preview === undefined ? {} : { preview }) };
2683
+ // Maintenance mode (fiche 24 task 5): every visitor of the *public*
2684
+ // site gets a 503 while it is on — `/api/*` and `/admin*` stay
2685
+ // reachable so a signed-in admin can still turn it back off, and any
2686
+ // already-authenticated actor is let straight through (an editor
2687
+ // previewing the live site during a maintenance window is not "a
2688
+ // visitor"). Never cached: an intermediary that stored this 503 would
2689
+ // turn "maintenance is over" into "still down" for whoever it serves
2690
+ // next.
2691
+ if (extras !== undefined &&
2692
+ actor.id === null &&
2693
+ !url.pathname.startsWith('/api/') &&
2694
+ url.pathname !== '/admin' &&
2695
+ !url.pathname.startsWith('/admin/')) {
2696
+ const maintenance = await extras.maintenance.get();
2697
+ if (maintenance.enabled) {
2698
+ res.writeHead(503, {
2699
+ 'content-type': 'text/html; charset=utf-8',
2700
+ 'retry-after': '120',
2701
+ 'cache-control': 'no-store',
2702
+ });
2703
+ res.end(renderMaintenancePage(extras.siteName, maintenance.message));
2704
+ return;
2705
+ }
2706
+ }
714
2707
  // The admin SPA's own built shell — never permission-checked here: it
715
2708
  // is static HTML/JS, not data. Every real action it takes goes through
716
2709
  // the same `/api/*` routes below, which already enforce permissions on
@@ -723,7 +2716,10 @@ export function createRequestListener(site, logger) {
723
2716
  }
724
2717
  const asset = await serveAdminAsset(url.pathname);
725
2718
  if (asset !== null) {
726
- res.writeHead(200, { 'content-type': asset.contentType });
2719
+ res.writeHead(200, {
2720
+ 'content-type': asset.contentType,
2721
+ 'cache-control': asset.cacheControl,
2722
+ });
727
2723
  res.end(asset.body);
728
2724
  return;
729
2725
  }
@@ -741,11 +2737,18 @@ export function createRequestListener(site, logger) {
741
2737
  res.writeHead(405, { allow: 'GET' }).end();
742
2738
  return;
743
2739
  }
744
- if (site.styles === null) {
2740
+ // Recomputed on every request (`resolveStyles`, fiche 14) rather
2741
+ // than the fixed startup snapshot — this is the one route where
2742
+ // "a skin swap must show up on the next request" actually has to be
2743
+ // true, and the ETag below is what keeps that promise cheap: a
2744
+ // browser that already has the current bytes gets a 304, not a
2745
+ // re-download, even though the server recomputed to know that.
2746
+ const liveStyles = await site.resolveStyles();
2747
+ if (liveStyles === null) {
745
2748
  jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This site has no stylesheet.');
746
2749
  return;
747
2750
  }
748
- const etag = cssEtag(site.styles);
2751
+ const etag = cssEtag(liveStyles);
749
2752
  if (req.headers['if-none-match'] === etag) {
750
2753
  res.writeHead(304, { etag }).end();
751
2754
  return;
@@ -758,7 +2761,25 @@ export function createRequestListener(site, logger) {
758
2761
  // ETag makes that revalidation a 304 rather than a re-download.
759
2762
  'cache-control': 'public, max-age=0, must-revalidate',
760
2763
  });
761
- res.end(site.styles);
2764
+ res.end(liveStyles);
2765
+ return;
2766
+ }
2767
+ // Cogenta's own logo, served for the public footer's default branding
2768
+ // (fiche L21 task 8) — public and unauthenticated for the same reason
2769
+ // `/_image` is: a visitor's browser fetches it with no session. The
2770
+ // bytes are baked into this `@cogenta/cli` release, not a database
2771
+ // asset, so unlike the stylesheet above there is nothing to
2772
+ // recompute per request and the cache header can be truly immutable.
2773
+ if (url.pathname === DEFAULT_LOGO_PATH) {
2774
+ if (req.method !== 'GET') {
2775
+ res.writeHead(405, { allow: 'GET' }).end();
2776
+ return;
2777
+ }
2778
+ res.writeHead(200, {
2779
+ 'content-type': DEFAULT_LOGO_CONTENT_TYPE,
2780
+ 'cache-control': 'public, max-age=31536000, immutable',
2781
+ });
2782
+ res.end(Buffer.from(defaultLogoBytes()));
762
2783
  return;
763
2784
  }
764
2785
  if (url.pathname.startsWith('/api/auth/')) {
@@ -766,7 +2787,7 @@ export function createRequestListener(site, logger) {
766
2787
  const request = toRestRequest(req, url, body);
767
2788
  const response = await site.authRouter.handle(request);
768
2789
  writeRestResponse(res, response);
769
- await recordAuthAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
2790
+ await recordAuthAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger, body);
770
2791
  // A refused sign-in is the only clock a brute-force alert can honestly
771
2792
  // have here (L14 task 4) — see `security-alerts.ts` for why not a timer.
772
2793
  await site.securityAlerts?.observe(response.status);
@@ -799,103 +2820,715 @@ export function createRequestListener(site, logger) {
799
2820
  await serveMediaFile(site, actor, decodeURIComponent(fileMatch[1] ?? ''), req, res);
800
2821
  return;
801
2822
  }
2823
+ // Fiche 47 task 9 — same reasoning as `serveMediaFile`: a streamed CSV
2824
+ // body has no shape `RestResponse`'s JSON-only contract can carry, so
2825
+ // it is handled directly rather than through `formsRouter`.
2826
+ if (url.pathname === '/api/forms/submissions/export.csv') {
2827
+ if (req.method !== 'GET') {
2828
+ res.writeHead(405, { allow: 'GET' }).end();
2829
+ return;
2830
+ }
2831
+ await serveFormsSubmissionsExport(site, actor, url, res);
2832
+ return;
2833
+ }
802
2834
  if (url.pathname === '/api/graphql') {
803
2835
  if (req.method !== 'POST') {
804
2836
  res.writeHead(405, { allow: 'POST' }).end();
805
2837
  return;
806
2838
  }
807
- const body = (await readBody(req));
808
- const query = typeof body?.query === 'string' ? body.query : '';
809
- const result = await executeGraphQL({
810
- query,
811
- variables: typeof body?.variables === 'object' && body.variables !== null
812
- ? body.variables
813
- : undefined,
814
- operationName: typeof body?.operationName === 'string' ? body.operationName : undefined,
815
- }, { schema: site.graphqlSchema, gateway: site.gateway, access: context, logger });
816
- res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
817
- res.end(JSON.stringify(result));
818
- return;
819
- }
820
- if (url.pathname.startsWith('/api/content')) {
821
- const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
822
- const request = toRestRequest(req, url, body);
823
- const response = await site.restRouter.handle(request, context);
824
- writeRestResponse(res, response);
825
- await recordContentAudit(site, actor, req.method ?? 'GET', url.pathname, body, response, logger);
2839
+ const body = (await readBody(req));
2840
+ const query = typeof body?.query === 'string' ? body.query : '';
2841
+ const result = await executeGraphQL({
2842
+ query,
2843
+ variables: typeof body?.variables === 'object' && body.variables !== null
2844
+ ? body.variables
2845
+ : undefined,
2846
+ operationName: typeof body?.operationName === 'string' ? body.operationName : undefined,
2847
+ }, { schema: site.graphqlSchema, gateway: site.gateway, access: context, logger });
2848
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
2849
+ res.end(JSON.stringify(result));
2850
+ return;
2851
+ }
2852
+ if (url.pathname.startsWith('/api/content')) {
2853
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2854
+ const request = toRestRequest(req, url, body);
2855
+ const response = await site.restRouter.handle(request, context);
2856
+ writeRestResponse(res, response);
2857
+ await recordContentAudit(site, actor, req.method ?? 'GET', url.pathname, body, response, logger);
2858
+ await notifyIndexNowOnContentChange(site, actor, url.pathname, response, logger);
2859
+ return;
2860
+ }
2861
+ // Terms live apart from content on purpose: a taxonomy is not a
2862
+ // collection, and a site may legitimately name both the same thing
2863
+ // (ADR-0022). Its router owns its own permission door.
2864
+ if (url.pathname.startsWith('/api/taxonomies')) {
2865
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2866
+ const request = toRestRequest(req, url, body);
2867
+ writeRestResponse(res, await site.taxonomyRouter.handle(request, context));
2868
+ return;
2869
+ }
2870
+ if (url.pathname.startsWith('/api/marketplace')) {
2871
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2872
+ const request = toRestRequest(req, url, body);
2873
+ writeRestResponse(res, await site.marketplaceRouter.handle(request, context.actor));
2874
+ return;
2875
+ }
2876
+ // A menu is not schema-declared like a taxonomy, but it gets its own
2877
+ // mount for the same reason: it is not a collection, and its router owns
2878
+ // its own (fixed, not per-site-configurable) permission door.
2879
+ if (url.pathname.startsWith('/api/menus')) {
2880
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2881
+ const request = toRestRequest(req, url, body);
2882
+ writeRestResponse(res, await site.menuRouter.handle(request, context));
2883
+ return;
2884
+ }
2885
+ // The page builder's motif/model library (fiche 43 sub-chantier A):
2886
+ // also not schema-declared, also its own fixed door — admin/editor on
2887
+ // every method, the same reasoning `redirectRouter` below already
2888
+ // applies to a builder fixture that is never content a visitor reads.
2889
+ if (url.pathname.startsWith('/api/patterns')) {
2890
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2891
+ const request = toRestRequest(req, url, body);
2892
+ writeRestResponse(res, await site.patternRouter.handle(request, context));
2893
+ return;
2894
+ }
2895
+ // Contract E's own back office, gated by its own permission vocabulary
2896
+ // (`commerce.*`, ADR-0024) — never contract A's five actions, which do
2897
+ // not stretch to "refund" or "issue an invoice".
2898
+ if (url.pathname.startsWith('/api/commerce')) {
2899
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2900
+ const request = toCommerceRequest(req, url, body);
2901
+ const response = await site.commerceRouter.handle(request, context.actor);
2902
+ // Two routes whose body is not JSON: an invoice PDF (bytes) and the
2903
+ // accounting CSV export (fiche 52 task 7, plain text). Checked by
2904
+ // shape, not by path — the router already decided what to send, this
2905
+ // layer only has to notice how.
2906
+ if (response.body instanceof Uint8Array) {
2907
+ res.writeHead(response.status, { 'content-type': 'application/pdf' });
2908
+ res.end(Buffer.from(response.body));
2909
+ return;
2910
+ }
2911
+ if (typeof response.body === 'string') {
2912
+ res.writeHead(response.status, {
2913
+ 'content-type': 'text/csv; charset=utf-8',
2914
+ 'content-disposition': 'attachment; filename="orders.csv"',
2915
+ });
2916
+ res.end(response.body);
2917
+ return;
2918
+ }
2919
+ res.writeHead(response.status, { 'content-type': 'application/json; charset=utf-8' });
2920
+ res.end(response.body === null ? undefined : JSON.stringify(response.body));
2921
+ return;
2922
+ }
2923
+ // Contract F (ADR-0025): the moderation queue AND
2924
+ // `POST /api/comments`, the CMS's first public write route. The
2925
+ // router itself decides whether an actor is needed at all — an
2926
+ // anonymous `context.actor` reaches the public POST branch exactly the
2927
+ // way `resolveActor` already resolves it for any unauthenticated
2928
+ // request, so nothing special happens here beyond routing the request.
2929
+ if (url.pathname.startsWith('/api/comments')) {
2930
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2931
+ const request = toCommentsRequest(req, url, body);
2932
+ const response = await site.commentsRouter.handle(request, context.actor);
2933
+ // The one shape besides JSON this router ever answers with: a 303
2934
+ // redirect back to the page a no-JS `<form>` posted from
2935
+ // (`response.headers.location`, set only when the submission carried
2936
+ // `redirectTo`). No body follows a redirect.
2937
+ if (response.headers?.location !== undefined) {
2938
+ res.writeHead(response.status, response.headers);
2939
+ res.end();
2940
+ return;
2941
+ }
2942
+ res.writeHead(response.status, { 'content-type': 'application/json; charset=utf-8' });
2943
+ res.end(response.body === null || response.body === undefined
2944
+ ? undefined
2945
+ : JSON.stringify(response.body));
2946
+ return;
2947
+ }
2948
+ if (url.pathname.startsWith('/api/media')) {
2949
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2950
+ const request = toRestRequest(req, url, body);
2951
+ const response = await site.mediaRouter.handle(request, context.actor);
2952
+ writeRestResponse(res, response);
2953
+ await recordMediaAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
2954
+ return;
2955
+ }
2956
+ // The admin screen the redirect table never had: creating, editing and
2957
+ // removing a rule from a browser instead of the database directly
2958
+ // (audit follow-up to L10 task 2), extended by fiche 12 with prefix
2959
+ // patterns and CSV import/export under the same prefix — all
2960
+ // admin-only, checked by the router itself.
2961
+ if (url.pathname.startsWith('/api/redirects')) {
2962
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2963
+ const request = toRestRequest(req, url, body);
2964
+ writeRestResponse(res, await site.redirectRouter.handle(request, context));
2965
+ return;
2966
+ }
2967
+ // Fiche 63, ADR-0028: a role's grant on a collection or taxonomy
2968
+ // action, writable in production without a deploy cycle. Admin-only,
2969
+ // checked by the router itself; a successful write is journaled
2970
+ // unconditionally (`recordRolePermissionAudit`), the server half of
2971
+ // "aucun changement de permission sans... entrée d'audit systématique".
2972
+ if (url.pathname.startsWith('/api/role-permissions')) {
2973
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
2974
+ const request = toRestRequest(req, url, body);
2975
+ const response = await site.rolePermissionRouter.handle(request, context);
2976
+ writeRestResponse(res, response);
2977
+ await recordRolePermissionAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
2978
+ return;
2979
+ }
2980
+ // Contract G (ADR-0026, fiche 16): form definitions/submissions
2981
+ // (admin-only) and the public `POST .../submit` this same mount also
2982
+ // serves — the router itself decides which is which (see
2983
+ // `forms-router.ts`'s own comment). The submit route is the CMS's
2984
+ // second public write route, and it must work with a plain HTML
2985
+ // `<form>` and no JavaScript at all (fiche 16 task 3) — that is the one
2986
+ // path handled specially below, everything else on this mount is a
2987
+ // normal JSON admin route.
2988
+ if (url.pathname.startsWith('/api/forms')) {
2989
+ const submitMatch = /^\/api\/forms\/([^/]+)\/submit$/u.exec(url.pathname);
2990
+ const submitContentType = req.headers['content-type'] ?? '';
2991
+ // Fiche 47 task 3: a step (or a step containing a `file` field)
2992
+ // arrives as `multipart/form-data`, not `application/x-www-form-urlencoded`
2993
+ // — both are a plain no-JS `<form method="post">`, never a JSON API
2994
+ // client, so both get the HTML treatment below.
2995
+ const isHtmlSubmit = submitMatch !== null &&
2996
+ req.method === 'POST' &&
2997
+ (submitContentType.includes('application/x-www-form-urlencoded') ||
2998
+ submitContentType.includes('multipart/form-data'));
2999
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3000
+ const request = toRestRequest(req, url, body);
3001
+ const formsContext = { actor: context.actor, ip: clientIpOf(req) };
3002
+ const response = await site.formsRouter.handle(request, formsContext);
3003
+ if (isHtmlSubmit) {
3004
+ const formName = submitMatch[1];
3005
+ const postedFields = isMultipartFormData(body)
3006
+ ? { ...body.fields }
3007
+ : typeof body === 'object' && body !== null
3008
+ ? body
3009
+ : {};
3010
+ if (response.status === 201) {
3011
+ const data = response.body.data;
3012
+ const location = data.redirectTo ?? `/forms/${encodeURIComponent(formName)}?submitted=1`;
3013
+ res.writeHead(303, { location, 'cache-control': 'no-store' });
3014
+ res.end();
3015
+ return;
3016
+ }
3017
+ if (response.status === 202) {
3018
+ // Fiche 47 task 2 — an intermediate multi-step page: render the
3019
+ // next step directly in this same response, no redirect. The
3020
+ // definition is already known to exist (the router only answers
3021
+ // 202 after finding it), so this never has to handle "not found"
3022
+ // here.
3023
+ const data = response.body.data;
3024
+ const definition = await site.formStore.definitions.readByName(formName);
3025
+ const formPageOptions = {
3026
+ site: site.site,
3027
+ styles: await site.resolveStyles(),
3028
+ now: Date.now,
3029
+ menus: { menuRouter: site.menuRouter },
3030
+ branding: () => brandingForSite(site),
3031
+ activeTheme: () => activeThemeForSite(site),
3032
+ identity: () => identityForSite(site),
3033
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3034
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3035
+ };
3036
+ const html = definition === null
3037
+ ? await renderFormNotFoundPage(formPageOptions, context)
3038
+ : await renderFormPage(definition, { step: data.nextStep, accumulated: data.values, ts: data.ts }, formPageOptions, context);
3039
+ res.writeHead(definition === null ? 404 : 200, {
3040
+ 'content-type': 'text/html; charset=utf-8',
3041
+ 'cache-control': 'no-store',
3042
+ });
3043
+ res.end(html);
3044
+ return;
3045
+ }
3046
+ const definition = await site.formStore.definitions.readByName(formName);
3047
+ const errorBody = response.body;
3048
+ const formPageOptions = {
3049
+ site: site.site,
3050
+ styles: await site.resolveStyles(),
3051
+ now: Date.now,
3052
+ menus: { menuRouter: site.menuRouter },
3053
+ branding: () => brandingForSite(site),
3054
+ activeTheme: () => activeThemeForSite(site),
3055
+ identity: () => identityForSite(site),
3056
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3057
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3058
+ };
3059
+ // A failure on a multi-step form only ever comes from the final
3060
+ // step's real validation (an intermediate step never calls it —
3061
+ // see `forms-router.ts`'s own comment), so redisplaying "the last
3062
+ // step, with everything posted" is always the right page.
3063
+ const stepsCount = definition?.steps.length ?? 0;
3064
+ const accumulatedFromBody = (() => {
3065
+ const raw = postedFields['_accumulated'];
3066
+ if (typeof raw !== 'string' || raw.trim() === '')
3067
+ return {};
3068
+ try {
3069
+ const parsed = JSON.parse(raw);
3070
+ return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
3071
+ ? parsed
3072
+ : {};
3073
+ }
3074
+ catch {
3075
+ return {};
3076
+ }
3077
+ })();
3078
+ const html = definition === null
3079
+ ? await renderFormNotFoundPage(formPageOptions, context)
3080
+ : await renderFormPage(definition, {
3081
+ errorMessage: errorBody.error?.message ?? 'This submission could not be accepted.',
3082
+ errorField: errorBody.error?.field ?? null,
3083
+ values: { ...accumulatedFromBody, ...postedFields },
3084
+ ...(stepsCount > 1
3085
+ ? {
3086
+ step: stepsCount - 1,
3087
+ accumulated: accumulatedFromBody,
3088
+ ...(typeof postedFields['_ts'] === 'string'
3089
+ ? { ts: postedFields['_ts'] }
3090
+ : {}),
3091
+ }
3092
+ : {}),
3093
+ }, formPageOptions, context);
3094
+ res.writeHead(definition === null ? 404 : response.status, {
3095
+ 'content-type': 'text/html; charset=utf-8',
3096
+ 'cache-control': 'no-store',
3097
+ });
3098
+ res.end(html);
3099
+ return;
3100
+ }
3101
+ writeRestResponse(res, response);
3102
+ return;
3103
+ }
3104
+ // The 404 log's own admin screen (fiche 12 task 1) — read and dismiss
3105
+ // only; the log fills itself from the public GET path below.
3106
+ if (url.pathname === '/api/not-found') {
3107
+ const body = req.method === 'DELETE' || req.method === 'GET' ? undefined : await readBody(req);
3108
+ const request = toRestRequest(req, url, body);
3109
+ writeRestResponse(res, await site.notFoundRouter.handle(request, context));
3110
+ return;
3111
+ }
3112
+ // Read-only mirrors of `security`/`webhooks`/`config` from the config
3113
+ // file (audit follow-up to L10 task 6 / L14 task 1; `config-status`
3114
+ // added by fiche 23 task 5), plus the trash auto-purge's live sweep
3115
+ // state (fiche 07 task 5) — see `ops-status-router.ts` for why editing
3116
+ // them here would be the wrong architecture.
3117
+ if (url.pathname === '/api/security-status' ||
3118
+ url.pathname === '/api/webhooks-status' ||
3119
+ url.pathname === '/api/trash-status' ||
3120
+ url.pathname === '/api/config-status') {
3121
+ const request = toRestRequest(req, url, undefined);
3122
+ writeRestResponse(res, await site.opsStatusRouter.handle(request, context));
3123
+ return;
3124
+ }
3125
+ // The editorial site settings (fiche 23, ADR-0025): read is public —
3126
+ // the theme's own homepage/tagline render must answer the same thing
3127
+ // to an anonymous visitor as `GET /api/settings` does — write is
3128
+ // admin-only, checked per setting by the router itself.
3129
+ if (url.pathname === '/api/settings') {
3130
+ const body = req.method === 'PATCH' ? await readBody(req) : undefined;
3131
+ const request = toRestRequest(req, url, body);
3132
+ const response = await site.siteSettingsRouter.handle(request, context);
3133
+ writeRestResponse(res, response);
3134
+ await recordSiteSettingsAudit(site, actor, req.method ?? 'GET', response, logger);
3135
+ return;
3136
+ }
3137
+ // The admin's own runtime template + personalisation (L21 task 2) —
3138
+ // distinct from `/api/theme` (contract D, the public site's own
3139
+ // theming, `themeRouter` above): read is public (the login screen
3140
+ // paints in the chosen template before a session exists), write is
3141
+ // admin-only, checked by the router itself.
3142
+ if (url.pathname === '/api/admin-theme') {
3143
+ const body = req.method === 'PUT' ? await readBody(req) : undefined;
3144
+ const request = toRestRequest(req, url, body);
3145
+ const response = await site.adminThemeRouter.handle(request, context);
3146
+ writeRestResponse(res, response);
3147
+ await recordAdminThemeAudit(site, actor, req.method ?? 'GET', response, logger);
3148
+ return;
3149
+ }
3150
+ // The "Santé" screen (fiche 24 tasks 1, 2, 4): the same `runDoctor`
3151
+ // `cogenta doctor` calls, migrations, audit integrity and the bounded
3152
+ // server error log — all read-only, admin-only, all injected rather
3153
+ // than recomputed here.
3154
+ if (extras !== undefined &&
3155
+ (url.pathname === '/api/health-report' ||
3156
+ url.pathname === '/api/migrations-status' ||
3157
+ url.pathname === '/api/migrations-apply' ||
3158
+ url.pathname === '/api/audit-integrity' ||
3159
+ url.pathname === '/api/disk-usage' ||
3160
+ url.pathname === '/api/error-log' ||
3161
+ url.pathname === '/api/maintenance')) {
3162
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
3163
+ const request = toRestRequest(req, url, body);
3164
+ writeRestResponse(res, await extras.healthRouter.handle(request, context));
3165
+ return;
3166
+ }
3167
+ // The "Exploitation" > Observability screen (fiche L22 task 5):
3168
+ // recent request traces and structured-log lines this process has
3169
+ // captured locally, admin-only, read-only.
3170
+ if (extras?.observabilityRouter !== undefined && url.pathname === '/api/observability') {
3171
+ const request = toRestRequest(req, url, undefined);
3172
+ writeRestResponse(res, await extras.observabilityRouter.handle(request, context));
3173
+ return;
3174
+ }
3175
+ // The "Outils" screen (fiche 24 task 3): purge caches, reindex,
3176
+ // regenerate image variants, check links, test email, purge expired
3177
+ // trash — every one of them queued, never run inline in this request.
3178
+ if (extras !== undefined && url.pathname.startsWith('/api/tools')) {
3179
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
3180
+ const request = toRestRequest(req, url, body);
3181
+ writeRestResponse(res, await extras.toolsRouter.handle(request, context));
3182
+ return;
3183
+ }
3184
+ // The "Tâches planifiées" screen (fiche 28 task 2, L20 audit §1 point
3185
+ // 6). Same shape as the two routes above: admin-only, a thin
3186
+ // read-through the router itself enforces.
3187
+ if (extras?.scheduledTasksRouter !== undefined &&
3188
+ url.pathname.startsWith('/api/scheduled-tasks')) {
3189
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
3190
+ const request = toRestRequest(req, url, body);
3191
+ writeRestResponse(res, await extras.scheduledTasksRouter.handle(request, context));
3192
+ return;
3193
+ }
3194
+ // The full-text index, reachable at last (L10 task 3). Its own router
3195
+ // decides which collections this actor may search — never this layer.
3196
+ if (url.pathname === '/api/search') {
3197
+ const request = toRestRequest(req, url, undefined);
3198
+ writeRestResponse(res, await site.searchRouter.handle(request, context));
3199
+ return;
3200
+ }
3201
+ // The Search Console connector (fiche 70 task 4, ADR-0032) — checked
3202
+ // *before* the generic `/api/seo` prefix below, which would otherwise
3203
+ // swallow it (`startsWith('/api/seo')` matches this path too). Its
3204
+ // `callback` route is the one place on this whole server that a
3205
+ // request with no `Authorization` header is expected and correct —
3206
+ // see the router's own module comment.
3207
+ if (url.pathname.startsWith('/api/seo/search-console')) {
3208
+ const request = toRestRequest(req, url, undefined);
3209
+ writeRestResponse(res, await site.searchConsoleRouter.handle(request, context));
3210
+ return;
3211
+ }
3212
+ // Fiche 13's admin-only door onto `@cogenta/seo`: a live preview of one
3213
+ // edit in progress, and the site-wide diagnostic. Both permission
3214
+ // checks live in the router itself, exactly like `/api/redirects`.
3215
+ if (url.pathname.startsWith('/api/seo')) {
3216
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
3217
+ const request = toRestRequest(req, url, body);
3218
+ writeRestResponse(res, await site.seoRouter.handle(request, context));
3219
+ return;
3220
+ }
3221
+ // The review queue (`schema@2.1`, ADR-0027, fiche 37 task 3) — its own
3222
+ // router, same reasoning as search: it decides which collections are
3223
+ // in scope for this actor and this tab, never this layer.
3224
+ if (url.pathname === '/api/review') {
3225
+ const request = toRestRequest(req, url, undefined);
3226
+ writeRestResponse(res, await site.reviewRouter.handle(request, context));
3227
+ return;
3228
+ }
3229
+ // `/api/analytics/beacon` (public) and `/api/analytics/summary`
3230
+ // (admin-only) — see `@cogenta/analytics` and `analytics-router.ts` for
3231
+ // why both live behind one router with opposite trust models.
3232
+ if (url.pathname.startsWith('/api/analytics')) {
3233
+ const request = toRestRequest(req, url, undefined);
3234
+ writeRestResponse(res, await site.analyticsRouter.handle(request, { actor: context.actor, ip: clientIpOf(req) }));
3235
+ return;
3236
+ }
3237
+ if (url.pathname.startsWith('/api/audit')) {
3238
+ const request = toRestRequest(req, url, undefined);
3239
+ const response = await site.auditRouter.handle(request, context.actor);
3240
+ writeRestResponse(res, response);
3241
+ await recordAuditExportAudit(site, context.actor, req.method ?? 'GET', url.pathname, request.query, response, logger);
3242
+ return;
3243
+ }
3244
+ // The admin chrome's one aggregated read (fiche 35 task 3) — badges
3245
+ // and feature flags in a single round trip, never one request per
3246
+ // nav entry.
3247
+ if (url.pathname === '/api/shell-status') {
3248
+ const request = toRestRequest(req, url, undefined);
3249
+ writeRestResponse(res, await site.shellStatusRouter.handle(request, context));
3250
+ return;
3251
+ }
3252
+ if (url.pathname.startsWith('/api/notices/channels')) {
3253
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3254
+ const request = toRestRequest(req, url, body);
3255
+ writeRestResponse(res, await site.noticeChannelSettingsRouter.handle(request, context.actor));
3256
+ return;
3257
+ }
3258
+ if (url.pathname.startsWith('/api/notices')) {
3259
+ // GET has no body; `POST .../{id}/dismiss` never reads one either,
3260
+ // but `POST /api/notices/read` (fiche 38 task 2) does — same split
3261
+ // as `/api/users` below, not a GET-only assumption any more.
3262
+ const body = req.method === 'GET' ? undefined : await readBody(req);
3263
+ const request = toRestRequest(req, url, body);
3264
+ writeRestResponse(res, await site.noticeRouter.handle(request, context.actor));
3265
+ return;
3266
+ }
3267
+ if (url.pathname.startsWith('/api/users')) {
3268
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3269
+ const request = toRestRequest(req, url, body);
3270
+ const response = await site.usersRouter.handle(request, context.actor);
3271
+ writeRestResponse(res, response);
3272
+ // T09-05: account creation, password change and session revoke are
3273
+ // now recorded directly by `users-router.ts` itself, at the exact
3274
+ // point each mutates a row — `recordUserAudit`'s HTTP-path sniffing
3275
+ // is gone rather than kept as a redundant second writer.
3276
+ return;
3277
+ }
3278
+ // Machine-to-machine bearer credentials, admin-only (L13 task 8).
3279
+ if (url.pathname.startsWith('/api/api-keys')) {
3280
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3281
+ const request = toRestRequest(req, url, body);
3282
+ const response = await site.apiKeysRouter.handle(request, context.actor);
3283
+ writeRestResponse(res, response);
3284
+ await recordApiKeyAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
3285
+ return;
3286
+ }
3287
+ if (url.pathname.startsWith('/api/site-plans') && site.sitePlanRouter !== undefined) {
3288
+ // `SitePlanRouter` itself refuses every route to a non-admin actor,
3289
+ // but only after `readBody` has already buffered the whole request —
3290
+ // and this route, alone among this server's routes, invites
3291
+ // multi-megabyte bodies by design (uploaded documents). Checking the
3292
+ // role here, before the body is read at all, means an unauthenticated
3293
+ // or non-admin caller is turned away without the server ever reading
3294
+ // what they sent.
3295
+ if (!context.actor.roles.includes('admin')) {
3296
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may propose or apply a site plan.');
3297
+ return;
3298
+ }
3299
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3300
+ const request = toRestRequest(req, url, body);
3301
+ writeRestResponse(res, await site.sitePlanRouter.handle(request, context.actor));
3302
+ return;
3303
+ }
3304
+ // `/api/updates` — L22 task 9. Admin-only, same early check as
3305
+ // `/api/site-plans` above, before the body is read.
3306
+ if (url.pathname.startsWith('/api/updates')) {
3307
+ if (!context.actor.roles.includes('admin')) {
3308
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may check for or apply an update.');
3309
+ return;
3310
+ }
3311
+ const body = req.method === 'GET' ? undefined : await readBody(req);
3312
+ const request = toRestRequest(req, url, body);
3313
+ writeRestResponse(res, await site.updatesRouter.handle(request, context.actor));
3314
+ return;
3315
+ }
3316
+ // `/api/theme/preview` (fiche 14 task 2) — a candidate token/CSS
3317
+ // overlay, rendered on the real home page without saving it. Checked
3318
+ // before the generic `/api/theme` mount below since this is not a
3319
+ // `ThemeRouter` route: it needs `renderRequestedPage`, which that
3320
+ // router structurally cannot reach (same reason `/api/builder/render`
3321
+ // lives here rather than inside a router package).
3322
+ if (url.pathname === '/api/theme/preview') {
3323
+ if (req.method !== 'POST') {
3324
+ res.writeHead(405, { allow: 'POST' }).end();
3325
+ return;
3326
+ }
3327
+ if (!context.actor.roles.includes('admin')) {
3328
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may preview a theme change.');
3329
+ return;
3330
+ }
3331
+ if (site.previewStyles === undefined) {
3332
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This instance has no theme preview.');
3333
+ return;
3334
+ }
3335
+ const body = (await readBody(req));
3336
+ const pathname = typeof body?.pathname === 'string' && body.pathname.startsWith('/') ? body.pathname : '/';
3337
+ let previewStyles;
3338
+ try {
3339
+ previewStyles = await site.previewStyles({
3340
+ ...(typeof body?.tokens === 'object' && body.tokens !== null
3341
+ ? { tokens: body.tokens }
3342
+ : {}),
3343
+ ...(typeof body?.additionalCss === 'string'
3344
+ ? { additionalCss: body.additionalCss }
3345
+ : {}),
3346
+ });
3347
+ }
3348
+ catch (error) {
3349
+ writeRestResponse(res, errorResponse(error));
3350
+ return;
3351
+ }
3352
+ const html = await renderRequestedPage(pathname, {
3353
+ collections: site.collections,
3354
+ gateway: site.gateway,
3355
+ site: site.site,
3356
+ styles: previewStyles,
3357
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3358
+ // A preview is never a real visit, and never cacheable.
3359
+ analyticsBeacon: {},
3360
+ menuRouter: site.menuRouter,
3361
+ homePath: async () => {
3362
+ const setting = await site.siteSettingsStore.get('reading.homePath', SITE_SETTINGS_SITE_SCOPE);
3363
+ return typeof setting?.value === 'string' ? setting.value : null;
3364
+ },
3365
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3366
+ identity: () => identityForSite(site),
3367
+ chromeExtras: (locale) => chromeExtrasForSite(site, locale),
3368
+ authorFor: (userId) => authorForSite(site, userId),
3369
+ resolveTerm: site.resolveTaxonomyTerm,
3370
+ }, context);
3371
+ if (html === null) {
3372
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No page exists at this path to preview against.');
3373
+ return;
3374
+ }
3375
+ // `renderRequestedPage`'s own `styles` option only decides whether
3376
+ // the `<link rel="stylesheet">` tag is emitted — the browser then
3377
+ // fetches whatever `/_cogenta/styles.css` currently serves, which is
3378
+ // the *saved* overrides, never an unsaved candidate. An inline
3379
+ // `<style>` right before `</head>` overrides those custom
3380
+ // properties by cascade order (same `:root` specificity, later
3381
+ // wins) — the one place in this file a `<style>` tag is correct
3382
+ // rather than a CSP violation: this response is JSON, consumed by
3383
+ // the appearance screen's own iframe (`srcDoc`, not a served
3384
+ // document on the site's own origin), the same trust boundary
3385
+ // `PreviewFrame` already relies on for the page builder.
3386
+ const withPreviewCss = previewStyles === null
3387
+ ? html
3388
+ : html.replace('</head>', `<style>${previewStyles}</style></head>`);
3389
+ res.writeHead(200, {
3390
+ 'content-type': 'application/json; charset=utf-8',
3391
+ 'cache-control': 'no-store',
3392
+ });
3393
+ res.end(JSON.stringify({ data: { html: withPreviewCss } }));
3394
+ return;
3395
+ }
3396
+ // `/api/theme/gallery-preview` (fiche L24 task 5) — the appearance
3397
+ // screen's visual preview of a candidate theme *package*, distinct
3398
+ // from `/api/theme/preview` above (which previews a colour/token
3399
+ // candidate on the site's own real home page, in the currently active
3400
+ // theme). Checked before the generic `/api/theme` mount below for the
3401
+ // same structural reason `/api/theme/preview` is: it needs
3402
+ // `renderThemeGalleryPreview`, which that router cannot reach.
3403
+ //
3404
+ // Same principle as the visual page builder (L16) and the token
3405
+ // preview above — an iframe on a real server render, never a static
3406
+ // screenshot or a second React reimplementation of the twelve blocks.
3407
+ // What differs here is the entry: there is no real page to show yet on
3408
+ // a site with no content, so this renders one fixed, database-free
3409
+ // demo page identically across every theme asked for — see
3410
+ // `renderThemeGalleryPreview`'s own comment for why fixed content, the
3411
+ // same across every card, is the fairer comparison. `site.gateway` is
3412
+ // never touched, so this cannot leak a draft or private entry.
3413
+ //
3414
+ // `tokens` (L26 task 5) previews an unsaved candidate skin — from the
3415
+ // Theme Creator workshop — against a theme this site is not currently
3416
+ // running, the one combination `/api/theme/preview` above cannot
3417
+ // express (it only ever renders the active theme). Omitted, this
3418
+ // route behaves exactly as before: the named theme's own default skin.
3419
+ if (url.pathname === '/api/theme/gallery-preview') {
3420
+ if (req.method !== 'POST') {
3421
+ res.writeHead(405, { allow: 'POST' }).end();
3422
+ return;
3423
+ }
3424
+ if (!context.actor.roles.includes('admin')) {
3425
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may preview a theme.');
3426
+ return;
3427
+ }
3428
+ if (site.themeGalleryStyles === undefined) {
3429
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This instance has no theme gallery preview.');
3430
+ return;
3431
+ }
3432
+ const body = (await readBody(req));
3433
+ const themeName = typeof body?.theme === 'string' ? body.theme : '';
3434
+ if (!(await availableThemes()).some((candidate) => candidate.name === themeName)) {
3435
+ jsonError(res, 404, 'THEME_NOT_FOUND', `No theme named "${themeName}" is available on this instance.`);
3436
+ return;
3437
+ }
3438
+ const previewTokens = typeof body?.tokens === 'object' && body.tokens !== null
3439
+ ? body.tokens
3440
+ : undefined;
3441
+ let styles;
3442
+ try {
3443
+ styles = await site.themeGalleryStyles(themeName, previewTokens);
3444
+ }
3445
+ catch (error) {
3446
+ writeRestResponse(res, errorResponse(error));
3447
+ return;
3448
+ }
3449
+ const html = await renderThemeGalleryPreview(themeName, {
3450
+ site: site.site,
3451
+ styles,
3452
+ branding: () => brandingForSite(site),
3453
+ });
3454
+ res.writeHead(200, {
3455
+ 'content-type': 'application/json; charset=utf-8',
3456
+ 'cache-control': 'no-store',
3457
+ });
3458
+ res.end(JSON.stringify({ data: { html } }));
826
3459
  return;
827
3460
  }
828
- // Terms live apart from content on purpose: a taxonomy is not a
829
- // collection, and a site may legitimately name both the same thing
830
- // (ADR-0022). Its router owns its own permission door.
831
- if (url.pathname.startsWith('/api/taxonomies')) {
3461
+ // `/api/theme` (fiche 14) the appearance screen. Admin only, every
3462
+ // route; `ThemeRouter` itself refuses a non-admin, checked again here
3463
+ // is unnecessary since nothing below reads the body unbounded the way
3464
+ // `/api/site-plans` does. Absent only when this instance built no
3465
+ // theme wiring (never true for a real `cogenta serve`/`cogenta dev`).
3466
+ if (url.pathname.startsWith('/api/theme') && site.themeRouter !== undefined) {
832
3467
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
833
3468
  const request = toRestRequest(req, url, body);
834
- writeRestResponse(res, await site.taxonomyRouter.handle(request, context));
3469
+ writeRestResponse(res, await site.themeRouter.handle(request, context.actor));
835
3470
  return;
836
3471
  }
837
- if (url.pathname.startsWith('/api/media')) {
838
- const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3472
+ // The admin's WordPress importer. Same defensive order as
3473
+ // `/api/site-plans` just above and for the same reason: this route
3474
+ // invites a multi-megabyte upload by design, so the role is checked
3475
+ // before `readBody` buffers anything at all.
3476
+ if (url.pathname.startsWith('/api/import')) {
3477
+ if (!context.actor.roles.includes('admin')) {
3478
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may import content.');
3479
+ return;
3480
+ }
3481
+ const body = req.method === 'GET' ? undefined : await readBody(req);
839
3482
  const request = toRestRequest(req, url, body);
840
- const response = await site.mediaRouter.handle(request, context.actor);
3483
+ const response = await site.importRouter.handle(request, context.actor);
841
3484
  writeRestResponse(res, response);
842
- await recordMediaAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
843
- return;
844
- }
845
- // The full-text index, reachable at last (L10 task 3). Its own router
846
- // decides which collections this actor may search — never this layer.
847
- if (url.pathname === '/api/search') {
848
- const request = toRestRequest(req, url, undefined);
849
- writeRestResponse(res, await site.searchRouter.handle(request, context));
3485
+ await recordImportAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
850
3486
  return;
851
3487
  }
852
- if (url.pathname.startsWith('/api/audit')) {
853
- const request = toRestRequest(req, url, undefined);
854
- writeRestResponse(res, await site.auditRouter.handle(request, context.actor));
3488
+ // Always mounted, on every site (L18). On one with no AI provider it is
3489
+ // the route that answers `{available: false}`, which is precisely what
3490
+ // lets the admin panel disappear instead of failing.
3491
+ if (url.pathname.startsWith('/api/assistant')) {
3492
+ const body = req.method === 'GET' ? undefined : await readBody(req);
3493
+ const request = toRestRequest(req, url, body);
3494
+ writeRestResponse(res, await site.assistantRouter.handle(request, context));
855
3495
  return;
856
3496
  }
857
- if (url.pathname.startsWith('/api/notices')) {
858
- const request = toRestRequest(req, url, undefined);
859
- writeRestResponse(res, await site.noticeRouter.handle(request, context.actor));
3497
+ if (url.pathname.startsWith('/api/agents') && site.agentsRouter !== undefined) {
3498
+ // L22 task 1: create/update/run all carry a JSON body — the pre-L22
3499
+ // enable/disable-only router never needed one, this one does.
3500
+ const body = req.method === 'GET' ? undefined : await readBody(req);
3501
+ const request = toRestRequest(req, url, body);
3502
+ writeRestResponse(res, await site.agentsRouter.handle(request, context.actor));
860
3503
  return;
861
3504
  }
862
- if (url.pathname.startsWith('/api/users')) {
863
- const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3505
+ if (url.pathname.startsWith('/api/providers') && site.providersRouter !== undefined) {
3506
+ const body = req.method === 'GET' ? undefined : await readBody(req);
864
3507
  const request = toRestRequest(req, url, body);
865
- const response = await site.usersRouter.handle(request, context.actor);
866
- writeRestResponse(res, response);
867
- await recordUserAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
3508
+ writeRestResponse(res, await site.providersRouter.handle(request, context.actor));
868
3509
  return;
869
3510
  }
870
- if (url.pathname.startsWith('/api/site-plans') && site.sitePlanRouter !== undefined) {
871
- // `SitePlanRouter` itself refuses every route to a non-admin actor,
872
- // but only after `readBody` has already buffered the whole request —
873
- // and this route, alone among this server's routes, invites
874
- // multi-megabyte bodies by design (uploaded documents). Checking the
875
- // role here, before the body is read at all, means an unauthenticated
876
- // or non-admin caller is turned away without the server ever reading
877
- // what they sent.
878
- if (!context.actor.roles.includes('admin')) {
879
- jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may propose or apply a site plan.');
880
- return;
881
- }
882
- const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
3511
+ if (url.pathname.startsWith('/api/agent-skills') && site.agentSkillsRouter !== undefined) {
3512
+ const body = req.method === 'GET' ? undefined : await readBody(req);
883
3513
  const request = toRestRequest(req, url, body);
884
- writeRestResponse(res, await site.sitePlanRouter.handle(request, context.actor));
3514
+ writeRestResponse(res, await site.agentSkillsRouter.handle(request, context.actor));
885
3515
  return;
886
3516
  }
887
- // Always mounted, on every site (L18). On one with no AI provider it is
888
- // the route that answers `{available: false}`, which is precisely what
889
- // lets the admin panel disappear instead of failing.
890
- if (url.pathname.startsWith('/api/assistant')) {
3517
+ // Fiche 58 tasks 2/3 "MCP Clients". `mcpConnectionsRouter` is built
3518
+ // unconditionally (see `assembleSite`), so this branch is always live
3519
+ // once a site has a database, unlike the three above it.
3520
+ if (url.pathname.startsWith('/api/mcp-connections') &&
3521
+ site.mcpConnectionsRouter !== undefined) {
891
3522
  const body = req.method === 'GET' ? undefined : await readBody(req);
892
3523
  const request = toRestRequest(req, url, body);
893
- writeRestResponse(res, await site.assistantRouter.handle(request, context));
3524
+ writeRestResponse(res, await site.mcpConnectionsRouter.handle(request, context.actor));
894
3525
  return;
895
3526
  }
896
- if (url.pathname.startsWith('/api/agents') && site.agentsRouter !== undefined) {
897
- const request = toRestRequest(req, url, undefined);
898
- writeRestResponse(res, await site.agentsRouter.handle(request, context.actor));
3527
+ if (url.pathname.startsWith('/api/prompt-templates') &&
3528
+ site.promptTemplatesRouter !== undefined) {
3529
+ const body = req.method === 'GET' ? undefined : await readBody(req);
3530
+ const request = toRestRequest(req, url, body);
3531
+ writeRestResponse(res, await site.promptTemplatesRouter.handle(request, context.actor));
899
3532
  return;
900
3533
  }
901
3534
  // Driver connectivity/latency, not process metrics or uptime — the
@@ -971,6 +3604,43 @@ export function createRequestListener(site, logger) {
971
3604
  site: site.site,
972
3605
  styles: site.styles,
973
3606
  loadMedia: (ids) => loadRenderMedia(site, ids),
3607
+ // Present so the preview's `<body>` stays byte-identical to the
3608
+ // published page's (the property `theme-render-fidelity`
3609
+ // proves) — a POST carries no navigation `Referer` to report,
3610
+ // so this omits `referrer` the same way an ordinary page view
3611
+ // with no referrer does. The preview does still count as a
3612
+ // view; there is no distinct "not a real visit" signal to send
3613
+ // that would not itself become a body difference.
3614
+ analyticsBeacon: {},
3615
+ menuRouter: site.menuRouter,
3616
+ // Present for the same reason `homePath` is on the public GET
3617
+ // below: the L16 fidelity test asserts this preview's `<head>`
3618
+ // differs from the published page's by *only* `noindex` and the
3619
+ // missing canonical — a title template applied to one but not
3620
+ // the other would be a second, spurious difference.
3621
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3622
+ // `comments` deliberately absent here, unlike the public render
3623
+ // below: the thread's own form embeds a render timestamp
3624
+ // (`_ts`, the minimum-fill-delay field, fiche 15 task 6) that
3625
+ // cannot be identical across two separate renders no matter
3626
+ // how close together they happen — comparing it byte-for-byte
3627
+ // against the published page would be comparing two different
3628
+ // legitimate values, not catching a real divergence. The page
3629
+ // builder edits blocks; the visitor comment thread is not one
3630
+ // of them, so the preview simply does not render it — the same
3631
+ // reasoning that already keeps `adminBar` out of this render.
3632
+ // `theme-render-fidelity`-style byte equality still holds for
3633
+ // everything this preview *does* claim to show.
3634
+ branding: () => brandingForSite(site),
3635
+ activeTheme: () => activeThemeForSite(site),
3636
+ identity: () => identityForSite(site),
3637
+ // Present for the same reason `seo` above is: the fidelity
3638
+ // test compares this preview's `<body>` against the published
3639
+ // page's byte for byte, and `PageContent.entry` (contract D
3640
+ // `theme@1.4`) is part of that body now.
3641
+ chromeExtras: (locale) => chromeExtrasForSite(site, locale),
3642
+ authorFor: (userId) => authorForSite(site, userId),
3643
+ resolveTerm: site.resolveTaxonomyTerm,
974
3644
  }, context);
975
3645
  }
976
3646
  catch (error) {
@@ -997,13 +3667,28 @@ export function createRequestListener(site, logger) {
997
3667
  // answer its old URL with the 301 the rename recorded, not a 404 (L10
998
3668
  // task 2). Before route matching, so a redirect wins even when some
999
3669
  // other entry has since taken the old path — that is what `release()`
1000
- // is for on the write side.
3670
+ // is for on the write side. Prefix patterns (fiche 12 task 4) are
3671
+ // checked only when the exact-match table finds nothing — a curated
3672
+ // rule for one path always wins over a broad prefix rewrite.
1001
3673
  if (req.method === 'GET' || req.method === 'HEAD') {
1002
- const redirect = await site.redirects.resolve(url.pathname);
3674
+ const redirect = (await site.redirects.resolve(url.pathname)) ??
3675
+ (await site.redirectPatterns.resolve(url.pathname));
1003
3676
  if (redirect !== null) {
3677
+ if (redirect.status === 410) {
3678
+ // Not a redirect at all: no `Location`, and cacheable for a
3679
+ // while — "gone for good" does not change from one request to
3680
+ // the next the way a temporary hop might.
3681
+ res.writeHead(410, {
3682
+ 'content-type': 'text/plain; charset=utf-8',
3683
+ 'cache-control': 'public, max-age=3600',
3684
+ });
3685
+ res.end('Gone');
3686
+ return;
3687
+ }
3688
+ const cacheableByBrowsersAndCrawlers = redirect.status === 301 || redirect.status === 308;
1004
3689
  res.writeHead(redirect.status, {
1005
3690
  location: `${redirect.to}${url.search}`,
1006
- 'cache-control': redirect.status === 301 ? 'public, max-age=3600' : 'no-store',
3691
+ 'cache-control': cacheableByBrowsersAndCrawlers ? 'public, max-age=3600' : 'no-store',
1007
3692
  });
1008
3693
  res.end();
1009
3694
  return;
@@ -1018,20 +3703,109 @@ export function createRequestListener(site, logger) {
1018
3703
  res.writeHead(405, { allow: 'GET' }).end();
1019
3704
  return;
1020
3705
  }
3706
+ // Fiche 50 task 4 — an admin's own robots.txt lines, merged in
3707
+ // verbatim by `renderRobots`. Read fresh, same "no restart" contract
3708
+ // as everything else `readSeoRenderDefaults` feeds.
3709
+ const { robotsCustomRules } = await readSeoRenderDefaults(site.siteSettingsStore);
1021
3710
  res.writeHead(200, {
1022
3711
  'content-type': 'text/plain; charset=utf-8',
1023
3712
  'cache-control': 'public, max-age=3600',
1024
3713
  });
1025
- res.end(renderRobots(seoSiteFor(site.site)));
3714
+ res.end(renderRobots(seoSiteFor(site.site), {
3715
+ ...(robotsCustomRules === '' ? {} : { customRules: robotsCustomRules }),
3716
+ }));
3717
+ return;
3718
+ }
3719
+ // `/feed.xml` (RSS 2.0) and `/atom.xml` (Atom 1.0), from the same live
3720
+ // content, read the same `ANONYMOUS` way `sitemap.xml` and `robots.txt`
3721
+ // are: a reader and a signed-in editor must get the same document, or
3722
+ // the feed advertises entries the reader cannot fetch.
3723
+ //
3724
+ // `@cogenta/seo`'s `feedItemsFor`/`renderRssFeed`/`renderAtomFeed` were
3725
+ // written and unit-tested in L3 and never served by any route — the
3726
+ // whole of this change is the two routes (audit 2026-09-01,
3727
+ // 06-redirections-seo.md T03 / 07-apparence-themes-rendu.md T03).
3728
+ //
3729
+ // On by default, unlike IndexNow and llms.txt: a feed publishes only
3730
+ // what is already published, at URLs already in the sitemap, so there
3731
+ // is nothing here for an operator to consent to. `feedItemsFor` drops
3732
+ // drafts itself and takes no option to stop it — a feed is the one
3733
+ // output that cannot be retracted.
3734
+ if (url.pathname === '/feed.xml' || url.pathname === '/atom.xml') {
3735
+ if (req.method !== 'GET') {
3736
+ res.writeHead(405, { allow: 'GET' }).end();
3737
+ return;
3738
+ }
3739
+ const seoDefaults = await readSeoRenderDefaults(site.siteSettingsStore);
3740
+ const seoSite = seoSiteFor(site.site, seoDefaults);
3741
+ const items = feedItemsFor(seoSite, await collectRoutedResources(site.collections, site.gateway));
3742
+ const input = {
3743
+ site: seoSite,
3744
+ selfPath: url.pathname,
3745
+ language: site.site.defaultLocale,
3746
+ items,
3747
+ ...(seoDefaults.defaultMetaDescription === ''
3748
+ ? {}
3749
+ : { description: seoDefaults.defaultMetaDescription }),
3750
+ };
3751
+ res.writeHead(200, {
3752
+ 'content-type': `${url.pathname === '/feed.xml' ? 'application/rss+xml' : 'application/atom+xml'}; charset=utf-8`,
3753
+ 'cache-control': 'public, max-age=600',
3754
+ });
3755
+ res.end(url.pathname === '/feed.xml' ? renderRssFeed(input) : renderAtomFeed(input));
3756
+ return;
3757
+ }
3758
+ // `llms.txt` (fiche 50 task 5) — off by default (`seo.llmsTxtEnabled`),
3759
+ // reusing `llmsTxtSectionsFor`/`renderLlmsTxt` (`@cogenta/seo`), written
3760
+ // and unit-tested back in L3/L9 but never served by any route until now.
3761
+ if (url.pathname === '/llms.txt') {
3762
+ if (req.method !== 'GET') {
3763
+ res.writeHead(405, { allow: 'GET' }).end();
3764
+ return;
3765
+ }
3766
+ const { llmsTxtEnabled } = await readSeoOperationalSettings(site.siteSettingsStore);
3767
+ if (!llmsTxtEnabled) {
3768
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This site does not serve llms.txt.');
3769
+ return;
3770
+ }
3771
+ const seoDefaults = await readSeoRenderDefaults(site.siteSettingsStore);
3772
+ const seoSite = seoSiteFor(site.site, seoDefaults);
3773
+ const resources = await collectRoutedResources(site.collections, site.gateway);
3774
+ res.writeHead(200, {
3775
+ 'content-type': 'text/markdown; charset=utf-8',
3776
+ 'cache-control': 'public, max-age=600',
3777
+ });
3778
+ res.end(renderLlmsTxt({ site: seoSite, sections: llmsTxtSectionsFor(seoSite, resources) }));
1026
3779
  return;
1027
3780
  }
3781
+ // IndexNow's own ownership-proof key file (fiche 50 task 3) — served
3782
+ // only when IndexNow is on and the requested key is the one currently
3783
+ // configured. A path that merely *looks* like a key file, or a key
3784
+ // that does not match, falls through to the ordinary 404 below rather
3785
+ // than answering a distinct "wrong key" response that would let a
3786
+ // prober learn whether IndexNow is configured at all.
3787
+ {
3788
+ const keyFileMatch = INDEXNOW_KEY_FILE_PATTERN.exec(url.pathname);
3789
+ if (keyFileMatch !== null && req.method === 'GET') {
3790
+ const operational = await readSeoOperationalSettings(site.siteSettingsStore);
3791
+ if (operational.indexNowEnabled && operational.indexNowKey === keyFileMatch[1]) {
3792
+ res.writeHead(200, {
3793
+ 'content-type': 'text/plain; charset=utf-8',
3794
+ 'cache-control': 'public, max-age=3600',
3795
+ });
3796
+ res.end(indexNowKeyFile(operational.indexNowKey).contents);
3797
+ return;
3798
+ }
3799
+ }
3800
+ }
1028
3801
  if (SITEMAP_PATH.test(url.pathname)) {
1029
3802
  if (req.method !== 'GET') {
1030
3803
  res.writeHead(405, { allow: 'GET' }).end();
1031
3804
  return;
1032
3805
  }
1033
- const seoSite = seoSiteFor(site.site);
1034
- const files = buildSitemapFiles(seoSite, await collectRoutedResources(site.collections, site.gateway));
3806
+ const seoDefaults = await readSeoRenderDefaults(site.siteSettingsStore);
3807
+ const seoSite = seoSiteFor(site.site, seoDefaults);
3808
+ const files = buildSitemapFiles(seoSite, await collectRoutedResources(site.collections, site.gateway), seoDefaults.sitemapCollectionSettings, await site.termSitemapUrls());
1035
3809
  const file = files.find((candidate) => candidate.path === url.pathname);
1036
3810
  if (file !== undefined) {
1037
3811
  res.writeHead(200, {
@@ -1057,7 +3831,13 @@ export function createRequestListener(site, logger) {
1057
3831
  gateway: site.gateway,
1058
3832
  collections: site.collections,
1059
3833
  site: site.site,
1060
- styles: site.styles,
3834
+ styles: await site.resolveStyles(),
3835
+ menus: { menuRouter: site.menuRouter },
3836
+ branding: () => brandingForSite(site),
3837
+ activeTheme: () => activeThemeForSite(site),
3838
+ identity: () => identityForSite(site),
3839
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3840
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
1061
3841
  }, context);
1062
3842
  res.writeHead(200, {
1063
3843
  'content-type': 'text/html; charset=utf-8',
@@ -1066,6 +3846,36 @@ export function createRequestListener(site, logger) {
1066
3846
  res.end(html);
1067
3847
  return;
1068
3848
  }
3849
+ // `GET /forms/{name}` — the public "route dédiée" ADR-0026 chose for a
3850
+ // form's first arrival on a page. `?submitted=1` (no `redirectTo`
3851
+ // configured) shows the confirmation view instead of the form itself.
3852
+ {
3853
+ const formPageMatch = /^\/forms\/([^/]+)$/u.exec(url.pathname);
3854
+ if (formPageMatch !== null && req.method === 'GET') {
3855
+ const formName = formPageMatch[1];
3856
+ const definition = await site.formStore.definitions.readByName(formName);
3857
+ const formPageOptions = {
3858
+ site: site.site,
3859
+ styles: await site.resolveStyles(),
3860
+ now: Date.now,
3861
+ menus: { menuRouter: site.menuRouter },
3862
+ branding: () => brandingForSite(site),
3863
+ activeTheme: () => activeThemeForSite(site),
3864
+ identity: () => identityForSite(site),
3865
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3866
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3867
+ };
3868
+ const html = definition === null || !definition.active
3869
+ ? await renderFormNotFoundPage(formPageOptions, context)
3870
+ : await renderFormPage(definition, { submitted: url.searchParams.get('submitted') === '1' }, formPageOptions, context);
3871
+ res.writeHead(definition === null || !definition.active ? 404 : 200, {
3872
+ 'content-type': 'text/html; charset=utf-8',
3873
+ 'cache-control': 'no-store',
3874
+ });
3875
+ res.end(html);
3876
+ return;
3877
+ }
3878
+ }
1069
3879
  // Real theme HTML for anything else — see `theme-render.ts`'s own
1070
3880
  // doc comment for what this is and, as importantly, what it isn't
1071
3881
  // (no Astro build, one theme, no image pipeline). GET only: rendering
@@ -1075,8 +3885,53 @@ export function createRequestListener(site, logger) {
1075
3885
  collections: site.collections,
1076
3886
  gateway: site.gateway,
1077
3887
  site: site.site,
1078
- styles: site.styles,
3888
+ // Live, not the startup snapshot — a saved appearance override
3889
+ // must show up on the very next page view (fiche 14).
3890
+ styles: await site.resolveStyles(),
1079
3891
  loadMedia: (ids) => loadRenderMedia(site, ids),
3892
+ // Self-hosted analytics (`@cogenta/analytics`): the referrer is read
3893
+ // from *this* request's own header, server-side — see
3894
+ // `analyticsBeaconTag` in `theme-render.ts` for why that, rather
3895
+ // than a client script, is how this page's beacon pixel gets it.
3896
+ analyticsBeacon: { referrer: req.headers.referer },
3897
+ menuRouter: site.menuRouter,
3898
+ // The homepage a rédacteur chose from the admin (fiche 23 task 4),
3899
+ // read fresh on every request — "sans redéployer" only holds if
3900
+ // this is not cached at startup. `theme-render.ts` falls back to
3901
+ // `/home` when nothing was ever written.
3902
+ homePath: async () => {
3903
+ const setting = await site.siteSettingsStore.get('reading.homePath', SITE_SETTINGS_SITE_SCOPE);
3904
+ return typeof setting?.value === 'string' ? setting.value : null;
3905
+ },
3906
+ // The SEO title templates, default description, Twitter handle and
3907
+ // default social image an admin set from `/seo` (fiche 21 task 3),
3908
+ // read fresh — same "no restart" contract as `homePath` above.
3909
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3910
+ comments: {
3911
+ action: '/api/comments',
3912
+ forEntry: (commentCollection, entryId, locale) => commentsForEntry(site, commentCollection, entryId, locale),
3913
+ },
3914
+ branding: () => brandingForSite(site),
3915
+ activeTheme: () => activeThemeForSite(site),
3916
+ identity: () => identityForSite(site),
3917
+ // `general.tagline`/`general.socialLinks`/`general.footerNote`
3918
+ // (contract D `theme@1.4`, L25 D2) and an entry's classified
3919
+ // terms/author (`PageContent.entry`, same contract bump) — read
3920
+ // fresh, same "no restart" contract as `homePath`/`seo` above.
3921
+ chromeExtras: (locale) => chromeExtrasForSite(site, locale),
3922
+ authorFor: (userId) => authorForSite(site, userId),
3923
+ resolveTerm: site.resolveTaxonomyTerm,
3924
+ // Fiche 35 task 6's admin bar. Its renderer was written, and this
3925
+ // flag — the one dispatch that is supposed to set it — never was,
3926
+ // so the bar had never appeared on a single page (audit
3927
+ // 2026-09-01). Set only here, never on the builder preview or the
3928
+ // theme preview: the L16 fidelity test asserts the preview's
3929
+ // `<body>` is byte-identical to the published page's, and this is
3930
+ // the one page-GET path an authenticated visitor actually
3931
+ // navigates to. `renderEntryPage` gates it a second time on the
3932
+ // actor really being authenticated, so an anonymous visitor never
3933
+ // carries the markup.
3934
+ adminBar: true,
1080
3935
  };
1081
3936
  const html = await renderRequestedPage(url.pathname, renderOptions, context);
1082
3937
  if (html !== null) {
@@ -1084,6 +3939,65 @@ export function createRequestListener(site, logger) {
1084
3939
  res.end(html);
1085
3940
  return;
1086
3941
  }
3942
+ // The taxonomy term archive, `/{taxonomy}/{term-slug}` (audit
3943
+ // 2026-09-01, 04-taxonomies-menus.md T01).
3944
+ //
3945
+ // Tried **after** every real collection route has already failed,
3946
+ // and that ordering is the whole design: a site with a `/blog/:slug`
3947
+ // route and a `blog` taxonomy cannot have one shadow the other,
3948
+ // because a URL that resolves to a real entry never reaches this
3949
+ // branch at all. It also means a taxonomy needs no `routing` of its
3950
+ // own — which would have been a contract A change (ADR-0022 gives a
3951
+ // `TaxonomyDefinition` no routing, on purpose) for a feature that
3952
+ // does not need one.
3953
+ {
3954
+ const archiveMatch = /^\/([^/]+)\/([^/]+)$/u.exec(url.pathname);
3955
+ if (archiveMatch !== null) {
3956
+ const resolution = await site.resolveTermArchive(decodeURIComponent(archiveMatch[1]), decodeURIComponent(archiveMatch[2]));
3957
+ if (resolution !== null) {
3958
+ const requested = Number.parseInt(url.searchParams.get('page') ?? '1', 10);
3959
+ const archive = await renderTermArchivePage(resolution, Number.isFinite(requested) ? requested : 0, {
3960
+ collections: site.collections,
3961
+ gateway: site.gateway,
3962
+ site: site.site,
3963
+ styles: await site.resolveStyles(),
3964
+ menus: { menuRouter: site.menuRouter },
3965
+ branding: () => brandingForSite(site),
3966
+ activeTheme: () => activeThemeForSite(site),
3967
+ seo: () => readSeoRenderDefaults(site.siteSettingsStore),
3968
+ identity: () => identityForSite(site),
3969
+ loadMedia: (ids) => loadRenderMedia(site, ids),
3970
+ }, context);
3971
+ // `null` means "?page=9 on a two-page archive" — a real 404,
3972
+ // handled by falling through. An *empty* term is not that: it
3973
+ // renders 200 with an empty list, because "this term exists and
3974
+ // classifies nothing published" is a true answer.
3975
+ if (archive !== null) {
3976
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
3977
+ res.end(archive);
3978
+ return;
3979
+ }
3980
+ }
3981
+ }
3982
+ }
3983
+ // The 404 log (fiche 12 task 1): every public GET that matched no
3984
+ // route, recorded by path — never by IP or user agent — so the ten
3985
+ // URLs most requested and never found are visible without anyone
3986
+ // discovering that by luck. A failed write here must never turn an
3987
+ // honest 404 into a 500. `/api/*` is excluded: an unmatched API path
3988
+ // falls through to this same branch (nothing above returns for it
3989
+ // either), but a wrong or retired API call is not a broken page link.
3990
+ if (site.notFoundLogEnabled && !url.pathname.startsWith('/api/')) {
3991
+ try {
3992
+ await site.notFoundLog.record({
3993
+ path: url.pathname,
3994
+ ...(req.headers.referer === undefined ? {} : { referrer: req.headers.referer }),
3995
+ });
3996
+ }
3997
+ catch (error) {
3998
+ logger.warn('not-found log write failed', { error: String(error) });
3999
+ }
4000
+ }
1087
4001
  // The site's own 404 page (L14 task 2). It is an ordinary entry at
1088
4002
  // `site.notFoundPath`, rendered by exactly the same function and
1089
4003
  // through exactly the same permission-checked gateway as any other
@@ -1111,10 +4025,27 @@ export function createRequestListener(site, logger) {
1111
4025
  logger.error('request failed', {
1112
4026
  error: isCogentaError(error) ? error.toJSON() : String(error),
1113
4027
  });
4028
+ // The error journal (fiche 24 task 4): a bounded, redacted record of
4029
+ // exactly this failure, readable from the admin on a host where the
4030
+ // process's own stdout is not — see `createErrorLog` for why redaction
4031
+ // here is not optional.
4032
+ extras?.errorLog.recordError(error, { method: req.method ?? 'GET', path: url.pathname });
1114
4033
  if (isCogentaError(error) && error.code === 'REQUEST_BODY_TOO_LARGE') {
1115
4034
  jsonError(res, 413, error.code, error.message);
1116
4035
  return;
1117
4036
  }
4037
+ // A valid, over-quota API key (fiche 20 task 3) — the one error this
4038
+ // listener turns into rate-limit headers, since `errorResponse` in
4039
+ // `@cogenta/api` deliberately never serialises `details` onto the wire
4040
+ // (that field is for logs, and could otherwise echo caller-controlled
4041
+ // data). `resolveActor` throws this only after resolving a real,
4042
+ // valid key, so the caller is exactly who it says it is; it just has
4043
+ // to wait.
4044
+ if (isCogentaError(error) && error.code === 'API_KEY_RATE_LIMITED') {
4045
+ res.writeHead(429, rateLimitHeaders(error.details));
4046
+ res.end(JSON.stringify({ error: { code: error.code, message: error.message } }));
4047
+ return;
4048
+ }
1118
4049
  res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
1119
4050
  res.end(JSON.stringify({
1120
4051
  error: { code: 'INTERNAL', message: 'The request could not be completed.' },
@@ -1122,10 +4053,139 @@ export function createRequestListener(site, logger) {
1122
4053
  }
1123
4054
  };
1124
4055
  }
4056
+ const UPDATES_AUTO_CHECK_TICK_MS = 24 * 60 * 60 * 1000;
1125
4057
  const DEFAULT_PORT = 4000;
1126
4058
  const DEFAULT_HOST = '127.0.0.1';
1127
4059
  /** How long a shutdown waits for open connections before cutting them. */
1128
4060
  const SHUTDOWN_GRACE_MS = 2_000;
4061
+ /**
4062
+ * How often `runServe` drains due scheduled-publication jobs (R1).
4063
+ *
4064
+ * `cogenta serve` has no persistent worker process beyond itself, so this
4065
+ * `setInterval` *is* the cron a hosted deployment with no worker would
4066
+ * otherwise need to configure by hand. The honest trade this makes: a page
4067
+ * scheduled for 09:00 goes live between 09:00 and 09:01, not exactly on the
4068
+ * hour. If the process is stopped when a publication comes due, nothing is
4069
+ * lost — the job is still in the `database` queue's table — it simply runs
4070
+ * on the first tick after the next start, however late that is.
4071
+ */
4072
+ const SCHEDULED_PUBLISH_TICK_MS = 60_000;
4073
+ /**
4074
+ * How often `runServe` purges the 404 log past its configured retention
4075
+ * (fiche 12 task 1). Daily, not every minute like publication: a log purge
4076
+ * has no visitor waiting on it, and the log's own `maxPaths` cap — not this
4077
+ * interval — is what actually bounds its size between purges.
4078
+ */
4079
+ const NOT_FOUND_PURGE_TICK_MS = 24 * 60 * 60 * 1000;
4080
+ /**
4081
+ * How often `runServe` runs the scheduled audit-integrity check (fiche 21
4082
+ * task 3). Daily by default — frequent enough that "altérer une ligne fait
4083
+ * apparaître une alerte dans les 24 heures" (the fiche's own acceptance
4084
+ * bound) holds with room to spare, rare enough that most ticks do the
4085
+ * cheap incremental form rather than a full replay.
4086
+ */
4087
+ const AUDIT_INTEGRITY_TICK_MS = 24 * 60 * 60 * 1000;
4088
+ /**
4089
+ * How often `runServe` runs the audit-log retention sweep (T09-01). Daily,
4090
+ * the same cadence as the integrity check right above it — nobody is
4091
+ * waiting on a page load for an old audit entry to disappear, and
4092
+ * `security.audit.retainDays` itself is measured in whole days. The tick
4093
+ * itself is always registered; whether it actually prunes anything depends
4094
+ * entirely on `retainDays` being configured — see `tickAuditPrune`'s own
4095
+ * comment.
4096
+ */
4097
+ const AUDIT_PRUNE_TICK_MS = 24 * 60 * 60 * 1000;
4098
+ /**
4099
+ * How often `runServe` sweeps every collection's trash past its
4100
+ * `retainDays` (fiche 07 task 5). Daily, not every minute like publication:
4101
+ * nobody is waiting on a page load for a trashed entry to disappear, and a
4102
+ * sweep that runs once a day is still the "purged automatically" the admin
4103
+ * screen advertises — `retainDays` itself is already measured in whole days.
4104
+ */
4105
+ const TRASH_PURGE_TICK_MS = 24 * 60 * 60 * 1000;
4106
+ /** Same daily cadence as the trash sweep (fiche 16 task 7's GDPR retention, ADR-0022's `retainDays`/`purgeExpired` model applied to submissions). */
4107
+ const FORMS_PURGE_TICK_MS = 24 * 60 * 60 * 1000;
4108
+ /**
4109
+ * How often `runServe` flushes queued/grouped channel notifications (fiche
4110
+ * 38 task 3, `NotificationDispatcher.flushDue`) — a quiet-hours deferral or
4111
+ * an hourly/daily digest sits in `@cogenta/channels`' own pending table
4112
+ * until this runs, the same R1-honest "no persistent worker" trade as
4113
+ * scheduled publication above.
4114
+ */
4115
+ const CHANNEL_NOTIFICATION_TICK_MS = 60_000;
4116
+ const COMMERCE_EMAIL_TICK_MS = 60_000;
4117
+ /**
4118
+ * How often `runServe` runs the subscription billing sweep (fiche 53 task 3,
4119
+ * audit T-COM-01): `runBilling` bills every subscription whose
4120
+ * `nextBillingAt` has come due, `runDunning` retries whatever renewal
4121
+ * payment is due for retry today, and `sendRenewalNotices` warns a
4122
+ * subscriber ahead of their charge (fiche 53 task 5). All three are
4123
+ * idempotent per period/attempt on their own (compare-and-set on
4124
+ * `next_retry_at`, one row per period for a notice) — this tick is only the
4125
+ * clock, the same "no persistent worker" trade `SCHEDULED_PUBLISH_TICK_MS`
4126
+ * documents. Daily, not every minute: nobody is waiting on a page load for a
4127
+ * subscription renewal the way a scheduled publish is waited on.
4128
+ */
4129
+ const COMMERCE_BILLING_TICK_MS = 24 * 60 * 60 * 1000;
4130
+ /**
4131
+ * How often `runServe` runs the abandoned-cart sweep (audit A1-commerce P2):
4132
+ * `CartStore.abandon()` existed since fiche 32 with no automatic caller, so
4133
+ * a cart nobody touched stayed `open` forever on a real site. Idempotent on
4134
+ * its own (a cart this abandons no longer matches `status = 'open'`, so a
4135
+ * rerun before another cart goes stale finds nothing) — this tick is only
4136
+ * the clock. Hourly, not daily: unlike a subscription renewal, "has this
4137
+ * shopper given up" is worth noticing sooner, and unlike scheduled
4138
+ * publication nobody is watching a page load for it either, so a middle
4139
+ * cadence.
4140
+ */
4141
+ const CART_ABANDON_TICK_MS = 60 * 60 * 1000;
4142
+ /**
4143
+ * How often `runServe` purges analytics events (and their daily salts) past
4144
+ * the site's configured retention (fiche 27 task 3). Once a day: the events
4145
+ * table is the largest table on a site with real traffic, but retention is
4146
+ * measured in days, so nothing is lost by a sweep that runs on this cadence
4147
+ * rather than every minute — same honest trade `SCHEDULED_PUBLISH_TICK_MS`
4148
+ * documents for publication.
4149
+ */
4150
+ const ANALYTICS_PURGE_TICK_MS = 24 * 60 * 60 * 1000;
4151
+ /**
4152
+ * How often `runServe` re-reads `observability.enabled`/
4153
+ * `observability.logLevel` from the settings store (fiche L22 task 5).
4154
+ * Every 15 seconds: frequent enough that flipping either from the admin
4155
+ * feels close to immediate, infrequent enough that it is nowhere near the
4156
+ * cost of a query per log call or per request.
4157
+ */
4158
+ const OBSERVABILITY_SETTINGS_TICK_MS = 15_000;
4159
+ /**
4160
+ * Builds `/api/config-status`'s answer (fiche 23 task 5) from what
4161
+ * `loadConfig()` already resolved — a hand-picked, secret-free subset
4162
+ * (`ConfigStatusInput` has no field a secret could occupy), plus the
4163
+ * `secretHygiene` report `loadConfig()` computed from the raw file.
4164
+ */
4165
+ function buildConfigStatus(config, secretHygiene) {
4166
+ return {
4167
+ site: { name: config.site.name, url: config.site.url, notFoundPath: config.site.notFoundPath },
4168
+ database: { driver: config.database.driver },
4169
+ cache: { driver: config.cache.driver },
4170
+ queue: { driver: config.queue.driver },
4171
+ storage: {
4172
+ driver: config.storage.driver,
4173
+ bucket: config.storage.bucket,
4174
+ region: config.storage.region,
4175
+ endpoint: config.storage.endpoint,
4176
+ },
4177
+ llm: config.llm === undefined
4178
+ ? undefined
4179
+ : { provider: config.llm.provider, model: config.llm.model },
4180
+ embeddings: { provider: config.embeddings.provider, model: config.embeddings.model },
4181
+ imageGeneration: config.imageGeneration === undefined
4182
+ ? undefined
4183
+ : { provider: config.imageGeneration.provider, model: config.imageGeneration.model },
4184
+ vector: { driver: config.vector.driver },
4185
+ billingConfigured: config.billing !== undefined,
4186
+ secretHygiene,
4187
+ };
4188
+ }
1129
4189
  /**
1130
4190
  * Runs until `options.signal` aborts. Returns 0 on a clean shutdown, 1 if
1131
4191
  * startup failed — nothing here calls `process.exit` (same convention as
@@ -1134,7 +4194,10 @@ const SHUTDOWN_GRACE_MS = 2_000;
1134
4194
  export async function runServe(options) {
1135
4195
  const { out, stderr } = options;
1136
4196
  const env = options.env ?? process.env;
1137
- const logger = options.logger ?? createLogger({ level: 'silent' });
4197
+ // Reassigned once `siteSettingsStore` exists (below): from that point on
4198
+ // every use of `logger` also feeds the observability recent-log buffer,
4199
+ // gated by the live `observability.logLevel` setting.
4200
+ let logger = options.logger ?? createLogger({ level: 'silent' });
1138
4201
  const loaded = await loadConfig({
1139
4202
  ...(options.cwd === undefined ? {} : { cwd: options.cwd }),
1140
4203
  env,
@@ -1166,7 +4229,17 @@ export async function runServe(options) {
1166
4229
  }
1167
4230
  const selection = await createDatabaseRegistry({ logger }).select(loaded.config.database);
1168
4231
  const storageSelection = await createStorageRegistry({ logger }).select(loaded.config.storage);
1169
- const styles = joinStyles(await loadSkinCss((path) => readFile(path, 'utf8'), join(projectRoot, 'theme.tokens.json')), await loadThemeCss({ read: (url) => readFile(url, 'utf8') }));
4232
+ // Per-API-key request quota (fiche 20 task 3, R1): Redis when configured
4233
+ // and reachable, an in-process counter otherwise — never a hard dependency
4234
+ // on either.
4235
+ const rateLimitSelection = await createRateLimitRegistry({ logger }).select(loaded.config.rateLimit);
4236
+ // Memoised per theme *package* name (`createThemeCssResolver`) — reading
4237
+ // and flattening a theme's stylesheet is real file I/O, so it happens once
4238
+ // per theme this process actually renders with, not on every request that
4239
+ // merely re-reads which one is currently active.
4240
+ const themeCssFor = createThemeCssResolver({ read: (url) => readFile(url, 'utf8') });
4241
+ const themeCss = await themeCssFor(DEFAULT_THEME_NAME);
4242
+ const styles = joinStyles(await loadSkinCss((path) => readFile(path, 'utf8'), join(projectRoot, 'theme.tokens.json')), themeCss);
1170
4243
  const images = await selectMediaImageProcessor(logger);
1171
4244
  // One signed channel for both outbound events — the content lifecycle (task
1172
4245
  // 1) and the suspicious-activity alert (task 4). One set of endpoints, one
@@ -1179,14 +4252,161 @@ export async function runServe(options) {
1179
4252
  // L18. Never fatal: everything inside degrades to "off" with a log line
1180
4253
  // rather than stopping the site from serving (R2).
1181
4254
  const searchIndex = await createSearchIndex({ db: selection.instance });
4255
+ // Its own instance, separate from `assembleSite`'s internal one
4256
+ // (`createSiteSettingsStore` is stateless — every call hits the same table,
4257
+ // there is nothing to share): `buildAssistant` runs before `assembleSite`
4258
+ // does, and the `assistant.indexedCollections` toggle (L22 task 4) has to
4259
+ // be readable from the moment the first store wrap is built.
4260
+ const assistantSettings = createSiteSettingsStore({ db: selection.instance });
4261
+ // Fiche 45 — built here (before `buildAgentRuntime`, inside `assembleSite`
4262
+ // below, opens its own instance over the same directory) so the writing
4263
+ // assistant's `assist.*` tools resolve their instruction text from the
4264
+ // exact store the "Prompt Settings" admin screen edits. Two file-store
4265
+ // instances over one directory is safe: neither caches, so an edit either
4266
+ // makes is visible to the other on its next read (`agent-runtime.ts`'s own
4267
+ // `PROMPT_TEMPLATES_SUBDIR` comment explains this in full).
4268
+ const promptTemplates = createFilePromptTemplateStore({
4269
+ dir: join(projectRoot, '.cogenta', 'agents-runtime', 'prompt-templates'),
4270
+ });
4271
+ await ensureBuiltinPromptTemplates(promptTemplates);
1182
4272
  const assistant = await buildAssistant({
1183
4273
  config: loaded.config,
1184
4274
  db: selection.instance,
1185
4275
  logger,
4276
+ collections,
4277
+ settings: assistantSettings,
4278
+ siteId: loaded.config.site.url,
1186
4279
  // Beside the full-text index, never instead of it: the semantic half is
1187
4280
  // fused with this one by RRF (L18 task 5).
1188
4281
  fullText: searchIndex,
4282
+ promptTemplates,
4283
+ });
4284
+ // The "Santé" / "Outils" screens (fiche 24). `migrator` is built once here
4285
+ // — not per request, unlike `cogenta migrate`'s own CLI invocation, which
4286
+ // has no long-running process to amortise the cost across.
4287
+ const migrations = await loadMigrations(join(projectRoot, MIGRATIONS_DIRECTORY));
4288
+ const migrator = createMigrator({ db: selection.instance, migrations, logger });
4289
+ await ensureMaintenanceTable(selection.instance);
4290
+ const maintenanceStore = createMaintenanceStore({ db: selection.instance });
4291
+ const errorLog = createErrorLog();
4292
+ // The registry the doctor report already selects from for its own check —
4293
+ // built again here because a page cache and a diagnostic snapshot are
4294
+ // different lifetimes, not because the driver logic differs. Never fatal:
4295
+ // an install with no reachable cache backend still serves (R1's degraded
4296
+ // tier — `memory` — always resolves).
4297
+ const cacheSelection = await createCacheRegistry({ logger }).select(loaded.config.cache);
4298
+ const emailTransport = createFileEmailTransport({
4299
+ directory: join(projectRoot, '.cogenta', 'mail'),
4300
+ });
4301
+ const toolsQueue = createDatabaseQueue({ db: selection.instance, logger });
4302
+ // The update system (L22 task 9). `@cogenta/core`/`@cogenta/cli` are the
4303
+ // two packages the lot names explicitly as "déjà la source de vérité de
4304
+ // version" — each one's own self-reported version (`getCoreVersion()`,
4305
+ // `getCliVersion()`), never a guess read from `node_modules` by path. One
4306
+ // `AuditLog` handle over this site's own table — the same one
4307
+ // `site.auth.audit` will separately open once `assembleSite` runs below;
4308
+ // both read/write the same `cogenta_audit_log` table, so history recorded
4309
+ // here (before `site` exists) is exactly as visible through `site.auth.audit`
4310
+ // afterwards as any other action.
4311
+ const updatesAuditLog = createAuditLog(selection.instance);
4312
+ // Guards the `updates-auto-check` scheduled task below against re-applying
4313
+ // the same already-applied version on every subsequent tick — see that
4314
+ // task's own comment for why this process cannot otherwise tell.
4315
+ let lastAutoAppliedSignature = null;
4316
+ const updatesBackupDir = join(projectRoot, '.cogenta', 'backups');
4317
+ const updatePackages = () => [
4318
+ { name: '@cogenta/core', installed: getCoreVersion() },
4319
+ { name: '@cogenta/cli', installed: getCliVersion() },
4320
+ ];
4321
+ async function recordUpdateOutcome(result, actorId) {
4322
+ if (result.kind === 'up-to-date' || result.kind === 'confirmation-required')
4323
+ return;
4324
+ await recordUpdateHistory(updatesAuditLog, {
4325
+ actorId,
4326
+ actorRoles: ['admin'],
4327
+ action: UPDATE_APPLIED_ACTION,
4328
+ diff: { installed: result.installed, restorePoint: result.restorePoint.path },
4329
+ }).catch((error) => {
4330
+ logger.error('update history record failed', { error: String(error) });
4331
+ });
4332
+ }
4333
+ const updatesRouter = createUpdateRouter({
4334
+ checker: {
4335
+ check: () => checkForUpdates({
4336
+ packages: updatePackages(),
4337
+ ...(options.updatesFetchImpl === undefined
4338
+ ? {}
4339
+ : { fetchImpl: options.updatesFetchImpl }),
4340
+ }),
4341
+ },
4342
+ applier: {
4343
+ apply: async (input) => {
4344
+ let result;
4345
+ try {
4346
+ result = await applyUpdate({
4347
+ cwd: projectRoot,
4348
+ env,
4349
+ logger,
4350
+ packages: updatePackages(),
4351
+ confirmBreakingChange: input.confirmBreakingChange,
4352
+ backupDir: updatesBackupDir,
4353
+ ...(options.updatesFetchImpl === undefined
4354
+ ? {}
4355
+ : { fetchImpl: options.updatesFetchImpl }),
4356
+ ...(options.updatesRunInstall === undefined
4357
+ ? {}
4358
+ : { runInstall: options.updatesRunInstall }),
4359
+ });
4360
+ }
4361
+ catch (error) {
4362
+ await recordUpdateHistory(updatesAuditLog, {
4363
+ actorId: input.actorId,
4364
+ actorRoles: ['admin'],
4365
+ action: UPDATE_APPLY_FAILED_ACTION,
4366
+ diff: { error: isCogentaError(error) ? error.message : String(error) },
4367
+ }).catch(() => undefined);
4368
+ throw error;
4369
+ }
4370
+ await recordUpdateOutcome(result, input.actorId);
4371
+ return result;
4372
+ },
4373
+ },
4374
+ history: {
4375
+ entries: () => listUpdateHistory(updatesAuditLog),
4376
+ restorePoints: () => listRestorePoints(updatesBackupDir),
4377
+ },
1189
4378
  });
4379
+ // The same directory `agentsRuntimeConfig` below points `buildAgentRuntime`
4380
+ // at — computed once, here, so the two can never drift apart.
4381
+ const agentsRuntimeDataDir = join(projectRoot, '.cogenta', 'agents-runtime');
4382
+ // L26 task 5 — shared ingredients for `theme`'s own AI `generator` and for
4383
+ // `theme.propose_theme`'s registration below, resolved once from the same
4384
+ // provider store/`availableThemes()` (never a second, independently-built
4385
+ // provider). `providerStore` points at the exact same encrypted files
4386
+ // `buildAgentRuntime` reads for every other agent's client — an admin who
4387
+ // configures a provider from `/admin/providers` must see this feature
4388
+ // answer "available" too, not only `config.llm` (a config-file provider
4389
+ // this store's own `resolveThemeProvider` still falls back to, for a site
4390
+ // that never uses the admin UI for this at all).
4391
+ const themeWiringOptions = {
4392
+ projectRoot,
4393
+ db: selection.instance,
4394
+ config: loaded.config,
4395
+ development: options.development ?? false,
4396
+ readOnly: options.readOnly ?? false,
4397
+ providerStore: createFileProviderConfigStore({
4398
+ dir: join(agentsRuntimeDataDir, PROVIDERS_SUBDIR),
4399
+ signingKey: loaded.config.auth.signingKey,
4400
+ }),
4401
+ // So the theme generator follows the "Cogenta Theme Creator" agent's own
4402
+ // admin-configured model preference instead of a hardcoded guess at it —
4403
+ // a second reader of the exact same files `buildAgentRuntime` reads for
4404
+ // that agent's declaration, same pattern as `providerStore` above.
4405
+ agentStore: createFileAgentDeclarationStore({
4406
+ dir: join(agentsRuntimeDataDir, AGENTS_SUBDIR),
4407
+ }),
4408
+ };
4409
+ const themeCreatorTools = await createThemeCreatorToolWiring(themeWiringOptions);
1190
4410
  const site = await assembleSite({
1191
4411
  db: selection.instance,
1192
4412
  assistant,
@@ -1197,18 +4417,60 @@ export async function runServe(options) {
1197
4417
  site: loaded.config.site,
1198
4418
  storage: storageSelection.instance,
1199
4419
  logger,
4420
+ updatesRouter,
1200
4421
  health: async () => ({
1201
4422
  database: await selection.health(),
1202
4423
  storage: await storageSelection.health(),
1203
4424
  }),
1204
4425
  readOnly: options.readOnly ?? false,
1205
4426
  styles,
4427
+ themeCss,
4428
+ themeCssFor,
4429
+ theme: await createThemeWiring(themeWiringOptions),
4430
+ // L26 task 5 — "Cogenta Theme Creator"'s tool, resolved from the exact
4431
+ // same `config.llm`/`availableThemes()` `theme` above already used for
4432
+ // its own `generator`. Absent (no LLM provider) means `undefined`, and
4433
+ // `theme.propose_theme` is then simply not registered (R2).
4434
+ ...(themeCreatorTools === undefined ? {} : { themeCreatorTools }),
1206
4435
  images: images?.processor ?? null,
1207
4436
  security: loaded.config.security,
4437
+ notFoundLog: loaded.config.notFoundLog,
4438
+ webhooks: loaded.config.webhooks,
4439
+ billing: loaded.config.billing,
4440
+ payment: loaded.config.payment,
4441
+ // L22 task 1/1bis: always on for a real `cogenta serve` — the superagent
4442
+ // and its two example built-ins exist in configuration from the very
4443
+ // first boot (R2: nothing here attempts a network call without a
4444
+ // configured provider, only `POST .../run` can, and it refuses first).
4445
+ agentsRuntimeConfig: {
4446
+ dataDir: agentsRuntimeDataDir,
4447
+ projectRoot,
4448
+ },
4449
+ emailTransport,
4450
+ configStatus: buildConfigStatus(loaded.config, loaded.secretHygiene),
4451
+ pendingMigrations: {
4452
+ countPending: async () => (await migrator.status()).filter((item) => !item.applied).length,
4453
+ hasDestructive: async () => (await migrator.status()).some((item) => !item.applied && item.destructive),
4454
+ },
4455
+ analytics: loaded.config.analytics,
4456
+ // Fiche 70 task 4, ADR-0032 — present only once both the client id and
4457
+ // secret are set. Neither alone is enough to talk to Google, and
4458
+ // `assembleSite` treats "absent" as the single source of truth for
4459
+ // "this connector is not offered" (R1/R2).
4460
+ ...(loaded.config.searchConsole.clientId !== undefined &&
4461
+ loaded.config.searchConsole.clientSecret !== undefined
4462
+ ? {
4463
+ searchConsole: {
4464
+ clientId: loaded.config.searchConsole.clientId,
4465
+ clientSecret: loaded.config.searchConsole.clientSecret,
4466
+ },
4467
+ }
4468
+ : {}),
1208
4469
  sitePlans: await createSitePlanning({
1209
4470
  projectRoot,
1210
4471
  db: selection.instance,
1211
4472
  collections,
4473
+ taxonomies,
1212
4474
  config: loaded.config,
1213
4475
  logger,
1214
4476
  readOnly: options.readOnly ?? false,
@@ -1222,8 +4484,421 @@ export async function runServe(options) {
1222
4484
  // endpoint, or configured one without a signing secret.
1223
4485
  onContentEvent: webhooks.emit,
1224
4486
  onSecurityEvent: webhooks.send,
4487
+ requestQuota: rateLimitSelection.instance,
4488
+ // Same mail this site's `cogenta users reset-password --email` already
4489
+ // sends (`../reset-mail.js`), just pointed at the admin's reset screen
4490
+ // instead of a terminal command — see that file for why the wording is
4491
+ // written once rather than twice.
4492
+ onForgotPassword: ({ user, token, expiresAt }) => sendResetMail({
4493
+ mailDir: join(projectRoot, '.cogenta', 'mail'),
4494
+ resetUrl: new URL('/admin/reset-password', loaded.config.site.url).toString(),
4495
+ }, loaded.config.site, user.email, token, expiresAt).then(() => undefined),
4496
+ // Fiche 17 task 1. Same file, same transport, same "the token is redeemed
4497
+ // at /admin/reset-password" screen `onForgotPassword` already points at
4498
+ // — accepting an invitation and resetting a forgotten password are the
4499
+ // same action from `POST /api/auth/reset-password`'s point of view (see
4500
+ // that route's comment for the one line that tells them apart: whether
4501
+ // the account was `invited`).
4502
+ onInvite: ({ user, roles, token, expiresAt }) => sendInviteMail({
4503
+ mailDir: join(projectRoot, '.cogenta', 'mail'),
4504
+ acceptUrl: new URL('/admin/reset-password', loaded.config.site.url).toString(),
4505
+ }, loaded.config.site, user.email, roles, token, expiresAt).then(() => undefined),
4506
+ });
4507
+ // Observability (fiche L22 task 5): OpenTelemetry tracing plus a local,
4508
+ // bounded recent-events buffer the admin's "Exploitation" screen reads.
4509
+ // The OTLP export destination is infra config, resolved once here
4510
+ // (`loaded.config.observability` — it can carry a bearer-token header,
4511
+ // rule R7); whether collection runs at all, and how verbose it is, are
4512
+ // the editorial `observability.enabled`/`observability.logLevel` site
4513
+ // settings instead, changeable from the admin with no restart. A DB read
4514
+ // on every request or log call would be its own cost, so both are cached
4515
+ // and refreshed on a short interval — eventually consistent, the same
4516
+ // trade every other "no restart" setting in this file already makes (see
4517
+ // `homePath`'s own comment further up), never a query per call.
4518
+ let observabilityEnabled = true;
4519
+ let observabilityLogLevel = 'info';
4520
+ const OBSERVABILITY_LOG_LEVELS = ['error', 'warn', 'info', 'debug'];
4521
+ async function refreshObservabilitySettings() {
4522
+ try {
4523
+ const [enabledSetting, logLevelSetting] = await Promise.all([
4524
+ site.siteSettingsStore.get('observability.enabled', SITE_SETTINGS_SITE_SCOPE),
4525
+ site.siteSettingsStore.get('observability.logLevel', SITE_SETTINGS_SITE_SCOPE),
4526
+ ]);
4527
+ observabilityEnabled =
4528
+ typeof enabledSetting?.value === 'boolean' ? enabledSetting.value : true;
4529
+ const level = logLevelSetting?.value;
4530
+ observabilityLogLevel =
4531
+ typeof level === 'string' && OBSERVABILITY_LOG_LEVELS.includes(level)
4532
+ ? level
4533
+ : 'info';
4534
+ }
4535
+ catch (error) {
4536
+ logger.warn('failed to refresh observability settings', { error: String(error) });
4537
+ }
4538
+ }
4539
+ await refreshObservabilitySettings();
4540
+ const observabilityRuntime = createObservabilityRuntime({
4541
+ serviceName: loaded.config.observability.serviceName,
4542
+ ...(loaded.config.observability.otlpEndpoint === undefined
4543
+ ? {}
4544
+ : {
4545
+ otlp: {
4546
+ endpoint: loaded.config.observability.otlpEndpoint,
4547
+ ...(loaded.config.observability.otlpHeaders === undefined
4548
+ ? {}
4549
+ : { headers: loaded.config.observability.otlpHeaders }),
4550
+ },
4551
+ }),
4552
+ isEnabled: () => observabilityEnabled,
4553
+ });
4554
+ // From here on, every `logger.debug/info/warn/error` call in this function
4555
+ // also feeds the observability recent-log buffer (gated by the live
4556
+ // `observabilityLogLevel` above) — nothing before this line could have,
4557
+ // since `site.siteSettingsStore` did not exist yet to read the setting
4558
+ // from. `assembleSite` above already captured the pre-wrap `logger` by
4559
+ // value, so its own internal logging is unaffected — an accepted, narrow
4560
+ // gap (documented in the task report) rather than a reason to thread a
4561
+ // mutable logger reference through a function whose every other caller
4562
+ // (tests included) passes a plain, already-built one.
4563
+ logger = observabilityRuntime.wrapLogger(logger, () => observabilityLogLevel);
4564
+ const observabilityRouter = createObservabilityRouter({
4565
+ isEnabled: () => observabilityEnabled,
4566
+ getRecentTraces: () => observabilityRuntime.recentStore.recentTraces(),
4567
+ getRecentLogs: () => observabilityRuntime.recentStore.recentLogs(),
4568
+ });
4569
+ const toolRunner = createToolRunner({
4570
+ queue: toolsQueue,
4571
+ logger,
4572
+ bodies: buildToolBodies({
4573
+ db: selection.instance,
4574
+ collections,
4575
+ locales: loaded.config.site.locales,
4576
+ defaultLocale: loaded.config.site.defaultLocale,
4577
+ cache: cacheSelection.instance,
4578
+ searchIndex,
4579
+ vectors: assistant.vectors === undefined
4580
+ ? null
4581
+ : { siteId: loaded.config.site.url, ...assistant.vectors },
4582
+ mediaStore: site.mediaStore,
4583
+ storage: storageSelection.instance,
4584
+ images: images?.processor ?? null,
4585
+ emailTransport,
4586
+ siteName: loaded.config.site.name,
4587
+ }),
4588
+ });
4589
+ const healthRouter = createHealthRouter({
4590
+ // The literal function `cogenta doctor` calls — task 1's acceptance
4591
+ // criterion ("le diagnostic de l'admin est le même code") holds by
4592
+ // construction, not by convention.
4593
+ getReport: () => runDoctor({ cwd: projectRoot, env, logger }),
4594
+ getMigrations: async () => {
4595
+ const status = await migrator.status();
4596
+ return {
4597
+ items: status.map((item) => ({
4598
+ id: item.id,
4599
+ name: item.name,
4600
+ applied: item.applied,
4601
+ destructive: item.destructive,
4602
+ ...(item.appliedAt === undefined ? {} : { appliedAt: item.appliedAt }),
4603
+ ...(item.impact === undefined ? {} : { impact: item.impact }),
4604
+ })),
4605
+ };
4606
+ },
4607
+ // "Appliquer seulement les migrations non destructives, et renvoyer
4608
+ // explicitement à la CLI pour les destructives" (fiche 24 task 2's
4609
+ // recommendation, taken). `up({ to })` already applies in order up to a
4610
+ // named id — the id right before the first pending destructive one — so
4611
+ // this never needs to touch `confirmDestructive`/`backupVerified` at all.
4612
+ applyMigrations: async () => {
4613
+ const status = await migrator.status();
4614
+ const pending = status.filter((item) => !item.applied);
4615
+ const firstDestructive = pending.find((item) => item.destructive);
4616
+ if (firstDestructive === undefined) {
4617
+ const outcomes = await migrator.up();
4618
+ return { applied: outcomes.map((outcome) => outcome.id), remainingDestructive: [] };
4619
+ }
4620
+ const before = pending.slice(0, pending.findIndex((item) => item.id === firstDestructive.id));
4621
+ const remainingDestructive = pending
4622
+ .slice(pending.findIndex((item) => item.id === firstDestructive.id))
4623
+ .filter((item) => item.destructive)
4624
+ .map((item) => item.id);
4625
+ const cutoff = before[before.length - 1];
4626
+ if (cutoff === undefined)
4627
+ return { applied: [], remainingDestructive };
4628
+ const outcomes = await migrator.up({ to: cutoff.id });
4629
+ return { applied: outcomes.map((outcome) => outcome.id), remainingDestructive };
4630
+ },
4631
+ getAuditIntegrity: async () => {
4632
+ try {
4633
+ await site.auth.audit.verify();
4634
+ return { ok: true, checkedAt: new Date().toISOString(), error: undefined };
4635
+ }
4636
+ catch (error) {
4637
+ return {
4638
+ ok: false,
4639
+ checkedAt: new Date().toISOString(),
4640
+ error: error instanceof Error ? error.message : String(error),
4641
+ };
4642
+ }
4643
+ },
4644
+ getDiskUsage: async () => {
4645
+ if (loaded.config.storage.driver === 's3')
4646
+ return { available: false };
4647
+ try {
4648
+ const stats = await statfs(join(projectRoot, '.cogenta', 'storage'));
4649
+ return {
4650
+ available: true,
4651
+ freeBytes: stats.bfree * stats.bsize,
4652
+ totalBytes: stats.blocks * stats.bsize,
4653
+ path: join(projectRoot, '.cogenta', 'storage'),
4654
+ };
4655
+ }
4656
+ catch {
4657
+ return { available: false };
4658
+ }
4659
+ },
4660
+ getErrorLog: () => errorLog.entries(),
4661
+ getMaintenance: () => maintenanceStore.get(),
4662
+ setMaintenance: (input, actorId) => maintenanceStore.set({
4663
+ enabled: input.enabled,
4664
+ ...(input.message === undefined ? {} : { message: input.message }),
4665
+ updatedBy: actorId,
4666
+ }),
4667
+ });
4668
+ const toolsRouter = createToolsRouter({
4669
+ tools: TOOL_DEFINITIONS,
4670
+ run: (id, runOptions) => toolRunner.run(id, runOptions),
4671
+ getRun: (id) => toolRunner.getRun(id),
4672
+ listRuns: () => toolRunner.listRuns(),
4673
+ });
4674
+ // "Tâches planifiées" (fiche 28 task 2, L20 audit §1 point 6): the registry
4675
+ // and its router already existed (`@cogenta/schema`, `@cogenta/api`) —
4676
+ // `cogenta serve` never actually constructed either, so `GET
4677
+ // /api/scheduled-tasks` 404'd through the generic content-route "no route
4678
+ // matches this path" for every admin that opened the screen. This registers
4679
+ // the same seven recurring jobs the raw `setInterval`s below used to run
4680
+ // blind, so "run now"/history/next-run in the admin reflect the real thing
4681
+ // rather than nothing at all.
4682
+ const scheduledTaskRegistry = createScheduledTaskRegistry({
4683
+ db: site.db,
4684
+ logger,
4685
+ });
4686
+ scheduledTaskRegistry.register({
4687
+ name: 'scheduled-publish',
4688
+ description: 'Publish entries whose scheduled time has come, and drain the tools queue.',
4689
+ intervalMs: options.scheduledPublishTickMs ?? SCHEDULED_PUBLISH_TICK_MS,
4690
+ run: async () => {
4691
+ const published = await site.tickScheduledPublishing();
4692
+ await toolsQueue.tick();
4693
+ return { summary: `${published} published` };
4694
+ },
4695
+ });
4696
+ scheduledTaskRegistry.register({
4697
+ name: 'not-found-purge',
4698
+ description: "Purge 404 log entries past the site's retention window.",
4699
+ intervalMs: options.notFoundPurgeTickMs ?? NOT_FOUND_PURGE_TICK_MS,
4700
+ run: async () => ({ summary: `${await site.tickNotFoundPurge()} purged` }),
4701
+ });
4702
+ scheduledTaskRegistry.register({
4703
+ name: 'audit-integrity',
4704
+ description: 'Verify the audit log hash chain has not been tampered with.',
4705
+ intervalMs: options.auditIntegrityTickMs ?? AUDIT_INTEGRITY_TICK_MS,
4706
+ run: async () => {
4707
+ await site.checkAuditIntegrity();
4708
+ return undefined;
4709
+ },
4710
+ });
4711
+ scheduledTaskRegistry.register({
4712
+ name: 'audit-prune',
4713
+ description: "Purge audit-log entries past the site's configured retention window.",
4714
+ intervalMs: options.auditPruneTickMs ?? AUDIT_PRUNE_TICK_MS,
4715
+ destructive: true,
4716
+ run: async () => {
4717
+ const result = await site.tickAuditPrune();
4718
+ return { summary: `${result.pruned} purged` };
4719
+ },
4720
+ });
4721
+ scheduledTaskRegistry.register({
4722
+ name: 'trash-purge',
4723
+ description: "Permanently delete trashed content past the site's retention window.",
4724
+ intervalMs: options.trashPurgeTickMs ?? TRASH_PURGE_TICK_MS,
4725
+ destructive: true,
4726
+ run: async () => {
4727
+ const result = await site.tickTrashPurge();
4728
+ return { summary: `${result.purged} purged` };
4729
+ },
4730
+ });
4731
+ scheduledTaskRegistry.register({
4732
+ name: 'forms-purge',
4733
+ description: "Purge form submissions past each form's own GDPR retention window.",
4734
+ intervalMs: options.formsPurgeTickMs ?? FORMS_PURGE_TICK_MS,
4735
+ run: async () => ({ summary: `${await site.tickFormsPurge()} purged` }),
4736
+ });
4737
+ scheduledTaskRegistry.register({
4738
+ name: 'channel-notifications',
4739
+ description: 'Flush any due grouped notification to its channel.',
4740
+ intervalMs: options.channelNotificationTickMs ?? CHANNEL_NOTIFICATION_TICK_MS,
4741
+ run: async () => ({ summary: `${(await site.tickChannelNotifications()).length} sent` }),
4742
+ });
4743
+ scheduledTaskRegistry.register({
4744
+ name: 'analytics-purge',
4745
+ description: "Purge page-view events past the site's configured retention window.",
4746
+ intervalMs: options.analyticsPurgeTickMs ?? ANALYTICS_PURGE_TICK_MS,
4747
+ run: async () => ({ summary: `${await site.tickAnalyticsPurge()} purged` }),
4748
+ });
4749
+ // Absent on a site with no e-mail transport configured (R1/R2) — registering
4750
+ // a task that would always no-op is worse than not registering it at all.
4751
+ if (site.tickCommerceEmails !== null) {
4752
+ const tickCommerceEmails = site.tickCommerceEmails;
4753
+ scheduledTaskRegistry.register({
4754
+ name: 'commerce-order-emails',
4755
+ description: 'Send any due order confirmation/shipment e-mail, retrying a past failure.',
4756
+ intervalMs: options.commerceEmailTickMs ?? COMMERCE_EMAIL_TICK_MS,
4757
+ run: async () => {
4758
+ const result = await tickCommerceEmails();
4759
+ return { summary: `${result.sent} sent, ${result.failed} failed` };
4760
+ },
4761
+ });
4762
+ }
4763
+ // Fiche 53 tasks 3 and 5, audit T-COM-01 (P0): `runBilling`/`runDunning`/
4764
+ // `sendRenewalNotices` existed, were fully tested against the store, and
4765
+ // were never once called by `cogenta serve` — a subscription's own
4766
+ // idempotence guarantees (compare-and-set on `next_retry_at`, one row per
4767
+ // billing period) were true of code nothing ever ran. Always registered,
4768
+ // unlike `commerce-order-emails` above: subscription billing needs no
4769
+ // email transport, only this site's own commerce tables, which
4770
+ // `ensureCommerceTables` already created unconditionally.
4771
+ scheduledTaskRegistry.register({
4772
+ name: 'commerce-subscriptions',
4773
+ description: 'Bill due subscriptions, retry due dunning payments, and send due renewal notices.',
4774
+ intervalMs: options.commerceBillingTickMs ?? COMMERCE_BILLING_TICK_MS,
4775
+ run: async () => {
4776
+ const result = await site.tickCommerceSubscriptions();
4777
+ return {
4778
+ summary: `${result.billed} billed, ${result.dunningRetried} dunning retried, ${result.dunningSuspended} suspended, ${result.renewalNoticesSent} renewal notices sent`,
4779
+ };
4780
+ },
4781
+ });
4782
+ // Audit A1-commerce (P2): `CartStore.abandon()` existed since fiche 32
4783
+ // with no automatic caller — an open cart nobody touched stayed `open`
4784
+ // forever on a real site. Always registered, same reasoning as
4785
+ // `commerce-subscriptions` just above: no email transport needed, only
4786
+ // this site's own unconditionally-created commerce tables.
4787
+ scheduledTaskRegistry.register({
4788
+ name: 'commerce-carts',
4789
+ description: 'Mark every open cart abandoned once it has been inactive past its threshold.',
4790
+ intervalMs: options.cartAbandonTickMs ?? CART_ABANDON_TICK_MS,
4791
+ run: async () => {
4792
+ const result = await site.tickCartAbandon(options.cartAbandonAfterMs === undefined ? {} : { olderThanMs: options.cartAbandonAfterMs });
4793
+ return { summary: `${result.abandoned} abandoned` };
4794
+ },
4795
+ });
4796
+ scheduledTaskRegistry.register({
4797
+ name: 'updates-auto-check',
4798
+ description: 'Check npm for a newer @cogenta/core/@cogenta/cli, and apply it when the auto-update policy allows and no contract risk was flagged.',
4799
+ intervalMs: options.updatesAutoCheckTickMs ?? UPDATES_AUTO_CHECK_TICK_MS,
4800
+ run: async () => {
4801
+ const setting = await site.siteSettingsStore.get('updates.autoUpdatePolicy', SITE_SETTINGS_SITE_SCOPE);
4802
+ const rawPolicy = setting?.value;
4803
+ const policy = AUTO_UPDATE_POLICIES.includes(rawPolicy)
4804
+ ? rawPolicy
4805
+ : 'off';
4806
+ // Off by default (the registry's own default value) — no network call
4807
+ // at all in that case, R1/R2's "nothing surprising happens without an
4808
+ // explicit opt-in" applied to this feature too.
4809
+ if (policy === 'off')
4810
+ return { summary: 'auto-update is off' };
4811
+ const report = await checkForUpdates({
4812
+ packages: updatePackages(),
4813
+ ...(options.updatesFetchImpl === undefined ? {} : { fetchImpl: options.updatesFetchImpl }),
4814
+ });
4815
+ // Never a package the policy does not cover, and never one whose
4816
+ // changelog scan flagged a frozen-contract mention — an unattended
4817
+ // tick applies exactly nothing it cannot already tell is safe by this
4818
+ // system's own honest standard (`contract-risk.ts`'s module comment).
4819
+ const applicable = report.packages.filter((pkg) => pkg.updateAvailable &&
4820
+ pkg.latest !== null &&
4821
+ policyAllows(policy, pkg.bump) &&
4822
+ (pkg.contractRisk?.warnings.length ?? 0) === 0);
4823
+ if (applicable.length === 0) {
4824
+ return {
4825
+ summary: report.updateAvailable
4826
+ ? 'an update exists but is outside this policy or was flagged risky — left for manual review'
4827
+ : 'up to date',
4828
+ };
4829
+ }
4830
+ // `getCoreVersion()`/`getCliVersion()` are cached after their first
4831
+ // real call (self-describing, `readOwnPackageVersion`) and never
4832
+ // change for the lifetime of this process, even after a real `npm
4833
+ // install` really does swap the files on disk — the running code stays
4834
+ // old until an actual restart. Without this guard, every tick after a
4835
+ // successful auto-apply would see the exact same "update available"
4836
+ // and try again, taking a fresh restore point and re-running `npm
4837
+ // install` forever until someone restarts the process.
4838
+ const signature = applicable
4839
+ .map((pkg) => `${pkg.name}@${pkg.latest}`)
4840
+ .sort()
4841
+ .join(',');
4842
+ if (signature === lastAutoAppliedSignature) {
4843
+ return { summary: 'already auto-updated to this version — waiting for a restart' };
4844
+ }
4845
+ const result = await applyUpdate({
4846
+ cwd: projectRoot,
4847
+ env,
4848
+ logger,
4849
+ packages: applicable.map((pkg) => ({ name: pkg.name, installed: pkg.installed })),
4850
+ confirmBreakingChange: false,
4851
+ backupDir: updatesBackupDir,
4852
+ ...(options.updatesFetchImpl === undefined ? {} : { fetchImpl: options.updatesFetchImpl }),
4853
+ ...(options.updatesRunInstall === undefined
4854
+ ? {}
4855
+ : { runInstall: options.updatesRunInstall }),
4856
+ });
4857
+ if (result.kind === 'applied') {
4858
+ await recordUpdateOutcome(result, null);
4859
+ lastAutoAppliedSignature = signature;
4860
+ return {
4861
+ summary: `auto-updated: ${result.installed.map((pkg) => `${pkg.name}@${pkg.version}`).join(', ')} — restart to run the new version`,
4862
+ };
4863
+ }
4864
+ // A risk this tick's own filter did not see (e.g. the tarball scan
4865
+ // failing between the two checks) — refused rather than guessed at,
4866
+ // same as a human's "confirmation-required" would be.
4867
+ return {
4868
+ summary: 'a re-check before applying found a reason not to — left for manual review',
4869
+ };
4870
+ },
4871
+ });
4872
+ const scheduledTasksRouter = createScheduledTasksRouter({
4873
+ registry: scheduledTaskRegistry,
4874
+ queue: toolsQueue,
4875
+ mode: 'internal',
4876
+ onManualRun: ({ taskName, outcome, actorId }) => {
4877
+ // Best-effort, same as every other audit write in this file (e.g.
4878
+ // `recordAuditExportAudit`) — a failed journal entry must not undo the
4879
+ // task run it is describing. `assertAdmin` inside the router already
4880
+ // guarantees the caller holds `admin`.
4881
+ void site.auth.audit
4882
+ .record({
4883
+ actorId,
4884
+ actorRoles: ['admin'],
4885
+ action: 'scheduled_task.run',
4886
+ diff: { taskName, outcome },
4887
+ })
4888
+ .catch((error) => {
4889
+ logger.error('scheduled task audit record failed', { error: String(error) });
4890
+ });
4891
+ },
1225
4892
  });
1226
- const server = createServer(createRequestListener(site, logger));
4893
+ const server = createServer(withRequestTracing(createRequestListener(site, logger, {
4894
+ healthRouter,
4895
+ toolsRouter,
4896
+ scheduledTasksRouter,
4897
+ maintenance: maintenanceStore,
4898
+ errorLog,
4899
+ siteName: loaded.config.site.name,
4900
+ observabilityRouter,
4901
+ }), observabilityRuntime));
1227
4902
  const port = options.port ?? DEFAULT_PORT;
1228
4903
  const host = options.host ?? DEFAULT_HOST;
1229
4904
  await new Promise((resolve, reject) => {
@@ -1239,6 +4914,44 @@ export async function runServe(options) {
1239
4914
  out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}, image driver: ${images?.driver ?? 'none'}`);
1240
4915
  out.detail(assistant.summary);
1241
4916
  options.onListening?.({ port: boundPort, host });
4917
+ // The recurring jobs above (scheduled publication, the tools queue drain
4918
+ // riding along with it, the 404 log purge, audit integrity, audit-log
4919
+ // retention (T09-01), the trash sweep, forms GDPR retention, channel
4920
+ // notification flush, analytics retention, updates auto-check, the
4921
+ // commerce order-email retry queue (fiche 52 task 2), the subscription
4922
+ // billing/dunning/renewal-notice sweep (fiche 53 tasks 3/5, audit
4923
+ // T-COM-01), and — audit A1-commerce P2 — the abandoned-cart sweep) are all
4924
+ // `scheduledTaskRegistry` entries rather
4925
+ // than independent `setInterval`s: one heartbeat drives `registry.tick()`,
4926
+ // which itself decides which tasks are actually due against the interval
4927
+ // each was registered with above — the same cadence as before, since the
4928
+ // heartbeat is at least as frequent as the fastest of them. Every one of
4929
+ // their own tick overrides must be folded into `scheduledTasksHeartbeatMs`
4930
+ // below, or a test that speeds up only its own task's interval sees no
4931
+ // effect — a real bug this fiche found and fixed for its own task. A
4932
+ // single failed tick is logged by the registry's own `execute()`, never
4933
+ // fatal.
4934
+ const scheduledTasksHeartbeatMs = Math.min(options.scheduledPublishTickMs ?? SCHEDULED_PUBLISH_TICK_MS, options.notFoundPurgeTickMs ?? NOT_FOUND_PURGE_TICK_MS, options.auditIntegrityTickMs ?? AUDIT_INTEGRITY_TICK_MS, options.auditPruneTickMs ?? AUDIT_PRUNE_TICK_MS, options.trashPurgeTickMs ?? TRASH_PURGE_TICK_MS, options.formsPurgeTickMs ?? FORMS_PURGE_TICK_MS, options.channelNotificationTickMs ?? CHANNEL_NOTIFICATION_TICK_MS, options.analyticsPurgeTickMs ?? ANALYTICS_PURGE_TICK_MS, options.updatesAutoCheckTickMs ?? UPDATES_AUTO_CHECK_TICK_MS, options.commerceEmailTickMs ?? COMMERCE_EMAIL_TICK_MS, options.commerceBillingTickMs ?? COMMERCE_BILLING_TICK_MS, options.cartAbandonTickMs ?? CART_ABANDON_TICK_MS);
4935
+ const runScheduledTasksHeartbeat = () => {
4936
+ // Sequenced, not concurrent: `registry.tick()` already runs its due
4937
+ // tasks one at a time for exactly this reason (see its own comment) —
4938
+ // two heartbeats overlapping would open two transactions on the same
4939
+ // SQLite connection.
4940
+ scheduledTaskRegistry.tick().catch((error) => {
4941
+ logger.error('scheduled tasks heartbeat failed', { error: String(error) });
4942
+ });
4943
+ };
4944
+ runScheduledTasksHeartbeat();
4945
+ const scheduledTasksTimer = setInterval(runScheduledTasksHeartbeat, scheduledTasksHeartbeatMs);
4946
+ // Never keeps the process alive on its own: a `signal`-driven shutdown with
4947
+ // no open connections must still be able to exit.
4948
+ scheduledTasksTimer.unref();
4949
+ // Refreshes the cached `observabilityEnabled`/`observabilityLogLevel`
4950
+ // values from the settings store (see their own comment above) — this is
4951
+ // what makes flipping either one from the admin take effect without a
4952
+ // restart, bounded by this interval rather than instant.
4953
+ const observabilitySettingsTimer = setInterval(() => void refreshObservabilitySettings(), options.observabilitySettingsTickMs ?? OBSERVABILITY_SETTINGS_TICK_MS);
4954
+ observabilitySettingsTimer.unref();
1242
4955
  await new Promise((resolve) => {
1243
4956
  if (options.signal === undefined)
1244
4957
  return;
@@ -1248,6 +4961,8 @@ export async function runServe(options) {
1248
4961
  }
1249
4962
  options.signal.addEventListener('abort', () => resolve(), { once: true });
1250
4963
  });
4964
+ clearInterval(scheduledTasksTimer);
4965
+ clearInterval(observabilitySettingsTimer);
1251
4966
  await new Promise((resolve, reject) => {
1252
4967
  server.close((error) => (error ? reject(error) : resolve()));
1253
4968
  // `close()` alone waits for every open connection to end, and a client
@@ -1259,9 +4974,13 @@ export async function runServe(options) {
1259
4974
  const grace = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
1260
4975
  grace.unref();
1261
4976
  });
4977
+ await observabilityRuntime.shutdown();
1262
4978
  await assistant.dispose();
4979
+ await toolsQueue.close();
4980
+ await cacheSelection.dispose();
1263
4981
  await selection.dispose();
1264
4982
  await storageSelection.dispose();
4983
+ await rateLimitSelection.dispose();
1265
4984
  await site.dispose().catch(() => undefined); // selection.dispose() already closed the same handle
1266
4985
  return 0;
1267
4986
  }