@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.
- package/CHANGELOG.md +3951 -0
- package/dist/admin-assets/assets/index-Bc_X94A_.js +1090 -0
- package/dist/admin-assets/assets/index-CxZHGwVA.css +1 -0
- package/dist/admin-assets/assets/vips-_dmTUXFO.wasm +0 -0
- package/dist/admin-assets/assets/vips-es6-DrQTTS8Q.js +7 -0
- package/dist/admin-assets/branding/logo-cogenta-dark.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-icon.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-light.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-small.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta-transparent.png +0 -0
- package/dist/admin-assets/branding/logo-cogenta.png +0 -0
- package/dist/admin-assets/favicon.png +0 -0
- package/dist/admin-assets/fonts/jetbrains-mono-var.woff2 +0 -0
- package/dist/admin-assets/fonts/space-grotesk-var.woff2 +0 -0
- package/dist/admin-assets/index.html +3 -2
- package/dist/bin.js +6 -0
- package/dist/bin.js.map +1 -1
- package/dist/commands/admin-assets.d.ts +1 -0
- package/dist/commands/admin-assets.d.ts.map +1 -1
- package/dist/commands/admin-assets.js +19 -1
- package/dist/commands/admin-assets.js.map +1 -1
- package/dist/commands/agent-runtime.d.ts +160 -0
- package/dist/commands/agent-runtime.d.ts.map +1 -0
- package/dist/commands/agent-runtime.js +524 -0
- package/dist/commands/agent-runtime.js.map +1 -0
- package/dist/commands/assistant.d.ts +89 -3
- package/dist/commands/assistant.d.ts.map +1 -1
- package/dist/commands/assistant.js +209 -41
- package/dist/commands/assistant.js.map +1 -1
- package/dist/commands/audit-integrity-alert.d.ts +23 -0
- package/dist/commands/audit-integrity-alert.d.ts.map +1 -0
- package/dist/commands/audit-integrity-alert.js +33 -0
- package/dist/commands/audit-integrity-alert.js.map +1 -0
- package/dist/commands/backup.d.ts +78 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +238 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/channels.d.ts +47 -0
- package/dist/commands/channels.d.ts.map +1 -0
- package/dist/commands/channels.js +284 -0
- package/dist/commands/channels.js.map +1 -0
- package/dist/commands/default-logo.d.ts +27 -0
- package/dist/commands/default-logo.d.ts.map +1 -0
- package/dist/commands/default-logo.js +30 -0
- package/dist/commands/default-logo.js.map +1 -0
- package/dist/commands/doctor.d.ts +5 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +74 -2
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/export.d.ts +22 -0
- package/dist/commands/export.d.ts.map +1 -0
- package/dist/commands/export.js +182 -0
- package/dist/commands/export.js.map +1 -0
- package/dist/commands/forms-page.d.ts +75 -0
- package/dist/commands/forms-page.d.ts.map +1 -0
- package/dist/commands/forms-page.js +191 -0
- package/dist/commands/forms-page.js.map +1 -0
- package/dist/commands/import.d.ts.map +1 -1
- package/dist/commands/import.js +13 -1
- package/dist/commands/import.js.map +1 -1
- package/dist/commands/mcp.d.ts +35 -0
- package/dist/commands/mcp.d.ts.map +1 -0
- package/dist/commands/mcp.js +277 -0
- package/dist/commands/mcp.js.map +1 -0
- package/dist/commands/roles.d.ts +22 -0
- package/dist/commands/roles.d.ts.map +1 -0
- package/dist/commands/roles.js +79 -0
- package/dist/commands/roles.js.map +1 -0
- package/dist/commands/search-page.d.ts +15 -0
- package/dist/commands/search-page.d.ts.map +1 -1
- package/dist/commands/search-page.js +22 -16
- package/dist/commands/search-page.js.map +1 -1
- package/dist/commands/seo.d.ts +92 -4
- package/dist/commands/seo.d.ts.map +1 -1
- package/dist/commands/seo.js +168 -4
- package/dist/commands/seo.js.map +1 -1
- package/dist/commands/serve.d.ts +439 -7
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +3515 -181
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/site-plan.d.ts +27 -1
- package/dist/commands/site-plan.d.ts.map +1 -1
- package/dist/commands/site-plan.js +79 -8
- package/dist/commands/site-plan.js.map +1 -1
- package/dist/commands/skin.d.ts.map +1 -1
- package/dist/commands/skin.js +7 -2
- package/dist/commands/skin.js.map +1 -1
- package/dist/commands/term-archive-page.d.ts +94 -0
- package/dist/commands/term-archive-page.d.ts.map +1 -0
- package/dist/commands/term-archive-page.js +195 -0
- package/dist/commands/term-archive-page.js.map +1 -0
- package/dist/commands/theme-css.d.ts +22 -7
- package/dist/commands/theme-css.d.ts.map +1 -1
- package/dist/commands/theme-css.js +33 -8
- package/dist/commands/theme-css.js.map +1 -1
- package/dist/commands/theme-registry.d.ts +94 -0
- package/dist/commands/theme-registry.d.ts.map +1 -0
- package/dist/commands/theme-registry.js +130 -0
- package/dist/commands/theme-registry.js.map +1 -0
- package/dist/commands/theme-render.d.ts +330 -9
- package/dist/commands/theme-render.d.ts.map +1 -1
- package/dist/commands/theme-render.js +932 -66
- package/dist/commands/theme-render.js.map +1 -1
- package/dist/commands/theme-wiring.d.ts +107 -0
- package/dist/commands/theme-wiring.d.ts.map +1 -0
- package/dist/commands/theme-wiring.js +308 -0
- package/dist/commands/theme-wiring.js.map +1 -0
- package/dist/commands/tools.d.ts +71 -0
- package/dist/commands/tools.d.ts.map +1 -0
- package/dist/commands/tools.js +330 -0
- package/dist/commands/tools.js.map +1 -0
- package/dist/commands/update.d.ts +25 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +193 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +146 -4
- package/dist/index.js.map +1 -1
- package/dist/invite-mail.d.ts +39 -0
- package/dist/invite-mail.d.ts.map +1 -0
- package/dist/invite-mail.js +39 -0
- package/dist/invite-mail.js.map +1 -0
- package/dist/update/apply.d.ts +54 -0
- package/dist/update/apply.d.ts.map +1 -0
- package/dist/update/apply.js +77 -0
- package/dist/update/apply.js.map +1 -0
- package/dist/update/changelog-risk.d.ts +20 -0
- package/dist/update/changelog-risk.d.ts.map +1 -0
- package/dist/update/changelog-risk.js +55 -0
- package/dist/update/changelog-risk.js.map +1 -0
- package/dist/update/check.d.ts +60 -0
- package/dist/update/check.d.ts.map +1 -0
- package/dist/update/check.js +56 -0
- package/dist/update/check.js.map +1 -0
- package/dist/update/contract-risk.d.ts +55 -0
- package/dist/update/contract-risk.d.ts.map +1 -0
- package/dist/update/contract-risk.js +75 -0
- package/dist/update/contract-risk.js.map +1 -0
- package/dist/update/history.d.ts +30 -0
- package/dist/update/history.d.ts.map +1 -0
- package/dist/update/history.js +38 -0
- package/dist/update/history.js.map +1 -0
- package/dist/update/index.d.ts +28 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +11 -0
- package/dist/update/index.js.map +1 -0
- package/dist/update/list-restore-points.d.ts +20 -0
- package/dist/update/list-restore-points.d.ts.map +1 -0
- package/dist/update/list-restore-points.js +35 -0
- package/dist/update/list-restore-points.js.map +1 -0
- package/dist/update/npm-registry.d.ts +11 -0
- package/dist/update/npm-registry.d.ts.map +1 -0
- package/dist/update/npm-registry.js +74 -0
- package/dist/update/npm-registry.js.map +1 -0
- package/dist/update/restore-point.d.ts +26 -0
- package/dist/update/restore-point.d.ts.map +1 -0
- package/dist/update/restore-point.js +23 -0
- package/dist/update/restore-point.js.map +1 -0
- package/dist/update/tar.d.ts +27 -0
- package/dist/update/tar.d.ts.map +1 -0
- package/dist/update/tar.js +56 -0
- package/dist/update/tar.js.map +1 -0
- package/dist/update/version-compare.d.ts +17 -0
- package/dist/update/version-compare.d.ts.map +1 -0
- package/dist/update/version-compare.js +42 -0
- package/dist/update/version-compare.js.map +1 -0
- package/dist/version.d.ts +13 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +18 -0
- package/dist/version.js.map +1 -0
- package/package.json +33 -16
- package/dist/admin-assets/assets/index-C9a7O_Xs.css +0 -1
- package/dist/admin-assets/assets/index-DXvMgWvn.js +0 -74
package/dist/commands/serve.js
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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 {
|
|
25
|
-
import {
|
|
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(
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
|
|
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
|
|
332
|
-
|
|
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
|
|
338
|
-
? entry.values
|
|
339
|
-
: typeof entry.values
|
|
340
|
-
? entry.values
|
|
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 {
|
|
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
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
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:
|
|
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
|
|
1117
|
+
const commerceSeller = billing === undefined
|
|
397
1118
|
? undefined
|
|
398
|
-
:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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({
|
|
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({
|
|
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
|
-
|
|
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
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
-
|
|
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
|
|
684
|
-
.replace(/^\/api\/
|
|
2089
|
+
const segments = pathname
|
|
2090
|
+
.replace(/^\/api\/content\/?/u, '')
|
|
685
2091
|
.split('/')
|
|
686
2092
|
.filter((segment) => segment.length > 0);
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
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
|
|
2307
|
+
.record({ actorId: userId, actorRoles: roles, action })
|
|
749
2308
|
.catch((error) => logger.error('audit record failed', { error: String(error) }));
|
|
750
2309
|
}
|
|
751
2310
|
/**
|
|
752
|
-
*
|
|
753
|
-
*
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
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
|
|
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', '
|
|
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
|
-
? '
|
|
772
|
-
: method === '
|
|
773
|
-
? '
|
|
774
|
-
: method === 'POST' && sub === '
|
|
775
|
-
? '
|
|
776
|
-
: method === 'DELETE' && sub === '
|
|
777
|
-
? '
|
|
778
|
-
:
|
|
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
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
799
|
-
*
|
|
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
|
|
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
|
|
805
|
-
|
|
806
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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
|
|
1145
|
-
// a rule from a browser instead of the database directly
|
|
1146
|
-
// follow-up to L10 task 2)
|
|
1147
|
-
|
|
1148
|
-
|
|
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
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
|
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
|
|
1392
|
-
const
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1629
|
-
//
|
|
1630
|
-
//
|
|
1631
|
-
//
|
|
1632
|
-
//
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
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
|
-
|
|
1639
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
}
|