@cogenta/cli 0.3.0 → 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.
@@ -3,10 +3,15 @@ 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, createAssistantRouter, createAuditRouter, createAuthRouter, createContentGateway, createContentService, createMediaRouter, createMfaRecommendationSource, createNoticeDismissalStore, createNoticeRouter, createPermissionLayer, createRestRouter, createSearchRouter, createSitePlanRouter, createSuspiciousActivitySource, createTaxonomyRouter, createUsersRouter, errorResponse, executeGraphQL, resolveActor, variantKeyFor, } 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, createSearchIndex, createTaxonomyStore, withLifecycleEvents, withReadOnlyStore, withSearchIndexing, } 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
16
  import { buildAssistant, withVectorIndexing } from './assistant.js';
12
17
  import { createContentWebhookEmitter } from './content-webhooks.js';
@@ -170,6 +175,14 @@ async function assembleSite(options) {
170
175
  // assistant so the semantic half can be fused with *this* index rather than
171
176
  // with a second one over the same table.
172
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 });
173
186
  const stores = new Map();
174
187
  const storeFor = (collection) => {
175
188
  const existing = stores.get(collection.name);
@@ -180,9 +193,20 @@ async function assembleSite(options) {
180
193
  // left to refuse at that moment.
181
194
  const created = createContentStore({ db, collection, siblings: collections });
182
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
+ });
183
207
  // Outermost, so a read-only refusal happens *before* anything is indexed:
184
208
  // a write that never landed must not change the index either.
185
- const indexed = withSearchIndexing(guarded, {
209
+ const indexed = withSearchIndexing(schedulable, {
186
210
  collection,
187
211
  index: searchIndex,
188
212
  onError: (error) => logger.error('search index write failed', {
@@ -232,6 +256,19 @@ async function assembleSite(options) {
232
256
  // same, already-complete map.
233
257
  for (const collection of collections)
234
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 });
235
272
  const redirects = createRedirectStore({ db });
236
273
  await redirects.ensureTable();
237
274
  const permissions = createPermissionLayer({ collections });
@@ -262,11 +299,127 @@ async function assembleSite(options) {
262
299
  const mediaStore = createDatabaseMediaStore({ db });
263
300
  const noticeDismissals = createNoticeDismissalStore(db);
264
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;
265
413
  return {
266
414
  db,
267
415
  auth,
268
416
  restRouter: createRestRouter({ service, siteUrl: site.url }),
269
- authRouter: createAuthRouter({ auth }),
417
+ authRouter: createAuthRouter({
418
+ auth,
419
+ ...(options.onForgotPassword == null ? {} : { onForgotPassword: options.onForgotPassword }),
420
+ }),
421
+ analyticsStore,
422
+ analyticsRouter: createAnalyticsRouter({ store: analyticsStore, siteHost }),
270
423
  mediaRouter: createMediaRouter({
271
424
  store: mediaStore,
272
425
  storage,
@@ -280,6 +433,26 @@ async function assembleSite(options) {
280
433
  permissions,
281
434
  storeFor: (taxonomy) => taxonomyStoreFor(taxonomy),
282
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
+ }),
283
456
  searchRouter: createSearchRouter({
284
457
  index: searchIndex,
285
458
  collections,
@@ -309,6 +482,7 @@ async function assembleSite(options) {
309
482
  dismissals: noticeDismissals,
310
483
  }),
311
484
  usersRouter: createUsersRouter({ auth }),
485
+ apiKeysRouter: createApiKeysRouter({ auth }),
312
486
  assistantRouter: createAssistantRouter({
313
487
  toolset: (options.assistant?.toolset ?? EMPTY_TOOLSET),
314
488
  collections,
@@ -320,11 +494,18 @@ async function assembleSite(options) {
320
494
  ...(options.sitePlans === undefined
321
495
  ? {}
322
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
+ }),
323
504
  mediaStore,
324
505
  storage,
325
506
  images: options.images ?? null,
326
507
  graphqlSchema: buildContentSchema({ collections }),
327
- gateway: createContentGateway({ collections, stores, permissions }),
508
+ gateway,
328
509
  permissions,
329
510
  schemaDocument: buildSchemaDocument(collections, { locales: site.locales, defaultLocale: site.defaultLocale }, taxonomies),
330
511
  redirects,
@@ -334,7 +515,9 @@ async function assembleSite(options) {
334
515
  styles,
335
516
  security: options.security,
336
517
  health: options.health,
518
+ tickScheduledPublishing: () => scheduledPublishQueue.tick(),
337
519
  dispose: async () => {
520
+ await scheduledPublishQueue.close();
338
521
  await db.close();
339
522
  },
340
523
  };
@@ -408,6 +591,35 @@ function toRestRequest(req, url, body) {
408
591
  ...(body === undefined ? {} : { body }),
409
592
  };
410
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
+ }
411
623
  function responseId(response) {
412
624
  const data = response.body?.data;
413
625
  return typeof data?.id === 'string' ? data.id : undefined;
@@ -491,6 +703,28 @@ async function recordMediaAudit(site, actor, method, pathname, response, logger)
491
703
  })
492
704
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
493
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
+ }
494
728
  async function recordAuthAudit(site, actor, method, pathname, response, logger) {
495
729
  if (response.status < 200 || response.status >= 300)
496
730
  return;
@@ -557,6 +791,37 @@ async function recordUserAudit(site, actor, method, pathname, response, logger)
557
791
  })
558
792
  .catch((error) => logger.error('audit record failed', { error: String(error) }));
559
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
+ }
560
825
  function writeRestResponse(res, response) {
561
826
  res.writeHead(response.status, response.headers);
562
827
  res.end(response.body === null || response.body === undefined
@@ -834,6 +1099,40 @@ export function createRequestListener(site, logger) {
834
1099
  writeRestResponse(res, await site.taxonomyRouter.handle(request, context));
835
1100
  return;
836
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
+ }
837
1136
  if (url.pathname.startsWith('/api/media')) {
838
1137
  const body = req.method === 'GET' || req.method === 'DELETE' ? undefined : await readBody(req);
839
1138
  const request = toRestRequest(req, url, body);
@@ -842,6 +1141,23 @@ export function createRequestListener(site, logger) {
842
1141
  await recordMediaAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
843
1142
  return;
844
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
+ }
845
1161
  // The full-text index, reachable at last (L10 task 3). Its own router
846
1162
  // decides which collections this actor may search — never this layer.
847
1163
  if (url.pathname === '/api/search') {
@@ -849,6 +1165,14 @@ export function createRequestListener(site, logger) {
849
1165
  writeRestResponse(res, await site.searchRouter.handle(request, context));
850
1166
  return;
851
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
+ }
852
1176
  if (url.pathname.startsWith('/api/audit')) {
853
1177
  const request = toRestRequest(req, url, undefined);
854
1178
  writeRestResponse(res, await site.auditRouter.handle(request, context.actor));
@@ -867,6 +1191,15 @@ export function createRequestListener(site, logger) {
867
1191
  await recordUserAudit(site, actor, req.method ?? 'GET', url.pathname, response, logger);
868
1192
  return;
869
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
+ }
870
1203
  if (url.pathname.startsWith('/api/site-plans') && site.sitePlanRouter !== undefined) {
871
1204
  // `SitePlanRouter` itself refuses every route to a non-admin actor,
872
1205
  // but only after `readBody` has already buffered the whole request —
@@ -884,6 +1217,22 @@ export function createRequestListener(site, logger) {
884
1217
  writeRestResponse(res, await site.sitePlanRouter.handle(request, context.actor));
885
1218
  return;
886
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
+ }
887
1236
  // Always mounted, on every site (L18). On one with no AI provider it is
888
1237
  // the route that answers `{available: false}`, which is precisely what
889
1238
  // lets the admin panel disappear instead of failing.
@@ -971,6 +1320,15 @@ export function createRequestListener(site, logger) {
971
1320
  site: site.site,
972
1321
  styles: site.styles,
973
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,
974
1332
  }, context);
975
1333
  }
976
1334
  catch (error) {
@@ -1077,6 +1435,12 @@ export function createRequestListener(site, logger) {
1077
1435
  site: site.site,
1078
1436
  styles: site.styles,
1079
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,
1080
1444
  };
1081
1445
  const html = await renderRequestedPage(url.pathname, renderOptions, context);
1082
1446
  if (html !== null) {
@@ -1126,6 +1490,18 @@ const DEFAULT_PORT = 4000;
1126
1490
  const DEFAULT_HOST = '127.0.0.1';
1127
1491
  /** How long a shutdown waits for open connections before cutting them. */
1128
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;
1129
1505
  /**
1130
1506
  * Runs until `options.signal` aborts. Returns 0 on a clean shutdown, 1 if
1131
1507
  * startup failed — nothing here calls `process.exit` (same convention as
@@ -1205,6 +1581,8 @@ export async function runServe(options) {
1205
1581
  styles,
1206
1582
  images: images?.processor ?? null,
1207
1583
  security: loaded.config.security,
1584
+ webhooks: loaded.config.webhooks,
1585
+ billing: loaded.config.billing,
1208
1586
  sitePlans: await createSitePlanning({
1209
1587
  projectRoot,
1210
1588
  db: selection.instance,
@@ -1222,6 +1600,14 @@ export async function runServe(options) {
1222
1600
  // endpoint, or configured one without a signing secret.
1223
1601
  onContentEvent: webhooks.emit,
1224
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),
1225
1611
  });
1226
1612
  const server = createServer(createRequestListener(site, logger));
1227
1613
  const port = options.port ?? DEFAULT_PORT;
@@ -1239,6 +1625,21 @@ export async function runServe(options) {
1239
1625
  out.detail(`${collections.length} collection(s), db driver: ${selection.driver}, storage driver: ${storageSelection.driver}, image driver: ${images?.driver ?? 'none'}`);
1240
1626
  out.detail(assistant.summary);
1241
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();
1242
1643
  await new Promise((resolve) => {
1243
1644
  if (options.signal === undefined)
1244
1645
  return;
@@ -1248,6 +1649,7 @@ export async function runServe(options) {
1248
1649
  }
1249
1650
  options.signal.addEventListener('abort', () => resolve(), { once: true });
1250
1651
  });
1652
+ clearInterval(scheduledPublishTimer);
1251
1653
  await new Promise((resolve, reject) => {
1252
1654
  server.close((error) => (error ? reject(error) : resolve()));
1253
1655
  // `close()` alone waits for every open connection to end, and a client