@actuate-media/cms-core 0.25.0 → 0.26.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.
Files changed (39) hide show
  1. package/dist/__tests__/api/stats-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/stats-routes.test.js +251 -0
  4. package/dist/__tests__/api/stats-routes.test.js.map +1 -0
  5. package/dist/__tests__/auth/password.test.js +39 -12
  6. package/dist/__tests__/auth/password.test.js.map +1 -1
  7. package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
  8. package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
  9. package/dist/__tests__/content-health/scanner.test.js +151 -0
  10. package/dist/__tests__/content-health/scanner.test.js.map +1 -0
  11. package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
  12. package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
  13. package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
  14. package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
  15. package/dist/api/handler-factory.d.ts.map +1 -1
  16. package/dist/api/handler-factory.js +31 -1
  17. package/dist/api/handler-factory.js.map +1 -1
  18. package/dist/api/handlers.d.ts.map +1 -1
  19. package/dist/api/handlers.js +263 -0
  20. package/dist/api/handlers.js.map +1 -1
  21. package/dist/auth/password.d.ts +11 -0
  22. package/dist/auth/password.d.ts.map +1 -1
  23. package/dist/auth/password.js +13 -0
  24. package/dist/auth/password.js.map +1 -1
  25. package/dist/content-health/cron.d.ts +67 -0
  26. package/dist/content-health/cron.d.ts.map +1 -0
  27. package/dist/content-health/cron.js +167 -0
  28. package/dist/content-health/cron.js.map +1 -0
  29. package/dist/content-health/scanner.d.ts +118 -0
  30. package/dist/content-health/scanner.d.ts.map +1 -0
  31. package/dist/content-health/scanner.js +263 -0
  32. package/dist/content-health/scanner.js.map +1 -0
  33. package/dist/diagnostics/api-metrics.d.ts +135 -0
  34. package/dist/diagnostics/api-metrics.d.ts.map +1 -0
  35. package/dist/diagnostics/api-metrics.js +374 -0
  36. package/dist/diagnostics/api-metrics.js.map +1 -0
  37. package/package.json +1 -1
  38. package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
  39. package/prisma/schema.prisma +180 -73
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Content health scanner — produces the issue rows that power the
3
+ * dashboard "Content Health" card.
4
+ *
5
+ * ## Why a separate module from the cron / endpoint
6
+ *
7
+ * The scanner is pure logic — give it a document, get back an array
8
+ * of issues. The cron drives it across the corpus and persists the
9
+ * results; the endpoint reads the persisted results. Keeping the
10
+ * scanner free of I/O dependencies makes it trivially testable
11
+ * (~10 µs per call, no DB) and lets future callers (CI checks, a
12
+ * pre-publish hook, etc.) reuse it without dragging in cron
13
+ * infrastructure.
14
+ *
15
+ * ## Why HEAD probes for the link checker
16
+ *
17
+ * Internal links 404 in two flavors: a hard 404 from the framework
18
+ * (page genuinely doesn't exist) and a soft 404 where the framework
19
+ * returns 200 with a "not found" template. We only catch the first.
20
+ * That's fine — the dashboard's job is to surface the high-confidence
21
+ * issues, not chase ambiguous soft-404 heuristics. The scanner can
22
+ * be extended with response-body matching later if the editor
23
+ * feedback warrants it.
24
+ *
25
+ * ## Why we count alt-less images per document, not per image
26
+ *
27
+ * The dashboard surfaces total issue counts. Storing one row per
28
+ * image would blow up the issue table for pages with 20+
29
+ * unannotated photos and provide no extra value (the editor still
30
+ * gets a single "12 images need alt text" row in the UI). We
31
+ * collapse to one row per (document, type) with `count` carrying
32
+ * the per-document magnitude.
33
+ */
34
+ // ─── Helpers ───────────────────────────────────────────────────────────────
35
+ /**
36
+ * Return the longest plausible HTML payload from a document. CMS
37
+ * collections use different field names (`body`, `content`,
38
+ * `richText`, `html`) so we try each and pick the longest — that
39
+ * resolves the common case where a document has both a plain
40
+ * `excerpt` and a rich `body`, and we want to scan the body.
41
+ */
42
+ function extractHtml(data) {
43
+ const candidates = ['body', 'content', 'richText', 'richtext', 'html', 'description'];
44
+ let best = '';
45
+ for (const key of candidates) {
46
+ const v = data[key];
47
+ if (typeof v === 'string' && v.length > best.length)
48
+ best = v;
49
+ }
50
+ return best;
51
+ }
52
+ const META_DESCRIPTION_KEYS = ['metaDescription', 'seoDescription', 'meta_description'];
53
+ function getMetaDescription(data) {
54
+ for (const key of META_DESCRIPTION_KEYS) {
55
+ const v = data[key];
56
+ if (typeof v === 'string' && v.trim().length > 0)
57
+ return v.trim();
58
+ }
59
+ // Nested SEO objects (e.g. data.seo.description).
60
+ const seo = data['seo'];
61
+ if (seo && typeof seo === 'object') {
62
+ const obj = seo;
63
+ const v = obj['description'] ?? obj['metaDescription'];
64
+ if (typeof v === 'string' && v.trim().length > 0)
65
+ return v.trim();
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Count <img> tags in the HTML that don't carry an `alt` attribute.
71
+ * Matches the existing a11y rule in `a11y/index.ts` so the scanner
72
+ * agrees with the per-document audit panel.
73
+ */
74
+ export function countMissingAltImages(html) {
75
+ if (!html)
76
+ return 0;
77
+ const re = /<img(?![^>]*\salt\s*=)[^>]*>/gi;
78
+ let count = 0;
79
+ // We don't iterate matches into an array — just count, so memory
80
+ // stays bounded even on huge pages.
81
+ while (re.exec(html) !== null)
82
+ count++;
83
+ return count;
84
+ }
85
+ /**
86
+ * Extract candidate URLs from <a href="..."> attributes. Returns
87
+ * the raw href values; classification (internal vs external,
88
+ * absolute vs relative) happens in the caller.
89
+ */
90
+ export function extractLinks(html) {
91
+ if (!html)
92
+ return [];
93
+ const out = [];
94
+ const re = /<a\b[^>]*\shref\s*=\s*("([^"]*)"|'([^']*)')/gi;
95
+ let match;
96
+ while ((match = re.exec(html)) !== null) {
97
+ const url = (match[2] ?? match[3] ?? '').trim();
98
+ if (url.length > 0)
99
+ out.push(url);
100
+ }
101
+ return out;
102
+ }
103
+ /**
104
+ * Decide whether a URL is "internal" relative to `siteOrigin`.
105
+ * Internal links include:
106
+ * - relative paths: `/about`, `./page`, `../foo`
107
+ * - same-origin absolute URLs: `https://example.com/page`
108
+ *
109
+ * Excluded:
110
+ * - external URLs (different origin)
111
+ * - hash-only anchors (`#section`)
112
+ * - non-http schemes (`mailto:`, `tel:`, `javascript:`, etc.)
113
+ * - protocol-relative URLs to a different host (`//other.com/x`)
114
+ */
115
+ export function classifyLink(url, siteOrigin) {
116
+ const trimmed = url.trim();
117
+ if (trimmed.length === 0)
118
+ return { isInternal: false, resolved: null };
119
+ if (trimmed.startsWith('#'))
120
+ return { isInternal: false, resolved: null };
121
+ // Reject non-http schemes outright.
122
+ const schemeMatch = /^([a-z][a-z0-9+.-]*):/i.exec(trimmed);
123
+ if (schemeMatch) {
124
+ const scheme = schemeMatch[1].toLowerCase();
125
+ if (scheme !== 'http' && scheme !== 'https')
126
+ return { isInternal: false, resolved: null };
127
+ }
128
+ let parsedOrigin;
129
+ try {
130
+ parsedOrigin = new URL(siteOrigin);
131
+ }
132
+ catch {
133
+ return { isInternal: false, resolved: null };
134
+ }
135
+ try {
136
+ const resolved = new URL(trimmed, parsedOrigin);
137
+ if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') {
138
+ return { isInternal: false, resolved: null };
139
+ }
140
+ return {
141
+ isInternal: resolved.origin === parsedOrigin.origin,
142
+ resolved: resolved.toString(),
143
+ };
144
+ }
145
+ catch {
146
+ return { isInternal: false, resolved: null };
147
+ }
148
+ }
149
+ /**
150
+ * Single-link reachability probe. Default implementation used by
151
+ * the cron — issues a HEAD with a 5s timeout, falls back to GET if
152
+ * HEAD returns 405/501 (some frameworks reject HEAD).
153
+ */
154
+ export async function checkInternalLinkReachable(url, timeoutMs = 5000) {
155
+ const controller = new AbortController();
156
+ const t = setTimeout(() => controller.abort(), timeoutMs);
157
+ try {
158
+ const head = await fetch(url, {
159
+ method: 'HEAD',
160
+ redirect: 'follow',
161
+ signal: controller.signal,
162
+ });
163
+ if (head.status === 405 || head.status === 501) {
164
+ // Method not allowed / not implemented → retry with GET.
165
+ const get = await fetch(url, {
166
+ method: 'GET',
167
+ redirect: 'follow',
168
+ signal: controller.signal,
169
+ });
170
+ return get.status < 400;
171
+ }
172
+ return head.status < 400;
173
+ }
174
+ catch {
175
+ return false;
176
+ }
177
+ finally {
178
+ clearTimeout(t);
179
+ }
180
+ }
181
+ // ─── Scanner ───────────────────────────────────────────────────────────────
182
+ /**
183
+ * Run all checks on a single document. The link checker is
184
+ * concurrency-bounded to `LINK_CHECK_CONCURRENCY` per document so a
185
+ * page with 200 links doesn't open 200 sockets at once.
186
+ *
187
+ * Each issue type appears at most once in the returned array. The
188
+ * shape `{ type, count, details }` lets the caller upsert by
189
+ * `(documentId, type)` and rely on `count` for the magnitude
190
+ * surfaced in the dashboard.
191
+ */
192
+ export async function scanDocument(doc, opts = {}) {
193
+ const outdatedDays = opts.outdatedDays ?? 90;
194
+ const issues = [];
195
+ // 1. Missing meta description --------------------------------------------
196
+ if (!getMetaDescription(doc.data)) {
197
+ issues.push({ type: 'MISSING_META_DESCRIPTION', count: 1 });
198
+ }
199
+ // 2. Missing alt text ----------------------------------------------------
200
+ const html = extractHtml(doc.data);
201
+ const altLessImages = countMissingAltImages(html);
202
+ if (altLessImages > 0) {
203
+ issues.push({ type: 'MISSING_ALT_TEXT', count: altLessImages });
204
+ }
205
+ // 3. Outdated content ----------------------------------------------------
206
+ const referenceTime = doc.publishedAt ?? doc.updatedAt;
207
+ const ageMs = Date.now() - referenceTime.getTime();
208
+ if (ageMs > outdatedDays * 24 * 60 * 60 * 1000) {
209
+ issues.push({
210
+ type: 'OUTDATED_CONTENT',
211
+ count: 1,
212
+ details: {
213
+ ageDays: Math.floor(ageMs / (24 * 60 * 60 * 1000)),
214
+ threshold: outdatedDays,
215
+ },
216
+ });
217
+ }
218
+ // 4. Broken internal links ----------------------------------------------
219
+ if (opts.checkLink && opts.siteOrigin) {
220
+ const links = extractLinks(html);
221
+ const internalUrls = new Set();
222
+ for (const raw of links) {
223
+ const { isInternal, resolved } = classifyLink(raw, opts.siteOrigin);
224
+ if (isInternal && resolved)
225
+ internalUrls.add(resolved);
226
+ }
227
+ const broken = [];
228
+ if (internalUrls.size > 0) {
229
+ const checker = opts.checkLink;
230
+ await Promise.all(chunk([...internalUrls], LINK_CHECK_CONCURRENCY).map(async (group) => {
231
+ const results = await Promise.all(group.map((u) => checker(u).catch(() => false)));
232
+ group.forEach((u, i) => {
233
+ if (!results[i])
234
+ broken.push(u);
235
+ });
236
+ }));
237
+ }
238
+ if (broken.length > 0) {
239
+ issues.push({
240
+ type: 'BROKEN_INTERNAL_LINK',
241
+ count: broken.length,
242
+ details: { urls: broken.slice(0, 10) }, // cap stored details
243
+ });
244
+ }
245
+ }
246
+ return issues;
247
+ }
248
+ /**
249
+ * Concurrency cap for the per-document link checker. Vercel
250
+ * functions are wall-time bounded; opening 5 concurrent sockets
251
+ * keeps us under the typical 60s soft cap even for pages with
252
+ * 100+ internal links.
253
+ */
254
+ const LINK_CHECK_CONCURRENCY = 5;
255
+ function chunk(arr, size) {
256
+ if (size <= 0)
257
+ return [arr];
258
+ const out = [];
259
+ for (let i = 0; i < arr.length; i += size)
260
+ out.push(arr.slice(i, i + size));
261
+ return out;
262
+ }
263
+ //# sourceMappingURL=scanner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scanner.js","sourceRoot":"","sources":["../../src/content-health/scanner.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * API request instrumentation that powers the Dashboard "Delivery API"
3
+ * tiles (request volume / error rate / avg latency / p95).
4
+ *
5
+ * ## Why per-minute aggregated buckets and not per-request rows
6
+ *
7
+ * A single 24-hour window at 100 req/s on 20 routes is ~17M raw rows,
8
+ * but the same window aggregated to (minute × route × status-bucket)
9
+ * is ≤ 1440 × 20 × 5 = 144k rows — and in practice far less because
10
+ * most route/minute combinations have zero traffic. The reader
11
+ * (`/stats/api-delivery`) folds the last 1440 minute buckets into
12
+ * four scalars; the writer does one atomic UPSERT per request via
13
+ * raw SQL.
14
+ *
15
+ * ## Why p95 via histogram and not from raw samples
16
+ *
17
+ * Storing a histogram per bucket gives us percentile estimates with
18
+ * ~10% error vs ground truth while keeping the table tiny. The
19
+ * `LATENCY_BUCKETS` boundary list is log-scale (powers of 2 in
20
+ * milliseconds) so it covers ~1 ms heartbeats through multi-second
21
+ * outliers in 12 columns. p95 falls out as a `findIndex` once the
22
+ * histogram is merged across all minute buckets in the window.
23
+ *
24
+ * ## Why fire-and-forget and not awaited
25
+ *
26
+ * Metric recording MUST NOT slow the user-facing response. The
27
+ * `record()` call dispatches the UPSERT to a `void`-discarded promise
28
+ * so a slow DB round-trip never blocks the request that produced
29
+ * the metric. Errors are swallowed at the source — losing a metric
30
+ * sample is strictly preferable to surfacing a metric-pipeline error
31
+ * to the user.
32
+ *
33
+ * ## Why we self-exempt the dashboard polling routes
34
+ *
35
+ * The dashboard refetches `/stats` and `/stats/api-delivery` on a
36
+ * timer. If we counted those requests they'd dominate the volume
37
+ * tile and inflate the error rate (a logged-out tab would hit
38
+ * `/stats` and get 401 repeatedly). The route-normaliser excludes
39
+ * them explicitly — see `shouldRecord`.
40
+ */
41
+ /**
42
+ * Upper bound (exclusive) of each histogram bucket, in milliseconds.
43
+ * The last bucket is `>= 1024 ms` (no upper bound).
44
+ *
45
+ * Indexed:
46
+ * 0: <1ms 1: 1-2ms 2: 2-4ms 3: 4-8ms
47
+ * 4: 8-16ms 5: 16-32ms 6: 32-64ms 7: 64-128ms
48
+ * 8: 128-256 9: 256-512 10: 512-1024 11: >=1024
49
+ */
50
+ export declare const LATENCY_BUCKETS: readonly [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024];
51
+ export declare const HISTOGRAM_LENGTH: number;
52
+ export type StatusBucket = '2xx' | '3xx' | '4xx' | '5xx' | 'other';
53
+ export interface ApiMetricSample {
54
+ /** Request path, CMS-relative — e.g. `/collections/posts/abc123`. */
55
+ pathname: string;
56
+ status: number;
57
+ /** Response time in milliseconds. May be a float. */
58
+ durationMs: number;
59
+ }
60
+ /**
61
+ * Bucket a status code into one of 5 families. We keep the 1xx family
62
+ * out of the public type because no CMS route ever responds with one;
63
+ * if it ever happens, the `other` bucket catches it instead of
64
+ * silently being lost.
65
+ */
66
+ export declare function statusBucketOf(status: number): StatusBucket;
67
+ /**
68
+ * Return the index (0..HISTOGRAM_LENGTH-1) of the histogram bucket
69
+ * that this latency falls into. NaN / negative values fall in bucket
70
+ * 0 (treated as "<1ms") so a metric write never crashes on a bad
71
+ * input.
72
+ */
73
+ export declare function histogramBucketIndex(durationMs: number): number;
74
+ /**
75
+ * Build a fresh histogram array with a single sample placed in the
76
+ * correct bucket. Used to produce the JSON payload for an INSERT.
77
+ */
78
+ export declare function makeHistogramFor(durationMs: number): number[];
79
+ /**
80
+ * Element-wise add two histograms. Tolerant of either operand being
81
+ * missing or shorter than `HISTOGRAM_LENGTH` — defends against schema
82
+ * mismatches (e.g. an old row written before a bucket boundary change).
83
+ */
84
+ export declare function mergeHistograms(a: number[] | null | undefined, b: number[]): number[];
85
+ /**
86
+ * Snap a Date to the start of its UTC minute. Returns a new Date —
87
+ * never mutates the input.
88
+ */
89
+ export declare function bucketStartFor(now?: Date | number): Date;
90
+ /**
91
+ * Estimate percentile-N from a merged histogram. Returns the upper
92
+ * bound of the bucket that contains the requested percentile —
93
+ * which is the right "user-visible" answer (we are happy to say
94
+ * "p95 < 256ms" rather than guess the exact value inside the
95
+ * 128-256 bucket).
96
+ *
97
+ * For the overflow bucket (no upper bound), we report
98
+ * `LATENCY_BUCKETS[last]` as a lower-bound estimate; callers
99
+ * displaying this should annotate with a "≥" prefix if they want
100
+ * to expose the open-ended nature.
101
+ */
102
+ export declare function estimatePercentile(histogram: number[], percentile: number): number;
103
+ /**
104
+ * Normalise a request path to bound metric-row cardinality.
105
+ *
106
+ * /collections/posts/abc123 → /collections/posts/:id
107
+ * /globals/site-settings → /globals/:slug
108
+ * /collections/posts/abc123/restore→ /collections/posts/:id/restore
109
+ * /unknown/long/path/that/leaks → /other
110
+ *
111
+ * The "/other" bucket catches anything we don't explicitly know
112
+ * about so a single hostile client with random URLs can't blow up
113
+ * the table.
114
+ */
115
+ export declare function normalizeRoute(pathname: string): string;
116
+ /**
117
+ * Routes that the dashboard polls itself, plus webhooks the cron
118
+ * fires. Counting these would either (a) cause feedback loops
119
+ * inflating the volume tile or (b) report cron-only traffic the
120
+ * editor never produces. The instrumentation skips them.
121
+ */
122
+ export declare function shouldRecord(normalizedRoute: string): boolean;
123
+ /**
124
+ * Record one API request to the metrics table. **Fire-and-forget** —
125
+ * callers MUST NOT `await` this. Errors are swallowed; the next
126
+ * request retries, and 1-minute aggregates are robust to occasional
127
+ * write loss.
128
+ *
129
+ * Writes a single SQL UPSERT so two concurrent requests landing in
130
+ * the same bucket interleave cleanly via Postgres's MVCC + the
131
+ * unique-on-conflict path. The histogram column is merged
132
+ * element-wise inside SQL, so we never read-modify-write from JS.
133
+ */
134
+ export declare function recordApiRequest(sample: ApiMetricSample): void;
135
+ //# sourceMappingURL=api-metrics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"api-metrics.d.ts","sourceRoot":"","sources":["../../src/diagnostics/api-metrics.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuCG;AASH;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,wDAAyD,CAAA;AAErF,eAAO,MAAM,gBAAgB,QAA6B,CAAA;AAE1D,MAAM,MAAM,YAAY,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,GAAG,OAAO,CAAA;AAElE,MAAM,WAAW,eAAe;IAC9B,qEAAqE;IACrE,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,qDAAqD;IACrD,UAAU,EAAE,MAAM,CAAA;CACnB;AAID;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,YAAY,CAM3D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,CAM/D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,UAAU,EAAE,MAAM,GAAG,MAAM,EAAE,CAI7D;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,GAAG,SAAS,EAAE,CAAC,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAMrF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,GAAG,GAAE,IAAI,GAAG,MAAmB,GAAG,IAAI,CAGpE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,MAAM,EAAE,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAalF;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAiBvD;AAED;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAE7D;AAiGD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,eAAe,GAAG,IAAI,CAY9D"}