@cogenta/cli 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/admin-assets/assets/index-C9a7O_Xs.css +1 -0
  2. package/dist/admin-assets/assets/index-DXvMgWvn.js +74 -0
  3. package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
  4. package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
  5. package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
  6. package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
  7. package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
  8. package/dist/admin-assets/index.html +2 -2
  9. package/dist/commands/assistant.d.ts +65 -0
  10. package/dist/commands/assistant.d.ts.map +1 -0
  11. package/dist/commands/assistant.js +207 -0
  12. package/dist/commands/assistant.js.map +1 -0
  13. package/dist/commands/content-webhooks.d.ts +41 -0
  14. package/dist/commands/content-webhooks.d.ts.map +1 -0
  15. package/dist/commands/content-webhooks.js +61 -0
  16. package/dist/commands/content-webhooks.js.map +1 -0
  17. package/dist/commands/http-security.d.ts +39 -0
  18. package/dist/commands/http-security.d.ts.map +1 -0
  19. package/dist/commands/http-security.js +153 -0
  20. package/dist/commands/http-security.js.map +1 -0
  21. package/dist/commands/links.d.ts +24 -0
  22. package/dist/commands/links.d.ts.map +1 -0
  23. package/dist/commands/links.js +109 -0
  24. package/dist/commands/links.js.map +1 -0
  25. package/dist/commands/media-images.d.ts +67 -0
  26. package/dist/commands/media-images.d.ts.map +1 -0
  27. package/dist/commands/media-images.js +107 -0
  28. package/dist/commands/media-images.js.map +1 -0
  29. package/dist/commands/search-page.d.ts +40 -0
  30. package/dist/commands/search-page.d.ts.map +1 -0
  31. package/dist/commands/search-page.js +104 -0
  32. package/dist/commands/search-page.js.map +1 -0
  33. package/dist/commands/security-alerts.d.ts +24 -0
  34. package/dist/commands/security-alerts.d.ts.map +1 -0
  35. package/dist/commands/security-alerts.js +82 -0
  36. package/dist/commands/security-alerts.js.map +1 -0
  37. package/dist/commands/seo.d.ts +88 -0
  38. package/dist/commands/seo.d.ts.map +1 -0
  39. package/dist/commands/seo.js +155 -0
  40. package/dist/commands/seo.js.map +1 -0
  41. package/dist/commands/serve.d.ts +138 -4
  42. package/dist/commands/serve.d.ts.map +1 -1
  43. package/dist/commands/serve.js +1145 -43
  44. package/dist/commands/serve.js.map +1 -1
  45. package/dist/commands/site-plan.d.ts +86 -0
  46. package/dist/commands/site-plan.d.ts.map +1 -0
  47. package/dist/commands/site-plan.js +235 -0
  48. package/dist/commands/site-plan.js.map +1 -0
  49. package/dist/commands/theme-css.d.ts +54 -0
  50. package/dist/commands/theme-css.d.ts.map +1 -0
  51. package/dist/commands/theme-css.js +121 -0
  52. package/dist/commands/theme-css.js.map +1 -0
  53. package/dist/commands/theme-render.d.ts +115 -4
  54. package/dist/commands/theme-render.d.ts.map +1 -1
  55. package/dist/commands/theme-render.js +260 -17
  56. package/dist/commands/theme-render.js.map +1 -1
  57. package/dist/commands/users.d.ts +7 -1
  58. package/dist/commands/users.d.ts.map +1 -1
  59. package/dist/commands/users.js +129 -15
  60. package/dist/commands/users.js.map +1 -1
  61. package/dist/index.d.ts +2 -0
  62. package/dist/index.d.ts.map +1 -1
  63. package/dist/index.js +31 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/reset-mail.d.ts +39 -0
  66. package/dist/reset-mail.d.ts.map +1 -0
  67. package/dist/reset-mail.js +45 -0
  68. package/dist/reset-mail.js.map +1 -0
  69. package/package.json +15 -10
  70. package/dist/admin-assets/assets/index-21ZcDkDC.css +0 -1
  71. package/dist/admin-assets/assets/index-BXVsXHD2.js +0 -23
@@ -1,14 +1,37 @@
1
- import { readFile } from 'node:fs/promises';
1
+ import { readFile, stat } from 'node:fs/promises';
2
2
  import { createServer } from 'node:http';
3
3
  import { dirname, join } from 'node:path';
4
4
  import process from 'node:process';
5
5
  import { pathToFileURL } from 'node:url';
6
- import { buildContentSchema, createAgentsRouter, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createMediaRouter, createPermissionLayer, createRestRouter, executeGraphQL, resolveActor, } from '@cogenta/api';
6
+ import { createAnalyticsStore, ensureAnalyticsTables, } from '@cogenta/analytics';
7
+ import { buildContentSchema, createAgentsRouter, createAnalyticsRouter, createApiKeysRouter, createAssistantRouter, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createImportRouter, createMarketplaceRouter, createMediaRouter, createMenuRouter, createMfaRecommendationSource, createNoticeDismissalStore, createNoticeRouter, createOpsStatusRouter, createPermissionLayer, createRedirectRouter, createRestRouter, createSearchRouter, createSitePlanRouter, createSuspiciousActivitySource, createTaxonomyRouter, createUsersRouter, errorResponse, executeGraphQL, resolveActor, variantKeyFor, } from '@cogenta/api';
7
8
  import { createAuthStore } from '@cogenta/auth';
8
- import { CogentaError, createDatabaseMediaStore, createDatabaseRegistry, createLogger, createStorageRegistry, isCogentaError, loadConfig, } from '@cogenta/core';
9
- import { buildSchemaDocument, createContentStore, createRedirectStore, createSchemaTables, withReadOnlyStore, } from '@cogenta/schema';
9
+ import { createCartStore, createCatalogStore, createCommerceAdminRouter, createCommercePermissions, createCouponStore, createCustomerStore, createInvoiceStore, createManualPaymentGateway, createOrderStore, createPaymentStore, createShippingStore, createSubscriptionStore, createTaxStore, ensureCommerceTables, } from '@cogenta/commerce';
10
+ import { CogentaError, createDatabaseMediaStore, createDatabaseQueue, createDatabaseRegistry, createLogger, createStorageRegistry, isCogentaError, loadConfig, } from '@cogenta/core';
11
+ import { importWordPress } from '@cogenta/import';
12
+ import { createMarketplaceCatalog, createMarketplaceInstaller, createPluginGrantStore, ensureMarketplaceTables, ensurePluginTables, } from '@cogenta/plugins';
13
+ import { buildPath, buildSchemaDocument, createContentStore, createMenuStore, createRedirectStore, createSchemaTables, createSearchIndex, createTaxonomyStore, ensureMenuTables, registerScheduledPublishing, withLifecycleEvents, withReadOnlyStore, withScheduledPublishEnqueue, withSearchIndexing, } from '@cogenta/schema';
14
+ import { sendResetMail } from '../reset-mail.js';
10
15
  import { serveAdminAsset } from './admin-assets.js';
11
- import { loadSkinCss, renderRequestedPage } from './theme-render.js';
16
+ import { buildAssistant, withVectorIndexing } from './assistant.js';
17
+ import { createContentWebhookEmitter } from './content-webhooks.js';
18
+ import { applySecurity } from './http-security.js';
19
+ import { selectMediaImageProcessor } from './media-images.js';
20
+ import { renderSearchPage } from './search-page.js';
21
+ import { createSecurityAlertWatch } from './security-alerts.js';
22
+ import { buildSitemapFiles, collectRoutedResources, renderRobots, seoSiteFor } from './seo.js';
23
+ import { createSitePlanning } from './site-plan.js';
24
+ import { cssEtag, loadThemeCss } from './theme-css.js';
25
+ import { DEFAULT_IMAGE_ENDPOINT, joinStyles, loadSkinCss, renderDraftPage, renderRequestedPage, STYLESHEET_PATH, } from './theme-render.js';
26
+ /** `/sitemap.xml` and the `/sitemap-N.xml` chunks a large site splits into. */
27
+ const SITEMAP_PATH = /^\/sitemap(?:-\d+)?\.xml$/u;
28
+ /** The only `Content-Type` values `/_image` will ever put on the wire. */
29
+ const SERVABLE_IMAGE_TYPES = new Set([
30
+ 'image/avif',
31
+ 'image/webp',
32
+ 'image/jpeg',
33
+ 'image/png',
34
+ ]);
12
35
  const SCHEMA_FILE_CANDIDATES = [
13
36
  'cogenta.schema.ts',
14
37
  'cogenta.schema.mts',
@@ -25,6 +48,16 @@ const SCHEMA_FILE_CANDIDATES = [
25
48
  * collections has nothing to serve.
26
49
  */
27
50
  export async function loadCollections(projectRoot) {
51
+ return (await loadSchemaModule(projectRoot)).collections;
52
+ }
53
+ /**
54
+ * The same file, read for both halves of the content model.
55
+ *
56
+ * Taxonomies arrive as a **named** export beside the default one
57
+ * (`export const taxonomies = [...]`), so every schema file written before
58
+ * `schema@2.0` keeps loading unchanged and simply declares none.
59
+ */
60
+ export async function loadSchemaModule(projectRoot) {
28
61
  for (const candidate of SCHEMA_FILE_CANDIDATES) {
29
62
  const path = join(projectRoot, candidate);
30
63
  let module;
@@ -49,7 +82,18 @@ export async function loadCollections(projectRoot) {
49
82
  hint: 'Export the array defineCollection() built, the same one passed to createSchemaTables in tests.',
50
83
  });
51
84
  }
52
- return collections;
85
+ const taxonomies = module.taxonomies;
86
+ if (taxonomies !== undefined && !Array.isArray(taxonomies)) {
87
+ throw new CogentaError({
88
+ code: 'SCHEMA_INVALID',
89
+ message: `${path} exports "taxonomies", but not as an array.`,
90
+ hint: 'Export the array defineTaxonomy() built: export const taxonomies = [category].',
91
+ });
92
+ }
93
+ return {
94
+ collections: collections,
95
+ taxonomies: (taxonomies ?? []),
96
+ };
53
97
  }
54
98
  throw new CogentaError({
55
99
  code: 'SCHEMA_INVALID',
@@ -57,6 +101,29 @@ export async function loadCollections(projectRoot) {
57
101
  hint: 'Create cogenta.schema.ts, default-exporting the array of collections defineCollection() built.',
58
102
  });
59
103
  }
104
+ /**
105
+ * The schema file this project actually loads, or `undefined` when it has
106
+ * none.
107
+ *
108
+ * Anything that *writes* the schema back has to target this, not a guessed
109
+ * name: `loadCollections` prefers `cogenta.schema.ts` (the form ADR-0010
110
+ * calls for — TypeScript in git), so a writer that assumed `.mjs` would
111
+ * create tables and then write a file nothing reads, leaving an operator
112
+ * with orphan tables and no collections after the restart it was told to do.
113
+ */
114
+ export async function findSchemaFile(projectRoot) {
115
+ for (const candidate of SCHEMA_FILE_CANDIDATES) {
116
+ const path = join(projectRoot, candidate);
117
+ try {
118
+ await stat(path);
119
+ return path;
120
+ }
121
+ catch {
122
+ // Try the next candidate — same order `loadCollections` uses.
123
+ }
124
+ }
125
+ return undefined;
126
+ }
60
127
  /**
61
128
  * True only when the candidate file itself does not exist — never for a
62
129
  * missing import *inside* it, which must surface as a real error rather than
@@ -75,33 +142,111 @@ function isModuleNotFound(error, path) {
75
142
  // `.ts`) surfaced as a hard SCHEMA_INVALID instead of trying the next one.
76
143
  return error.message.includes(pathToFileURL(path).href) || error.message.includes(path);
77
144
  }
145
+ /**
146
+ * What `/api/assistant` answers with when this process built no assistant at
147
+ * all — a caller that did not ask for one, in a test or an embedding. Exactly
148
+ * what `createAssistToolset` returns with no provider, restated here so
149
+ * `assembleSite` need not construct one to say "off".
150
+ */
151
+ const EMPTY_TOOLSET = Object.freeze({
152
+ available: false,
153
+ reason: 'No AI provider is configured for this site, so the writing assistant is switched off. Everything else in the CMS works exactly the same.',
154
+ tools: Object.freeze([]),
155
+ capabilities: Object.freeze([]),
156
+ });
78
157
  /** `relyingPartyId` is the bare host: WebAuthn ties a passkey to a domain, not a URL. */
79
158
  function webauthnConfigFor(site) {
80
159
  const host = new URL(site.url).hostname;
81
160
  return { relyingPartyName: site.name, relyingPartyId: host, origin: site.url };
82
161
  }
83
- async function assembleSite(db, collections, signingKey, site, storage, health,
84
- /** Optional: no caller constructs an agent registry today, and `/api/agents` simply is not mounted when this is absent — see `agentsRouter` on `Site`. */
85
- agents,
86
- /**
87
- * "Commencer par une démo en lecture seule" (L9 tâche 12, playground). Every
88
- * write REST or GraphQL could attempt refuses with `CONTENT_READ_ONLY`
89
- * instead of landing wrapped once here, at the one place both transports'
90
- * stores are actually constructed, so neither can bypass it.
91
- */
92
- readOnly = false,
93
- /** `null` when `theme.tokens.json` is absent or invalid see `loadSkinCss`. */
94
- skinCss = null) {
95
- await createSchemaTables(db, collections);
162
+ async function assembleSite(options) {
163
+ const { db, collections, site, storage, logger } = options;
164
+ const readOnly = options.readOnly ?? false;
165
+ const styles = options.styles ?? null;
166
+ const taxonomies = options.taxonomies ?? [];
167
+ // Taxonomies first: a `f.taxonomy()` field carries a real foreign key into
168
+ // the terms table, which therefore has to exist before the collection does.
169
+ await createSchemaTables(db, collections, taxonomies);
170
+ // Full-text search, connected for the first time (L10 task 3). The index is
171
+ // derived data and creates its own physical table, so a fresh install can
172
+ // index its first entry without a migration having run.
173
+ //
174
+ // Accepted from the caller when there is one: `runServe` builds it before the
175
+ // assistant so the semantic half can be fused with *this* index rather than
176
+ // with a second one over the same table.
177
+ const searchIndex = options.searchIndex ?? (await createSearchIndex({ db }));
178
+ // Scheduled publication (L1's `schedulePublication`/`registerScheduledPublishing`,
179
+ // written and tested from the start but never wired to anything — the admin
180
+ // showed "Scheduled" as a read-only badge). The `database` queue driver is
181
+ // the R1-honest choice: no Redis, no external worker, just a table in the
182
+ // site's own database, drained by `runServe`'s own `setInterval` tick — see
183
+ // the comment there for the lateness this trades for not requiring a
184
+ // persistent process.
185
+ const scheduledPublishQueue = createDatabaseQueue({ db, logger });
96
186
  const stores = new Map();
97
187
  const storeFor = (collection) => {
98
188
  const existing = stores.get(collection.name);
99
189
  if (existing !== undefined)
100
190
  return existing;
101
- const created = createContentStore({ db, collection });
102
- const stored = readOnly ? withReadOnlyStore(created) : created;
103
- stores.set(collection.name, stored);
104
- return stored;
191
+ // `siblings` is what lets `delete()` enforce `restrict` in application
192
+ // code (ADR-0022): trashing is an UPDATE, so the foreign key has nothing
193
+ // left to refuse at that moment.
194
+ const created = createContentStore({ db, collection, siblings: collections });
195
+ const guarded = readOnly ? withReadOnlyStore(created) : created;
196
+ // Queues the real publish job for a save that lands as `status:
197
+ // 'scheduled'`. Placed right after the read-only guard so a write that
198
+ // guard refused never reaches the queue either.
199
+ const schedulable = withScheduledPublishEnqueue(guarded, {
200
+ collection,
201
+ queue: scheduledPublishQueue,
202
+ onError: (error) => logger.error('scheduled publish enqueue failed', {
203
+ collection: collection.name,
204
+ error: String(error),
205
+ }),
206
+ });
207
+ // Outermost, so a read-only refusal happens *before* anything is indexed:
208
+ // a write that never landed must not change the index either.
209
+ const indexed = withSearchIndexing(schedulable, {
210
+ collection,
211
+ index: searchIndex,
212
+ onError: (error) => logger.error('search index write failed', {
213
+ collection: collection.name,
214
+ error: String(error),
215
+ }),
216
+ });
217
+ // The semantic half, wrapped the same way and for the same reason (L18
218
+ // task 5): REST and GraphQL are handed the same store instances, so one
219
+ // wrap covers both and neither can write content the index never hears
220
+ // about. Absent entirely when no embedder is available.
221
+ const stored = options.assistant?.vectors === undefined
222
+ ? indexed
223
+ : withVectorIndexing(indexed, {
224
+ collection,
225
+ siteId: site.url,
226
+ store: options.assistant.vectors.store,
227
+ embeddings: options.assistant.vectors.embeddings,
228
+ onError: (error) => logger.error('vector index write failed', {
229
+ collection: collection.name,
230
+ error: String(error),
231
+ }),
232
+ });
233
+ // Outermost of all: an event must describe a write that really landed, so
234
+ // it fires after the read-only guard has had its chance to refuse and
235
+ // after the index has been brought back in step. A receiver that rebuilt a
236
+ // page from an event the store then rejected would serve a page that never
237
+ // existed.
238
+ const observed = options.onContentEvent == null
239
+ ? stored
240
+ : withLifecycleEvents(stored, {
241
+ collection,
242
+ emit: options.onContentEvent,
243
+ onError: (error) => logger.error('content webhook emit failed', {
244
+ collection: collection.name,
245
+ error: String(error),
246
+ }),
247
+ });
248
+ stores.set(collection.name, observed);
249
+ return observed;
105
250
  };
106
251
  // The gateway (below) reads `stores` directly rather than through
107
252
  // `storeFor` — REST's own lazy population left it empty for any
@@ -111,6 +256,19 @@ skinCss = null) {
111
256
  // same, already-complete map.
112
257
  for (const collection of collections)
113
258
  storeFor(collection);
259
+ // The publish half of scheduling: re-reads the entry before acting, so an
260
+ // entry edited back to `draft` — or already published by hand — before its
261
+ // hour comes is left alone rather than redone by a job still sitting in
262
+ // the queue (see `withScheduledPublishEnqueue`, which enqueues again on
263
+ // every save rather than tracking a previous job id).
264
+ registerScheduledPublishing(scheduledPublishQueue, async (publication) => {
265
+ const target = stores.get(publication.collection);
266
+ if (target === undefined)
267
+ return;
268
+ const entry = await target.read(publication.entryId, { state: 'working' });
269
+ if (entry?.status === 'scheduled')
270
+ await target.publish(publication.entryId);
271
+ }, { logger });
114
272
  const redirects = createRedirectStore({ db });
115
273
  await redirects.ensureTable();
116
274
  const permissions = createPermissionLayer({ collections });
@@ -122,41 +280,283 @@ skinCss = null) {
122
280
  });
123
281
  const auth = await createAuthStore({
124
282
  db,
125
- signingKey,
283
+ signingKey: options.signingKey,
126
284
  collections,
127
285
  issuer: site.name,
128
286
  webauthn: webauthnConfigFor(site),
129
287
  });
288
+ // One store per taxonomy, made once: a term store holds no state beyond its
289
+ // table, but re-deriving it per request would re-resolve every identifier.
290
+ const taxonomyStores = new Map();
291
+ const taxonomyStoreFor = (taxonomy) => {
292
+ const existing = taxonomyStores.get(taxonomy.name);
293
+ if (existing !== undefined)
294
+ return existing;
295
+ const created = createTaxonomyStore({ db, taxonomy });
296
+ taxonomyStores.set(taxonomy.name, created);
297
+ return created;
298
+ };
130
299
  const mediaStore = createDatabaseMediaStore({ db });
300
+ const noticeDismissals = createNoticeDismissalStore(db);
301
+ await noticeDismissals.ensureTable();
302
+ // L17: a local/embedded catalog, not a distant service — L13's API keys,
303
+ // which the lot names as that dependency, were never built. Empty until a
304
+ // site configures one; a marketplace router that always answers is what
305
+ // lets the admin screen render instead of guessing whether one exists.
306
+ await ensurePluginTables(db);
307
+ await ensureMarketplaceTables(db);
308
+ const marketplaceGrants = createPluginGrantStore(db);
309
+ const marketplaceCatalog = createMarketplaceCatalog(options.marketplace?.catalog ?? []);
310
+ const marketplaceInstaller = createMarketplaceInstaller(db, {
311
+ grantStore: marketplaceGrants,
312
+ ...(options.marketplace?.trustedPublicKeys === undefined
313
+ ? {}
314
+ : { trustedPublicKeys: options.marketplace.trustedPublicKeys }),
315
+ });
316
+ // Menus (navigation). Not schema-declared, so one fixed pair of tables
317
+ // rather than one per taxonomy — see `menu-tables.ts`.
318
+ await ensureMenuTables(db);
319
+ const menuStore = createMenuStore({ db });
320
+ const gateway = createContentGateway({ collections, stores, permissions });
321
+ // Resolves an `entry`-kind menu item to a display label and public route,
322
+ // through the same permission-checked gateway everything else reads
323
+ // through. `ANONYMOUS`: a menu is public navigation, so an item is only
324
+ // ever resolved to what an anonymous visitor could also reach — an
325
+ // unpublished target resolves to `null` rather than leaking a draft's
326
+ // title into a public nav response.
327
+ const resolveMenuEntry = async (collectionName, entryId) => {
328
+ const collection = collections.find((candidate) => candidate.name === collectionName);
329
+ if (collection === undefined)
330
+ return null;
331
+ const entry = await gateway.read(collectionName, entryId, {
332
+ actor: { id: null, roles: ['public'] },
333
+ });
334
+ if (entry === null)
335
+ return null;
336
+ const stringValues = Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string'));
337
+ const label = typeof entry.values['title'] === 'string'
338
+ ? entry.values['title']
339
+ : typeof entry.values['name'] === 'string'
340
+ ? entry.values['name']
341
+ : entryId;
342
+ let route = null;
343
+ if (collection.routing !== undefined) {
344
+ try {
345
+ route = buildPath(collection, stringValues, entry.locale ?? undefined);
346
+ }
347
+ catch {
348
+ // A route field is missing on this entry (e.g. an empty slug on a
349
+ // draft). The item still resolves — with a label, no link — rather
350
+ // than failing the whole menu response over one broken reference.
351
+ route = null;
352
+ }
353
+ }
354
+ return { label, route };
355
+ };
356
+ // Contract E (ADR-0024): a whole separate domain, wired the same way the
357
+ // taxonomy tables are — created idempotently, once, here, so a site that
358
+ // never sells anything pays nothing beyond a handful of `create table if
359
+ // not exists` statements it never queries.
360
+ await ensureCommerceTables(db);
361
+ const commerceCatalog = createCatalogStore(db);
362
+ const commerceCustomers = createCustomerStore(db);
363
+ const commerceTax = createTaxStore(db);
364
+ const commerceShipping = createShippingStore(db);
365
+ const commerceCoupons = createCouponStore(db);
366
+ const commerceCarts = createCartStore(db, {
367
+ catalog: commerceCatalog,
368
+ tax: commerceTax,
369
+ shipping: commerceShipping,
370
+ coupons: commerceCoupons,
371
+ });
372
+ const commerceOrders = createOrderStore(db, {
373
+ catalog: commerceCatalog,
374
+ carts: commerceCarts,
375
+ customers: commerceCustomers,
376
+ coupons: commerceCoupons,
377
+ });
378
+ // The manual/bank-transfer driver: the one payment gateway that needs no
379
+ // provider keys, so a shop is sellable before anyone configures Stripe
380
+ // (mirrors R1 — a real degraded implementation, not a stub).
381
+ const commercePayments = createPaymentStore(db, {
382
+ gateway: createManualPaymentGateway(),
383
+ orders: commerceOrders,
384
+ });
385
+ const commercePermissions = createCommercePermissions();
386
+ const commerceSubscriptions = createSubscriptionStore(db, {
387
+ catalog: commerceCatalog,
388
+ customers: commerceCustomers,
389
+ orders: commerceOrders,
390
+ payments: commercePayments,
391
+ });
392
+ // Absent until the site fills in `billing` (contract E, ADR-0024): an
393
+ // invoice with a made-up seller address is worse than no invoicing at all,
394
+ // so the route stays unreachable rather than issuing one anyway.
395
+ const billing = options.billing;
396
+ const commerceInvoices = billing === undefined
397
+ ? undefined
398
+ : createInvoiceStore(db, {
399
+ orders: commerceOrders,
400
+ seller: {
401
+ address: [billing.legalName, ...billing.address],
402
+ ...(() => {
403
+ const footer = [billing.taxId, billing.footer]
404
+ .filter((part) => part !== undefined)
405
+ .join(' — ');
406
+ return footer === '' ? {} : { footer };
407
+ })(),
408
+ },
409
+ });
410
+ await ensureAnalyticsTables(db);
411
+ const analyticsStore = createAnalyticsStore(db);
412
+ const siteHost = new URL(site.url).hostname;
131
413
  return {
132
414
  db,
133
415
  auth,
134
416
  restRouter: createRestRouter({ service, siteUrl: site.url }),
135
- authRouter: createAuthRouter({ auth }),
136
- mediaRouter: createMediaRouter({ store: mediaStore, storage }),
417
+ authRouter: createAuthRouter({
418
+ auth,
419
+ ...(options.onForgotPassword == null ? {} : { onForgotPassword: options.onForgotPassword }),
420
+ }),
421
+ analyticsStore,
422
+ analyticsRouter: createAnalyticsRouter({ store: analyticsStore, siteHost }),
423
+ mediaRouter: createMediaRouter({
424
+ store: mediaStore,
425
+ storage,
426
+ ...(options.images === undefined || options.images === null
427
+ ? {}
428
+ : { images: options.images }),
429
+ }),
137
430
  auditRouter: createAuditRouter({ audit: auth.audit }),
138
- ...(agents === undefined ? {} : { agentsRouter: createAgentsRouter(agents) }),
431
+ taxonomyRouter: createTaxonomyRouter({
432
+ taxonomies,
433
+ permissions,
434
+ storeFor: (taxonomy) => taxonomyStoreFor(taxonomy),
435
+ }),
436
+ marketplaceRouter: createMarketplaceRouter({
437
+ catalog: marketplaceCatalog,
438
+ installer: marketplaceInstaller,
439
+ }),
440
+ menuRouter: createMenuRouter({ store: menuStore, resolveEntry: resolveMenuEntry }),
441
+ commerceRouter: createCommerceAdminRouter({
442
+ catalog: commerceCatalog,
443
+ orders: commerceOrders,
444
+ customers: commerceCustomers,
445
+ payments: commercePayments,
446
+ coupons: commerceCoupons,
447
+ subscriptions: commerceSubscriptions,
448
+ ...(commerceInvoices === undefined ? {} : { invoices: commerceInvoices }),
449
+ permissions: commercePermissions,
450
+ }),
451
+ redirectRouter: createRedirectRouter({ store: redirects }),
452
+ opsStatusRouter: createOpsStatusRouter({
453
+ security: options.security,
454
+ webhooks: options.webhooks,
455
+ }),
456
+ searchRouter: createSearchRouter({
457
+ index: searchIndex,
458
+ collections,
459
+ permissions,
460
+ defaultLocale: site.defaultLocale,
461
+ }),
462
+ securityAlerts: options.onSecurityEvent == null
463
+ ? null
464
+ : createSecurityAlertWatch({
465
+ rateLimit: auth.rateLimit,
466
+ send: options.onSecurityEvent,
467
+ siteUrl: site.url,
468
+ logger,
469
+ }),
470
+ noticeRouter: createNoticeRouter({
471
+ // One source today, and the seam is the array: a future recommendation
472
+ // (a plugin update waiting, a certificate about to expire) is one more
473
+ // entry here and nothing else anywhere.
474
+ sources: [
475
+ createMfaRecommendationSource({ collections, credentials: auth.credentials }),
476
+ // The failed-sign-in table has been written to since L2 and read by
477
+ // nothing but the limiter's own counter (L14 task 4). One extra source
478
+ // in this array is the whole wiring — the seam the notice mechanism was
479
+ // designed around.
480
+ createSuspiciousActivitySource({ rateLimit: auth.rateLimit }),
481
+ ],
482
+ dismissals: noticeDismissals,
483
+ }),
484
+ usersRouter: createUsersRouter({ auth }),
485
+ apiKeysRouter: createApiKeysRouter({ auth }),
486
+ assistantRouter: createAssistantRouter({
487
+ toolset: (options.assistant?.toolset ?? EMPTY_TOOLSET),
488
+ collections,
489
+ permissions,
490
+ site,
491
+ logger,
492
+ }),
493
+ ...(options.agents === undefined ? {} : { agentsRouter: createAgentsRouter(options.agents) }),
494
+ ...(options.sitePlans === undefined
495
+ ? {}
496
+ : { sitePlanRouter: createSitePlanRouter(options.sitePlans) }),
497
+ importRouter: createImportRouter({
498
+ // `db`/`storage` are the very ones already in scope for the rest of
499
+ // this function — `@cogenta/import`'s real importer, unchanged, never
500
+ // reimplemented here (R9: this package gains no dependency on it, only
501
+ // `@cogenta/cli` does, which already had one for the terminal command).
502
+ runWordPressImport: (xml) => importWordPress(xml, { db, storage }),
503
+ }),
139
504
  mediaStore,
140
505
  storage,
506
+ images: options.images ?? null,
141
507
  graphqlSchema: buildContentSchema({ collections }),
142
- gateway: createContentGateway({ collections, stores, permissions }),
143
- schemaDocument: buildSchemaDocument(collections, {
144
- locales: site.locales,
145
- defaultLocale: site.defaultLocale,
146
- }),
508
+ gateway,
509
+ permissions,
510
+ schemaDocument: buildSchemaDocument(collections, { locales: site.locales, defaultLocale: site.defaultLocale }, taxonomies),
511
+ redirects,
147
512
  collections,
513
+ taxonomies,
148
514
  site,
149
- skinCss,
150
- health,
515
+ styles,
516
+ security: options.security,
517
+ health: options.health,
518
+ tickScheduledPublishing: () => scheduledPublishQueue.tick(),
151
519
  dispose: async () => {
520
+ await scheduledPublishQueue.close();
152
521
  await db.close();
153
522
  },
154
523
  };
155
524
  }
525
+ /**
526
+ * No route on this server takes a JSON body anywhere near this size — the
527
+ * one exception, `/api/site-plans`, already caps its base64 document
528
+ * payloads at 60 MiB total inside `site-plan-router.ts`. This is a ceiling
529
+ * above that, not a route-specific limit: `readBody` runs for every mutating
530
+ * request, most of them long before any permission check, so an unbounded
531
+ * read here was a way for an anonymous caller to make the server buffer an
532
+ * arbitrarily large body before ever being told no.
533
+ */
534
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
156
535
  async function readBody(req) {
157
536
  const chunks = [];
158
- for await (const chunk of req)
159
- chunks.push(chunk);
537
+ let total = 0;
538
+ let tooLarge = false;
539
+ for await (const chunk of req) {
540
+ const buf = chunk;
541
+ total += buf.length;
542
+ if (total > MAX_REQUEST_BODY_BYTES) {
543
+ // Bound memory by not buffering any more chunks, but keep draining the
544
+ // socket rather than destroying it: a client mid-write over the same
545
+ // TCP connection this response has to go out on can be reset by an
546
+ // early `req.destroy()`, which loses the 413 response along with it.
547
+ // Letting the read finish costs bandwidth, never unbounded memory.
548
+ tooLarge = true;
549
+ continue;
550
+ }
551
+ chunks.push(buf);
552
+ }
553
+ if (tooLarge) {
554
+ throw new CogentaError({
555
+ code: 'REQUEST_BODY_TOO_LARGE',
556
+ message: `The request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit.`,
557
+ hint: 'Send a smaller payload.',
558
+ });
559
+ }
160
560
  if (chunks.length === 0)
161
561
  return undefined;
162
562
  const text = Buffer.concat(chunks).toString('utf8');
@@ -191,6 +591,35 @@ function toRestRequest(req, url, body) {
191
591
  ...(body === undefined ? {} : { body }),
192
592
  };
193
593
  }
594
+ /**
595
+ * `CommerceRequest`'s `query` is single-valued (contract E has no route that
596
+ * takes a repeated key), unlike `RestRequest`'s — so this is its own small
597
+ * adapter rather than a cast of `toRestRequest`'s output.
598
+ */
599
+ function toCommerceRequest(req, url, body) {
600
+ const query = {};
601
+ for (const key of url.searchParams.keys()) {
602
+ query[key] = url.searchParams.get(key) ?? undefined;
603
+ }
604
+ return {
605
+ method: req.method ?? 'GET',
606
+ path: url.pathname,
607
+ query,
608
+ ...(body === undefined ? {} : { body }),
609
+ };
610
+ }
611
+ /**
612
+ * The connecting socket's address — never trusted as anything more than an
613
+ * input to the daily session hash (`@cogenta/analytics`'s `hashSession`).
614
+ * No `x-forwarded-for` handling: trusting a client-supplied header for
615
+ * anything security- or privacy-relevant needs a configured trusted-proxy
616
+ * list this server does not have, and a wrong guess here would only ever
617
+ * make analytics *less* accurate, never leak anything (the header is never
618
+ * stored, only hashed).
619
+ */
620
+ function clientIpOf(req) {
621
+ return req.socket.remoteAddress ?? 'unknown';
622
+ }
194
623
  function responseId(response) {
195
624
  const data = response.body?.data;
196
625
  return typeof data?.id === 'string' ? data.id : undefined;
@@ -274,6 +703,28 @@ async function recordMediaAudit(site, actor, method, pathname, response, logger)
274
703
  })
275
704
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
276
705
  }
706
+ /**
707
+ * One entry per successful `POST /api/import/wordpress` — who ran it and how
708
+ * much it brought in, the same field the terminal command prints as
709
+ * `formatConversionReport`'s opening line. Never the document itself: a WXR
710
+ * export can carry a whole site's content, and the audit log is not a backup.
711
+ */
712
+ async function recordImportAudit(site, actor, method, pathname, response, logger) {
713
+ if (method !== 'POST' || response.status < 200 || response.status >= 300)
714
+ return;
715
+ if (!pathname.startsWith('/api/import/'))
716
+ return;
717
+ const report = response.body?.data
718
+ ?.imported;
719
+ await site.auth.audit
720
+ .record({
721
+ actorId: actor.id,
722
+ actorRoles: actor.roles,
723
+ action: 'import.wordpress',
724
+ ...(report === undefined ? {} : { diff: report }),
725
+ })
726
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
727
+ }
277
728
  async function recordAuthAudit(site, actor, method, pathname, response, logger) {
278
729
  if (response.status < 200 || response.status >= 300)
279
730
  return;
@@ -297,6 +748,80 @@ async function recordAuthAudit(site, actor, method, pathname, response, logger)
297
748
  .record({ actorId: userId, actorRoles: roles, action: 'auth.login' })
298
749
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
299
750
  }
751
+ /**
752
+ * Account management, in the audit log.
753
+ *
754
+ * Who created an account, who changed a role, who disabled someone and who cut
755
+ * a session short are exactly the events an append-only, hash-chained log
756
+ * exists for — and they were previously invisible, since the only way to do any
757
+ * of it was a terminal.
758
+ *
759
+ * Recorded here, at the transport boundary, for the same reason the content and
760
+ * media audits are: the router stays a pure request-in/response-out value, and
761
+ * only a response that actually succeeded is written down.
762
+ */
763
+ async function recordUserAudit(site, actor, method, pathname, response, logger) {
764
+ if (response.status < 200 || response.status >= 300)
765
+ return;
766
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
767
+ // ['api', 'users', <id?>, <'sessions' | 'password'>?, <sessionId?>]
768
+ const target = segments[2];
769
+ const sub = segments[3];
770
+ const action = method === 'POST' && target === undefined
771
+ ? 'user.create'
772
+ : method === 'PATCH' && target !== undefined && sub === undefined
773
+ ? 'user.update'
774
+ : method === 'POST' && sub === 'password'
775
+ ? 'user.password_change'
776
+ : method === 'DELETE' && sub === 'sessions'
777
+ ? 'user.session_revoke'
778
+ : null;
779
+ if (action === null)
780
+ return;
781
+ // The subject is named, never anything that could sign anyone in: no
782
+ // password, no token, not even the new roles' provenance beyond the id.
783
+ const created = response.body?.data?.user;
784
+ const subjectId = typeof created?.id === 'string' ? created.id : target === 'me' ? actor.id : (target ?? null);
785
+ await site.auth.audit
786
+ .record({
787
+ actorId: actor.id,
788
+ actorRoles: actor.roles,
789
+ action,
790
+ ...(subjectId === null ? {} : { entryId: subjectId }),
791
+ })
792
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
793
+ }
794
+ /**
795
+ * Who minted or revoked a machine credential, in the same append-only log as
796
+ * every other account action (L13 task 8). The raw key itself never reaches
797
+ * this function — `POST`'s response carries it once, but the audit entry
798
+ * only ever names the key's id, exactly like `recordUserAudit` never logs a
799
+ * password.
800
+ */
801
+ async function recordApiKeyAudit(site, actor, method, pathname, response, logger) {
802
+ if (response.status < 200 || response.status >= 300)
803
+ return;
804
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
805
+ // ['api', 'api-keys', <id?>]
806
+ const target = segments[2];
807
+ const action = method === 'POST' && target === undefined
808
+ ? 'apikey.create'
809
+ : method === 'DELETE' && target !== undefined
810
+ ? 'apikey.revoke'
811
+ : null;
812
+ if (action === null)
813
+ return;
814
+ const created = response.body?.data;
815
+ const subjectId = typeof created?.id === 'string' ? created.id : (target ?? null);
816
+ await site.auth.audit
817
+ .record({
818
+ actorId: actor.id,
819
+ actorRoles: actor.roles,
820
+ action,
821
+ ...(subjectId === null ? {} : { entryId: subjectId }),
822
+ })
823
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
824
+ }
300
825
  function writeRestResponse(res, response) {
301
826
  res.writeHead(response.status, response.headers);
302
827
  res.end(response.body === null || response.body === undefined
@@ -330,6 +855,104 @@ async function serveMediaFile(site, actor, id, req, res) {
330
855
  stream.on('error', () => res.destroy());
331
856
  stream.pipe(res);
332
857
  }
858
+ /**
859
+ * `GET /_image?id=…&w=…` — the public delivery endpoint for images.
860
+ *
861
+ * **Public on purpose, and only for images.** A `<img src>` in a published
862
+ * page is fetched by a visitor's browser with no session, so an endpoint the
863
+ * theme can point at cannot be behind the same authentication as
864
+ * `/api/media/{id}/file`. Restricting it to `kind === 'image'` is what keeps
865
+ * that from widening to every uploaded PDF and video: those stay behind the
866
+ * authenticated route, unchanged.
867
+ *
868
+ * It serves the rendition the upload already produced, and falls back to the
869
+ * original when there is none — an asset uploaded before the pipeline
870
+ * existed, a width outside the ladder, or a host with no image driver. It
871
+ * never renders on demand: nothing here decodes an image, so a public URL
872
+ * cannot be turned into CPU by asking for a size nobody stored.
873
+ */
874
+ async function serveImageVariant(site, url, req, res) {
875
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
876
+ res.writeHead(405, { allow: 'GET' }).end();
877
+ return;
878
+ }
879
+ const id = url.searchParams.get('id');
880
+ if (id === null || id === '') {
881
+ jsonError(res, 400, 'QUERY_INVALID', 'An image request must name the media it wants.');
882
+ return;
883
+ }
884
+ const asset = await site.mediaStore.get(id);
885
+ if (asset === null || asset.kind !== 'image') {
886
+ jsonError(res, 404, 'MEDIA_NOT_FOUND', `No image asset with id "${id}".`);
887
+ return;
888
+ }
889
+ let key = asset.storageKey;
890
+ // Never the asset's recorded `mimeType` unquestioned. Uploads now record
891
+ // the sniffed type, but an asset stored before that fix — or by a future
892
+ // writer that skips the route — could carry `text/html`, and this endpoint
893
+ // is public, unauthenticated and on the site's own origin. A type that is
894
+ // not an image serves as an opaque download instead of executing.
895
+ let contentType = SERVABLE_IMAGE_TYPES.has(asset.mimeType)
896
+ ? asset.mimeType
897
+ : 'application/octet-stream';
898
+ const requested = Number(url.searchParams.get('w'));
899
+ if (site.images !== null &&
900
+ Number.isInteger(requested) &&
901
+ requested > 0 &&
902
+ asset.width !== null &&
903
+ asset.height !== null) {
904
+ const names = site.images.variantNames({ width: asset.width, height: asset.height });
905
+ const wanted = `${requested}.`;
906
+ const match = names.find((name) => name.startsWith(wanted));
907
+ if (match !== undefined) {
908
+ const variantKey = variantKeyFor(id, match);
909
+ if (await site.storage.exists(variantKey)) {
910
+ key = variantKey;
911
+ if (match.endsWith('.webp'))
912
+ contentType = 'image/webp';
913
+ }
914
+ }
915
+ }
916
+ const stream = await site.storage.get(key);
917
+ res.writeHead(200, {
918
+ 'content-type': contentType,
919
+ // Long, because the URL names an immutable rendition of an immutable
920
+ // upload: replacing an image means a new media id, never new bytes under
921
+ // the same one.
922
+ 'cache-control': 'public, max-age=31536000, immutable',
923
+ });
924
+ stream.on('error', () => res.destroy());
925
+ stream.pipe(res);
926
+ }
927
+ /**
928
+ * Loads the media a theme render references, as `@cogenta/render`'s
929
+ * `MediaAsset`.
930
+ *
931
+ * The two shapes are deliberately different types (ADR-0016: the delivery
932
+ * plane declares its own wire types rather than importing the engine's), so
933
+ * this is the one place they are mapped. Only images and videos exist in that
934
+ * shape at all — a PDF has no `srcset` — so anything else is left out and
935
+ * `ctx.image()` refuses it clearly.
936
+ */
937
+ async function loadRenderMedia(site, ids) {
938
+ const found = new Map();
939
+ for (const id of new Set(ids)) {
940
+ const asset = await site.mediaStore.get(id);
941
+ if (asset === null)
942
+ continue;
943
+ if (asset.kind !== 'image' && asset.kind !== 'video')
944
+ continue;
945
+ found.set(id, {
946
+ id: asset.id,
947
+ kind: asset.kind,
948
+ alt: asset.alt,
949
+ ...(asset.width === null ? {} : { width: asset.width }),
950
+ ...(asset.height === null ? {} : { height: asset.height }),
951
+ focal: asset.focal,
952
+ });
953
+ }
954
+ return found;
955
+ }
333
956
  /**
334
957
  * Builds the Node request handler from an already-assembled site.
335
958
  *
@@ -342,6 +965,11 @@ async function serveMediaFile(site, actor, id, req, res) {
342
965
  export function createRequestListener(site, logger) {
343
966
  return async (req, res) => {
344
967
  const url = new URL(req.url ?? '/', 'http://localhost');
968
+ // Before anything else, and once: CORS, the security headers and the
969
+ // cache-control class of this path (L10 task 6). A preflight is answered
970
+ // here and never reaches a route.
971
+ if (applySecurity(req, res, url.pathname, site.security))
972
+ return;
345
973
  try {
346
974
  const actor = await resolveActor(site.auth, Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
347
975
  key,
@@ -367,12 +995,46 @@ export function createRequestListener(site, logger) {
367
995
  jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No admin asset matches this path.');
368
996
  return;
369
997
  }
998
+ // The theme's stylesheet: public, cacheable, and the same URL every
999
+ // page links, so a visitor pays for ~26 kB once instead of on every
1000
+ // page. Inlining it in each document would cost that on every
1001
+ // navigation; a `<link>` with a real ETag costs a conditional request
1002
+ // that answers 304. There is nothing to permission-check — the sheet is
1003
+ // derived from the skin's tokens and contains no content.
1004
+ if (url.pathname === STYLESHEET_PATH) {
1005
+ if (req.method !== 'GET') {
1006
+ res.writeHead(405, { allow: 'GET' }).end();
1007
+ return;
1008
+ }
1009
+ if (site.styles === null) {
1010
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This site has no stylesheet.');
1011
+ return;
1012
+ }
1013
+ const etag = cssEtag(site.styles);
1014
+ if (req.headers['if-none-match'] === etag) {
1015
+ res.writeHead(304, { etag }).end();
1016
+ return;
1017
+ }
1018
+ res.writeHead(200, {
1019
+ 'content-type': 'text/css; charset=utf-8',
1020
+ etag,
1021
+ // Revalidate every time: a skin swap must show up on the next
1022
+ // request, which is the whole promise of contract D's hot swap. The
1023
+ // ETag makes that revalidation a 304 rather than a re-download.
1024
+ 'cache-control': 'public, max-age=0, must-revalidate',
1025
+ });
1026
+ res.end(site.styles);
1027
+ return;
1028
+ }
370
1029
  if (url.pathname.startsWith('/api/auth/')) {
371
1030
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
372
1031
  const request = toRestRequest(req, url, body);
373
1032
  const response = await site.authRouter.handle(request);
374
1033
  writeRestResponse(res, response);
375
1034
  await recordAuthAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
1035
+ // A refused sign-in is the only clock a brute-force alert can honestly
1036
+ // have here (L14 task 4) — see `security-alerts.ts` for why not a timer.
1037
+ await site.securityAlerts?.observe(response.status);
376
1038
  return;
377
1039
  }
378
1040
  // Public and read-only: `schema.json` describes collection shapes and
@@ -390,6 +1052,13 @@ export function createRequestListener(site, logger) {
390
1052
  // Serving the file itself sits outside `mediaRouter`: its `RestResponse`
391
1053
  // is JSON-only, and a binary body has no shape to fit into that without
392
1054
  // widening the transport contract every other route relies on.
1055
+ // The public image endpoint (L10 task 5). Before the `/api/*` block on
1056
+ // purpose: it is not an API route, and it is the one media path a
1057
+ // visitor's browser reaches with no session.
1058
+ if (url.pathname === DEFAULT_IMAGE_ENDPOINT) {
1059
+ await serveImageVariant(site, url, req, res);
1060
+ return;
1061
+ }
393
1062
  const fileMatch = /^\/api\/media\/([^/]+)\/file$/u.exec(url.pathname);
394
1063
  if (fileMatch !== null) {
395
1064
  await serveMediaFile(site, actor, decodeURIComponent(fileMatch[1] ?? ''), req, res);
@@ -421,6 +1090,49 @@ export function createRequestListener(site, logger) {
421
1090
  await recordContentAudit(site, actor, req.method ?? 'GET', url.pathname, body, response, logger);
422
1091
  return;
423
1092
  }
1093
+ // Terms live apart from content on purpose: a taxonomy is not a
1094
+ // collection, and a site may legitimately name both the same thing
1095
+ // (ADR-0022). Its router owns its own permission door.
1096
+ if (url.pathname.startsWith('/api/taxonomies')) {
1097
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1098
+ const request = toRestRequest(req, url, body);
1099
+ writeRestResponse(res, await site.taxonomyRouter.handle(request, context));
1100
+ return;
1101
+ }
1102
+ if (url.pathname.startsWith('/api/marketplace')) {
1103
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1104
+ const request = toRestRequest(req, url, body);
1105
+ writeRestResponse(res, await site.marketplaceRouter.handle(request, context.actor));
1106
+ return;
1107
+ }
1108
+ // A menu is not schema-declared like a taxonomy, but it gets its own
1109
+ // mount for the same reason: it is not a collection, and its router owns
1110
+ // its own (fixed, not per-site-configurable) permission door.
1111
+ if (url.pathname.startsWith('/api/menus')) {
1112
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1113
+ const request = toRestRequest(req, url, body);
1114
+ writeRestResponse(res, await site.menuRouter.handle(request, context));
1115
+ return;
1116
+ }
1117
+ // Contract E's own back office, gated by its own permission vocabulary
1118
+ // (`commerce.*`, ADR-0024) — never contract A's five actions, which do
1119
+ // not stretch to "refund" or "issue an invoice".
1120
+ if (url.pathname.startsWith('/api/commerce')) {
1121
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1122
+ const request = toCommerceRequest(req, url, body);
1123
+ const response = await site.commerceRouter.handle(request, context.actor);
1124
+ // The one route whose body is not JSON: an invoice PDF. Checked by
1125
+ // shape, not by path — the router already decided what to send, this
1126
+ // layer only has to notice how.
1127
+ if (response.body instanceof Uint8Array) {
1128
+ res.writeHead(response.status, { 'content-type': 'application/pdf' });
1129
+ res.end(Buffer.from(response.body));
1130
+ return;
1131
+ }
1132
+ res.writeHead(response.status, { 'content-type': 'application/json; charset=utf-8' });
1133
+ res.end(response.body === null ? undefined : JSON.stringify(response.body));
1134
+ return;
1135
+ }
424
1136
  if (url.pathname.startsWith('/api/media')) {
425
1137
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
426
1138
  const request = toRestRequest(req, url, body);
@@ -429,11 +1141,107 @@ export function createRequestListener(site, logger) {
429
1141
  await recordMediaAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
430
1142
  return;
431
1143
  }
1144
+ // The admin screen the redirect table never had: creating and removing
1145
+ // a rule from a browser instead of the database directly (audit
1146
+ // follow-up to L10 task 2). Admin-only, checked by the router itself.
1147
+ if (url.pathname === '/api/redirects') {
1148
+ const body = req.method === 'POST' ? await readBody(req) : undefined;
1149
+ const request = toRestRequest(req, url, body);
1150
+ writeRestResponse(res, await site.redirectRouter.handle(request, context));
1151
+ return;
1152
+ }
1153
+ // Read-only mirrors of `security`/`webhooks` from the config file (audit
1154
+ // follow-up to L10 task 6 / L14 task 1) — see `ops-status-router.ts` for
1155
+ // why editing them here would be the wrong architecture.
1156
+ if (url.pathname === '/api/security-status' || url.pathname === '/api/webhooks-status') {
1157
+ const request = toRestRequest(req, url, undefined);
1158
+ writeRestResponse(res, await site.opsStatusRouter.handle(request, context));
1159
+ return;
1160
+ }
1161
+ // The full-text index, reachable at last (L10 task 3). Its own router
1162
+ // decides which collections this actor may search — never this layer.
1163
+ if (url.pathname === '/api/search') {
1164
+ const request = toRestRequest(req, url, undefined);
1165
+ writeRestResponse(res, await site.searchRouter.handle(request, context));
1166
+ return;
1167
+ }
1168
+ // `/api/analytics/beacon` (public) and `/api/analytics/summary`
1169
+ // (admin-only) — see `@cogenta/analytics` and `analytics-router.ts` for
1170
+ // why both live behind one router with opposite trust models.
1171
+ if (url.pathname.startsWith('/api/analytics')) {
1172
+ const request = toRestRequest(req, url, undefined);
1173
+ writeRestResponse(res, await site.analyticsRouter.handle(request, { actor: context.actor, ip: clientIpOf(req) }));
1174
+ return;
1175
+ }
432
1176
  if (url.pathname.startsWith('/api/audit')) {
433
1177
  const request = toRestRequest(req, url, undefined);
434
1178
  writeRestResponse(res, await site.auditRouter.handle(request, context.actor));
435
1179
  return;
436
1180
  }
1181
+ if (url.pathname.startsWith('/api/notices')) {
1182
+ const request = toRestRequest(req, url, undefined);
1183
+ writeRestResponse(res, await site.noticeRouter.handle(request, context.actor));
1184
+ return;
1185
+ }
1186
+ if (url.pathname.startsWith('/api/users')) {
1187
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1188
+ const request = toRestRequest(req, url, body);
1189
+ const response = await site.usersRouter.handle(request, context.actor);
1190
+ writeRestResponse(res, response);
1191
+ await recordUserAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
1192
+ return;
1193
+ }
1194
+ // Machine-to-machine bearer credentials, admin-only (L13 task 8).
1195
+ if (url.pathname.startsWith('/api/api-keys')) {
1196
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1197
+ const request = toRestRequest(req, url, body);
1198
+ const response = await site.apiKeysRouter.handle(request, context.actor);
1199
+ writeRestResponse(res, response);
1200
+ await recordApiKeyAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
1201
+ return;
1202
+ }
1203
+ if (url.pathname.startsWith('/api/site-plans') && site.sitePlanRouter !== undefined) {
1204
+ // `SitePlanRouter` itself refuses every route to a non-admin actor,
1205
+ // but only after `readBody` has already buffered the whole request —
1206
+ // and this route, alone among this server's routes, invites
1207
+ // multi-megabyte bodies by design (uploaded documents). Checking the
1208
+ // role here, before the body is read at all, means an unauthenticated
1209
+ // or non-admin caller is turned away without the server ever reading
1210
+ // what they sent.
1211
+ if (!context.actor.roles.includes('admin')) {
1212
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may propose or apply a site plan.');
1213
+ return;
1214
+ }
1215
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
1216
+ const request = toRestRequest(req, url, body);
1217
+ writeRestResponse(res, await site.sitePlanRouter.handle(request, context.actor));
1218
+ return;
1219
+ }
1220
+ // The admin's WordPress importer. Same defensive order as
1221
+ // `/api/site-plans` just above and for the same reason: this route
1222
+ // invites a multi-megabyte upload by design, so the role is checked
1223
+ // before `readBody` buffers anything at all.
1224
+ if (url.pathname.startsWith('/api/import')) {
1225
+ if (!context.actor.roles.includes('admin')) {
1226
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may import content.');
1227
+ return;
1228
+ }
1229
+ const body = req.method === 'GET' ? undefined : await readBody(req);
1230
+ const request = toRestRequest(req, url, body);
1231
+ const response = await site.importRouter.handle(request, context.actor);
1232
+ writeRestResponse(res, response);
1233
+ await recordImportAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
1234
+ return;
1235
+ }
1236
+ // Always mounted, on every site (L18). On one with no AI provider it is
1237
+ // the route that answers `{available: false}`, which is precisely what
1238
+ // lets the admin panel disappear instead of failing.
1239
+ if (url.pathname.startsWith('/api/assistant')) {
1240
+ const body = req.method === 'GET' ? undefined : await readBody(req);
1241
+ const request = toRestRequest(req, url, body);
1242
+ writeRestResponse(res, await site.assistantRouter.handle(request, context));
1243
+ return;
1244
+ }
437
1245
  if (url.pathname.startsWith('/api/agents') && site.agentsRouter !== undefined) {
438
1246
  const request = toRestRequest(req, url, undefined);
439
1247
  writeRestResponse(res, await site.agentsRouter.handle(request, context.actor));
@@ -459,22 +1267,204 @@ export function createRequestListener(site, logger) {
459
1267
  res.end(JSON.stringify({ data: health }));
460
1268
  return;
461
1269
  }
1270
+ // The visual page builder's preview (L16). It renders an *unsaved* block
1271
+ // list through the very function that renders the published page, so the
1272
+ // builder can show the real thing in an iframe instead of a React
1273
+ // approximation of the twelve blocks.
1274
+ //
1275
+ // Three gates, in this order, before any of that happens:
1276
+ // 1. an authenticated actor — an anonymous caller has no editing
1277
+ // session, so it has no business asking for a render of a page state
1278
+ // that does not exist yet;
1279
+ // 2. `update` on the collection, asked of the same `PermissionLayer`
1280
+ // every other write path asks (R4: the route verifies, the renderer
1281
+ // does not);
1282
+ // 3. `renderDraftPage` reads the stored entry through the same
1283
+ // permission-checked gateway, and every `collectionList` block on
1284
+ // the page queries through it too — so a draft cannot be used to
1285
+ // read content this actor could not already read.
1286
+ if (url.pathname === '/api/builder/render') {
1287
+ if (req.method !== 'POST') {
1288
+ res.writeHead(405, { allow: 'POST' }).end();
1289
+ return;
1290
+ }
1291
+ if (actor.id === null) {
1292
+ jsonError(res, 401, 'UNAUTHENTICATED', 'This preview needs a signed-in editor.');
1293
+ return;
1294
+ }
1295
+ const body = (await readBody(req));
1296
+ const collectionName = typeof body?.collection === 'string' ? body.collection : '';
1297
+ const entryId = typeof body?.entryId === 'string' ? body.entryId : '';
1298
+ const collection = site.collections.find((entry) => entry.name === collectionName);
1299
+ if (collection === undefined || entryId === '') {
1300
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No such collection or entry.');
1301
+ return;
1302
+ }
1303
+ // `errorResponse` rather than the outer catch: it is what turns a
1304
+ // `CogentaError` into the status its code deserves (403 for
1305
+ // `FORBIDDEN`), and it is already the mapping every `/api/*` router
1306
+ // uses. The outer catch would answer 500 to a refusal.
1307
+ let html;
1308
+ try {
1309
+ site.permissions.assert('update', collection, context);
1310
+ html = await renderDraftPage({
1311
+ collection: collectionName,
1312
+ entryId,
1313
+ blocks: (body?.blocks ?? {}),
1314
+ ...(typeof body?.values === 'object' && body.values !== null
1315
+ ? { values: body.values }
1316
+ : {}),
1317
+ }, {
1318
+ collections: site.collections,
1319
+ gateway: site.gateway,
1320
+ site: site.site,
1321
+ styles: site.styles,
1322
+ loadMedia: (ids) => loadRenderMedia(site, ids),
1323
+ // Present so the preview's `<body>` stays byte-identical to the
1324
+ // published page's (the property `theme-render-fidelity`
1325
+ // proves) — a POST carries no navigation `Referer` to report,
1326
+ // so this omits `referrer` the same way an ordinary page view
1327
+ // with no referrer does. The preview does still count as a
1328
+ // view; there is no distinct "not a real visit" signal to send
1329
+ // that would not itself become a body difference.
1330
+ analyticsBeacon: {},
1331
+ menuRouter: site.menuRouter,
1332
+ }, context);
1333
+ }
1334
+ catch (error) {
1335
+ logger.warn('builder preview refused', {
1336
+ error: isCogentaError(error) ? error.toJSON() : String(error),
1337
+ });
1338
+ writeRestResponse(res, errorResponse(error));
1339
+ return;
1340
+ }
1341
+ if (html === null) {
1342
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No such collection or entry.');
1343
+ return;
1344
+ }
1345
+ res.writeHead(200, {
1346
+ 'content-type': 'application/json; charset=utf-8',
1347
+ // A draft is never cacheable, by anyone, for any length of time.
1348
+ 'cache-control': 'no-store',
1349
+ });
1350
+ res.end(JSON.stringify({ data: { html } }));
1351
+ return;
1352
+ }
1353
+ // Everything below is the public site rather than the API, so the
1354
+ // redirect table gets its turn first: a page renamed last month must
1355
+ // answer its old URL with the 301 the rename recorded, not a 404 (L10
1356
+ // task 2). Before route matching, so a redirect wins even when some
1357
+ // other entry has since taken the old path — that is what `release()`
1358
+ // is for on the write side.
1359
+ if (req.method === 'GET' || req.method === 'HEAD') {
1360
+ const redirect = await site.redirects.resolve(url.pathname);
1361
+ if (redirect !== null) {
1362
+ res.writeHead(redirect.status, {
1363
+ location: `${redirect.to}${url.search}`,
1364
+ 'cache-control': redirect.status === 301 ? 'public, max-age=3600' : 'no-store',
1365
+ });
1366
+ res.end();
1367
+ return;
1368
+ }
1369
+ }
1370
+ // `robots.txt` and `sitemap.xml`, from the real content (L10 task 2).
1371
+ // Both are built as `ANONYMOUS` inside `collectRoutedResources`,
1372
+ // whoever asked: a crawler and a signed-in editor must get the same
1373
+ // document, or the sitemap advertises URLs the crawler cannot fetch.
1374
+ if (url.pathname === '/robots.txt') {
1375
+ if (req.method !== 'GET') {
1376
+ res.writeHead(405, { allow: 'GET' }).end();
1377
+ return;
1378
+ }
1379
+ res.writeHead(200, {
1380
+ 'content-type': 'text/plain; charset=utf-8',
1381
+ 'cache-control': 'public, max-age=3600',
1382
+ });
1383
+ res.end(renderRobots(seoSiteFor(site.site)));
1384
+ return;
1385
+ }
1386
+ if (SITEMAP_PATH.test(url.pathname)) {
1387
+ if (req.method !== 'GET') {
1388
+ res.writeHead(405, { allow: 'GET' }).end();
1389
+ return;
1390
+ }
1391
+ const seoSite = seoSiteFor(site.site);
1392
+ const files = buildSitemapFiles(seoSite, await collectRoutedResources(site.collections, site.gateway));
1393
+ const file = files.find((candidate) => candidate.path === url.pathname);
1394
+ if (file !== undefined) {
1395
+ res.writeHead(200, {
1396
+ 'content-type': 'application/xml; charset=utf-8',
1397
+ 'cache-control': 'public, max-age=600',
1398
+ });
1399
+ res.end(file.contents);
1400
+ return;
1401
+ }
1402
+ // `/sitemap-9.xml` on a site that only needs one file is a real 404,
1403
+ // not an empty urlset: an empty chunk would tell a crawler the site
1404
+ // has nothing there rather than that the URL is wrong.
1405
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No sitemap file at this path.');
1406
+ return;
1407
+ }
1408
+ // The public search page (L10 task 3): a real form and a real results
1409
+ // list, served through the same permission-checked search router the
1410
+ // API uses. Deliberately a route rather than a contract B block — see
1411
+ // `search-page.ts` for why.
1412
+ if (url.pathname === '/search' && req.method === 'GET') {
1413
+ const html = await renderSearchPage(url.searchParams.get('q') ?? '', {
1414
+ router: site.searchRouter,
1415
+ gateway: site.gateway,
1416
+ collections: site.collections,
1417
+ site: site.site,
1418
+ styles: site.styles,
1419
+ }, context);
1420
+ res.writeHead(200, {
1421
+ 'content-type': 'text/html; charset=utf-8',
1422
+ 'cache-control': 'no-store',
1423
+ });
1424
+ res.end(html);
1425
+ return;
1426
+ }
462
1427
  // Real theme HTML for anything else — see `theme-render.ts`'s own
463
1428
  // doc comment for what this is and, as importantly, what it isn't
464
1429
  // (no Astro build, one theme, no image pipeline). GET only: rendering
465
1430
  // a page has no meaningful response to any other method.
466
1431
  if (req.method === 'GET') {
467
- const html = await renderRequestedPage(url.pathname, {
1432
+ const renderOptions = {
468
1433
  collections: site.collections,
469
1434
  gateway: site.gateway,
470
1435
  site: site.site,
471
- skinCss: site.skinCss,
472
- }, context);
1436
+ styles: site.styles,
1437
+ loadMedia: (ids) => loadRenderMedia(site, ids),
1438
+ // Self-hosted analytics (`@cogenta/analytics`): the referrer is read
1439
+ // from *this* request's own header, server-side — see
1440
+ // `analyticsBeaconTag` in `theme-render.ts` for why that, rather
1441
+ // than a client script, is how this page's beacon pixel gets it.
1442
+ analyticsBeacon: { referrer: req.headers.referer },
1443
+ menuRouter: site.menuRouter,
1444
+ };
1445
+ const html = await renderRequestedPage(url.pathname, renderOptions, context);
473
1446
  if (html !== null) {
474
1447
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
475
1448
  res.end(html);
476
1449
  return;
477
1450
  }
1451
+ // The site's own 404 page (L14 task 2). It is an ordinary entry at
1452
+ // `site.notFoundPath`, rendered by exactly the same function and
1453
+ // through exactly the same permission-checked gateway as any other
1454
+ // page — a custom 404 that could show content the visitor may not read
1455
+ // would be a hole, not a feature.
1456
+ //
1457
+ // The guard matters: without it, a site whose 404 page is missing (or
1458
+ // whose `notFoundPath` is itself unroutable) would ask for it again
1459
+ // for every unmatched URL forever. One extra lookup, never two.
1460
+ if (url.pathname !== site.site.notFoundPath) {
1461
+ const notFound = await renderRequestedPage(site.site.notFoundPath, renderOptions, context);
1462
+ if (notFound !== null) {
1463
+ res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
1464
+ res.end(notFound);
1465
+ return;
1466
+ }
1467
+ }
478
1468
  }
479
1469
  res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
480
1470
  res.end(JSON.stringify({
@@ -485,6 +1475,10 @@ export function createRequestListener(site, logger) {
485
1475
  logger.error('request failed', {
486
1476
  error: isCogentaError(error) ? error.toJSON() : String(error),
487
1477
  });
1478
+ if (isCogentaError(error) && error.code === 'REQUEST_BODY_TOO_LARGE') {
1479
+ jsonError(res, 413, error.code, error.message);
1480
+ return;
1481
+ }
488
1482
  res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
489
1483
  res.end(JSON.stringify({
490
1484
  error: { code: 'INTERNAL', message: 'The request could not be completed.' },
@@ -494,6 +1488,20 @@ export function createRequestListener(site, logger) {
494
1488
  }
495
1489
  const DEFAULT_PORT = 4000;
496
1490
  const DEFAULT_HOST = '127.0.0.1';
1491
+ /** How long a shutdown waits for open connections before cutting them. */
1492
+ const SHUTDOWN_GRACE_MS = 2_000;
1493
+ /**
1494
+ * How often `runServe` drains due scheduled-publication jobs (R1).
1495
+ *
1496
+ * `cogenta serve` has no persistent worker process beyond itself, so this
1497
+ * `setInterval` *is* the cron a hosted deployment with no worker would
1498
+ * otherwise need to configure by hand. The honest trade this makes: a page
1499
+ * scheduled for 09:00 goes live between 09:00 and 09:01, not exactly on the
1500
+ * hour. If the process is stopped when a publication comes due, nothing is
1501
+ * lost — the job is still in the `database` queue's table — it simply runs
1502
+ * on the first tick after the next start, however late that is.
1503
+ */
1504
+ const SCHEDULED_PUBLISH_TICK_MS = 60_000;
497
1505
  /**
498
1506
  * Runs until `options.signal` aborts. Returns 0 on a clean shutdown, 1 if
499
1507
  * startup failed — nothing here calls `process.exit` (same convention as
@@ -515,8 +1523,11 @@ export async function runServe(options) {
515
1523
  return 1;
516
1524
  }
517
1525
  let collections;
1526
+ let taxonomies;
518
1527
  try {
519
- collections = await loadCollections(projectRoot);
1528
+ const schema = await loadSchemaModule(projectRoot);
1529
+ collections = schema.collections;
1530
+ taxonomies = schema.taxonomies;
520
1531
  }
521
1532
  catch (error) {
522
1533
  if (isCogentaError(error)) {
@@ -531,8 +1542,73 @@ export async function runServe(options) {
531
1542
  }
532
1543
  const selection = await createDatabaseRegistry({ logger }).select(loaded.config.database);
533
1544
  const storageSelection = await createStorageRegistry({ logger }).select(loaded.config.storage);
534
- const skinCss = await loadSkinCss((path) => readFile(path, 'utf8'), join(projectRoot, 'theme.tokens.json'));
535
- const site = await assembleSite(selection.instance, collections, loaded.config.auth.signingKey, loaded.config.site, storageSelection.instance, async () => ({ database: await selection.health(), storage: await storageSelection.health() }), undefined, options.readOnly ?? false, skinCss);
1545
+ const styles = joinStyles(await loadSkinCss((path) => readFile(path, 'utf8'), join(projectRoot, 'theme.tokens.json')), await loadThemeCss({ read: (url) => readFile(url, 'utf8') }));
1546
+ const images = await selectMediaImageProcessor(logger);
1547
+ // One signed channel for both outbound events — the content lifecycle (task
1548
+ // 1) and the suspicious-activity alert (task 4). One set of endpoints, one
1549
+ // secret, one signing path.
1550
+ const webhooks = createContentWebhookEmitter({
1551
+ webhooks: loaded.config.webhooks,
1552
+ siteUrl: loaded.config.site.url,
1553
+ logger,
1554
+ });
1555
+ // L18. Never fatal: everything inside degrades to "off" with a log line
1556
+ // rather than stopping the site from serving (R2).
1557
+ const searchIndex = await createSearchIndex({ db: selection.instance });
1558
+ const assistant = await buildAssistant({
1559
+ config: loaded.config,
1560
+ db: selection.instance,
1561
+ logger,
1562
+ // Beside the full-text index, never instead of it: the semantic half is
1563
+ // fused with this one by RRF (L18 task 5).
1564
+ fullText: searchIndex,
1565
+ });
1566
+ const site = await assembleSite({
1567
+ db: selection.instance,
1568
+ assistant,
1569
+ searchIndex,
1570
+ collections,
1571
+ taxonomies,
1572
+ signingKey: loaded.config.auth.signingKey,
1573
+ site: loaded.config.site,
1574
+ storage: storageSelection.instance,
1575
+ logger,
1576
+ health: async () => ({
1577
+ database: await selection.health(),
1578
+ storage: await storageSelection.health(),
1579
+ }),
1580
+ readOnly: options.readOnly ?? false,
1581
+ styles,
1582
+ images: images?.processor ?? null,
1583
+ security: loaded.config.security,
1584
+ webhooks: loaded.config.webhooks,
1585
+ billing: loaded.config.billing,
1586
+ sitePlans: await createSitePlanning({
1587
+ projectRoot,
1588
+ db: selection.instance,
1589
+ collections,
1590
+ config: loaded.config,
1591
+ logger,
1592
+ readOnly: options.readOnly ?? false,
1593
+ // ADR-0010: the schema is writable in development only. `cogenta dev`
1594
+ // says development; `cogenta serve` does not, and a plan can then be
1595
+ // proposed and reviewed but never applied.
1596
+ development: options.development ?? false,
1597
+ }),
1598
+ // The signed outbound webhook channel, connected to the content lifecycle
1599
+ // for the first time (L14 task 1). `null` when the site configured no
1600
+ // endpoint, or configured one without a signing secret.
1601
+ onContentEvent: webhooks.emit,
1602
+ onSecurityEvent: webhooks.send,
1603
+ // Same mail this site's `cogenta users reset-password --email` already
1604
+ // sends (`../reset-mail.js`), just pointed at the admin's reset screen
1605
+ // instead of a terminal command — see that file for why the wording is
1606
+ // written once rather than twice.
1607
+ onForgotPassword: ({ user, token, expiresAt }) => sendResetMail({
1608
+ mailDir: join(projectRoot, '.cogenta', 'mail'),
1609
+ resetUrl: new URL('/admin/reset-password', loaded.config.site.url).toString(),
1610
+ }, loaded.config.site, user.email, token, expiresAt).then(() => undefined),
1611
+ });
536
1612
  const server = createServer(createRequestListener(site, logger));
537
1613
  const port = options.port ?? DEFAULT_PORT;
538
1614
  const host = options.host ?? DEFAULT_HOST;
@@ -546,8 +1622,24 @@ export async function runServe(options) {
546
1622
  const address = server.address();
547
1623
  const boundPort = typeof address === 'object' && address !== null ? address.port : port;
548
1624
  out.ok(`Listening on http://${host}:${boundPort}`);
549
- out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}`);
1625
+ out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}, image driver: ${images?.driver ?? 'none'}`);
1626
+ out.detail(assistant.summary);
550
1627
  options.onListening?.({ port: boundPort, host });
1628
+ // Scheduled publication (task 1): a first tick right away catches up on
1629
+ // anything that came due while the process was down, then one every
1630
+ // `SCHEDULED_PUBLISH_TICK_MS` for as long as this server runs. A failed
1631
+ // tick is logged, never fatal — a scheduling hiccup must not take the
1632
+ // whole site down.
1633
+ const runScheduledPublishTick = () => {
1634
+ site.tickScheduledPublishing().catch((error) => {
1635
+ logger.error('scheduled publish tick failed', { error: String(error) });
1636
+ });
1637
+ };
1638
+ runScheduledPublishTick();
1639
+ const scheduledPublishTimer = setInterval(runScheduledPublishTick, options.scheduledPublishTickMs ?? SCHEDULED_PUBLISH_TICK_MS);
1640
+ // Never keeps the process alive on its own: a `signal`-driven shutdown with
1641
+ // no open connections must still be able to exit.
1642
+ scheduledPublishTimer.unref();
551
1643
  await new Promise((resolve) => {
552
1644
  if (options.signal === undefined)
553
1645
  return;
@@ -557,9 +1649,19 @@ export async function runServe(options) {
557
1649
  }
558
1650
  options.signal.addEventListener('abort', () => resolve(), { once: true });
559
1651
  });
1652
+ clearInterval(scheduledPublishTimer);
560
1653
  await new Promise((resolve, reject) => {
561
1654
  server.close((error) => (error ? reject(error) : resolve()));
1655
+ // `close()` alone waits for every open connection to end, and a client
1656
+ // that fetched a large response and never read the body holds one open
1657
+ // indefinitely — a media download is exactly that shape. Without the
1658
+ // grace period, one such client turns Ctrl-C into a hang. Found while
1659
+ // writing the image tests, where a deliberately unread image body kept
1660
+ // the whole process alive.
1661
+ const grace = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
1662
+ grace.unref();
562
1663
  });
1664
+ await assistant.dispose();
563
1665
  await selection.dispose();
564
1666
  await storageSelection.dispose();
565
1667
  await site.dispose().catch(() => undefined); // selection.dispose() already closed the same handle