@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.
- package/dist/ai-tools.d.ts +289 -273
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +342 -323
- package/dist/ai-tools.js.map +1 -1
- package/dist/auth-forms.d.ts.map +1 -1
- package/dist/auth-forms.js +4 -1
- package/dist/auth-forms.js.map +1 -1
- package/dist/base-css.d.ts +35 -0
- package/dist/base-css.d.ts.map +1 -0
- package/dist/base-css.js +40 -0
- package/dist/base-css.js.map +1 -0
- package/dist/build-page.d.ts +330 -0
- package/dist/build-page.d.ts.map +1 -0
- package/dist/build-page.js +282 -0
- package/dist/build-page.js.map +1 -0
- package/dist/content.d.ts +322 -9
- package/dist/content.d.ts.map +1 -1
- package/dist/content.js +354 -11
- package/dist/content.js.map +1 -1
- package/dist/css-gradient-scan.d.ts +14 -0
- package/dist/css-gradient-scan.d.ts.map +1 -0
- package/dist/css-gradient-scan.js +81 -0
- package/dist/css-gradient-scan.js.map +1 -0
- package/dist/css-var-scan.d.ts +56 -0
- package/dist/css-var-scan.d.ts.map +1 -0
- package/dist/css-var-scan.js +97 -0
- package/dist/css-var-scan.js.map +1 -0
- package/dist/design-draft-shell.d.ts +21 -0
- package/dist/design-draft-shell.d.ts.map +1 -0
- package/dist/design-draft-shell.js +81 -0
- package/dist/design-draft-shell.js.map +1 -0
- package/dist/design-manifest.d.ts +36 -0
- package/dist/design-manifest.d.ts.map +1 -0
- package/dist/design-manifest.js +90 -0
- package/dist/design-manifest.js.map +1 -0
- package/dist/fonts.d.ts +89 -0
- package/dist/fonts.d.ts.map +1 -0
- package/dist/fonts.js +241 -0
- package/dist/fonts.js.map +1 -0
- package/dist/genesis-inventory.d.ts +32 -0
- package/dist/genesis-inventory.d.ts.map +1 -0
- package/dist/genesis-inventory.js +186 -0
- package/dist/genesis-inventory.js.map +1 -0
- package/dist/genesis.d.ts +102 -0
- package/dist/genesis.d.ts.map +1 -0
- package/dist/genesis.js +145 -0
- package/dist/genesis.js.map +1 -0
- package/dist/i18n.d.ts +29 -36
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +53 -128
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +28 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -1
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +23 -0
- package/dist/interactions.d.ts.map +1 -0
- package/dist/interactions.js +44 -0
- package/dist/interactions.js.map +1 -0
- package/dist/media.d.ts +101 -16
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +126 -15
- package/dist/media.js.map +1 -1
- package/dist/page-log.d.ts +94 -0
- package/dist/page-log.d.ts.map +1 -0
- package/dist/page-log.js +111 -0
- package/dist/page-log.js.map +1 -0
- package/dist/preview-compose.d.ts +85 -12
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +157 -67
- package/dist/preview-compose.js.map +1 -1
- package/dist/proposal-status.d.ts +40 -0
- package/dist/proposal-status.d.ts.map +1 -0
- package/dist/proposal-status.js +34 -0
- package/dist/proposal-status.js.map +1 -0
- package/dist/responsive-images.d.ts +64 -0
- package/dist/responsive-images.d.ts.map +1 -0
- package/dist/responsive-images.js +98 -0
- package/dist/responsive-images.js.map +1 -0
- package/dist/safe-keys.d.ts +9 -0
- package/dist/safe-keys.d.ts.map +1 -0
- package/dist/safe-keys.js +20 -0
- package/dist/safe-keys.js.map +1 -0
- package/dist/seo.d.ts +11 -16
- package/dist/seo.d.ts.map +1 -1
- package/dist/seo.js +7 -23
- package/dist/seo.js.map +1 -1
- package/dist/skills.d.ts +14 -68
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +19 -113
- package/dist/skills.js.map +1 -1
- package/dist/strip-cdata.d.ts +7 -0
- package/dist/strip-cdata.d.ts.map +1 -0
- package/dist/strip-cdata.js +48 -0
- package/dist/strip-cdata.js.map +1 -0
- package/dist/structured-sets.d.ts +6 -52
- package/dist/structured-sets.d.ts.map +1 -1
- package/dist/structured-sets.js +6 -68
- package/dist/structured-sets.js.map +1 -1
- package/dist/subagents.d.ts +105 -3
- package/dist/subagents.d.ts.map +1 -1
- package/dist/subagents.js +224 -41
- package/dist/subagents.js.map +1 -1
- package/dist/template-engine.d.ts +85 -0
- package/dist/template-engine.d.ts.map +1 -0
- package/dist/template-engine.js +403 -0
- package/dist/template-engine.js.map +1 -0
- package/dist/theme-importers/auto-detect.d.ts +26 -0
- package/dist/theme-importers/auto-detect.d.ts.map +1 -0
- package/dist/theme-importers/auto-detect.js +42 -0
- package/dist/theme-importers/auto-detect.js.map +1 -0
- package/dist/theme-importers/css-comments.d.ts +12 -0
- package/dist/theme-importers/css-comments.d.ts.map +1 -0
- package/dist/theme-importers/css-comments.js +15 -0
- package/dist/theme-importers/css-comments.js.map +1 -0
- package/dist/theme-importers/dtcg.d.ts +46 -0
- package/dist/theme-importers/dtcg.d.ts.map +1 -0
- package/dist/theme-importers/dtcg.js +111 -0
- package/dist/theme-importers/dtcg.js.map +1 -0
- package/dist/theme-importers/loose.d.ts +3 -0
- package/dist/theme-importers/loose.d.ts.map +1 -0
- package/dist/theme-importers/loose.js +76 -0
- package/dist/theme-importers/loose.js.map +1 -0
- package/dist/theme-importers/shadcn.d.ts +24 -0
- package/dist/theme-importers/shadcn.d.ts.map +1 -0
- package/dist/theme-importers/shadcn.js +135 -0
- package/dist/theme-importers/shadcn.js.map +1 -0
- package/dist/theme-importers/style-dictionary.d.ts +17 -0
- package/dist/theme-importers/style-dictionary.d.ts.map +1 -0
- package/dist/theme-importers/style-dictionary.js +125 -0
- package/dist/theme-importers/style-dictionary.js.map +1 -0
- package/dist/theme-importers/tailwind.d.ts +3 -0
- package/dist/theme-importers/tailwind.d.ts.map +1 -0
- package/dist/theme-importers/tailwind.js +218 -0
- package/dist/theme-importers/tailwind.js.map +1 -0
- package/dist/theme-literal-binding.d.ts +37 -0
- package/dist/theme-literal-binding.d.ts.map +1 -0
- package/dist/theme-literal-binding.js +138 -0
- package/dist/theme-literal-binding.js.map +1 -0
- package/dist/theme-normalize.d.ts +31 -0
- package/dist/theme-normalize.d.ts.map +1 -0
- package/dist/theme-normalize.js +587 -0
- package/dist/theme-normalize.js.map +1 -0
- package/dist/theme-ramp.d.ts +55 -0
- package/dist/theme-ramp.d.ts.map +1 -0
- package/dist/theme-ramp.js +149 -0
- package/dist/theme-ramp.js.map +1 -0
- package/dist/theme-render.d.ts +105 -0
- package/dist/theme-render.d.ts.map +1 -0
- package/dist/theme-render.js +441 -0
- package/dist/theme-render.js.map +1 -0
- package/dist/themes-errors.d.ts +109 -0
- package/dist/themes-errors.d.ts.map +1 -0
- package/dist/themes-errors.js +170 -0
- package/dist/themes-errors.js.map +1 -0
- package/dist/themes.d.ts +343 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +697 -0
- package/dist/themes.js.map +1 -0
- package/dist/version.d.ts +7 -4
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +6 -3
- package/dist/version.js.map +1 -1
- package/package.json +10 -2
- package/src/__tests__/redos-hardening.test.ts +160 -0
- package/src/ai-tools-add-module-modes.test.ts +106 -0
- package/src/ai-tools-position.test.ts +134 -0
- package/src/ai-tools.test.ts +81 -0
- package/src/ai-tools.ts +1105 -0
- package/src/auth-forms.ts +36 -0
- package/src/base-css.ts +42 -0
- package/src/build-page.test.ts +228 -0
- package/src/build-page.ts +319 -0
- package/src/cap-failures.ts +67 -0
- package/src/content.test.ts +170 -0
- package/src/content.ts +620 -0
- package/src/context.ts +43 -0
- package/src/css-gradient-scan.ts +88 -0
- package/src/css-var-scan.test.ts +96 -0
- package/src/css-var-scan.ts +144 -0
- package/src/derive-module-type.test.ts +80 -0
- package/src/design-draft-shell.test.ts +85 -0
- package/src/design-draft-shell.ts +109 -0
- package/src/design-manifest.ts +93 -0
- package/src/fonts.test.ts +157 -0
- package/src/fonts.ts +296 -0
- package/src/genesis-inventory.test.ts +86 -0
- package/src/genesis-inventory.ts +215 -0
- package/src/genesis-sanitize.test.ts +35 -0
- package/src/genesis.ts +158 -0
- package/src/i18n.test.ts +58 -0
- package/src/i18n.ts +91 -0
- package/src/index.test.ts +10 -0
- package/src/index.ts +59 -0
- package/src/interactions.ts +48 -0
- package/src/logger.ts +147 -0
- package/src/media.test.ts +160 -0
- package/src/media.ts +355 -0
- package/src/page-log.test.ts +163 -0
- package/src/page-log.ts +124 -0
- package/src/preview-compose.test.ts +637 -0
- package/src/preview-compose.ts +656 -0
- package/src/preview-scanner.test.ts +96 -0
- package/src/preview-scanner.ts +214 -0
- package/src/proposal-status.test.ts +69 -0
- package/src/proposal-status.ts +40 -0
- package/src/responsive-images.test.ts +104 -0
- package/src/responsive-images.ts +151 -0
- package/src/result.ts +29 -0
- package/src/safe-keys.ts +21 -0
- package/src/seo.test.ts +194 -0
- package/src/seo.ts +233 -0
- package/src/skills.ts +48 -0
- package/src/snapshots.test.ts +80 -0
- package/src/snapshots.ts +81 -0
- package/src/strip-cdata.test.ts +41 -0
- package/src/strip-cdata.ts +50 -0
- package/src/structured-sets.ts +114 -0
- package/src/subagents.test.ts +262 -0
- package/src/subagents.ts +432 -0
- package/src/template-engine.test.ts +379 -0
- package/src/template-engine.ts +520 -0
- package/src/theme-gradient.test.ts +92 -0
- package/src/theme-importers/__tests__/proto-pollution.test.ts +54 -0
- package/src/theme-importers/auto-detect.ts +84 -0
- package/src/theme-importers/css-comments.ts +15 -0
- package/src/theme-importers/dtcg.ts +106 -0
- package/src/theme-importers/loose.ts +76 -0
- package/src/theme-importers/shadcn.ts +133 -0
- package/src/theme-importers/style-dictionary.ts +125 -0
- package/src/theme-importers/tailwind.ts +217 -0
- package/src/theme-literal-binding.test.ts +71 -0
- package/src/theme-literal-binding.ts +159 -0
- package/src/theme-motion.test.ts +115 -0
- package/src/theme-normalize-envelope.test.ts +43 -0
- package/src/theme-normalize-gradient.test.ts +135 -0
- package/src/theme-normalize.ts +661 -0
- package/src/theme-ramp.ts +187 -0
- package/src/theme-render-sanitize.test.ts +45 -0
- package/src/theme-render.test.ts +119 -0
- package/src/theme-render.ts +487 -0
- package/src/theme-shadow.test.ts +56 -0
- package/src/themes-errors.ts +199 -0
- package/src/themes.ts +842 -0
- package/src/version.ts +66 -0
- package/dist/translation.d.ts +0 -127
- package/dist/translation.d.ts.map +0 -1
- package/dist/translation.js +0 -208
- 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
|
+
}
|