@caelo-cms/shared 0.10.22 → 0.10.24

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 (249) hide show
  1. package/dist/ai-tools.d.ts +289 -273
  2. package/dist/ai-tools.d.ts.map +1 -1
  3. package/dist/ai-tools.js +342 -323
  4. package/dist/ai-tools.js.map +1 -1
  5. package/dist/auth-forms.d.ts.map +1 -1
  6. package/dist/auth-forms.js +4 -1
  7. package/dist/auth-forms.js.map +1 -1
  8. package/dist/base-css.d.ts +35 -0
  9. package/dist/base-css.d.ts.map +1 -0
  10. package/dist/base-css.js +40 -0
  11. package/dist/base-css.js.map +1 -0
  12. package/dist/build-page.d.ts +330 -0
  13. package/dist/build-page.d.ts.map +1 -0
  14. package/dist/build-page.js +282 -0
  15. package/dist/build-page.js.map +1 -0
  16. package/dist/content.d.ts +322 -9
  17. package/dist/content.d.ts.map +1 -1
  18. package/dist/content.js +354 -11
  19. package/dist/content.js.map +1 -1
  20. package/dist/css-gradient-scan.d.ts +14 -0
  21. package/dist/css-gradient-scan.d.ts.map +1 -0
  22. package/dist/css-gradient-scan.js +81 -0
  23. package/dist/css-gradient-scan.js.map +1 -0
  24. package/dist/css-var-scan.d.ts +56 -0
  25. package/dist/css-var-scan.d.ts.map +1 -0
  26. package/dist/css-var-scan.js +97 -0
  27. package/dist/css-var-scan.js.map +1 -0
  28. package/dist/design-draft-shell.d.ts +21 -0
  29. package/dist/design-draft-shell.d.ts.map +1 -0
  30. package/dist/design-draft-shell.js +81 -0
  31. package/dist/design-draft-shell.js.map +1 -0
  32. package/dist/design-manifest.d.ts +36 -0
  33. package/dist/design-manifest.d.ts.map +1 -0
  34. package/dist/design-manifest.js +90 -0
  35. package/dist/design-manifest.js.map +1 -0
  36. package/dist/fonts.d.ts +89 -0
  37. package/dist/fonts.d.ts.map +1 -0
  38. package/dist/fonts.js +241 -0
  39. package/dist/fonts.js.map +1 -0
  40. package/dist/genesis-inventory.d.ts +32 -0
  41. package/dist/genesis-inventory.d.ts.map +1 -0
  42. package/dist/genesis-inventory.js +186 -0
  43. package/dist/genesis-inventory.js.map +1 -0
  44. package/dist/genesis.d.ts +102 -0
  45. package/dist/genesis.d.ts.map +1 -0
  46. package/dist/genesis.js +145 -0
  47. package/dist/genesis.js.map +1 -0
  48. package/dist/i18n.d.ts +29 -36
  49. package/dist/i18n.d.ts.map +1 -1
  50. package/dist/i18n.js +53 -128
  51. package/dist/i18n.js.map +1 -1
  52. package/dist/index.d.ts +28 -1
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +28 -1
  55. package/dist/index.js.map +1 -1
  56. package/dist/interactions.d.ts +23 -0
  57. package/dist/interactions.d.ts.map +1 -0
  58. package/dist/interactions.js +44 -0
  59. package/dist/interactions.js.map +1 -0
  60. package/dist/media.d.ts +101 -16
  61. package/dist/media.d.ts.map +1 -1
  62. package/dist/media.js +126 -15
  63. package/dist/media.js.map +1 -1
  64. package/dist/page-log.d.ts +94 -0
  65. package/dist/page-log.d.ts.map +1 -0
  66. package/dist/page-log.js +111 -0
  67. package/dist/page-log.js.map +1 -0
  68. package/dist/preview-compose.d.ts +85 -12
  69. package/dist/preview-compose.d.ts.map +1 -1
  70. package/dist/preview-compose.js +157 -67
  71. package/dist/preview-compose.js.map +1 -1
  72. package/dist/proposal-status.d.ts +40 -0
  73. package/dist/proposal-status.d.ts.map +1 -0
  74. package/dist/proposal-status.js +34 -0
  75. package/dist/proposal-status.js.map +1 -0
  76. package/dist/responsive-images.d.ts +64 -0
  77. package/dist/responsive-images.d.ts.map +1 -0
  78. package/dist/responsive-images.js +98 -0
  79. package/dist/responsive-images.js.map +1 -0
  80. package/dist/safe-keys.d.ts +9 -0
  81. package/dist/safe-keys.d.ts.map +1 -0
  82. package/dist/safe-keys.js +20 -0
  83. package/dist/safe-keys.js.map +1 -0
  84. package/dist/seo.d.ts +11 -16
  85. package/dist/seo.d.ts.map +1 -1
  86. package/dist/seo.js +7 -23
  87. package/dist/seo.js.map +1 -1
  88. package/dist/skills.d.ts +14 -68
  89. package/dist/skills.d.ts.map +1 -1
  90. package/dist/skills.js +19 -113
  91. package/dist/skills.js.map +1 -1
  92. package/dist/strip-cdata.d.ts +7 -0
  93. package/dist/strip-cdata.d.ts.map +1 -0
  94. package/dist/strip-cdata.js +48 -0
  95. package/dist/strip-cdata.js.map +1 -0
  96. package/dist/structured-sets.d.ts +6 -52
  97. package/dist/structured-sets.d.ts.map +1 -1
  98. package/dist/structured-sets.js +6 -68
  99. package/dist/structured-sets.js.map +1 -1
  100. package/dist/subagents.d.ts +105 -3
  101. package/dist/subagents.d.ts.map +1 -1
  102. package/dist/subagents.js +224 -41
  103. package/dist/subagents.js.map +1 -1
  104. package/dist/template-engine.d.ts +85 -0
  105. package/dist/template-engine.d.ts.map +1 -0
  106. package/dist/template-engine.js +403 -0
  107. package/dist/template-engine.js.map +1 -0
  108. package/dist/theme-importers/auto-detect.d.ts +26 -0
  109. package/dist/theme-importers/auto-detect.d.ts.map +1 -0
  110. package/dist/theme-importers/auto-detect.js +42 -0
  111. package/dist/theme-importers/auto-detect.js.map +1 -0
  112. package/dist/theme-importers/css-comments.d.ts +12 -0
  113. package/dist/theme-importers/css-comments.d.ts.map +1 -0
  114. package/dist/theme-importers/css-comments.js +15 -0
  115. package/dist/theme-importers/css-comments.js.map +1 -0
  116. package/dist/theme-importers/dtcg.d.ts +46 -0
  117. package/dist/theme-importers/dtcg.d.ts.map +1 -0
  118. package/dist/theme-importers/dtcg.js +111 -0
  119. package/dist/theme-importers/dtcg.js.map +1 -0
  120. package/dist/theme-importers/loose.d.ts +3 -0
  121. package/dist/theme-importers/loose.d.ts.map +1 -0
  122. package/dist/theme-importers/loose.js +76 -0
  123. package/dist/theme-importers/loose.js.map +1 -0
  124. package/dist/theme-importers/shadcn.d.ts +24 -0
  125. package/dist/theme-importers/shadcn.d.ts.map +1 -0
  126. package/dist/theme-importers/shadcn.js +135 -0
  127. package/dist/theme-importers/shadcn.js.map +1 -0
  128. package/dist/theme-importers/style-dictionary.d.ts +17 -0
  129. package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
  130. package/dist/theme-importers/style-dictionary.js +125 -0
  131. package/dist/theme-importers/style-dictionary.js.map +1 -0
  132. package/dist/theme-importers/tailwind.d.ts +3 -0
  133. package/dist/theme-importers/tailwind.d.ts.map +1 -0
  134. package/dist/theme-importers/tailwind.js +218 -0
  135. package/dist/theme-importers/tailwind.js.map +1 -0
  136. package/dist/theme-literal-binding.d.ts +37 -0
  137. package/dist/theme-literal-binding.d.ts.map +1 -0
  138. package/dist/theme-literal-binding.js +138 -0
  139. package/dist/theme-literal-binding.js.map +1 -0
  140. package/dist/theme-normalize.d.ts +31 -0
  141. package/dist/theme-normalize.d.ts.map +1 -0
  142. package/dist/theme-normalize.js +587 -0
  143. package/dist/theme-normalize.js.map +1 -0
  144. package/dist/theme-ramp.d.ts +55 -0
  145. package/dist/theme-ramp.d.ts.map +1 -0
  146. package/dist/theme-ramp.js +149 -0
  147. package/dist/theme-ramp.js.map +1 -0
  148. package/dist/theme-render.d.ts +105 -0
  149. package/dist/theme-render.d.ts.map +1 -0
  150. package/dist/theme-render.js +441 -0
  151. package/dist/theme-render.js.map +1 -0
  152. package/dist/themes-errors.d.ts +109 -0
  153. package/dist/themes-errors.d.ts.map +1 -0
  154. package/dist/themes-errors.js +170 -0
  155. package/dist/themes-errors.js.map +1 -0
  156. package/dist/themes.d.ts +343 -0
  157. package/dist/themes.d.ts.map +1 -0
  158. package/dist/themes.js +697 -0
  159. package/dist/themes.js.map +1 -0
  160. package/dist/version.d.ts +7 -4
  161. package/dist/version.d.ts.map +1 -1
  162. package/dist/version.js +6 -3
  163. package/dist/version.js.map +1 -1
  164. package/package.json +10 -2
  165. package/src/__tests__/redos-hardening.test.ts +160 -0
  166. package/src/ai-tools-add-module-modes.test.ts +106 -0
  167. package/src/ai-tools-position.test.ts +134 -0
  168. package/src/ai-tools.test.ts +81 -0
  169. package/src/ai-tools.ts +1105 -0
  170. package/src/auth-forms.ts +36 -0
  171. package/src/base-css.ts +42 -0
  172. package/src/build-page.test.ts +228 -0
  173. package/src/build-page.ts +319 -0
  174. package/src/cap-failures.ts +67 -0
  175. package/src/content.test.ts +170 -0
  176. package/src/content.ts +620 -0
  177. package/src/context.ts +43 -0
  178. package/src/css-gradient-scan.ts +88 -0
  179. package/src/css-var-scan.test.ts +96 -0
  180. package/src/css-var-scan.ts +144 -0
  181. package/src/derive-module-type.test.ts +80 -0
  182. package/src/design-draft-shell.test.ts +85 -0
  183. package/src/design-draft-shell.ts +109 -0
  184. package/src/design-manifest.ts +93 -0
  185. package/src/fonts.test.ts +157 -0
  186. package/src/fonts.ts +296 -0
  187. package/src/genesis-inventory.test.ts +86 -0
  188. package/src/genesis-inventory.ts +215 -0
  189. package/src/genesis-sanitize.test.ts +35 -0
  190. package/src/genesis.ts +158 -0
  191. package/src/i18n.test.ts +58 -0
  192. package/src/i18n.ts +91 -0
  193. package/src/index.test.ts +10 -0
  194. package/src/index.ts +59 -0
  195. package/src/interactions.ts +48 -0
  196. package/src/logger.ts +147 -0
  197. package/src/media.test.ts +160 -0
  198. package/src/media.ts +355 -0
  199. package/src/page-log.test.ts +163 -0
  200. package/src/page-log.ts +124 -0
  201. package/src/preview-compose.test.ts +637 -0
  202. package/src/preview-compose.ts +656 -0
  203. package/src/preview-scanner.test.ts +96 -0
  204. package/src/preview-scanner.ts +214 -0
  205. package/src/proposal-status.test.ts +69 -0
  206. package/src/proposal-status.ts +40 -0
  207. package/src/responsive-images.test.ts +104 -0
  208. package/src/responsive-images.ts +151 -0
  209. package/src/result.ts +29 -0
  210. package/src/safe-keys.ts +21 -0
  211. package/src/seo.test.ts +194 -0
  212. package/src/seo.ts +233 -0
  213. package/src/skills.ts +48 -0
  214. package/src/snapshots.test.ts +80 -0
  215. package/src/snapshots.ts +81 -0
  216. package/src/strip-cdata.test.ts +41 -0
  217. package/src/strip-cdata.ts +50 -0
  218. package/src/structured-sets.ts +114 -0
  219. package/src/subagents.test.ts +262 -0
  220. package/src/subagents.ts +432 -0
  221. package/src/template-engine.test.ts +379 -0
  222. package/src/template-engine.ts +520 -0
  223. package/src/theme-gradient.test.ts +92 -0
  224. package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
  225. package/src/theme-importers/auto-detect.ts +84 -0
  226. package/src/theme-importers/css-comments.ts +15 -0
  227. package/src/theme-importers/dtcg.ts +106 -0
  228. package/src/theme-importers/loose.ts +76 -0
  229. package/src/theme-importers/shadcn.ts +133 -0
  230. package/src/theme-importers/style-dictionary.ts +125 -0
  231. package/src/theme-importers/tailwind.ts +217 -0
  232. package/src/theme-literal-binding.test.ts +71 -0
  233. package/src/theme-literal-binding.ts +159 -0
  234. package/src/theme-motion.test.ts +115 -0
  235. package/src/theme-normalize-envelope.test.ts +43 -0
  236. package/src/theme-normalize-gradient.test.ts +135 -0
  237. package/src/theme-normalize.ts +661 -0
  238. package/src/theme-ramp.ts +187 -0
  239. package/src/theme-render-sanitize.test.ts +45 -0
  240. package/src/theme-render.test.ts +119 -0
  241. package/src/theme-render.ts +487 -0
  242. package/src/theme-shadow.test.ts +56 -0
  243. package/src/themes-errors.ts +199 -0
  244. package/src/themes.ts +842 -0
  245. package/src/version.ts +66 -0
  246. package/dist/translation.d.ts +0 -127
  247. package/dist/translation.d.ts.map +0 -1
  248. package/dist/translation.js +0 -208
  249. package/dist/translation.js.map +0 -1
package/src/logger.ts ADDED
@@ -0,0 +1,147 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * P16 — structured logger. JSON-per-line to stderr; the host environment
5
+ * (operator's log aggregator: Loki/Datadog/CloudWatch/etc.) ships from
6
+ * there. Stable shape across every Caelo service so a single
7
+ * `request_id` query reconstructs the full timeline.
8
+ *
9
+ * Redaction pass replaces values for keys matching common-secret regex
10
+ * with `***` so a stray `{password: "..."}` extra never lands in logs.
11
+ *
12
+ * NOT a replacement for `audit_events` — audit captures intent (op,
13
+ * actor, succeeded), structured logs capture trace (request_id,
14
+ * service-to-service hops, latency, errors). Cross-correlate via the
15
+ * shared `request_id` column.
16
+ */
17
+
18
+ export type LogLevel = "debug" | "info" | "warn" | "error";
19
+
20
+ export type ServiceName = "admin" | "gateway" | "orchestrator" | "plugin-host" | "static-gen";
21
+
22
+ export interface LogContext {
23
+ readonly requestId: string;
24
+ readonly actorId?: string;
25
+ readonly actorKind?: "human" | "ai" | "system" | "plugin";
26
+ readonly opName?: string;
27
+ readonly chatSessionId?: string;
28
+ readonly pluginSlug?: string;
29
+ }
30
+
31
+ export interface StructuredLogEntry {
32
+ readonly ts: string;
33
+ readonly level: LogLevel;
34
+ readonly msg: string;
35
+ readonly service: ServiceName;
36
+ readonly env: string;
37
+ readonly ctx: LogContext;
38
+ readonly extra?: Record<string, unknown>;
39
+ }
40
+
41
+ // Word-anchored — without the boundaries `key` would match `monkey`,
42
+ // `token` would match `tokenizer`, `secret` would match `secretary`.
43
+ // Whole-key match is the right semantic for log-field redaction.
44
+ const SECRET_KEY_RE =
45
+ /^(password|passwd|secret|secrets|token|tokens|api[-_]?key|api[-_]?keys|cookie|cookies|authorization|bearer|x[-_]api[-_]?key|csrf[-_]?secret|cookie[-_]?secret)$/i;
46
+
47
+ export function redact(input: unknown): unknown {
48
+ if (input === null || typeof input !== "object") return input;
49
+ if (Array.isArray(input)) return input.map(redact);
50
+ const out: Record<string, unknown> = {};
51
+ for (const [k, v] of Object.entries(input)) {
52
+ if (SECRET_KEY_RE.test(k)) {
53
+ out[k] = "***";
54
+ } else if (v && typeof v === "object") {
55
+ out[k] = redact(v);
56
+ } else {
57
+ out[k] = v;
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ export interface Logger {
64
+ with(ctx: Partial<LogContext>): Logger;
65
+ debug(msg: string, extra?: Record<string, unknown>): void;
66
+ info(msg: string, extra?: Record<string, unknown>): void;
67
+ warn(msg: string, extra?: Record<string, unknown>): void;
68
+ error(msg: string, extra?: Record<string, unknown>): void;
69
+ }
70
+
71
+ export interface LoggerOptions {
72
+ readonly service: ServiceName;
73
+ readonly env?: string;
74
+ /** Defaults to console.error so structured logs flow to stderr. */
75
+ readonly sink?: (entry: StructuredLogEntry) => void;
76
+ /** Minimum level to emit; default 'info' (suppresses 'debug'). */
77
+ readonly minLevel?: LogLevel;
78
+ }
79
+
80
+ const LEVEL_RANK: Record<LogLevel, number> = {
81
+ debug: 10,
82
+ info: 20,
83
+ warn: 30,
84
+ error: 40,
85
+ };
86
+
87
+ export function makeLogger(opts: LoggerOptions): Logger {
88
+ const env = opts.env ?? process.env.CAELO_ENV ?? "dev";
89
+ const minRank = LEVEL_RANK[opts.minLevel ?? "info"];
90
+ const sink =
91
+ opts.sink ??
92
+ ((entry: StructuredLogEntry) => {
93
+ process.stderr.write(`${JSON.stringify(entry)}\n`);
94
+ });
95
+
96
+ function emit(
97
+ ctx: LogContext,
98
+ level: LogLevel,
99
+ msg: string,
100
+ extra?: Record<string, unknown>,
101
+ ): void {
102
+ if (LEVEL_RANK[level] < minRank) return;
103
+ const entry: StructuredLogEntry = {
104
+ ts: new Date().toISOString(),
105
+ level,
106
+ msg,
107
+ service: opts.service,
108
+ env,
109
+ ctx,
110
+ extra: extra ? (redact(extra) as Record<string, unknown>) : undefined,
111
+ };
112
+ sink(entry);
113
+ }
114
+
115
+ function buildLogger(ctx: LogContext): Logger {
116
+ return {
117
+ with(extraCtx) {
118
+ return buildLogger({ ...ctx, ...extraCtx });
119
+ },
120
+ debug(msg, extra) {
121
+ emit(ctx, "debug", msg, extra);
122
+ },
123
+ info(msg, extra) {
124
+ emit(ctx, "info", msg, extra);
125
+ },
126
+ warn(msg, extra) {
127
+ emit(ctx, "warn", msg, extra);
128
+ },
129
+ error(msg, extra) {
130
+ emit(ctx, "error", msg, extra);
131
+ },
132
+ };
133
+ }
134
+
135
+ // Default ctx has a synthetic request_id so a logger created outside
136
+ // of any request boundary still produces correlatable output (visible
137
+ // in the log stream as `synthetic-…`).
138
+ return buildLogger({ requestId: `synthetic-${crypto.randomUUID().slice(0, 8)}` });
139
+ }
140
+
141
+ /**
142
+ * Mint a fresh request_id at a request boundary. Caller threads through
143
+ * ExecutionContext + outbound HTTP headers (`X-Caelo-Request-Id`).
144
+ */
145
+ export function mintRequestId(): string {
146
+ return crypto.randomUUID();
147
+ }
@@ -0,0 +1,160 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ import { describe, expect, it } from "bun:test";
4
+ import {
5
+ buildMediaUrl,
6
+ buildStorageKey,
7
+ extractMediaRefs,
8
+ MEDIA_ALLOWED_MIMES,
9
+ MEDIA_HARD_LIMIT_BYTES,
10
+ MEDIA_SIZE_CAPS,
11
+ MEDIA_VARIANT_TAGS,
12
+ MEDIA_VARIANT_WIDTHS,
13
+ mediaListInputSchema,
14
+ mediaSetCdnInputSchema,
15
+ mediaUploadInputSchema,
16
+ slugifyMediaName,
17
+ } from "./media.js";
18
+
19
+ describe("media URL helpers", () => {
20
+ it("builds a slug URL: orig is flat, named variants nest under the slug", () => {
21
+ expect(buildMediaUrl("searchviu-logo", "orig")).toBe("/_caelo/media/searchviu-logo");
22
+ expect(buildMediaUrl("searchviu-hero", "webp-800")).toBe(
23
+ "/_caelo/media/searchviu-hero/webp-800",
24
+ );
25
+ });
26
+
27
+ it("extracts slug refs (orig implied when flat) deduped", () => {
28
+ const html = `
29
+ <img src="/_caelo/media/searchviu-hero/webp-800" alt="x" />
30
+ <img src="/_caelo/media/searchviu-hero/webp-800" alt="y" />
31
+ <img src="/_caelo/media/searchviu-logo" />
32
+ `;
33
+ expect(extractMediaRefs(html)).toEqual([
34
+ { ref: "searchviu-hero", isSlug: true, variant: "webp-800" },
35
+ { ref: "searchviu-logo", isSlug: true, variant: "orig" },
36
+ ]);
37
+ });
38
+
39
+ it("still parses the legacy /_caelo/media/<uuid>/<variant> form as an id ref", () => {
40
+ const html = `
41
+ <img src="/_caelo/media/short-slug/webp-800" />
42
+ <img src="/_caelo/media/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/square-800" />
43
+ `;
44
+ expect(extractMediaRefs(html)).toEqual([
45
+ { ref: "short-slug", isSlug: true, variant: "webp-800" },
46
+ { ref: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", isSlug: false, variant: "square-800" },
47
+ ]);
48
+ });
49
+
50
+ it("slugifies a human label: accents stripped, kebab-cased, extension dropped", () => {
51
+ expect(slugifyMediaName("SearchVIU Logo.png")).toBe("searchviu-logo");
52
+ expect(slugifyMediaName("Über uns – Team!")).toBe("uber-uns-team");
53
+ expect(slugifyMediaName(" ")).toBe("image");
54
+ expect(slugifyMediaName("a".repeat(80)).length).toBeLessThanOrEqual(60);
55
+ });
56
+
57
+ it("buildStorageKey is sha-prefixed", () => {
58
+ expect(buildStorageKey("abc123", "webp-800", "webp")).toBe("abc123/webp-800.webp");
59
+ });
60
+ });
61
+
62
+ describe("media size caps + allowlist", () => {
63
+ it("each allowed MIME has a positive size cap below the hard limit", () => {
64
+ for (const m of MEDIA_ALLOWED_MIMES) {
65
+ const cap = MEDIA_SIZE_CAPS[m];
66
+ expect(cap).toBeGreaterThan(0);
67
+ expect(cap).toBeLessThanOrEqual(MEDIA_HARD_LIMIT_BYTES);
68
+ }
69
+ });
70
+
71
+ it("variant widths cover the non-orig tags only", () => {
72
+ for (const t of MEDIA_VARIANT_TAGS) {
73
+ if (t === "orig") continue;
74
+ expect(MEDIA_VARIANT_WIDTHS[t]).toBeGreaterThan(0);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe("media schemas", () => {
80
+ it("mediaUploadInputSchema accepts a minimal valid payload", () => {
81
+ const r = mediaUploadInputSchema.safeParse({
82
+ sha256: "a".repeat(64),
83
+ originalName: "hero.jpg",
84
+ mime: "image/jpeg",
85
+ sizeBytes: 1024,
86
+ width: 1920,
87
+ height: 1080,
88
+ alt: "",
89
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
90
+ variants: [
91
+ {
92
+ variant: "orig",
93
+ format: "jpeg",
94
+ width: 1920,
95
+ height: 1080,
96
+ sizeBytes: 1024,
97
+ storageKey: `${"a".repeat(64)}/orig.jpg`,
98
+ },
99
+ ],
100
+ });
101
+ expect(r.success).toBe(true);
102
+ });
103
+
104
+ it("mediaUploadInputSchema rejects non-hex sha256", () => {
105
+ const r = mediaUploadInputSchema.safeParse({
106
+ sha256: "not-a-sha",
107
+ originalName: "x",
108
+ mime: "image/jpeg",
109
+ sizeBytes: 1,
110
+ width: null,
111
+ height: null,
112
+ storageKey: "x",
113
+ variants: [
114
+ {
115
+ variant: "orig",
116
+ format: "jpeg",
117
+ width: null,
118
+ height: null,
119
+ sizeBytes: 1,
120
+ storageKey: "x",
121
+ },
122
+ ],
123
+ });
124
+ expect(r.success).toBe(false);
125
+ });
126
+
127
+ it("mediaUploadInputSchema rejects unknown MIME", () => {
128
+ const r = mediaUploadInputSchema.safeParse({
129
+ sha256: "a".repeat(64),
130
+ originalName: "x",
131
+ mime: "application/zip",
132
+ sizeBytes: 1,
133
+ width: null,
134
+ height: null,
135
+ storageKey: "x",
136
+ variants: [
137
+ {
138
+ variant: "orig",
139
+ format: "jpeg",
140
+ width: null,
141
+ height: null,
142
+ sizeBytes: 1,
143
+ storageKey: "x",
144
+ },
145
+ ],
146
+ });
147
+ expect(r.success).toBe(false);
148
+ });
149
+
150
+ it("mediaListInputSchema defaults sort=recent and limit=60", () => {
151
+ const r = mediaListInputSchema.parse({});
152
+ expect(r.sort).toBe("recent");
153
+ expect(r.limit).toBe(60);
154
+ expect(r.offset).toBe(0);
155
+ });
156
+
157
+ it("mediaSetCdnInputSchema rejects threshold below 1", () => {
158
+ expect(mediaSetCdnInputSchema.safeParse({ enabled: true, threshold: 0 }).success).toBe(false);
159
+ });
160
+ });
package/src/media.ts ADDED
@@ -0,0 +1,355 @@
1
+ // SPDX-License-Identifier: MPL-2.0
2
+
3
+ /**
4
+ * Media library — shared primitives.
5
+ *
6
+ * Browser-safe: Zod schemas, MIME allowlist, size caps, the variant
7
+ * convention. Sharp + filesystem adapters live in `@caelo-cms/admin-core`
8
+ * (server-only). The storage-key shape is stable here so the static
9
+ * generator's URL rewriter and the admin's iframe resolver agree on
10
+ * the canonical form `<sha>/<variant>.<ext>`.
11
+ */
12
+
13
+ import { z } from "zod";
14
+
15
+ /**
16
+ * Allowlisted MIME types. Anything outside this set is rejected at the
17
+ * upload endpoint with `415 Unsupported Media Type`. SVG is allowed
18
+ * but capped tight to discourage XSS via embedded scripts; the upload
19
+ * pipeline strips `<script>` and event-handler attributes before
20
+ * persisting (see {@link sanitizeSvg} in admin-core).
21
+ */
22
+ export const MEDIA_ALLOWED_MIMES = [
23
+ "image/jpeg",
24
+ "image/png",
25
+ "image/webp",
26
+ "image/avif",
27
+ "image/gif",
28
+ "image/svg+xml",
29
+ "application/pdf",
30
+ "video/mp4",
31
+ // issue #249 — webfonts. Migrated sites reference their own font
32
+ // files from replayed CSS; the media-migration pass downloads them
33
+ // into the library so the rebuilt site survives the source host
34
+ // going away. Stored as-is (no derived variants).
35
+ "font/woff2",
36
+ "font/woff",
37
+ "font/ttf",
38
+ "font/otf",
39
+ ] as const;
40
+ export type MediaMime = (typeof MEDIA_ALLOWED_MIMES)[number];
41
+
42
+ /** Per-MIME size caps (bytes). Server enforces; client display only. */
43
+ export const MEDIA_SIZE_CAPS: Record<MediaMime, number> = {
44
+ "image/jpeg": 10 * 1024 * 1024,
45
+ "image/png": 10 * 1024 * 1024,
46
+ "image/webp": 10 * 1024 * 1024,
47
+ "image/avif": 10 * 1024 * 1024,
48
+ "image/gif": 8 * 1024 * 1024,
49
+ "image/svg+xml": 1 * 1024 * 1024,
50
+ "application/pdf": 20 * 1024 * 1024,
51
+ "video/mp4": 50 * 1024 * 1024,
52
+ "font/woff2": 5 * 1024 * 1024,
53
+ "font/woff": 5 * 1024 * 1024,
54
+ "font/ttf": 5 * 1024 * 1024,
55
+ "font/otf": 5 * 1024 * 1024,
56
+ };
57
+
58
+ /** Hard ceiling on the multipart body. Per-MIME caps narrow further. */
59
+ export const MEDIA_HARD_LIMIT_BYTES = 50 * 1024 * 1024;
60
+
61
+ /**
62
+ * Variant tags. `orig` is always present (re-encoded only for SVG
63
+ * sanitisation). Image-only WebP variants are emitted at breakpoints
64
+ * the source can satisfy — a 600px-wide source skips webp-1200 +
65
+ * webp-1600 entirely.
66
+ */
67
+ export const MEDIA_VARIANT_TAGS = [
68
+ "orig",
69
+ "webp-1600",
70
+ "webp-1200",
71
+ "webp-800",
72
+ "webp-400",
73
+ ] as const;
74
+ export type MediaVariantTag = (typeof MEDIA_VARIANT_TAGS)[number];
75
+
76
+ /** Width-in-pixels target for each WebP variant. */
77
+ export const MEDIA_VARIANT_WIDTHS: Record<Exclude<MediaVariantTag, "orig">, number> = {
78
+ "webp-1600": 1600,
79
+ "webp-1200": 1200,
80
+ "webp-800": 800,
81
+ "webp-400": 400,
82
+ };
83
+
84
+ /**
85
+ * Renderer-agnostic asset URL used in module HTML. Both the SvelteKit
86
+ * admin endpoint and the static generator's media-pass parse this
87
+ * shape; the static generator rewrites to `/_assets/...` (or a CDN
88
+ * URL) at deploy time.
89
+ *
90
+ * Format (current): `/_caelo/media/<slug>` for the orig variant,
91
+ * `/_caelo/media/<slug>/<variant>` for a named variant (webp/crops). The
92
+ * `<slug>` is `media_assets.slug` — a human-meaningful name (e.g.
93
+ * `searchviu-logo`); the UUID id stays internal. The static generator
94
+ * rewrites these to `/_assets/<slug>.<ext>` (orig) / `/_assets/<slug>/<variant>.<ext>`.
95
+ *
96
+ * Legacy form `/_caelo/media/<uuid>/<variant>` is still PARSED (existing
97
+ * persisted embeds keep resolving), but never newly EMITTED.
98
+ */
99
+ export const MEDIA_URL_PREFIX = "/_caelo/media";
100
+
101
+ /** Full-uuid shape — used to tell a legacy id ref from a slug ref. */
102
+ const MEDIA_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
103
+
104
+ /**
105
+ * Build the media URL for an asset SLUG + variant. The orig variant is
106
+ * flat (`/_caelo/media/<slug>`) so a plain image reads as a name, not a
107
+ * path; named variants (srcset webp / focal crops) nest under the slug.
108
+ * Widened to `| string` (run #10 D4): pickAiImageVariant returns whichever
109
+ * variant tag actually exists.
110
+ */
111
+ export function buildMediaUrl(slug: string, variant: MediaVariantTag | string): string {
112
+ return variant === "orig"
113
+ ? `${MEDIA_URL_PREFIX}/${slug}`
114
+ : `${MEDIA_URL_PREFIX}/${slug}/${variant}`;
115
+ }
116
+
117
+ /**
118
+ * Turn a human/asset label into a URL-safe media slug: lowercase ascii,
119
+ * kebab-case, extension + junk stripped, capped at 60 chars. NOT
120
+ * uniquified — the caller resolves collisions against the live library
121
+ * (append `-2`/`-3`…); see `resolveUniqueMediaSlug` in admin-core.
122
+ */
123
+ export function slugifyMediaName(name: string): string {
124
+ const noExt = name.replace(/\.[a-z0-9]{1,8}$/i, "");
125
+ const slug = noExt
126
+ .normalize("NFKD")
127
+ .replace(/[̀-ͯ]/g, "") // strip combining accents
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9]+/g, "-")
130
+ .replace(/^-+|-+$/g, "")
131
+ .slice(0, 60)
132
+ .replace(/-+$/g, "");
133
+ return slug.length > 0 ? slug : "image";
134
+ }
135
+
136
+ // Segment token: a slug or uuid (`[a-z0-9-]`) optionally followed by a
137
+ // variant. `orig`, `webp-<width>`, `<crop-name>-<width>` all round-trip
138
+ // without a per-crop regex update.
139
+ const mediaUrlPattern = new RegExp(
140
+ `${MEDIA_URL_PREFIX}/([a-z0-9][a-z0-9-]{0,63})(?:/([a-z][a-z0-9-]{0,63}))?`,
141
+ "g",
142
+ );
143
+
144
+ /**
145
+ * One media reference parsed from HTML. `ref` is either an asset SLUG
146
+ * (`isSlug: true`) or a legacy UUID id (`isSlug: false`); callers resolve
147
+ * a slug ref to an asset id before touching the DB. `variant` defaults to
148
+ * `orig` for the flat slug form.
149
+ */
150
+ export interface MediaRef {
151
+ readonly ref: string;
152
+ readonly isSlug: boolean;
153
+ readonly variant: string;
154
+ }
155
+
156
+ /**
157
+ * Extract every media reference in an HTML string (deduped). Used by the
158
+ * post-write usage-tracker and the static-generator media-pass. Handles
159
+ * both the current slug form and the legacy `<uuid>/<variant>` form.
160
+ */
161
+ export function extractMediaRefs(html: string): MediaRef[] {
162
+ const seen = new Set<string>();
163
+ const out: MediaRef[] = [];
164
+ for (const m of html.matchAll(mediaUrlPattern)) {
165
+ const seg1 = m[1] as string;
166
+ const seg2 = m[2];
167
+ // A full-uuid first segment with a trailing variant is the legacy id
168
+ // form; everything else is a slug (orig when no explicit variant).
169
+ const isLegacyId = MEDIA_UUID_RE.test(seg1) && seg2 !== undefined;
170
+ const ref = seg1;
171
+ const isSlug = !isLegacyId;
172
+ const variant = seg2 ?? "orig";
173
+ const key = `${isSlug ? "s" : "i"}:${ref}/${variant}`;
174
+ if (seen.has(key)) continue;
175
+ seen.add(key);
176
+ out.push({ ref, isSlug, variant });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ // ---------------------------------------------------------------------
182
+ // Zod schemas — exposed at the Query-API boundary.
183
+ // ---------------------------------------------------------------------
184
+
185
+ const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/, "must be hex sha256");
186
+
187
+ export const mediaUploadInputSchema = z
188
+ .object({
189
+ sha256: sha256Schema,
190
+ originalName: z.string().min(1).max(512),
191
+ /**
192
+ * Meaningful, human-facing label for the asset (e.g. "SearchVIU logo").
193
+ * The handler slugifies + uniquifies it into `media_assets.slug`, which
194
+ * becomes the public URL (`/_assets/<slug>.<ext>`); the id stays internal.
195
+ * Optional: falls back to `alt` → `originalName` → "image".
196
+ */
197
+ name: z.string().max(200).optional(),
198
+ mime: z.enum(MEDIA_ALLOWED_MIMES),
199
+ sizeBytes: z.number().int().positive(),
200
+ width: z.number().int().positive().nullable(),
201
+ height: z.number().int().positive().nullable(),
202
+ alt: z.string().max(2048).default(""),
203
+ storageKey: z.string().min(1),
204
+ /** P7 optimization #3 — stamped by the upload endpoint via getMediaStorageProvider(). */
205
+ storageProvider: z.string().min(1).max(64).default("local"),
206
+ /**
207
+ * Media provenance (0181). Where the asset came from + its licence
208
+ * when known. All optional — a plain human upload may leave them
209
+ * unset. `sourceDetail` is the origin: source URL for imported /
210
+ * external, "<provider>/<model>" for ai_generated, filename/NULL for
211
+ * a plain upload.
212
+ */
213
+ sourceKind: z.enum(["upload", "ai_generated", "imported", "external"]).optional(),
214
+ sourceDetail: z.string().max(2048).optional(),
215
+ license: z.string().max(200).optional(),
216
+ variants: z
217
+ .array(
218
+ z.object({
219
+ variant: z.string().min(1).max(64),
220
+ format: z.string().min(1).max(32),
221
+ width: z.number().int().positive().nullable(),
222
+ height: z.number().int().positive().nullable(),
223
+ sizeBytes: z.number().int().positive(),
224
+ storageKey: z.string().min(1),
225
+ }),
226
+ )
227
+ .min(1),
228
+ })
229
+ .strict();
230
+ export type MediaUploadInput = z.infer<typeof mediaUploadInputSchema>;
231
+
232
+ export const mediaListInputSchema = z
233
+ .object({
234
+ query: z.string().max(256).optional(),
235
+ mime: z.enum(MEDIA_ALLOWED_MIMES).optional(),
236
+ sort: z.enum(["recent", "most_used"]).default("recent"),
237
+ limit: z.number().int().positive().max(200).default(60),
238
+ offset: z.number().int().nonnegative().default(0),
239
+ })
240
+ .strict();
241
+ export type MediaListInput = z.infer<typeof mediaListInputSchema>;
242
+
243
+ export const mediaUpdateAltInputSchema = z
244
+ .object({
245
+ assetId: z.string().uuid(),
246
+ alt: z.string().max(2048),
247
+ })
248
+ .strict();
249
+ export type MediaUpdateAltInput = z.infer<typeof mediaUpdateAltInputSchema>;
250
+
251
+ /**
252
+ * media.set_source (0181) — record/patch an asset's provenance after it
253
+ * exists. COALESCE semantics at the handler: omitted fields stay
254
+ * unchanged, so this both sets provenance the first time and patches a
255
+ * single field (e.g. a licence the operator states later).
256
+ */
257
+ export const mediaSetSourceInputSchema = z
258
+ .object({
259
+ assetId: z.string().uuid(),
260
+ sourceKind: z.enum(["upload", "ai_generated", "imported", "external"]).optional(),
261
+ sourceDetail: z.string().max(2048).optional(),
262
+ license: z.string().max(200).optional(),
263
+ })
264
+ .strict();
265
+ export type MediaSetSourceInput = z.infer<typeof mediaSetSourceInputSchema>;
266
+
267
+ export const mediaDeleteInputSchema = z
268
+ .object({
269
+ assetId: z.string().uuid(),
270
+ force: z.boolean().default(false),
271
+ })
272
+ .strict();
273
+
274
+ export const mediaRecordUsageInputSchema = z
275
+ .object({
276
+ /** Map of assetId → net delta (positive when added, negative when removed). */
277
+ deltas: z.record(z.string().uuid(), z.number().int()),
278
+ })
279
+ .strict();
280
+ export type MediaRecordUsageInput = z.infer<typeof mediaRecordUsageInputSchema>;
281
+
282
+ export const mediaRecentForAiInputSchema = z
283
+ .object({
284
+ limit: z.number().int().positive().max(60).default(30),
285
+ })
286
+ .strict();
287
+
288
+ export const mediaSetCdnInputSchema = z
289
+ .object({
290
+ enabled: z.boolean(),
291
+ threshold: z.number().int().min(1).max(10000),
292
+ })
293
+ .strict();
294
+ export type MediaSetCdnInput = z.infer<typeof mediaSetCdnInputSchema>;
295
+
296
+ // ---------------------------------------------------------------------
297
+ // Storage adapter interface — implemented by LocalVolumeAdapter in
298
+ // admin-core, by per-cloud adapters in P15.
299
+ // ---------------------------------------------------------------------
300
+
301
+ /**
302
+ * Object-storage abstraction. The DB never holds blob bytes — only
303
+ * metadata + the storage key. Adapters are responsible for the full
304
+ * key→bytes round-trip; the URL form they expose is renderer-agnostic
305
+ * (LocalVolumeAdapter returns `/_caelo/media/<assetId>/<variant>` so
306
+ * the SvelteKit endpoint can resolve; cloud adapters can return CDN
307
+ * URLs directly).
308
+ */
309
+ export interface MediaStorageAdapter {
310
+ put(key: string, body: Uint8Array, contentType: string): Promise<void>;
311
+ get(key: string): Promise<Uint8Array>;
312
+ delete(key: string): Promise<void>;
313
+ exists(key: string): Promise<boolean>;
314
+ /** Bytes-on-disk for capacity reporting. */
315
+ totalSizeBytes(): Promise<number>;
316
+ }
317
+
318
+ /**
319
+ * Object-store prefix for images the AI produced during a chat (screenshots
320
+ * of a page, of an external site, of a crawled source page).
321
+ *
322
+ * They are NOT media assets. The operator's library is their own curated
323
+ * space; filling it with machine screenshots would make it useless, and these
324
+ * images have no life outside the conversation that produced them. They live
325
+ * under their own prefix instead, and a scheduled sweep removes the ones whose
326
+ * conversations have aged out (see `chat_images.gc`).
327
+ *
328
+ * The key is `chat-images/<UTC day>/<sha256>.<ext>`:
329
+ * - the day segment makes age the first thing you can see in a key, so a
330
+ * sweep never has to open a file to decide;
331
+ * - the content hash means re-shooting an unchanged page writes the SAME
332
+ * key. That is not just a storage saving — an identical key keeps the
333
+ * message history byte-identical, so the provider's prompt cache survives
334
+ * a re-screenshot of something that did not change.
335
+ */
336
+ export const CHAT_IMAGE_PREFIX = "chat-images";
337
+
338
+ /** Build the storage key for a chat image. `day` is `YYYY-MM-DD` (UTC). */
339
+ export function buildChatImageKey(day: string, sha256: string, ext: string): string {
340
+ return `${CHAT_IMAGE_PREFIX}/${day}/${sha256}.${ext}`;
341
+ }
342
+
343
+ /** True for keys under the chat-image prefix — the sweep's safety check. */
344
+ export function isChatImageKey(key: string): boolean {
345
+ return key.startsWith(`${CHAT_IMAGE_PREFIX}/`);
346
+ }
347
+
348
+ /** Build the canonical storage key for a given asset variant. */
349
+ export function buildStorageKey(
350
+ sha256: string,
351
+ variant: MediaVariantTag | string,
352
+ ext: string,
353
+ ): string {
354
+ return `${sha256}/${variant}.${ext}`;
355
+ }