@cogenta/cli 0.4.0 → 0.7.0

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