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