@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
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Logger } from '@cogenta/core';
|
|
2
|
+
import type { Output, Writer } from '../output.js';
|
|
3
|
+
export type LinksSubcommand = 'check';
|
|
4
|
+
export interface LinksOptions {
|
|
5
|
+
readonly subcommand: string | undefined;
|
|
6
|
+
readonly cwd?: string;
|
|
7
|
+
readonly env?: Record<string, string | undefined>;
|
|
8
|
+
readonly logger?: Logger;
|
|
9
|
+
readonly out: Output;
|
|
10
|
+
readonly stderr: Writer;
|
|
11
|
+
/** Also follow `http(s)` links that leave the site. Off unless asked. */
|
|
12
|
+
readonly external?: boolean;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `cogenta links check` (L14 task 3).
|
|
16
|
+
*
|
|
17
|
+
* Exit codes are the ones a cron entry can act on: 0 nothing broken, 1 broken
|
|
18
|
+
* links were found or the site could not be read, 2 the command line was
|
|
19
|
+
* wrong. A non-zero exit for "found broken links" is deliberate — that is what
|
|
20
|
+
* makes this usable as a CI or scheduled check rather than something whose
|
|
21
|
+
* output somebody has to read.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runLinks(options: LinksOptions): Promise<number>;
|
|
24
|
+
//# sourceMappingURL=links.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/commands/links.ts"],"names":[],"mappings":"AAEA,OAAO,EAIL,KAAK,MAAM,EAEZ,MAAM,eAAe,CAAA;AAStB,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,cAAc,CAAA;AAGlD,MAAM,MAAM,eAAe,GAAG,OAAO,CAAA;AAErC,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;IACvC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAA;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,yEAAyE;IACzE,QAAQ,CAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAC5B;AA+BD;;;;;;;;GAQG;AACH,wBAAsB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CA+ErE"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { dirname, resolve as resolvePath } from 'node:path';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { createDatabaseRegistry, createLogger, isCogentaError, loadConfig, } from '@cogenta/core';
|
|
4
|
+
import { checkLinks, createContentStore, createSchemaTables, } from '@cogenta/schema';
|
|
5
|
+
import { loadCollections } from './serve.js';
|
|
6
|
+
const USAGE = `Usage
|
|
7
|
+
cogenta links check [--external]
|
|
8
|
+
|
|
9
|
+
Walks every published entry, follows every link it holds, and reports the ones
|
|
10
|
+
that lead nowhere: a reference to a deleted or still-unpublished entry, a
|
|
11
|
+
site-relative path that matches no route, and — with --external — an outside
|
|
12
|
+
URL that answers with an error or cannot be reached at all.
|
|
13
|
+
|
|
14
|
+
Nothing about this runs on its own. This project guarantees no durable
|
|
15
|
+
background worker (rule R1), so "periodically" means a cron entry calling this
|
|
16
|
+
command, not a scheduler pretending to exist inside the site.
|
|
17
|
+
`;
|
|
18
|
+
const REASON_TEXT = {
|
|
19
|
+
unknown_collection: 'points at a collection this site does not have',
|
|
20
|
+
target_missing: 'points at content that does not exist',
|
|
21
|
+
target_unpublished: 'points at content that is not published',
|
|
22
|
+
unroutable_path: 'is a path no route can serve',
|
|
23
|
+
http_error: 'answered with an error status',
|
|
24
|
+
unreachable: 'could not be reached',
|
|
25
|
+
};
|
|
26
|
+
function describe(broken) {
|
|
27
|
+
const target = broken.link.kind === 'url' ? broken.link.href : `${broken.link.collection}/${broken.link.id}`;
|
|
28
|
+
const status = broken.status === undefined ? '' : ` (${broken.status})`;
|
|
29
|
+
return `${broken.collection}/${broken.entryId} [${broken.locale}] ${broken.at}\n → ${target}${status} — ${REASON_TEXT[broken.reason]}`;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `cogenta links check` (L14 task 3).
|
|
33
|
+
*
|
|
34
|
+
* Exit codes are the ones a cron entry can act on: 0 nothing broken, 1 broken
|
|
35
|
+
* links were found or the site could not be read, 2 the command line was
|
|
36
|
+
* wrong. A non-zero exit for "found broken links" is deliberate — that is what
|
|
37
|
+
* makes this usable as a CI or scheduled check rather than something whose
|
|
38
|
+
* output somebody has to read.
|
|
39
|
+
*/
|
|
40
|
+
export async function runLinks(options) {
|
|
41
|
+
const { out, stderr } = options;
|
|
42
|
+
const env = options.env ?? process.env;
|
|
43
|
+
const logger = options.logger ?? createLogger({ level: 'silent' });
|
|
44
|
+
if (options.subcommand === undefined) {
|
|
45
|
+
stderr(`cogenta links needs a subcommand.\n\n${USAGE}`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
if (options.subcommand !== 'check') {
|
|
49
|
+
stderr(`Unknown subcommand "${options.subcommand}". Only "check" exists today.\n\n${USAGE}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
const loaded = await loadConfig({
|
|
53
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
54
|
+
env,
|
|
55
|
+
});
|
|
56
|
+
const projectRoot = loaded.path === null ? resolvePath(options.cwd ?? process.cwd()) : dirname(loaded.path);
|
|
57
|
+
let selection = null;
|
|
58
|
+
try {
|
|
59
|
+
const collections = await loadCollections(projectRoot);
|
|
60
|
+
selection = await createDatabaseRegistry({ logger }).select(loaded.config.database);
|
|
61
|
+
const db = selection.instance;
|
|
62
|
+
await createSchemaTables(db, collections);
|
|
63
|
+
const stores = new Map();
|
|
64
|
+
const storeFor = (collection) => {
|
|
65
|
+
const existing = stores.get(collection.name);
|
|
66
|
+
if (existing !== undefined)
|
|
67
|
+
return existing;
|
|
68
|
+
const created = createContentStore({ db, collection });
|
|
69
|
+
stores.set(collection.name, created);
|
|
70
|
+
return created;
|
|
71
|
+
};
|
|
72
|
+
const report = await checkLinks({
|
|
73
|
+
collections,
|
|
74
|
+
storeFor,
|
|
75
|
+
locales: loaded.config.site.locales,
|
|
76
|
+
defaultLocale: loaded.config.site.defaultLocale,
|
|
77
|
+
...(options.external === true ? { checkExternal: true } : {}),
|
|
78
|
+
});
|
|
79
|
+
if (report.broken.length === 0) {
|
|
80
|
+
out.ok(`Checked ${report.checkedLinks} link(s) across ${report.checkedEntries} published entries. Nothing broken.`);
|
|
81
|
+
if (report.skippedExternal > 0) {
|
|
82
|
+
out.detail(`${report.skippedExternal} external link(s) were not followed. Pass --external to check them.`);
|
|
83
|
+
}
|
|
84
|
+
return 0;
|
|
85
|
+
}
|
|
86
|
+
out.warn(`${report.broken.length} broken link(s) in ${report.checkedEntries} published entries:`);
|
|
87
|
+
for (const broken of report.broken)
|
|
88
|
+
out.detail(describe(broken));
|
|
89
|
+
if (report.skippedExternal > 0) {
|
|
90
|
+
out.detail(`${report.skippedExternal} external link(s) were not followed. Pass --external to check them.`);
|
|
91
|
+
}
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (isCogentaError(error)) {
|
|
96
|
+
stderr(`${error.code}: ${error.message}\n`);
|
|
97
|
+
if (error.hint !== undefined)
|
|
98
|
+
stderr(`${error.hint}\n`);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
stderr(`${error instanceof Error ? error.stack : String(error)}\n`);
|
|
102
|
+
}
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await selection?.instance.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=links.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"links.js","sourceRoot":"","sources":["../../src/commands/links.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ImageSize, MediaImageProcessor } from '@cogenta/api';
|
|
2
|
+
import type { Logger } from '@cogenta/core';
|
|
3
|
+
import { contentTypeOf, type ImageFormat, type ImageTransformer } from '@cogenta/render';
|
|
4
|
+
/**
|
|
5
|
+
* `@cogenta/render`'s image pipeline, wired to the media upload (L10 task 5).
|
|
6
|
+
*
|
|
7
|
+
* The pipeline, `srcset.ts` and the two driver tiers (sharp, WASM libvips)
|
|
8
|
+
* have existed since L3 and were called by nothing. This module is where they
|
|
9
|
+
* meet a real upload.
|
|
10
|
+
*
|
|
11
|
+
* ## Why variants are produced at upload rather than on demand
|
|
12
|
+
*
|
|
13
|
+
* `createImagePipeline` is deliberately lazy — it renders a variant the first
|
|
14
|
+
* time a browser asks for one — and that is the right design for a build step
|
|
15
|
+
* that would otherwise materialise N formats × M widths × K images. It is the
|
|
16
|
+
* wrong design here, and the lot says so explicitly ("au moment de l'upload,
|
|
17
|
+
* pas à la volée"), for a reason worth writing down: `cogenta serve` has no
|
|
18
|
+
* durable variant cache. `createMemoryVariantStore` dies with the process, so
|
|
19
|
+
* a lazy pipeline behind it re-decodes every image after every restart, and
|
|
20
|
+
* on the shared hosting Cogenta targets (R10's own list) that is the slowest
|
|
21
|
+
* possible answer on the coldest possible machine.
|
|
22
|
+
*
|
|
23
|
+
* Producing a bounded, deterministic set once — the `SRCSET_WIDTHS` ladder,
|
|
24
|
+
* capped at the intrinsic width, in WebP — makes the set nameable, which is
|
|
25
|
+
* what lets `variantNames` clean them up on delete without a `list` call the
|
|
26
|
+
* `StorageDriver` interface does not have.
|
|
27
|
+
*
|
|
28
|
+
* ## Why WebP and not AVIF
|
|
29
|
+
*
|
|
30
|
+
* WebP is universally supported in 2026 and encodes in a fraction of AVIF's
|
|
31
|
+
* time. AVIF's extra compression is real, but paying it per upload on a
|
|
32
|
+
* WASM tier — the tier that always exists (R10) — would make an upload of a
|
|
33
|
+
* handful of images take minutes. A second format can be added the day the
|
|
34
|
+
* pipeline is asked for one, and the ladder is the only thing that has to
|
|
35
|
+
* change.
|
|
36
|
+
*/
|
|
37
|
+
declare const VARIANT_FORMAT: ImageFormat;
|
|
38
|
+
/** The name of one rendition under an asset's variant prefix. */
|
|
39
|
+
export declare function variantName(width: number, format?: ImageFormat): string;
|
|
40
|
+
/**
|
|
41
|
+
* The widths actually produced for an image.
|
|
42
|
+
*
|
|
43
|
+
* `candidateWidths` caps the ladder at the intrinsic width, so a 500px logo
|
|
44
|
+
* yields 320 and 500 rather than five upscaled copies of itself — upscaling
|
|
45
|
+
* costs bytes and gains nothing.
|
|
46
|
+
*/
|
|
47
|
+
export declare function variantWidthsFor(intrinsic: ImageSize): readonly number[];
|
|
48
|
+
export interface MediaImageProcessorOptions {
|
|
49
|
+
readonly transformer: ImageTransformer;
|
|
50
|
+
readonly quality?: number;
|
|
51
|
+
}
|
|
52
|
+
export declare function createMediaImageProcessor(options: MediaImageProcessorOptions): MediaImageProcessor;
|
|
53
|
+
/**
|
|
54
|
+
* The processor for the driver this host can actually run, or `null`.
|
|
55
|
+
*
|
|
56
|
+
* `null` rather than a throw: an install where neither tier loads must still
|
|
57
|
+
* upload and serve images at their original size. That is the same "absent,
|
|
58
|
+
* not broken" degradation R2 applies to the LLM and R1 to every other driver
|
|
59
|
+
* — a CMS that refuses an upload because a WebAssembly module would not boot
|
|
60
|
+
* is worse than one that serves a full-size JPEG.
|
|
61
|
+
*/
|
|
62
|
+
export declare function selectMediaImageProcessor(logger: Logger): Promise<{
|
|
63
|
+
processor: MediaImageProcessor;
|
|
64
|
+
driver: string;
|
|
65
|
+
} | null>;
|
|
66
|
+
export { contentTypeOf, VARIANT_FORMAT };
|
|
67
|
+
//# sourceMappingURL=media-images.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-images.d.ts","sourceRoot":"","sources":["../../src/commands/media-images.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,mBAAmB,EAAwB,MAAM,cAAc,CAAA;AACxF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAC3C,OAAO,EAEL,aAAa,EAGb,KAAK,WAAW,EAChB,KAAK,gBAAgB,EAItB,MAAM,iBAAiB,CAAA;AAExB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAEH,QAAA,MAAM,cAAc,EAAE,WAAoB,CAAA;AAE1C,iEAAiE;AACjE,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,GAAE,WAA4B,GAAG,MAAM,CAEvF;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,SAAS,GAAG,SAAS,MAAM,EAAE,CAExE;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,WAAW,EAAE,gBAAgB,CAAA;IACtC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,0BAA0B,GAClC,mBAAmB,CAsCrB;AAED;;;;;;;;GAQG;AACH,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,SAAS,EAAE,mBAAmB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAapE;AAED,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,CAAA"}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { candidateWidths, contentTypeOf, createImageRegistry, DEFAULT_QUALITY, planTransform, SRCSET_WIDTHS, suffixOf, } from '@cogenta/render';
|
|
2
|
+
/**
|
|
3
|
+
* `@cogenta/render`'s image pipeline, wired to the media upload (L10 task 5).
|
|
4
|
+
*
|
|
5
|
+
* The pipeline, `srcset.ts` and the two driver tiers (sharp, WASM libvips)
|
|
6
|
+
* have existed since L3 and were called by nothing. This module is where they
|
|
7
|
+
* meet a real upload.
|
|
8
|
+
*
|
|
9
|
+
* ## Why variants are produced at upload rather than on demand
|
|
10
|
+
*
|
|
11
|
+
* `createImagePipeline` is deliberately lazy — it renders a variant the first
|
|
12
|
+
* time a browser asks for one — and that is the right design for a build step
|
|
13
|
+
* that would otherwise materialise N formats × M widths × K images. It is the
|
|
14
|
+
* wrong design here, and the lot says so explicitly ("au moment de l'upload,
|
|
15
|
+
* pas à la volée"), for a reason worth writing down: `cogenta serve` has no
|
|
16
|
+
* durable variant cache. `createMemoryVariantStore` dies with the process, so
|
|
17
|
+
* a lazy pipeline behind it re-decodes every image after every restart, and
|
|
18
|
+
* on the shared hosting Cogenta targets (R10's own list) that is the slowest
|
|
19
|
+
* possible answer on the coldest possible machine.
|
|
20
|
+
*
|
|
21
|
+
* Producing a bounded, deterministic set once — the `SRCSET_WIDTHS` ladder,
|
|
22
|
+
* capped at the intrinsic width, in WebP — makes the set nameable, which is
|
|
23
|
+
* what lets `variantNames` clean them up on delete without a `list` call the
|
|
24
|
+
* `StorageDriver` interface does not have.
|
|
25
|
+
*
|
|
26
|
+
* ## Why WebP and not AVIF
|
|
27
|
+
*
|
|
28
|
+
* WebP is universally supported in 2026 and encodes in a fraction of AVIF's
|
|
29
|
+
* time. AVIF's extra compression is real, but paying it per upload on a
|
|
30
|
+
* WASM tier — the tier that always exists (R10) — would make an upload of a
|
|
31
|
+
* handful of images take minutes. A second format can be added the day the
|
|
32
|
+
* pipeline is asked for one, and the ladder is the only thing that has to
|
|
33
|
+
* change.
|
|
34
|
+
*/
|
|
35
|
+
const VARIANT_FORMAT = 'webp';
|
|
36
|
+
/** The name of one rendition under an asset's variant prefix. */
|
|
37
|
+
export function variantName(width, format = VARIANT_FORMAT) {
|
|
38
|
+
return `${width}.${suffixOf(format)}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The widths actually produced for an image.
|
|
42
|
+
*
|
|
43
|
+
* `candidateWidths` caps the ladder at the intrinsic width, so a 500px logo
|
|
44
|
+
* yields 320 and 500 rather than five upscaled copies of itself — upscaling
|
|
45
|
+
* costs bytes and gains nothing.
|
|
46
|
+
*/
|
|
47
|
+
export function variantWidthsFor(intrinsic) {
|
|
48
|
+
return candidateWidths(intrinsic.width, intrinsic.width, SRCSET_WIDTHS);
|
|
49
|
+
}
|
|
50
|
+
export function createMediaImageProcessor(options) {
|
|
51
|
+
const { transformer } = options;
|
|
52
|
+
const quality = options.quality ?? DEFAULT_QUALITY;
|
|
53
|
+
return {
|
|
54
|
+
probe: async (bytes) => {
|
|
55
|
+
try {
|
|
56
|
+
const metadata = await transformer.metadata(bytes);
|
|
57
|
+
return { width: metadata.width, height: metadata.height };
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Not an image this tier can decode. The upload is still legitimate —
|
|
61
|
+
// `verifyRealType` already sniffed the container — so the honest
|
|
62
|
+
// answer is "no dimensions", not a refusal.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
variants: async (bytes, intrinsic) => {
|
|
67
|
+
const metadata = await transformer.metadata(bytes);
|
|
68
|
+
const produced = [];
|
|
69
|
+
for (const width of variantWidthsFor(intrinsic)) {
|
|
70
|
+
const rendered = await transformer.transform(bytes, planTransform(metadata, { width, format: VARIANT_FORMAT, quality }));
|
|
71
|
+
produced.push({
|
|
72
|
+
name: variantName(width, VARIANT_FORMAT),
|
|
73
|
+
bytes: rendered.bytes,
|
|
74
|
+
contentType: rendered.contentType,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return produced;
|
|
78
|
+
},
|
|
79
|
+
variantNames: (intrinsic) => variantWidthsFor(intrinsic).map((width) => variantName(width)),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The processor for the driver this host can actually run, or `null`.
|
|
84
|
+
*
|
|
85
|
+
* `null` rather than a throw: an install where neither tier loads must still
|
|
86
|
+
* upload and serve images at their original size. That is the same "absent,
|
|
87
|
+
* not broken" degradation R2 applies to the LLM and R1 to every other driver
|
|
88
|
+
* — a CMS that refuses an upload because a WebAssembly module would not boot
|
|
89
|
+
* is worse than one that serves a full-size JPEG.
|
|
90
|
+
*/
|
|
91
|
+
export async function selectMediaImageProcessor(logger) {
|
|
92
|
+
try {
|
|
93
|
+
const selection = await createImageRegistry({ logger }).select({});
|
|
94
|
+
return {
|
|
95
|
+
processor: createMediaImageProcessor({ transformer: selection.instance }),
|
|
96
|
+
driver: selection.driver,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
logger.warn('no image driver available; uploads will carry no variants', {
|
|
101
|
+
error: String(error),
|
|
102
|
+
});
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export { contentTypeOf, VARIANT_FORMAT };
|
|
107
|
+
//# sourceMappingURL=media-images.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-images.js","sourceRoot":"","sources":["../../src/commands/media-images.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { AccessContext, ContentGateway, SearchRouter } from '@cogenta/api';
|
|
2
|
+
import { type CollectionDefinition } from '@cogenta/schema';
|
|
3
|
+
/**
|
|
4
|
+
* `GET /search?q=…` — the public half of L10 task 3.
|
|
5
|
+
*
|
|
6
|
+
* The lot asks for "un bloc `search` optionnel côté thème public (formulaire
|
|
7
|
+
* + page de résultats)". The form and the results page are here; the **block**
|
|
8
|
+
* deliberately is not.
|
|
9
|
+
*
|
|
10
|
+
* Contract B is frozen (2026-08-13) and AGENTS.md forbids adding a block to
|
|
11
|
+
* the vocabulary without an RFC. A `search` block would be a contract B
|
|
12
|
+
* addition, so it would need a major version bump and a migration note — a
|
|
13
|
+
* decision that does not belong inside a lot whose whole premise is "no new
|
|
14
|
+
* capability, only wiring". A real route serving a real results page gives a
|
|
15
|
+
* visitor the same thing without touching the contract at all; when an RFC
|
|
16
|
+
* for a `search` block does happen, it can render through this same query.
|
|
17
|
+
*
|
|
18
|
+
* The page is `noindex`: a search results page is exactly the kind of thin,
|
|
19
|
+
* infinitely-many-URLs page a crawler must not index, and `buildMetaTags`
|
|
20
|
+
* takes `noindex` as an option precisely for it.
|
|
21
|
+
*/
|
|
22
|
+
export interface SearchPageOptions {
|
|
23
|
+
readonly router: SearchRouter;
|
|
24
|
+
readonly gateway: ContentGateway;
|
|
25
|
+
readonly collections: readonly CollectionDefinition[];
|
|
26
|
+
readonly site: {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly url: string;
|
|
29
|
+
readonly locales: readonly string[];
|
|
30
|
+
readonly defaultLocale: string;
|
|
31
|
+
};
|
|
32
|
+
/** The joined skin+theme stylesheet `cogenta serve` serves at `STYLESHEET_PATH` (`theme-render.ts`'s `joinStyles`). `null` when neither could be loaded — served unstyled rather than refused. */
|
|
33
|
+
readonly styles: string | null;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The whole page. Returns HTML; the caller decides the status code (always
|
|
37
|
+
* 200 — an empty result set is an answer, not a failure).
|
|
38
|
+
*/
|
|
39
|
+
export declare function renderSearchPage(query: string, options: SearchPageOptions, context: AccessContext): Promise<string>;
|
|
40
|
+
//# sourceMappingURL=search-page.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-page.d.ts","sourceRoot":"","sources":["../../src/commands/search-page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,cAAc,CAAA;AAC/E,OAAO,EAAa,KAAK,oBAAoB,EAAkB,MAAM,iBAAiB,CAAA;AAItF;;;;;;;;;;;;;;;;;;GAkBG;AAEH,MAAM,WAAW,iBAAiB;IAChC,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAA;IAC7B,QAAQ,CAAC,OAAO,EAAE,cAAc,CAAA;IAChC,QAAQ,CAAC,WAAW,EAAE,SAAS,oBAAoB,EAAE,CAAA;IACrD,QAAQ,CAAC,IAAI,EAAE;QACb,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;QACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;QACpB,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAA;QACnC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAA;KAC/B,CAAA;IACD,kMAAkM;IAClM,QAAQ,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAA;CAC/B;AA+ED;;;GAGG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,iBAAiB,EAC1B,OAAO,EAAE,aAAa,GACrB,OAAO,CAAC,MAAM,CAAC,CAmDjB"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { buildPath } from '@cogenta/schema';
|
|
2
|
+
import { escapeHtmlAttribute, escapeHtmlText } from '@cogenta/seo';
|
|
3
|
+
import { STYLESHEET_PATH } from './theme-render.js';
|
|
4
|
+
/**
|
|
5
|
+
* Turns hits into links.
|
|
6
|
+
*
|
|
7
|
+
* The hit itself carries no URL — the index stores text, not routes — so each
|
|
8
|
+
* one is read back through the same permission-checked gateway that answered
|
|
9
|
+
* the search. An entry in a collection with no `routing`, or whose route
|
|
10
|
+
* parameters are incomplete, is listed without a link rather than with a URL
|
|
11
|
+
* that 404s.
|
|
12
|
+
*/
|
|
13
|
+
async function resolveHits(hits, options, context) {
|
|
14
|
+
const byName = new Map(options.collections.map((collection) => [collection.name, collection]));
|
|
15
|
+
const resolved = [];
|
|
16
|
+
for (const hit of hits) {
|
|
17
|
+
const collection = byName.get(hit.collection);
|
|
18
|
+
if (collection === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
let href = null;
|
|
21
|
+
if (collection.routing !== undefined) {
|
|
22
|
+
const entry = await options.gateway.read(hit.collection, hit.id, context);
|
|
23
|
+
if (entry !== null) {
|
|
24
|
+
const params = Object.fromEntries(Object.entries(entry.values).filter((pair) => typeof pair[1] === 'string'));
|
|
25
|
+
const complete = collection.routing.pattern
|
|
26
|
+
.split('/')
|
|
27
|
+
.filter((segment) => segment.startsWith(':'))
|
|
28
|
+
.every((segment) => params[segment.slice(1)] !== undefined || segment === ':id');
|
|
29
|
+
if (complete) {
|
|
30
|
+
href = buildPath(collection, { ...params, id: entry.id }, collection.routing.locale === true ? entry.locale : undefined);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
resolved.push({ title: hit.title.length > 0 ? hit.title : hit.id, href });
|
|
35
|
+
}
|
|
36
|
+
return resolved;
|
|
37
|
+
}
|
|
38
|
+
/** The form on its own, so an empty query still gets a usable page. */
|
|
39
|
+
function searchForm(query) {
|
|
40
|
+
return `<form class="cg-search__form" action="/search" method="get" role="search">
|
|
41
|
+
<label for="cg-search-q">Search</label>
|
|
42
|
+
<input id="cg-search-q" type="search" name="q" value="${escapeHtmlAttribute(query)}" required>
|
|
43
|
+
<button type="submit">Search</button>
|
|
44
|
+
</form>`;
|
|
45
|
+
}
|
|
46
|
+
function resultList(results) {
|
|
47
|
+
if (results.length === 0)
|
|
48
|
+
return `<p class="cg-search__empty">Nothing matched that search.</p>`;
|
|
49
|
+
return `<ol class="cg-search__results">
|
|
50
|
+
${results
|
|
51
|
+
.map((result) => result.href === null
|
|
52
|
+
? `<li>${escapeHtmlText(result.title)}</li>`
|
|
53
|
+
: `<li><a href="${escapeHtmlAttribute(result.href)}">${escapeHtmlText(result.title)}</a></li>`)
|
|
54
|
+
.join('\n')}
|
|
55
|
+
</ol>`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The whole page. Returns HTML; the caller decides the status code (always
|
|
59
|
+
* 200 — an empty result set is an answer, not a failure).
|
|
60
|
+
*/
|
|
61
|
+
export async function renderSearchPage(query, options, context) {
|
|
62
|
+
const trimmed = query.trim();
|
|
63
|
+
let results = [];
|
|
64
|
+
let failure = null;
|
|
65
|
+
if (trimmed.length > 0) {
|
|
66
|
+
const response = await options.router.handle({ method: 'GET', path: '/api/search', query: { q: trimmed } }, context);
|
|
67
|
+
if (response.status === 200) {
|
|
68
|
+
const body = response.body;
|
|
69
|
+
results = await resolveHits(body.data, options, context);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// The router's own message, never a rewritten one: it already says what
|
|
73
|
+
// failed and what to do about it, and inventing a second wording here
|
|
74
|
+
// is how the two drift apart.
|
|
75
|
+
const body = response.body;
|
|
76
|
+
failure = body.error?.message ?? 'The search could not be completed.';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const heading = trimmed.length === 0 ? 'Search' : `Search results for “${escapeHtmlText(trimmed)}”`;
|
|
80
|
+
const main = failure !== null
|
|
81
|
+
? `<p class="cg-search__error" role="alert">${escapeHtmlText(failure)}</p>`
|
|
82
|
+
: trimmed.length === 0
|
|
83
|
+
? ''
|
|
84
|
+
: resultList(results);
|
|
85
|
+
return `<!doctype html>
|
|
86
|
+
<html lang="${escapeHtmlAttribute(options.site.defaultLocale)}">
|
|
87
|
+
<head>
|
|
88
|
+
<meta charset="utf-8">
|
|
89
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
90
|
+
<title>${heading} — ${escapeHtmlText(options.site.name)}</title>
|
|
91
|
+
<meta name="robots" content="noindex, follow" />
|
|
92
|
+
${options.styles === null ? '' : `<link rel="stylesheet" href="${STYLESHEET_PATH}">`}
|
|
93
|
+
</head>
|
|
94
|
+
<body>
|
|
95
|
+
<main class="cg-main" id="cg-main">
|
|
96
|
+
<h1 class="cg-page__title">${heading}</h1>
|
|
97
|
+
${searchForm(trimmed)}
|
|
98
|
+
${main}
|
|
99
|
+
</main>
|
|
100
|
+
</body>
|
|
101
|
+
</html>
|
|
102
|
+
`;
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=search-page.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"search-page.js","sourceRoot":"","sources":["../../src/commands/search-page.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { RateLimiter } from '@cogenta/auth';
|
|
2
|
+
import type { Logger } from '@cogenta/core';
|
|
3
|
+
export interface SecurityAlertOptions {
|
|
4
|
+
readonly rateLimit: RateLimiter;
|
|
5
|
+
/** Where the alert is delivered. `null` disables the outbound half entirely. */
|
|
6
|
+
readonly send: ((event: string, data: Readonly<Record<string, unknown>>) => Promise<void>) | null;
|
|
7
|
+
/** Absolute base URL of the site, for the alert's required admin link. */
|
|
8
|
+
readonly siteUrl: string;
|
|
9
|
+
readonly logger: Logger;
|
|
10
|
+
/** Attempts inside the limiter's window past which an alert is worth sending. */
|
|
11
|
+
readonly minAttempts?: number;
|
|
12
|
+
readonly cooldownMs?: number;
|
|
13
|
+
readonly now?: () => number;
|
|
14
|
+
}
|
|
15
|
+
export interface SecurityAlertWatch {
|
|
16
|
+
/**
|
|
17
|
+
* Called after every `/api/auth/*` response. Cheap and silent for the
|
|
18
|
+
* overwhelming majority — a successful sign-in never touches the database
|
|
19
|
+
* here at all.
|
|
20
|
+
*/
|
|
21
|
+
observe(status: number): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function createSecurityAlertWatch(options: SecurityAlertOptions): SecurityAlertWatch;
|
|
24
|
+
//# sourceMappingURL=security-alerts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security-alerts.d.ts","sourceRoot":"","sources":["../../src/commands/security-alerts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAuB,WAAW,EAAE,MAAM,eAAe,CAAA;AAErE,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AA2B3C,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAA;IAC/B,gFAAgF;IAChF,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC,GAAG,IAAI,CAAA;IACjG,0EAA0E;IAC1E,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAA;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,iFAAiF;IACjF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;IAC5B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CAC5B;AAED,MAAM,WAAW,kBAAkB;IACjC;;;;OAIG;IACH,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACvC;AAcD,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,oBAAoB,GAAG,kBAAkB,CAuD1F"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { buildAlert } from '@cogenta/channels';
|
|
2
|
+
/**
|
|
3
|
+
* The outbound half of L14 task 4 — "exposer ça comme alerte dans le
|
|
4
|
+
* dashboard/canaux, réutilisant `@cogenta/channels`".
|
|
5
|
+
*
|
|
6
|
+
* The dashboard half is `createSuspiciousActivitySource` in `@cogenta/api`.
|
|
7
|
+
* This is the half that leaves the site, and it deliberately reuses two things
|
|
8
|
+
* that already exist rather than inventing a third: `buildAlert` (L6, with its
|
|
9
|
+
* enforced title/severity/context/expected-action/admin-link shape and its
|
|
10
|
+
* screen budget) and the signed webhook sender L14 task 1 just connected. No
|
|
11
|
+
* second notification path, no second signature.
|
|
12
|
+
*
|
|
13
|
+
* **Why the trigger is a failed request, not a timer.** Rule R1 guarantees no
|
|
14
|
+
* durable worker, so anything that "checks periodically" here would be a
|
|
15
|
+
* promise the deployment cannot keep. A brute-force run *is* a stream of
|
|
16
|
+
* requests, so the requests themselves are the clock — which also means the
|
|
17
|
+
* alert arrives while the attack is happening rather than up to a poll
|
|
18
|
+
* interval later.
|
|
19
|
+
*/
|
|
20
|
+
/** Statuses that mean a sign-in attempt was refused. 429 is the limiter itself. */
|
|
21
|
+
const REFUSED_STATUSES = new Set([401, 429]);
|
|
22
|
+
/** At most one alert per this long, however many attempts arrive. */
|
|
23
|
+
const DEFAULT_COOLDOWN_MS = 5 * 60 * 1000;
|
|
24
|
+
function summarise(summaries) {
|
|
25
|
+
return {
|
|
26
|
+
attempts: summaries.reduce((total, summary) => total + summary.attempts, 0),
|
|
27
|
+
subjects: summaries.length,
|
|
28
|
+
blocked: summaries.filter((summary) => summary.blocked).length,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function createSecurityAlertWatch(options) {
|
|
32
|
+
const now = options.now ?? Date.now;
|
|
33
|
+
const cooldownMs = options.cooldownMs ?? DEFAULT_COOLDOWN_MS;
|
|
34
|
+
let lastAlertAt = 0;
|
|
35
|
+
return {
|
|
36
|
+
observe: async (status) => {
|
|
37
|
+
if (options.send === null)
|
|
38
|
+
return;
|
|
39
|
+
if (!REFUSED_STATUSES.has(status))
|
|
40
|
+
return;
|
|
41
|
+
// The cooldown is checked *before* the query, not after: a script making
|
|
42
|
+
// twenty attempts a second must not turn into twenty aggregate queries a
|
|
43
|
+
// second. The alert is about a situation, not about one request.
|
|
44
|
+
if (now() - lastAlertAt < cooldownMs)
|
|
45
|
+
return;
|
|
46
|
+
try {
|
|
47
|
+
const summaries = await options.rateLimit.recentFailures(options.minAttempts === undefined ? undefined : { minAttempts: options.minAttempts });
|
|
48
|
+
if (summaries.length === 0)
|
|
49
|
+
return;
|
|
50
|
+
const totals = summarise(summaries);
|
|
51
|
+
lastAlertAt = now();
|
|
52
|
+
const adminUrl = new URL('/admin', options.siteUrl).toString();
|
|
53
|
+
// Counts, never the accounts — same rule as the admin notice. An
|
|
54
|
+
// outbound message travels further than a screen does, and an email
|
|
55
|
+
// address in it is an address in somebody else's inbox and logs.
|
|
56
|
+
const alert = buildAlert({
|
|
57
|
+
title: 'Repeated failed sign-in attempts',
|
|
58
|
+
severity: totals.blocked > 0 ? 'critical' : 'warning',
|
|
59
|
+
context: `${totals.attempts} failed sign-in attempts across ${totals.subjects} account(s) in the last 15 minutes${totals.blocked > 0 ? `, ${totals.blocked} of them now rate-limited` : ''}.`,
|
|
60
|
+
expectedAction: 'Check the audit log, and confirm the affected accounts still have strong passwords and a second factor.',
|
|
61
|
+
adminUrl,
|
|
62
|
+
});
|
|
63
|
+
await options.send('security.suspicious_activity', {
|
|
64
|
+
severity: alert.severity,
|
|
65
|
+
title: alert.title,
|
|
66
|
+
context: alert.context,
|
|
67
|
+
expectedAction: alert.expectedAction,
|
|
68
|
+
adminUrl: alert.adminUrl,
|
|
69
|
+
attempts: totals.attempts,
|
|
70
|
+
subjects: totals.subjects,
|
|
71
|
+
blocked: totals.blocked,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
// Never let watching a failure turn into a second failure: the sign-in
|
|
76
|
+
// response has already been written by the time this runs.
|
|
77
|
+
options.logger.error('suspicious activity alert failed', { error: String(error) });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=security-alerts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"security-alerts.js","sourceRoot":"","sources":["../../src/commands/security-alerts.ts"],"names":[],"mappings":"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"}
|