@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.
- package/dist/admin-assets/assets/index-C9a7O_Xs.css +1 -0
- package/dist/admin-assets/assets/index-DXvMgWvn.js +74 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-400.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-500.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-600.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-mono-700.woff2 +0 -0
- package/dist/admin-assets/fonts/ibm-plex-sans-var.woff2 +0 -0
- package/dist/admin-assets/index.html +2 -2
- package/dist/commands/serve.d.ts +52 -1
- package/dist/commands/serve.d.ts.map +1 -1
- package/dist/commands/serve.js +408 -6
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/theme-render.d.ts +38 -1
- package/dist/commands/theme-render.d.ts.map +1 -1
- package/dist/commands/theme-render.js +93 -4
- package/dist/commands/theme-render.js.map +1 -1
- package/dist/commands/users.d.ts.map +1 -1
- package/dist/commands/users.js +6 -36
- package/dist/commands/users.js.map +1 -1
- package/dist/reset-mail.d.ts +39 -0
- package/dist/reset-mail.d.ts.map +1 -0
- package/dist/reset-mail.js +45 -0
- package/dist/reset-mail.js.map +1 -0
- package/package.json +15 -12
- package/dist/admin-assets/assets/index-Buwdj1V2.js +0 -71
- package/dist/admin-assets/assets/index-Csef0SiA.css +0 -1
package/dist/commands/serve.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
9
|
-
import {
|
|
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(
|
|
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({
|
|
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
|
|
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
|