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