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