@cogenta/cli 0.2.2 → 0.3.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 (62) hide show
  1. package/dist/admin-assets/assets/index-Buwdj1V2.js +71 -0
  2. package/dist/admin-assets/assets/index-Csef0SiA.css +1 -0
  3. package/dist/admin-assets/index.html +2 -2
  4. package/dist/commands/assistant.d.ts +65 -0
  5. package/dist/commands/assistant.d.ts.map +1 -0
  6. package/dist/commands/assistant.js +207 -0
  7. package/dist/commands/assistant.js.map +1 -0
  8. package/dist/commands/content-webhooks.d.ts +41 -0
  9. package/dist/commands/content-webhooks.d.ts.map +1 -0
  10. package/dist/commands/content-webhooks.js +61 -0
  11. package/dist/commands/content-webhooks.js.map +1 -0
  12. package/dist/commands/http-security.d.ts +39 -0
  13. package/dist/commands/http-security.d.ts.map +1 -0
  14. package/dist/commands/http-security.js +153 -0
  15. package/dist/commands/http-security.js.map +1 -0
  16. package/dist/commands/links.d.ts +24 -0
  17. package/dist/commands/links.d.ts.map +1 -0
  18. package/dist/commands/links.js +109 -0
  19. package/dist/commands/links.js.map +1 -0
  20. package/dist/commands/media-images.d.ts +67 -0
  21. package/dist/commands/media-images.d.ts.map +1 -0
  22. package/dist/commands/media-images.js +107 -0
  23. package/dist/commands/media-images.js.map +1 -0
  24. package/dist/commands/search-page.d.ts +40 -0
  25. package/dist/commands/search-page.d.ts.map +1 -0
  26. package/dist/commands/search-page.js +104 -0
  27. package/dist/commands/search-page.js.map +1 -0
  28. package/dist/commands/security-alerts.d.ts +24 -0
  29. package/dist/commands/security-alerts.d.ts.map +1 -0
  30. package/dist/commands/security-alerts.js +82 -0
  31. package/dist/commands/security-alerts.js.map +1 -0
  32. package/dist/commands/seo.d.ts +88 -0
  33. package/dist/commands/seo.d.ts.map +1 -0
  34. package/dist/commands/seo.js +155 -0
  35. package/dist/commands/seo.js.map +1 -0
  36. package/dist/commands/serve.d.ts +87 -4
  37. package/dist/commands/serve.d.ts.map +1 -1
  38. package/dist/commands/serve.js +740 -40
  39. package/dist/commands/serve.js.map +1 -1
  40. package/dist/commands/site-plan.d.ts +86 -0
  41. package/dist/commands/site-plan.d.ts.map +1 -0
  42. package/dist/commands/site-plan.js +235 -0
  43. package/dist/commands/site-plan.js.map +1 -0
  44. package/dist/commands/theme-css.d.ts +54 -0
  45. package/dist/commands/theme-css.d.ts.map +1 -0
  46. package/dist/commands/theme-css.js +121 -0
  47. package/dist/commands/theme-css.js.map +1 -0
  48. package/dist/commands/theme-render.d.ts +78 -4
  49. package/dist/commands/theme-render.d.ts.map +1 -1
  50. package/dist/commands/theme-render.js +170 -16
  51. package/dist/commands/theme-render.js.map +1 -1
  52. package/dist/commands/users.d.ts +7 -1
  53. package/dist/commands/users.d.ts.map +1 -1
  54. package/dist/commands/users.js +159 -15
  55. package/dist/commands/users.js.map +1 -1
  56. package/dist/index.d.ts +2 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +31 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +12 -10
  61. package/dist/admin-assets/assets/index-21ZcDkDC.css +0 -1
  62. package/dist/admin-assets/assets/index-BXVsXHD2.js +0 -23
@@ -1,14 +1,32 @@
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 { buildContentSchema, createAgentsRouter, createAssistantRouter, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createMediaRouter, createMfaRecommendationSource, createNoticeDismissalStore, createNoticeRouter, createPermissionLayer, createRestRouter, createSearchRouter, createSitePlanRouter, createSuspiciousActivitySource, createTaxonomyRouter, createUsersRouter, errorResponse, executeGraphQL, resolveActor, variantKeyFor, } from '@cogenta/api';
7
7
  import { createAuthStore } from '@cogenta/auth';
8
8
  import { CogentaError, createDatabaseMediaStore, createDatabaseRegistry, createLogger, createStorageRegistry, isCogentaError, loadConfig, } from '@cogenta/core';
9
- import { buildSchemaDocument, createContentStore, createRedirectStore, createSchemaTables, withReadOnlyStore, } from '@cogenta/schema';
9
+ import { buildSchemaDocument, createContentStore, createRedirectStore, createSchemaTables, createSearchIndex, createTaxonomyStore, withLifecycleEvents, withReadOnlyStore, withSearchIndexing, } from '@cogenta/schema';
10
10
  import { serveAdminAsset } from './admin-assets.js';
11
- import { loadSkinCss, renderRequestedPage } from './theme-render.js';
11
+ import { buildAssistant, withVectorIndexing } from './assistant.js';
12
+ import { createContentWebhookEmitter } from './content-webhooks.js';
13
+ import { applySecurity } from './http-security.js';
14
+ import { selectMediaImageProcessor } from './media-images.js';
15
+ import { renderSearchPage } from './search-page.js';
16
+ import { createSecurityAlertWatch } from './security-alerts.js';
17
+ import { buildSitemapFiles, collectRoutedResources, renderRobots, seoSiteFor } from './seo.js';
18
+ import { createSitePlanning } from './site-plan.js';
19
+ import { cssEtag, loadThemeCss } from './theme-css.js';
20
+ import { DEFAULT_IMAGE_ENDPOINT, joinStyles, loadSkinCss, renderDraftPage, renderRequestedPage, STYLESHEET_PATH, } from './theme-render.js';
21
+ /** `/sitemap.xml` and the `/sitemap-N.xml` chunks a large site splits into. */
22
+ const SITEMAP_PATH = /^\/sitemap(?:-\d+)?\.xml$/u;
23
+ /** The only `Content-Type` values `/_image` will ever put on the wire. */
24
+ const SERVABLE_IMAGE_TYPES = new Set([
25
+ 'image/avif',
26
+ 'image/webp',
27
+ 'image/jpeg',
28
+ 'image/png',
29
+ ]);
12
30
  const SCHEMA_FILE_CANDIDATES = [
13
31
  'cogenta.schema.ts',
14
32
  'cogenta.schema.mts',
@@ -25,6 +43,16 @@ const SCHEMA_FILE_CANDIDATES = [
25
43
  * collections has nothing to serve.
26
44
  */
27
45
  export async function loadCollections(projectRoot) {
46
+ return (await loadSchemaModule(projectRoot)).collections;
47
+ }
48
+ /**
49
+ * The same file, read for both halves of the content model.
50
+ *
51
+ * Taxonomies arrive as a **named** export beside the default one
52
+ * (`export const taxonomies = [...]`), so every schema file written before
53
+ * `schema@2.0` keeps loading unchanged and simply declares none.
54
+ */
55
+ export async function loadSchemaModule(projectRoot) {
28
56
  for (const candidate of SCHEMA_FILE_CANDIDATES) {
29
57
  const path = join(projectRoot, candidate);
30
58
  let module;
@@ -49,7 +77,18 @@ export async function loadCollections(projectRoot) {
49
77
  hint: 'Export the array defineCollection() built, the same one passed to createSchemaTables in tests.',
50
78
  });
51
79
  }
52
- return collections;
80
+ const taxonomies = module.taxonomies;
81
+ if (taxonomies !== undefined && !Array.isArray(taxonomies)) {
82
+ throw new CogentaError({
83
+ code: 'SCHEMA_INVALID',
84
+ message: `${path} exports "taxonomies", but not as an array.`,
85
+ hint: 'Export the array defineTaxonomy() built: export const taxonomies = [category].',
86
+ });
87
+ }
88
+ return {
89
+ collections: collections,
90
+ taxonomies: (taxonomies ?? []),
91
+ };
53
92
  }
54
93
  throw new CogentaError({
55
94
  code: 'SCHEMA_INVALID',
@@ -57,6 +96,29 @@ export async function loadCollections(projectRoot) {
57
96
  hint: 'Create cogenta.schema.ts, default-exporting the array of collections defineCollection() built.',
58
97
  });
59
98
  }
99
+ /**
100
+ * The schema file this project actually loads, or `undefined` when it has
101
+ * none.
102
+ *
103
+ * Anything that *writes* the schema back has to target this, not a guessed
104
+ * name: `loadCollections` prefers `cogenta.schema.ts` (the form ADR-0010
105
+ * calls for — TypeScript in git), so a writer that assumed `.mjs` would
106
+ * create tables and then write a file nothing reads, leaving an operator
107
+ * with orphan tables and no collections after the restart it was told to do.
108
+ */
109
+ export async function findSchemaFile(projectRoot) {
110
+ for (const candidate of SCHEMA_FILE_CANDIDATES) {
111
+ const path = join(projectRoot, candidate);
112
+ try {
113
+ await stat(path);
114
+ return path;
115
+ }
116
+ catch {
117
+ // Try the next candidate — same order `loadCollections` uses.
118
+ }
119
+ }
120
+ return undefined;
121
+ }
60
122
  /**
61
123
  * True only when the candidate file itself does not exist — never for a
62
124
  * missing import *inside* it, which must surface as a real error rather than
@@ -75,33 +137,92 @@ function isModuleNotFound(error, path) {
75
137
  // `.ts`) surfaced as a hard SCHEMA_INVALID instead of trying the next one.
76
138
  return error.message.includes(pathToFileURL(path).href) || error.message.includes(path);
77
139
  }
140
+ /**
141
+ * What `/api/assistant` answers with when this process built no assistant at
142
+ * all — a caller that did not ask for one, in a test or an embedding. Exactly
143
+ * what `createAssistToolset` returns with no provider, restated here so
144
+ * `assembleSite` need not construct one to say "off".
145
+ */
146
+ const EMPTY_TOOLSET = Object.freeze({
147
+ available: false,
148
+ 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.',
149
+ tools: Object.freeze([]),
150
+ capabilities: Object.freeze([]),
151
+ });
78
152
  /** `relyingPartyId` is the bare host: WebAuthn ties a passkey to a domain, not a URL. */
79
153
  function webauthnConfigFor(site) {
80
154
  const host = new URL(site.url).hostname;
81
155
  return { relyingPartyName: site.name, relyingPartyId: host, origin: site.url };
82
156
  }
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);
157
+ async function assembleSite(options) {
158
+ const { db, collections, site, storage, logger } = options;
159
+ const readOnly = options.readOnly ?? false;
160
+ const styles = options.styles ?? null;
161
+ const taxonomies = options.taxonomies ?? [];
162
+ // Taxonomies first: a `f.taxonomy()` field carries a real foreign key into
163
+ // the terms table, which therefore has to exist before the collection does.
164
+ await createSchemaTables(db, collections, taxonomies);
165
+ // Full-text search, connected for the first time (L10 task 3). The index is
166
+ // derived data and creates its own physical table, so a fresh install can
167
+ // index its first entry without a migration having run.
168
+ //
169
+ // Accepted from the caller when there is one: `runServe` builds it before the
170
+ // assistant so the semantic half can be fused with *this* index rather than
171
+ // with a second one over the same table.
172
+ const searchIndex = options.searchIndex ?? (await createSearchIndex({ db }));
96
173
  const stores = new Map();
97
174
  const storeFor = (collection) => {
98
175
  const existing = stores.get(collection.name);
99
176
  if (existing !== undefined)
100
177
  return existing;
101
- const created = createContentStore({ db, collection });
102
- const stored = readOnly ? withReadOnlyStore(created) : created;
103
- stores.set(collection.name, stored);
104
- return stored;
178
+ // `siblings` is what lets `delete()` enforce `restrict` in application
179
+ // code (ADR-0022): trashing is an UPDATE, so the foreign key has nothing
180
+ // left to refuse at that moment.
181
+ const created = createContentStore({ db, collection, siblings: collections });
182
+ const guarded = readOnly ? withReadOnlyStore(created) : created;
183
+ // Outermost, so a read-only refusal happens *before* anything is indexed:
184
+ // a write that never landed must not change the index either.
185
+ const indexed = withSearchIndexing(guarded, {
186
+ collection,
187
+ index: searchIndex,
188
+ onError: (error) => logger.error('search index write failed', {
189
+ collection: collection.name,
190
+ error: String(error),
191
+ }),
192
+ });
193
+ // The semantic half, wrapped the same way and for the same reason (L18
194
+ // task 5): REST and GraphQL are handed the same store instances, so one
195
+ // wrap covers both and neither can write content the index never hears
196
+ // about. Absent entirely when no embedder is available.
197
+ const stored = options.assistant?.vectors === undefined
198
+ ? indexed
199
+ : withVectorIndexing(indexed, {
200
+ collection,
201
+ siteId: site.url,
202
+ store: options.assistant.vectors.store,
203
+ embeddings: options.assistant.vectors.embeddings,
204
+ onError: (error) => logger.error('vector index write failed', {
205
+ collection: collection.name,
206
+ error: String(error),
207
+ }),
208
+ });
209
+ // Outermost of all: an event must describe a write that really landed, so
210
+ // it fires after the read-only guard has had its chance to refuse and
211
+ // after the index has been brought back in step. A receiver that rebuilt a
212
+ // page from an event the store then rejected would serve a page that never
213
+ // existed.
214
+ const observed = options.onContentEvent == null
215
+ ? stored
216
+ : withLifecycleEvents(stored, {
217
+ collection,
218
+ emit: options.onContentEvent,
219
+ onError: (error) => logger.error('content webhook emit failed', {
220
+ collection: collection.name,
221
+ error: String(error),
222
+ }),
223
+ });
224
+ stores.set(collection.name, observed);
225
+ return observed;
105
226
  };
106
227
  // The gateway (below) reads `stores` directly rather than through
107
228
  // `storeFor` — REST's own lazy population left it empty for any
@@ -122,41 +243,137 @@ skinCss = null) {
122
243
  });
123
244
  const auth = await createAuthStore({
124
245
  db,
125
- signingKey,
246
+ signingKey: options.signingKey,
126
247
  collections,
127
248
  issuer: site.name,
128
249
  webauthn: webauthnConfigFor(site),
129
250
  });
251
+ // One store per taxonomy, made once: a term store holds no state beyond its
252
+ // table, but re-deriving it per request would re-resolve every identifier.
253
+ const taxonomyStores = new Map();
254
+ const taxonomyStoreFor = (taxonomy) => {
255
+ const existing = taxonomyStores.get(taxonomy.name);
256
+ if (existing !== undefined)
257
+ return existing;
258
+ const created = createTaxonomyStore({ db, taxonomy });
259
+ taxonomyStores.set(taxonomy.name, created);
260
+ return created;
261
+ };
130
262
  const mediaStore = createDatabaseMediaStore({ db });
263
+ const noticeDismissals = createNoticeDismissalStore(db);
264
+ await noticeDismissals.ensureTable();
131
265
  return {
132
266
  db,
133
267
  auth,
134
268
  restRouter: createRestRouter({ service, siteUrl: site.url }),
135
269
  authRouter: createAuthRouter({ auth }),
136
- mediaRouter: createMediaRouter({ store: mediaStore, storage }),
270
+ mediaRouter: createMediaRouter({
271
+ store: mediaStore,
272
+ storage,
273
+ ...(options.images === undefined || options.images === null
274
+ ? {}
275
+ : { images: options.images }),
276
+ }),
137
277
  auditRouter: createAuditRouter({ audit: auth.audit }),
138
- ...(agents === undefined ? {} : { agentsRouter: createAgentsRouter(agents) }),
278
+ taxonomyRouter: createTaxonomyRouter({
279
+ taxonomies,
280
+ permissions,
281
+ storeFor: (taxonomy) => taxonomyStoreFor(taxonomy),
282
+ }),
283
+ searchRouter: createSearchRouter({
284
+ index: searchIndex,
285
+ collections,
286
+ permissions,
287
+ defaultLocale: site.defaultLocale,
288
+ }),
289
+ securityAlerts: options.onSecurityEvent == null
290
+ ? null
291
+ : createSecurityAlertWatch({
292
+ rateLimit: auth.rateLimit,
293
+ send: options.onSecurityEvent,
294
+ siteUrl: site.url,
295
+ logger,
296
+ }),
297
+ noticeRouter: createNoticeRouter({
298
+ // One source today, and the seam is the array: a future recommendation
299
+ // (a plugin update waiting, a certificate about to expire) is one more
300
+ // entry here and nothing else anywhere.
301
+ sources: [
302
+ createMfaRecommendationSource({ collections, credentials: auth.credentials }),
303
+ // The failed-sign-in table has been written to since L2 and read by
304
+ // nothing but the limiter's own counter (L14 task 4). One extra source
305
+ // in this array is the whole wiring — the seam the notice mechanism was
306
+ // designed around.
307
+ createSuspiciousActivitySource({ rateLimit: auth.rateLimit }),
308
+ ],
309
+ dismissals: noticeDismissals,
310
+ }),
311
+ usersRouter: createUsersRouter({ auth }),
312
+ assistantRouter: createAssistantRouter({
313
+ toolset: (options.assistant?.toolset ?? EMPTY_TOOLSET),
314
+ collections,
315
+ permissions,
316
+ site,
317
+ logger,
318
+ }),
319
+ ...(options.agents === undefined ? {} : { agentsRouter: createAgentsRouter(options.agents) }),
320
+ ...(options.sitePlans === undefined
321
+ ? {}
322
+ : { sitePlanRouter: createSitePlanRouter(options.sitePlans) }),
139
323
  mediaStore,
140
324
  storage,
325
+ images: options.images ?? null,
141
326
  graphqlSchema: buildContentSchema({ collections }),
142
327
  gateway: createContentGateway({ collections, stores, permissions }),
143
- schemaDocument: buildSchemaDocument(collections, {
144
- locales: site.locales,
145
- defaultLocale: site.defaultLocale,
146
- }),
328
+ permissions,
329
+ schemaDocument: buildSchemaDocument(collections, { locales: site.locales, defaultLocale: site.defaultLocale }, taxonomies),
330
+ redirects,
147
331
  collections,
332
+ taxonomies,
148
333
  site,
149
- skinCss,
150
- health,
334
+ styles,
335
+ security: options.security,
336
+ health: options.health,
151
337
  dispose: async () => {
152
338
  await db.close();
153
339
  },
154
340
  };
155
341
  }
342
+ /**
343
+ * No route on this server takes a JSON body anywhere near this size — the
344
+ * one exception, `/api/site-plans`, already caps its base64 document
345
+ * payloads at 60 MiB total inside `site-plan-router.ts`. This is a ceiling
346
+ * above that, not a route-specific limit: `readBody` runs for every mutating
347
+ * request, most of them long before any permission check, so an unbounded
348
+ * read here was a way for an anonymous caller to make the server buffer an
349
+ * arbitrarily large body before ever being told no.
350
+ */
351
+ const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
156
352
  async function readBody(req) {
157
353
  const chunks = [];
158
- for await (const chunk of req)
159
- chunks.push(chunk);
354
+ let total = 0;
355
+ let tooLarge = false;
356
+ for await (const chunk of req) {
357
+ const buf = chunk;
358
+ total += buf.length;
359
+ if (total > MAX_REQUEST_BODY_BYTES) {
360
+ // Bound memory by not buffering any more chunks, but keep draining the
361
+ // socket rather than destroying it: a client mid-write over the same
362
+ // TCP connection this response has to go out on can be reset by an
363
+ // early `req.destroy()`, which loses the 413 response along with it.
364
+ // Letting the read finish costs bandwidth, never unbounded memory.
365
+ tooLarge = true;
366
+ continue;
367
+ }
368
+ chunks.push(buf);
369
+ }
370
+ if (tooLarge) {
371
+ throw new CogentaError({
372
+ code: 'REQUEST_BODY_TOO_LARGE',
373
+ message: `The request body exceeds the ${MAX_REQUEST_BODY_BYTES}-byte limit.`,
374
+ hint: 'Send a smaller payload.',
375
+ });
376
+ }
160
377
  if (chunks.length === 0)
161
378
  return undefined;
162
379
  const text = Buffer.concat(chunks).toString('utf8');
@@ -297,6 +514,49 @@ async function recordAuthAudit(site, actor, method, pathname, response, logger)
297
514
  .record({ actorId: userId, actorRoles: roles, action: 'auth.login' })
298
515
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
299
516
  }
517
+ /**
518
+ * Account management, in the audit log.
519
+ *
520
+ * Who created an account, who changed a role, who disabled someone and who cut
521
+ * a session short are exactly the events an append-only, hash-chained log
522
+ * exists for — and they were previously invisible, since the only way to do any
523
+ * of it was a terminal.
524
+ *
525
+ * Recorded here, at the transport boundary, for the same reason the content and
526
+ * media audits are: the router stays a pure request-in/response-out value, and
527
+ * only a response that actually succeeded is written down.
528
+ */
529
+ async function recordUserAudit(site, actor, method, pathname, response, logger) {
530
+ if (response.status < 200 || response.status >= 300)
531
+ return;
532
+ const segments = pathname.split('/').filter((segment) => segment.length > 0);
533
+ // ['api', 'users', <id?>, <'sessions' | 'password'>?, <sessionId?>]
534
+ const target = segments[2];
535
+ const sub = segments[3];
536
+ const action = method === 'POST' && target === undefined
537
+ ? 'user.create'
538
+ : method === 'PATCH' && target !== undefined && sub === undefined
539
+ ? 'user.update'
540
+ : method === 'POST' && sub === 'password'
541
+ ? 'user.password_change'
542
+ : method === 'DELETE' && sub === 'sessions'
543
+ ? 'user.session_revoke'
544
+ : null;
545
+ if (action === null)
546
+ return;
547
+ // The subject is named, never anything that could sign anyone in: no
548
+ // password, no token, not even the new roles' provenance beyond the id.
549
+ const created = response.body?.data?.user;
550
+ const subjectId = typeof created?.id === 'string' ? created.id : target === 'me' ? actor.id : (target ?? null);
551
+ await site.auth.audit
552
+ .record({
553
+ actorId: actor.id,
554
+ actorRoles: actor.roles,
555
+ action,
556
+ ...(subjectId === null ? {} : { entryId: subjectId }),
557
+ })
558
+ .catch((error) => logger.error('audit record failed', { error: String(error) }));
559
+ }
300
560
  function writeRestResponse(res, response) {
301
561
  res.writeHead(response.status, response.headers);
302
562
  res.end(response.body === null || response.body === undefined
@@ -330,6 +590,104 @@ async function serveMediaFile(site, actor, id, req, res) {
330
590
  stream.on('error', () => res.destroy());
331
591
  stream.pipe(res);
332
592
  }
593
+ /**
594
+ * `GET /_image?id=…&w=…` — the public delivery endpoint for images.
595
+ *
596
+ * **Public on purpose, and only for images.** A `<img src>` in a published
597
+ * page is fetched by a visitor's browser with no session, so an endpoint the
598
+ * theme can point at cannot be behind the same authentication as
599
+ * `/api/media/{id}/file`. Restricting it to `kind === 'image'` is what keeps
600
+ * that from widening to every uploaded PDF and video: those stay behind the
601
+ * authenticated route, unchanged.
602
+ *
603
+ * It serves the rendition the upload already produced, and falls back to the
604
+ * original when there is none — an asset uploaded before the pipeline
605
+ * existed, a width outside the ladder, or a host with no image driver. It
606
+ * never renders on demand: nothing here decodes an image, so a public URL
607
+ * cannot be turned into CPU by asking for a size nobody stored.
608
+ */
609
+ async function serveImageVariant(site, url, req, res) {
610
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
611
+ res.writeHead(405, { allow: 'GET' }).end();
612
+ return;
613
+ }
614
+ const id = url.searchParams.get('id');
615
+ if (id === null || id === '') {
616
+ jsonError(res, 400, 'QUERY_INVALID', 'An image request must name the media it wants.');
617
+ return;
618
+ }
619
+ const asset = await site.mediaStore.get(id);
620
+ if (asset === null || asset.kind !== 'image') {
621
+ jsonError(res, 404, 'MEDIA_NOT_FOUND', `No image asset with id "${id}".`);
622
+ return;
623
+ }
624
+ let key = asset.storageKey;
625
+ // Never the asset's recorded `mimeType` unquestioned. Uploads now record
626
+ // the sniffed type, but an asset stored before that fix — or by a future
627
+ // writer that skips the route — could carry `text/html`, and this endpoint
628
+ // is public, unauthenticated and on the site's own origin. A type that is
629
+ // not an image serves as an opaque download instead of executing.
630
+ let contentType = SERVABLE_IMAGE_TYPES.has(asset.mimeType)
631
+ ? asset.mimeType
632
+ : 'application/octet-stream';
633
+ const requested = Number(url.searchParams.get('w'));
634
+ if (site.images !== null &&
635
+ Number.isInteger(requested) &&
636
+ requested > 0 &&
637
+ asset.width !== null &&
638
+ asset.height !== null) {
639
+ const names = site.images.variantNames({ width: asset.width, height: asset.height });
640
+ const wanted = `${requested}.`;
641
+ const match = names.find((name) => name.startsWith(wanted));
642
+ if (match !== undefined) {
643
+ const variantKey = variantKeyFor(id, match);
644
+ if (await site.storage.exists(variantKey)) {
645
+ key = variantKey;
646
+ if (match.endsWith('.webp'))
647
+ contentType = 'image/webp';
648
+ }
649
+ }
650
+ }
651
+ const stream = await site.storage.get(key);
652
+ res.writeHead(200, {
653
+ 'content-type': contentType,
654
+ // Long, because the URL names an immutable rendition of an immutable
655
+ // upload: replacing an image means a new media id, never new bytes under
656
+ // the same one.
657
+ 'cache-control': 'public, max-age=31536000, immutable',
658
+ });
659
+ stream.on('error', () => res.destroy());
660
+ stream.pipe(res);
661
+ }
662
+ /**
663
+ * Loads the media a theme render references, as `@cogenta/render`'s
664
+ * `MediaAsset`.
665
+ *
666
+ * The two shapes are deliberately different types (ADR-0016: the delivery
667
+ * plane declares its own wire types rather than importing the engine's), so
668
+ * this is the one place they are mapped. Only images and videos exist in that
669
+ * shape at all — a PDF has no `srcset` — so anything else is left out and
670
+ * `ctx.image()` refuses it clearly.
671
+ */
672
+ async function loadRenderMedia(site, ids) {
673
+ const found = new Map();
674
+ for (const id of new Set(ids)) {
675
+ const asset = await site.mediaStore.get(id);
676
+ if (asset === null)
677
+ continue;
678
+ if (asset.kind !== 'image' && asset.kind !== 'video')
679
+ continue;
680
+ found.set(id, {
681
+ id: asset.id,
682
+ kind: asset.kind,
683
+ alt: asset.alt,
684
+ ...(asset.width === null ? {} : { width: asset.width }),
685
+ ...(asset.height === null ? {} : { height: asset.height }),
686
+ focal: asset.focal,
687
+ });
688
+ }
689
+ return found;
690
+ }
333
691
  /**
334
692
  * Builds the Node request handler from an already-assembled site.
335
693
  *
@@ -342,6 +700,11 @@ async function serveMediaFile(site, actor, id, req, res) {
342
700
  export function createRequestListener(site, logger) {
343
701
  return async (req, res) => {
344
702
  const url = new URL(req.url ?? '/', 'http://localhost');
703
+ // Before anything else, and once: CORS, the security headers and the
704
+ // cache-control class of this path (L10 task 6). A preflight is answered
705
+ // here and never reaches a route.
706
+ if (applySecurity(req, res, url.pathname, site.security))
707
+ return;
345
708
  try {
346
709
  const actor = await resolveActor(site.auth, Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
347
710
  key,
@@ -367,12 +730,46 @@ export function createRequestListener(site, logger) {
367
730
  jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No admin asset matches this path.');
368
731
  return;
369
732
  }
733
+ // The theme's stylesheet: public, cacheable, and the same URL every
734
+ // page links, so a visitor pays for ~26 kB once instead of on every
735
+ // page. Inlining it in each document would cost that on every
736
+ // navigation; a `<link>` with a real ETag costs a conditional request
737
+ // that answers 304. There is nothing to permission-check — the sheet is
738
+ // derived from the skin's tokens and contains no content.
739
+ if (url.pathname === STYLESHEET_PATH) {
740
+ if (req.method !== 'GET') {
741
+ res.writeHead(405, { allow: 'GET' }).end();
742
+ return;
743
+ }
744
+ if (site.styles === null) {
745
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'This site has no stylesheet.');
746
+ return;
747
+ }
748
+ const etag = cssEtag(site.styles);
749
+ if (req.headers['if-none-match'] === etag) {
750
+ res.writeHead(304, { etag }).end();
751
+ return;
752
+ }
753
+ res.writeHead(200, {
754
+ 'content-type': 'text/css; charset=utf-8',
755
+ etag,
756
+ // Revalidate every time: a skin swap must show up on the next
757
+ // request, which is the whole promise of contract D's hot swap. The
758
+ // ETag makes that revalidation a 304 rather than a re-download.
759
+ 'cache-control': 'public, max-age=0, must-revalidate',
760
+ });
761
+ res.end(site.styles);
762
+ return;
763
+ }
370
764
  if (url.pathname.startsWith('/api/auth/')) {
371
765
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
372
766
  const request = toRestRequest(req, url, body);
373
767
  const response = await site.authRouter.handle(request);
374
768
  writeRestResponse(res, response);
375
769
  await recordAuthAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
770
+ // A refused sign-in is the only clock a brute-force alert can honestly
771
+ // have here (L14 task 4) — see `security-alerts.ts` for why not a timer.
772
+ await site.securityAlerts?.observe(response.status);
376
773
  return;
377
774
  }
378
775
  // Public and read-only: `schema.json` describes collection shapes and
@@ -390,6 +787,13 @@ export function createRequestListener(site, logger) {
390
787
  // Serving the file itself sits outside `mediaRouter`: its `RestResponse`
391
788
  // is JSON-only, and a binary body has no shape to fit into that without
392
789
  // widening the transport contract every other route relies on.
790
+ // The public image endpoint (L10 task 5). Before the `/api/*` block on
791
+ // purpose: it is not an API route, and it is the one media path a
792
+ // visitor's browser reaches with no session.
793
+ if (url.pathname === DEFAULT_IMAGE_ENDPOINT) {
794
+ await serveImageVariant(site, url, req, res);
795
+ return;
796
+ }
393
797
  const fileMatch = /^\/api\/media\/([^/]+)\/file$/u.exec(url.pathname);
394
798
  if (fileMatch !== null) {
395
799
  await serveMediaFile(site, actor, decodeURIComponent(fileMatch[1] ?? ''), req, res);
@@ -421,6 +825,15 @@ export function createRequestListener(site, logger) {
421
825
  await recordContentAudit(site, actor, req.method ?? 'GET', url.pathname, body, response, logger);
422
826
  return;
423
827
  }
828
+ // Terms live apart from content on purpose: a taxonomy is not a
829
+ // collection, and a site may legitimately name both the same thing
830
+ // (ADR-0022). Its router owns its own permission door.
831
+ if (url.pathname.startsWith('/api/taxonomies')) {
832
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
833
+ const request = toRestRequest(req, url, body);
834
+ writeRestResponse(res, await site.taxonomyRouter.handle(request, context));
835
+ return;
836
+ }
424
837
  if (url.pathname.startsWith('/api/media')) {
425
838
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
426
839
  const request = toRestRequest(req, url, body);
@@ -429,11 +842,57 @@ export function createRequestListener(site, logger) {
429
842
  await recordMediaAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
430
843
  return;
431
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));
850
+ return;
851
+ }
432
852
  if (url.pathname.startsWith('/api/audit')) {
433
853
  const request = toRestRequest(req, url, undefined);
434
854
  writeRestResponse(res, await site.auditRouter.handle(request, context.actor));
435
855
  return;
436
856
  }
857
+ if (url.pathname.startsWith('/api/notices')) {
858
+ const request = toRestRequest(req, url, undefined);
859
+ writeRestResponse(res, await site.noticeRouter.handle(request, context.actor));
860
+ return;
861
+ }
862
+ if (url.pathname.startsWith('/api/users')) {
863
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
864
+ const request = toRestRequest(req, url, body);
865
+ const response = await site.usersRouter.handle(request, context.actor);
866
+ writeRestResponse(res, response);
867
+ await recordUserAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
868
+ return;
869
+ }
870
+ if (url.pathname.startsWith('/api/site-plans') && site.sitePlanRouter !== undefined) {
871
+ // `SitePlanRouter` itself refuses every route to a non-admin actor,
872
+ // but only after `readBody` has already buffered the whole request —
873
+ // and this route, alone among this server's routes, invites
874
+ // multi-megabyte bodies by design (uploaded documents). Checking the
875
+ // role here, before the body is read at all, means an unauthenticated
876
+ // or non-admin caller is turned away without the server ever reading
877
+ // what they sent.
878
+ if (!context.actor.roles.includes('admin')) {
879
+ jsonError(res, 403, 'FORBIDDEN', 'Only the admin role may propose or apply a site plan.');
880
+ return;
881
+ }
882
+ const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
883
+ const request = toRestRequest(req, url, body);
884
+ writeRestResponse(res, await site.sitePlanRouter.handle(request, context.actor));
885
+ return;
886
+ }
887
+ // Always mounted, on every site (L18). On one with no AI provider it is
888
+ // the route that answers `{available: false}`, which is precisely what
889
+ // lets the admin panel disappear instead of failing.
890
+ if (url.pathname.startsWith('/api/assistant')) {
891
+ const body = req.method === 'GET' ? undefined : await readBody(req);
892
+ const request = toRestRequest(req, url, body);
893
+ writeRestResponse(res, await site.assistantRouter.handle(request, context));
894
+ return;
895
+ }
437
896
  if (url.pathname.startsWith('/api/agents') && site.agentsRouter !== undefined) {
438
897
  const request = toRestRequest(req, url, undefined);
439
898
  writeRestResponse(res, await site.agentsRouter.handle(request, context.actor));
@@ -459,22 +918,189 @@ export function createRequestListener(site, logger) {
459
918
  res.end(JSON.stringify({ data: health }));
460
919
  return;
461
920
  }
921
+ // The visual page builder's preview (L16). It renders an *unsaved* block
922
+ // list through the very function that renders the published page, so the
923
+ // builder can show the real thing in an iframe instead of a React
924
+ // approximation of the twelve blocks.
925
+ //
926
+ // Three gates, in this order, before any of that happens:
927
+ // 1. an authenticated actor — an anonymous caller has no editing
928
+ // session, so it has no business asking for a render of a page state
929
+ // that does not exist yet;
930
+ // 2. `update` on the collection, asked of the same `PermissionLayer`
931
+ // every other write path asks (R4: the route verifies, the renderer
932
+ // does not);
933
+ // 3. `renderDraftPage` reads the stored entry through the same
934
+ // permission-checked gateway, and every `collectionList` block on
935
+ // the page queries through it too — so a draft cannot be used to
936
+ // read content this actor could not already read.
937
+ if (url.pathname === '/api/builder/render') {
938
+ if (req.method !== 'POST') {
939
+ res.writeHead(405, { allow: 'POST' }).end();
940
+ return;
941
+ }
942
+ if (actor.id === null) {
943
+ jsonError(res, 401, 'UNAUTHENTICATED', 'This preview needs a signed-in editor.');
944
+ return;
945
+ }
946
+ const body = (await readBody(req));
947
+ const collectionName = typeof body?.collection === 'string' ? body.collection : '';
948
+ const entryId = typeof body?.entryId === 'string' ? body.entryId : '';
949
+ const collection = site.collections.find((entry) => entry.name === collectionName);
950
+ if (collection === undefined || entryId === '') {
951
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No such collection or entry.');
952
+ return;
953
+ }
954
+ // `errorResponse` rather than the outer catch: it is what turns a
955
+ // `CogentaError` into the status its code deserves (403 for
956
+ // `FORBIDDEN`), and it is already the mapping every `/api/*` router
957
+ // uses. The outer catch would answer 500 to a refusal.
958
+ let html;
959
+ try {
960
+ site.permissions.assert('update', collection, context);
961
+ html = await renderDraftPage({
962
+ collection: collectionName,
963
+ entryId,
964
+ blocks: (body?.blocks ?? {}),
965
+ ...(typeof body?.values === 'object' && body.values !== null
966
+ ? { values: body.values }
967
+ : {}),
968
+ }, {
969
+ collections: site.collections,
970
+ gateway: site.gateway,
971
+ site: site.site,
972
+ styles: site.styles,
973
+ loadMedia: (ids) => loadRenderMedia(site, ids),
974
+ }, context);
975
+ }
976
+ catch (error) {
977
+ logger.warn('builder preview refused', {
978
+ error: isCogentaError(error) ? error.toJSON() : String(error),
979
+ });
980
+ writeRestResponse(res, errorResponse(error));
981
+ return;
982
+ }
983
+ if (html === null) {
984
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No such collection or entry.');
985
+ return;
986
+ }
987
+ res.writeHead(200, {
988
+ 'content-type': 'application/json; charset=utf-8',
989
+ // A draft is never cacheable, by anyone, for any length of time.
990
+ 'cache-control': 'no-store',
991
+ });
992
+ res.end(JSON.stringify({ data: { html } }));
993
+ return;
994
+ }
995
+ // Everything below is the public site rather than the API, so the
996
+ // redirect table gets its turn first: a page renamed last month must
997
+ // answer its old URL with the 301 the rename recorded, not a 404 (L10
998
+ // task 2). Before route matching, so a redirect wins even when some
999
+ // other entry has since taken the old path — that is what `release()`
1000
+ // is for on the write side.
1001
+ if (req.method === 'GET' || req.method === 'HEAD') {
1002
+ const redirect = await site.redirects.resolve(url.pathname);
1003
+ if (redirect !== null) {
1004
+ res.writeHead(redirect.status, {
1005
+ location: `${redirect.to}${url.search}`,
1006
+ 'cache-control': redirect.status === 301 ? 'public, max-age=3600' : 'no-store',
1007
+ });
1008
+ res.end();
1009
+ return;
1010
+ }
1011
+ }
1012
+ // `robots.txt` and `sitemap.xml`, from the real content (L10 task 2).
1013
+ // Both are built as `ANONYMOUS` inside `collectRoutedResources`,
1014
+ // whoever asked: a crawler and a signed-in editor must get the same
1015
+ // document, or the sitemap advertises URLs the crawler cannot fetch.
1016
+ if (url.pathname === '/robots.txt') {
1017
+ if (req.method !== 'GET') {
1018
+ res.writeHead(405, { allow: 'GET' }).end();
1019
+ return;
1020
+ }
1021
+ res.writeHead(200, {
1022
+ 'content-type': 'text/plain; charset=utf-8',
1023
+ 'cache-control': 'public, max-age=3600',
1024
+ });
1025
+ res.end(renderRobots(seoSiteFor(site.site)));
1026
+ return;
1027
+ }
1028
+ if (SITEMAP_PATH.test(url.pathname)) {
1029
+ if (req.method !== 'GET') {
1030
+ res.writeHead(405, { allow: 'GET' }).end();
1031
+ return;
1032
+ }
1033
+ const seoSite = seoSiteFor(site.site);
1034
+ const files = buildSitemapFiles(seoSite, await collectRoutedResources(site.collections, site.gateway));
1035
+ const file = files.find((candidate) => candidate.path === url.pathname);
1036
+ if (file !== undefined) {
1037
+ res.writeHead(200, {
1038
+ 'content-type': 'application/xml; charset=utf-8',
1039
+ 'cache-control': 'public, max-age=600',
1040
+ });
1041
+ res.end(file.contents);
1042
+ return;
1043
+ }
1044
+ // `/sitemap-9.xml` on a site that only needs one file is a real 404,
1045
+ // not an empty urlset: an empty chunk would tell a crawler the site
1046
+ // has nothing there rather than that the URL is wrong.
1047
+ jsonError(res, 404, 'CONTENT_NOT_FOUND', 'No sitemap file at this path.');
1048
+ return;
1049
+ }
1050
+ // The public search page (L10 task 3): a real form and a real results
1051
+ // list, served through the same permission-checked search router the
1052
+ // API uses. Deliberately a route rather than a contract B block — see
1053
+ // `search-page.ts` for why.
1054
+ if (url.pathname === '/search' && req.method === 'GET') {
1055
+ const html = await renderSearchPage(url.searchParams.get('q') ?? '', {
1056
+ router: site.searchRouter,
1057
+ gateway: site.gateway,
1058
+ collections: site.collections,
1059
+ site: site.site,
1060
+ styles: site.styles,
1061
+ }, context);
1062
+ res.writeHead(200, {
1063
+ 'content-type': 'text/html; charset=utf-8',
1064
+ 'cache-control': 'no-store',
1065
+ });
1066
+ res.end(html);
1067
+ return;
1068
+ }
462
1069
  // Real theme HTML for anything else — see `theme-render.ts`'s own
463
1070
  // doc comment for what this is and, as importantly, what it isn't
464
1071
  // (no Astro build, one theme, no image pipeline). GET only: rendering
465
1072
  // a page has no meaningful response to any other method.
466
1073
  if (req.method === 'GET') {
467
- const html = await renderRequestedPage(url.pathname, {
1074
+ const renderOptions = {
468
1075
  collections: site.collections,
469
1076
  gateway: site.gateway,
470
1077
  site: site.site,
471
- skinCss: site.skinCss,
472
- }, context);
1078
+ styles: site.styles,
1079
+ loadMedia: (ids) => loadRenderMedia(site, ids),
1080
+ };
1081
+ const html = await renderRequestedPage(url.pathname, renderOptions, context);
473
1082
  if (html !== null) {
474
1083
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
475
1084
  res.end(html);
476
1085
  return;
477
1086
  }
1087
+ // The site's own 404 page (L14 task 2). It is an ordinary entry at
1088
+ // `site.notFoundPath`, rendered by exactly the same function and
1089
+ // through exactly the same permission-checked gateway as any other
1090
+ // page — a custom 404 that could show content the visitor may not read
1091
+ // would be a hole, not a feature.
1092
+ //
1093
+ // The guard matters: without it, a site whose 404 page is missing (or
1094
+ // whose `notFoundPath` is itself unroutable) would ask for it again
1095
+ // for every unmatched URL forever. One extra lookup, never two.
1096
+ if (url.pathname !== site.site.notFoundPath) {
1097
+ const notFound = await renderRequestedPage(site.site.notFoundPath, renderOptions, context);
1098
+ if (notFound !== null) {
1099
+ res.writeHead(404, { 'content-type': 'text/html; charset=utf-8' });
1100
+ res.end(notFound);
1101
+ return;
1102
+ }
1103
+ }
478
1104
  }
479
1105
  res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' });
480
1106
  res.end(JSON.stringify({
@@ -485,6 +1111,10 @@ export function createRequestListener(site, logger) {
485
1111
  logger.error('request failed', {
486
1112
  error: isCogentaError(error) ? error.toJSON() : String(error),
487
1113
  });
1114
+ if (isCogentaError(error) && error.code === 'REQUEST_BODY_TOO_LARGE') {
1115
+ jsonError(res, 413, error.code, error.message);
1116
+ return;
1117
+ }
488
1118
  res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' });
489
1119
  res.end(JSON.stringify({
490
1120
  error: { code: 'INTERNAL', message: 'The request could not be completed.' },
@@ -494,6 +1124,8 @@ export function createRequestListener(site, logger) {
494
1124
  }
495
1125
  const DEFAULT_PORT = 4000;
496
1126
  const DEFAULT_HOST = '127.0.0.1';
1127
+ /** How long a shutdown waits for open connections before cutting them. */
1128
+ const SHUTDOWN_GRACE_MS = 2_000;
497
1129
  /**
498
1130
  * Runs until `options.signal` aborts. Returns 0 on a clean shutdown, 1 if
499
1131
  * startup failed — nothing here calls `process.exit` (same convention as
@@ -515,8 +1147,11 @@ export async function runServe(options) {
515
1147
  return 1;
516
1148
  }
517
1149
  let collections;
1150
+ let taxonomies;
518
1151
  try {
519
- collections = await loadCollections(projectRoot);
1152
+ const schema = await loadSchemaModule(projectRoot);
1153
+ collections = schema.collections;
1154
+ taxonomies = schema.taxonomies;
520
1155
  }
521
1156
  catch (error) {
522
1157
  if (isCogentaError(error)) {
@@ -531,8 +1166,63 @@ export async function runServe(options) {
531
1166
  }
532
1167
  const selection = await createDatabaseRegistry({ logger }).select(loaded.config.database);
533
1168
  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);
1169
+ const styles = joinStyles(await loadSkinCss((path) => readFile(path, 'utf8'), join(projectRoot, 'theme.tokens.json')), await loadThemeCss({ read: (url) => readFile(url, 'utf8') }));
1170
+ const images = await selectMediaImageProcessor(logger);
1171
+ // One signed channel for both outbound events — the content lifecycle (task
1172
+ // 1) and the suspicious-activity alert (task 4). One set of endpoints, one
1173
+ // secret, one signing path.
1174
+ const webhooks = createContentWebhookEmitter({
1175
+ webhooks: loaded.config.webhooks,
1176
+ siteUrl: loaded.config.site.url,
1177
+ logger,
1178
+ });
1179
+ // L18. Never fatal: everything inside degrades to "off" with a log line
1180
+ // rather than stopping the site from serving (R2).
1181
+ const searchIndex = await createSearchIndex({ db: selection.instance });
1182
+ const assistant = await buildAssistant({
1183
+ config: loaded.config,
1184
+ db: selection.instance,
1185
+ logger,
1186
+ // Beside the full-text index, never instead of it: the semantic half is
1187
+ // fused with this one by RRF (L18 task 5).
1188
+ fullText: searchIndex,
1189
+ });
1190
+ const site = await assembleSite({
1191
+ db: selection.instance,
1192
+ assistant,
1193
+ searchIndex,
1194
+ collections,
1195
+ taxonomies,
1196
+ signingKey: loaded.config.auth.signingKey,
1197
+ site: loaded.config.site,
1198
+ storage: storageSelection.instance,
1199
+ logger,
1200
+ health: async () => ({
1201
+ database: await selection.health(),
1202
+ storage: await storageSelection.health(),
1203
+ }),
1204
+ readOnly: options.readOnly ?? false,
1205
+ styles,
1206
+ images: images?.processor ?? null,
1207
+ security: loaded.config.security,
1208
+ sitePlans: await createSitePlanning({
1209
+ projectRoot,
1210
+ db: selection.instance,
1211
+ collections,
1212
+ config: loaded.config,
1213
+ logger,
1214
+ readOnly: options.readOnly ?? false,
1215
+ // ADR-0010: the schema is writable in development only. `cogenta dev`
1216
+ // says development; `cogenta serve` does not, and a plan can then be
1217
+ // proposed and reviewed but never applied.
1218
+ development: options.development ?? false,
1219
+ }),
1220
+ // The signed outbound webhook channel, connected to the content lifecycle
1221
+ // for the first time (L14 task 1). `null` when the site configured no
1222
+ // endpoint, or configured one without a signing secret.
1223
+ onContentEvent: webhooks.emit,
1224
+ onSecurityEvent: webhooks.send,
1225
+ });
536
1226
  const server = createServer(createRequestListener(site, logger));
537
1227
  const port = options.port ?? DEFAULT_PORT;
538
1228
  const host = options.host ?? DEFAULT_HOST;
@@ -546,7 +1236,8 @@ export async function runServe(options) {
546
1236
  const address = server.address();
547
1237
  const boundPort = typeof address === 'object' && address !== null ? address.port : port;
548
1238
  out.ok(`Listening on http://${host}:${boundPort}`);
549
- out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}`);
1239
+ out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}, image driver: ${images?.driver ?? 'none'}`);
1240
+ out.detail(assistant.summary);
550
1241
  options.onListening?.({ port: boundPort, host });
551
1242
  await new Promise((resolve) => {
552
1243
  if (options.signal === undefined)
@@ -559,7 +1250,16 @@ export async function runServe(options) {
559
1250
  });
560
1251
  await new Promise((resolve, reject) => {
561
1252
  server.close((error) => (error ? reject(error) : resolve()));
1253
+ // `close()` alone waits for every open connection to end, and a client
1254
+ // that fetched a large response and never read the body holds one open
1255
+ // indefinitely — a media download is exactly that shape. Without the
1256
+ // grace period, one such client turns Ctrl-C into a hang. Found while
1257
+ // writing the image tests, where a deliberately unread image body kept
1258
+ // the whole process alive.
1259
+ const grace = setTimeout(() => server.closeAllConnections(), SHUTDOWN_GRACE_MS);
1260
+ grace.unref();
562
1261
  });
1262
+ await assistant.dispose();
563
1263
  await selection.dispose();
564
1264
  await storageSelection.dispose();
565
1265
  await site.dispose().catch(() => undefined); // selection.dispose() already closed the same handle