@jini-ai/cms 0.3.5 → 0.3.7
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/README.md +22 -17
- package/dist/content-types/agent-tools.js +1 -1
- package/dist/content-types/errors.d.ts +19 -1
- package/dist/content-types/errors.js +22 -1
- package/dist/content-types/index-provisioning.d.ts +56 -10
- package/dist/content-types/index-provisioning.js +86 -13
- package/dist/content-types/index.d.ts +15 -3
- package/dist/content-types/index.js +14 -2
- package/dist/content-types/lifecycle.js +3 -3
- package/dist/content-types/types.d.ts +64 -3
- package/dist/content-types/types.js +67 -3
- package/dist/content-types/write-service.d.ts +18 -3
- package/dist/content-types/write-service.js +28 -2
- package/dist/core/ports.d.ts +12 -2
- package/dist/entries/field-validation.js +96 -1
- package/dist/entries/write-service.d.ts +14 -0
- package/dist/entries/write-service.js +2 -2
- package/dist/identity/admin-crud-service.d.ts +28 -0
- package/dist/identity/admin-crud-service.js +30 -0
- package/dist/identity/auth-service.d.ts +32 -1
- package/dist/identity/auth-service.js +44 -15
- package/dist/identity/index.d.ts +4 -1
- package/dist/identity/index.js +4 -1
- package/dist/identity/seed.d.ts +14 -2
- package/dist/identity/seed.js +197 -45
- package/dist/media/agent-tools.d.ts +13 -3
- package/dist/media/agent-tools.js +45 -10
- package/dist/media/content-type-sniffer.d.ts +1 -1
- package/dist/media/content-type-sniffer.js +52 -4
- package/dist/media/html-attributes.d.ts +90 -0
- package/dist/media/html-attributes.js +161 -0
- package/dist/media/image-transformer.sharp.d.ts +35 -1
- package/dist/media/image-transformer.sharp.js +44 -2
- package/dist/media/index.d.ts +2 -1
- package/dist/media/index.js +2 -1
- package/dist/media/media-service.d.ts +62 -2
- package/dist/media/media-service.js +230 -7
- package/dist/media/ports.d.ts +6 -0
- package/dist/media/rendition-service.d.ts +7 -1
- package/dist/media/rendition-service.js +10 -4
- package/dist/media/repo.memory.d.ts +4 -0
- package/dist/media/repo.memory.js +3 -0
- package/dist/media/tool-registrations.d.ts +12 -0
- package/dist/media/tool-registrations.js +20 -12
- package/dist/media/types.d.ts +26 -0
- package/dist/navigation/agent-tools.js +8 -1
- package/dist/navigation/index.d.ts +10 -1
- package/dist/navigation/index.js +10 -1
- package/dist/navigation/menu-service.d.ts +60 -4
- package/dist/navigation/menu-service.js +106 -8
- package/dist/settings/dictionaries/index.d.ts +3 -3
- package/dist/settings/dictionaries/index.js +3 -3
- package/dist/taxonomy/agent-tools.d.ts +5 -4
- package/dist/taxonomy/agent-tools.js +33 -10
- package/dist/taxonomy/content-lookup.d.ts +35 -0
- package/dist/taxonomy/content-lookup.js +28 -0
- package/dist/taxonomy/index.d.ts +2 -2
- package/dist/taxonomy/index.js +2 -2
- package/dist/taxonomy/repo.memory.d.ts +11 -2
- package/dist/taxonomy/repo.memory.js +11 -0
- package/dist/taxonomy/write-service.d.ts +44 -15
- package/dist/taxonomy/write-service.js +60 -8
- package/package.json +3 -2
- package/dist/content-types/agent-tools.d.ts.map +0 -1
- package/dist/content-types/agent-tools.js.map +0 -1
- package/dist/content-types/cleanup.d.ts.map +0 -1
- package/dist/content-types/cleanup.js.map +0 -1
- package/dist/content-types/errors.d.ts.map +0 -1
- package/dist/content-types/errors.js.map +0 -1
- package/dist/content-types/field-defs.d.ts.map +0 -1
- package/dist/content-types/field-defs.js.map +0 -1
- package/dist/content-types/index-provisioning.d.ts.map +0 -1
- package/dist/content-types/index-provisioning.js.map +0 -1
- package/dist/content-types/index.d.ts.map +0 -1
- package/dist/content-types/index.js.map +0 -1
- package/dist/content-types/lifecycle-dispatch.d.ts.map +0 -1
- package/dist/content-types/lifecycle-dispatch.js.map +0 -1
- package/dist/content-types/lifecycle.d.ts.map +0 -1
- package/dist/content-types/lifecycle.js.map +0 -1
- package/dist/content-types/list.d.ts.map +0 -1
- package/dist/content-types/list.js.map +0 -1
- package/dist/content-types/repo.memory.d.ts.map +0 -1
- package/dist/content-types/repo.memory.js.map +0 -1
- package/dist/content-types/tool-registrations.d.ts.map +0 -1
- package/dist/content-types/tool-registrations.js.map +0 -1
- package/dist/content-types/types.d.ts.map +0 -1
- package/dist/content-types/types.js.map +0 -1
- package/dist/content-types/write-service.d.ts.map +0 -1
- package/dist/content-types/write-service.js.map +0 -1
- package/dist/core/commands/change-set.d.ts.map +0 -1
- package/dist/core/commands/change-set.js.map +0 -1
- package/dist/core/commands/command.d.ts.map +0 -1
- package/dist/core/commands/command.js.map +0 -1
- package/dist/core/index.d.ts.map +0 -1
- package/dist/core/index.js.map +0 -1
- package/dist/core/ports.d.ts.map +0 -1
- package/dist/core/ports.js.map +0 -1
- package/dist/core/tools/registration-kit.d.ts.map +0 -1
- package/dist/core/tools/registration-kit.js.map +0 -1
- package/dist/entries/agent-tools.d.ts.map +0 -1
- package/dist/entries/agent-tools.js.map +0 -1
- package/dist/entries/errors.d.ts.map +0 -1
- package/dist/entries/errors.js.map +0 -1
- package/dist/entries/field-validation.d.ts.map +0 -1
- package/dist/entries/field-validation.js.map +0 -1
- package/dist/entries/index.d.ts.map +0 -1
- package/dist/entries/index.js.map +0 -1
- package/dist/entries/lifecycle-dispatch.d.ts.map +0 -1
- package/dist/entries/lifecycle-dispatch.js.map +0 -1
- package/dist/entries/list.d.ts.map +0 -1
- package/dist/entries/list.js.map +0 -1
- package/dist/entries/repo.memory.d.ts.map +0 -1
- package/dist/entries/repo.memory.js.map +0 -1
- package/dist/entries/tool-registrations.d.ts.map +0 -1
- package/dist/entries/tool-registrations.js.map +0 -1
- package/dist/entries/types.d.ts.map +0 -1
- package/dist/entries/types.js.map +0 -1
- package/dist/entries/write-service.d.ts.map +0 -1
- package/dist/entries/write-service.js.map +0 -1
- package/dist/identity/admin-crud-service.d.ts.map +0 -1
- package/dist/identity/admin-crud-service.js.map +0 -1
- package/dist/identity/agent-tool-input.d.ts.map +0 -1
- package/dist/identity/agent-tool-input.js.map +0 -1
- package/dist/identity/agent-tools.d.ts.map +0 -1
- package/dist/identity/agent-tools.js.map +0 -1
- package/dist/identity/auth-service.d.ts.map +0 -1
- package/dist/identity/auth-service.js.map +0 -1
- package/dist/identity/authorize.d.ts.map +0 -1
- package/dist/identity/authorize.js.map +0 -1
- package/dist/identity/grant-service.d.ts.map +0 -1
- package/dist/identity/grant-service.js.map +0 -1
- package/dist/identity/hasher.d.ts.map +0 -1
- package/dist/identity/hasher.js.map +0 -1
- package/dist/identity/index.d.ts.map +0 -1
- package/dist/identity/index.js.map +0 -1
- package/dist/identity/password-policy.d.ts.map +0 -1
- package/dist/identity/password-policy.js.map +0 -1
- package/dist/identity/permission-migrations.d.ts.map +0 -1
- package/dist/identity/permission-migrations.js.map +0 -1
- package/dist/identity/permissions.d.ts.map +0 -1
- package/dist/identity/permissions.js.map +0 -1
- package/dist/identity/ports.d.ts.map +0 -1
- package/dist/identity/ports.js.map +0 -1
- package/dist/identity/repo.memory.d.ts.map +0 -1
- package/dist/identity/repo.memory.js.map +0 -1
- package/dist/identity/seed.d.ts.map +0 -1
- package/dist/identity/seed.js.map +0 -1
- package/dist/identity/tool-registrations.d.ts.map +0 -1
- package/dist/identity/tool-registrations.js.map +0 -1
- package/dist/identity/types.d.ts.map +0 -1
- package/dist/identity/types.js.map +0 -1
- package/dist/identity/username.d.ts.map +0 -1
- package/dist/identity/username.js.map +0 -1
- package/dist/media/agent-tools.d.ts.map +0 -1
- package/dist/media/agent-tools.js.map +0 -1
- package/dist/media/blob-gc-lock.d.ts.map +0 -1
- package/dist/media/blob-gc-lock.js.map +0 -1
- package/dist/media/blob-gc.d.ts.map +0 -1
- package/dist/media/blob-gc.js.map +0 -1
- package/dist/media/blob-key.d.ts.map +0 -1
- package/dist/media/blob-key.js.map +0 -1
- package/dist/media/blob-store.fs.d.ts.map +0 -1
- package/dist/media/blob-store.fs.js.map +0 -1
- package/dist/media/blob-store.memory.d.ts.map +0 -1
- package/dist/media/blob-store.memory.js.map +0 -1
- package/dist/media/content-type-sniffer.d.ts.map +0 -1
- package/dist/media/content-type-sniffer.js.map +0 -1
- package/dist/media/image-transformer.d.ts.map +0 -1
- package/dist/media/image-transformer.js.map +0 -1
- package/dist/media/image-transformer.sharp.d.ts.map +0 -1
- package/dist/media/image-transformer.sharp.js.map +0 -1
- package/dist/media/index.d.ts.map +0 -1
- package/dist/media/index.js.map +0 -1
- package/dist/media/media-service.d.ts.map +0 -1
- package/dist/media/media-service.js.map +0 -1
- package/dist/media/ports.d.ts.map +0 -1
- package/dist/media/ports.js.map +0 -1
- package/dist/media/rendition-service.d.ts.map +0 -1
- package/dist/media/rendition-service.js.map +0 -1
- package/dist/media/repo.memory.d.ts.map +0 -1
- package/dist/media/repo.memory.js.map +0 -1
- package/dist/media/tool-registrations.d.ts.map +0 -1
- package/dist/media/tool-registrations.js.map +0 -1
- package/dist/media/transform-lock.d.ts.map +0 -1
- package/dist/media/transform-lock.js.map +0 -1
- package/dist/media/transform-registry.d.ts.map +0 -1
- package/dist/media/transform-registry.js.map +0 -1
- package/dist/media/transform-types.d.ts.map +0 -1
- package/dist/media/transform-types.js.map +0 -1
- package/dist/media/types.d.ts.map +0 -1
- package/dist/media/types.js.map +0 -1
- package/dist/navigation/agent-tools.d.ts.map +0 -1
- package/dist/navigation/agent-tools.js.map +0 -1
- package/dist/navigation/contracts.d.ts.map +0 -1
- package/dist/navigation/contracts.js.map +0 -1
- package/dist/navigation/index.d.ts.map +0 -1
- package/dist/navigation/index.js.map +0 -1
- package/dist/navigation/menu-service.d.ts.map +0 -1
- package/dist/navigation/menu-service.js.map +0 -1
- package/dist/navigation/ports.d.ts.map +0 -1
- package/dist/navigation/ports.js.map +0 -1
- package/dist/navigation/read-model.d.ts.map +0 -1
- package/dist/navigation/read-model.js.map +0 -1
- package/dist/navigation/reconcile.d.ts.map +0 -1
- package/dist/navigation/reconcile.js.map +0 -1
- package/dist/navigation/repo.memory.d.ts.map +0 -1
- package/dist/navigation/repo.memory.js.map +0 -1
- package/dist/navigation/resolver.d.ts.map +0 -1
- package/dist/navigation/resolver.js.map +0 -1
- package/dist/navigation/tool-registrations.d.ts.map +0 -1
- package/dist/navigation/tool-registrations.js.map +0 -1
- package/dist/navigation/types.d.ts.map +0 -1
- package/dist/navigation/types.js.map +0 -1
- package/dist/presentation/index.d.ts.map +0 -1
- package/dist/presentation/index.js.map +0 -1
- package/dist/presentation/presentation.d.ts.map +0 -1
- package/dist/presentation/presentation.js.map +0 -1
- package/dist/presentation/repo.memory.d.ts.map +0 -1
- package/dist/presentation/repo.memory.js.map +0 -1
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/settings/agent-tools.d.ts.map +0 -1
- package/dist/settings/agent-tools.js.map +0 -1
- package/dist/settings/agent-writable-preferences.d.ts.map +0 -1
- package/dist/settings/agent-writable-preferences.js.map +0 -1
- package/dist/settings/change-feed.d.ts.map +0 -1
- package/dist/settings/change-feed.js.map +0 -1
- package/dist/settings/definitions-dispatch.d.ts.map +0 -1
- package/dist/settings/definitions-dispatch.js.map +0 -1
- package/dist/settings/dictionaries/index.d.ts.map +0 -1
- package/dist/settings/dictionaries/index.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ar.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ar.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.bn.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.de.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.es.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fa.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.fr.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hi.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.hu.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.id.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.it.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ja.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ko.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pl.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.pt-BR.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ru.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.th.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.tr.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.uk.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.ur.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-CN.js.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts.map +0 -1
- package/dist/settings/dictionaries/settings-dialog.zh-TW.js.map +0 -1
- package/dist/settings/ensure-definitions.d.ts.map +0 -1
- package/dist/settings/ensure-definitions.js.map +0 -1
- package/dist/settings/errors.d.ts.map +0 -1
- package/dist/settings/errors.js.map +0 -1
- package/dist/settings/index.d.ts.map +0 -1
- package/dist/settings/index.js.map +0 -1
- package/dist/settings/ports.d.ts.map +0 -1
- package/dist/settings/ports.js.map +0 -1
- package/dist/settings/purge-service.d.ts.map +0 -1
- package/dist/settings/purge-service.js.map +0 -1
- package/dist/settings/repo.memory.d.ts.map +0 -1
- package/dist/settings/repo.memory.js.map +0 -1
- package/dist/settings/settings.d.ts.map +0 -1
- package/dist/settings/settings.js.map +0 -1
- package/dist/settings/tool-registrations.d.ts.map +0 -1
- package/dist/settings/tool-registrations.js.map +0 -1
- package/dist/settings/types.d.ts.map +0 -1
- package/dist/settings/types.js.map +0 -1
- package/dist/settings/ui-tab-definitions.d.ts.map +0 -1
- package/dist/settings/ui-tab-definitions.js.map +0 -1
- package/dist/settings/write-service.d.ts.map +0 -1
- package/dist/settings/write-service.js.map +0 -1
- package/dist/taxonomy/agent-tools.d.ts.map +0 -1
- package/dist/taxonomy/agent-tools.js.map +0 -1
- package/dist/taxonomy/content-lookup.d.ts.map +0 -1
- package/dist/taxonomy/content-lookup.js.map +0 -1
- package/dist/taxonomy/index.d.ts.map +0 -1
- package/dist/taxonomy/index.js.map +0 -1
- package/dist/taxonomy/list.d.ts.map +0 -1
- package/dist/taxonomy/list.js.map +0 -1
- package/dist/taxonomy/merge-term.d.ts.map +0 -1
- package/dist/taxonomy/merge-term.js.map +0 -1
- package/dist/taxonomy/repo.memory.d.ts.map +0 -1
- package/dist/taxonomy/repo.memory.js.map +0 -1
- package/dist/taxonomy/validation-chain.d.ts.map +0 -1
- package/dist/taxonomy/validation-chain.js.map +0 -1
- package/dist/taxonomy/write-service.d.ts.map +0 -1
- package/dist/taxonomy/write-service.js.map +0 -1
- package/dist/workspace/agent-tools.d.ts.map +0 -1
- package/dist/workspace/agent-tools.js.map +0 -1
- package/dist/workspace/create.d.ts.map +0 -1
- package/dist/workspace/create.js.map +0 -1
- package/dist/workspace/delete.d.ts.map +0 -1
- package/dist/workspace/delete.js.map +0 -1
- package/dist/workspace/index.d.ts.map +0 -1
- package/dist/workspace/index.js.map +0 -1
- package/dist/workspace/repo.memory.d.ts.map +0 -1
- package/dist/workspace/repo.memory.js.map +0 -1
- package/dist/workspace/tool-registrations.d.ts.map +0 -1
- package/dist/workspace/tool-registrations.js.map +0 -1
- package/dist/workspace/update.d.ts.map +0 -1
- package/dist/workspace/update.js.map +0 -1
|
@@ -54,21 +54,46 @@ import { createHash } from "node:crypto";
|
|
|
54
54
|
import { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, } from "./types.js";
|
|
55
55
|
import { withSha256Lock } from "./blob-gc-lock.js";
|
|
56
56
|
import { tombstoneBlobIfUnreferenced } from "./blob-gc.js";
|
|
57
|
+
import { describeMediaHtmlAttributeError, parseMediaHtmlAttributes } from "./html-attributes.js";
|
|
57
58
|
export { MediaConflictError, MediaNotFoundError, MediaSourceImmutableError, MediaStillReferencedError, MediaValidationError, };
|
|
58
59
|
/** Default byte-size cap (this pass's stand-in for the real ingress policy's streaming cap). */
|
|
59
60
|
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MiB
|
|
61
|
+
/**
|
|
62
|
+
* Renders a byte count as a human-readable MB figure for the size-cap rejection below (2026-09-21).
|
|
63
|
+
* Every real caller passes a MiB-scale `maxUploadBytes` (10 MiB default, or a host override such as
|
|
64
|
+
* Tovu's 50 MiB), so one decimal place is enough precision without ever showing a raw byte count —
|
|
65
|
+
* the whole point of this fix (see `uploadMedia`'s size-cap check, and `agent-tools.ts`'s file header
|
|
66
|
+
* for why the catalog description can no longer state a specific number either). `toFixed(1)`'s
|
|
67
|
+
* trailing `.0` is trimmed so a clean multiple of 1 MB (the common case) reads as `10 MB`, not `10.0
|
|
68
|
+
* MB`.
|
|
69
|
+
*
|
|
70
|
+
* @complexity O(1).
|
|
71
|
+
*/
|
|
72
|
+
function formatMaxUploadBytesAsMb(bytes) {
|
|
73
|
+
const mb = bytes / (1024 * 1024);
|
|
74
|
+
const rounded = mb.toFixed(1);
|
|
75
|
+
return `${rounded.endsWith(".0") ? rounded.slice(0, -2) : rounded} MB`;
|
|
76
|
+
}
|
|
60
77
|
/**
|
|
61
78
|
* Advisory MIME allowlist. SVG is deliberately EXCLUDED (not "TODO, forgot") —
|
|
62
79
|
* SVG must be sanitized at ingest before it's safe to store;
|
|
63
80
|
* that sanitizer is not built in this pass, so SVG upload is rejected rather
|
|
64
81
|
* than accepted unsanitized.
|
|
65
82
|
*
|
|
83
|
+
* `image/avif` (owner-directed, 2026-09-06): a fifth still-image type, on the
|
|
84
|
+
* same footing as the four above — `content-type-sniffer.ts` identifies it by
|
|
85
|
+
* ISO-BMFF brand and the installed `sharp`/libvips build decodes it, so it
|
|
86
|
+
* flows through the ordinary transform/rendition pipeline and is re-encoded
|
|
87
|
+
* like any other image. Adding it required a matching sniffer fix, not just
|
|
88
|
+
* this line: AVIF shares MP4's `ftyp` container tag, so before that fix an
|
|
89
|
+
* AVIF was sniffed as `video/mp4` and served as an unplayable video.
|
|
90
|
+
*
|
|
66
91
|
* `video/mp4`/`video/webm` (owner-directed, 2026-08-24): the two formats
|
|
67
92
|
* `content-type-sniffer.ts` already recognizes by magic bytes. Unlike the four
|
|
68
93
|
* image types above, an accepted video is never re-encoded — there is no
|
|
69
94
|
* `TransformFormat` for video (`transform-types.ts`'s union is image-only), so
|
|
70
95
|
* a video asset's public URL bypasses the transform/rendition pipeline
|
|
71
|
-
* entirely and serves the original bytes as-is (host concern; see
|
|
96
|
+
* entirely and serves the original bytes as-is (host concern; see the host's
|
|
72
97
|
* `routes/site/media-rendition.ts`). This widens the SAME advisory,
|
|
73
98
|
* client-declared-string check item 4 above already discloses as untrusted —
|
|
74
99
|
* no new ingress hardening was added for video.
|
|
@@ -78,6 +103,7 @@ export const DEFAULT_ALLOWED_MIME_TYPES = new Set([
|
|
|
78
103
|
"image/png",
|
|
79
104
|
"image/webp",
|
|
80
105
|
"image/gif",
|
|
106
|
+
"image/avif",
|
|
81
107
|
"video/mp4",
|
|
82
108
|
"video/webm",
|
|
83
109
|
]);
|
|
@@ -108,6 +134,103 @@ function deriveTitleFromFilename(filename) {
|
|
|
108
134
|
const base = filename.trim().replace(/\.[^./\\]+$/, "");
|
|
109
135
|
return base.trim() || "Untitled";
|
|
110
136
|
}
|
|
137
|
+
/** Shared slug-format rule (2026-09-07) — same shape as `post`'s `SLUG_FORMAT_PATTERN`: lowercase
|
|
138
|
+
* letters, digits, and dashes only. Media slugs have no reserved-word list (`post`'s `admin`/`api`
|
|
139
|
+
* exclusions exist because a post slug can become a literal URL path segment a route dispatches
|
|
140
|
+
* on; a media slug is only ever a lookup key, never a route itself, so that hazard doesn't apply). */
|
|
141
|
+
const MEDIA_SLUG_FORMAT_PATTERN = /^[a-z0-9-]+$/;
|
|
142
|
+
/**
|
|
143
|
+
* Slugs that spell a UUID are refused outright (2026-09-07 fix).
|
|
144
|
+
*
|
|
145
|
+
* A lowercase UUID is `[a-z0-9-]+` character for character, so {@link MEDIA_SLUG_FORMAT_PATTERN}
|
|
146
|
+
* happily accepted one — and {@link findMediaByIdOrSlug} used to resolve SLUG FIRST, so pointing one
|
|
147
|
+
* asset's slug at another asset's `id` silently took over every reference already authored against
|
|
148
|
+
* that id (every `/m/{id}/…` URL a host's renderer has ever emitted for the victim). That resolution
|
|
149
|
+
* order is now id-first, which closes the hijack for rows that already carry such a slug; this rule
|
|
150
|
+
* is the write-path half — a slug that can only ever shadow an id is not a slug anyone wants, so it
|
|
151
|
+
* is refused where a human can still see why rather than silently shadowed at read time.
|
|
152
|
+
*
|
|
153
|
+
* Shape-based, not a lookup: it rejects EVERY UUID-shaped value, not only ones that currently
|
|
154
|
+
* collide, so a slug cannot become a hijack later when some future upload happens to mint that id.
|
|
155
|
+
* Ordinary hex-and-dash slugs (`abc-123-def`, `2026-09-07-launch-clip`) are unaffected — nothing but
|
|
156
|
+
* the exact 8-4-4-4-12 hex grouping matches.
|
|
157
|
+
*/
|
|
158
|
+
const MEDIA_SLUG_UUID_SHAPE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
159
|
+
/** Shared slug max-length rule (2026-09-07) — same bound as `post.ts`'s `MAX_SLUG_LENGTH`, reused
|
|
160
|
+
* rather than inventing a second number. `post.ts` only enforces this on its EXPLICIT/caller-
|
|
161
|
+
* supplied slug path (`resolveExplicitSlug`, reject-with-error); its own title-derived path is
|
|
162
|
+
* uncapped. Media applies the same bound to BOTH paths, but differently per path's nature: an
|
|
163
|
+
* explicit edit ({@link resolveSlugForUpdate}) rejects with an error, exactly like `post.ts`,
|
|
164
|
+
* because a human typed it and should see why it was refused; a derived slug
|
|
165
|
+
* ({@link deriveUniqueMediaSlug}) truncates instead of rejecting, because nothing is prompting a
|
|
166
|
+
* human for a shorter value — silently truncating an internally-derived value is the same
|
|
167
|
+
* "coerce, don't fail" convention `deriveTitleFromFilename`/`slugifyMediaTitle` already use. */
|
|
168
|
+
const MEDIA_MAX_SLUG_LENGTH = 120;
|
|
169
|
+
export function isValidMediaSlugFormat(slug) {
|
|
170
|
+
return MEDIA_SLUG_FORMAT_PATTERN.test(slug) && !MEDIA_SLUG_UUID_SHAPE_PATTERN.test(slug);
|
|
171
|
+
}
|
|
172
|
+
/** Truncates a slug candidate to `maxLength`, stripping a trailing dash the cut can introduce (so a
|
|
173
|
+
* truncation never leaves a slug ending mid-word with a dangling "-"), falling back to `"untitled"`
|
|
174
|
+
* if nothing legible survives (only reachable when `maxLength` itself is tiny, e.g. while reserving
|
|
175
|
+
* room for a long numeric suffix — see {@link deriveUniqueMediaSlug}). A no-op when `candidate` is
|
|
176
|
+
* already within bounds. */
|
|
177
|
+
function capSlugCandidate(candidate, maxLength) {
|
|
178
|
+
if (candidate.length <= maxLength)
|
|
179
|
+
return candidate;
|
|
180
|
+
return candidate.slice(0, Math.max(maxLength, 0)).replace(/-+$/, "") || "untitled";
|
|
181
|
+
}
|
|
182
|
+
/** Turns free text into a slug candidate: lowercase, non-alphanumeric runs collapsed to one dash,
|
|
183
|
+
* leading/trailing dashes trimmed. Same algorithm as `post.ts`'s private `slugify` (duplicated, not
|
|
184
|
+
* imported — cross-repo: `post.ts` lives in the host, this file in `@jini-ai/cms`; this codebase's own
|
|
185
|
+
* precedent for a small hand-copied helper mirrored across a repo boundary is `DEFAULT_ALLOWED_MIME_TYPES`
|
|
186
|
+
* vs. the host's `FILE_HANDLER_ALLOWED_MIME_TYPES`/`IMPORTABLE_CONTENT_TYPES`). Never throws and never
|
|
187
|
+
* returns `undefined` — an all-punctuation input collapses to `""`, which every caller here treats
|
|
188
|
+
* as "derive nothing, fall back to a fixed default" rather than a malformed-input error. */
|
|
189
|
+
function slugifyMediaTitle(title) {
|
|
190
|
+
return title
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.trim()
|
|
193
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
194
|
+
.replace(/^-+|-+$/g, "");
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Derives a unique-per-workspace slug from `title`, suffixing `-2`, `-3`, … on collision — the
|
|
198
|
+
* identical loop shape `post.ts`'s `createPost` uses for its own derived-slug path. Shared by
|
|
199
|
+
* {@link uploadMedia} (derive-on-create) and the backfill a host runs once for pre-existing rows
|
|
200
|
+
* with no slug yet (see the host's `development/scripts/backfill-media-slugs.ts`).
|
|
201
|
+
*
|
|
202
|
+
* `base` falls back to `"untitled"` when `title` slugifies to the empty string (all-punctuation or
|
|
203
|
+
* non-Latin titles that `slugifyMediaTitle` strips to nothing) — `uploadMedia`'s own title is never
|
|
204
|
+
* empty (`deriveTitleFromFilename` guarantees a non-empty string), but the backfill script
|
|
205
|
+
* (`development/scripts/backfill-media-slugs.ts`, host-side) calls this against arbitrary pre-existing
|
|
206
|
+
* titles, so this fallback is load-bearing there, not just a defensive floor. Note this is the SAME
|
|
207
|
+
* fallback base every empty-slugifying title collapses onto, so two such titles in one workspace
|
|
208
|
+
* collide on `"untitled"` and are disambiguated by the ordinary suffix loop below exactly like any
|
|
209
|
+
* other collision — an empty derived slug is never inserted as a bare empty string (which a unique
|
|
210
|
+
* index would treat as one specific value, not "no value," and a SECOND empty string would then
|
|
211
|
+
* violate it outright rather than surfacing a friendly conflict).
|
|
212
|
+
*
|
|
213
|
+
* `base` (after the empty-string fallback) is also capped at {@link MEDIA_MAX_SLUG_LENGTH} via
|
|
214
|
+
* {@link capSlugCandidate} before any suffix is considered — a long machine-generated title must not
|
|
215
|
+
* produce an equally long slug. When a collision forces a suffix, the BASE (never the suffix itself)
|
|
216
|
+
* is re-truncated just enough to keep the whole `base-suffix` candidate within the cap, so every
|
|
217
|
+
* candidate this loop ever checks already obeys the length bound, not just the first one.
|
|
218
|
+
*
|
|
219
|
+
* @complexity O(n) repo round-trips in the worst case, where n is the number of prior collisions on
|
|
220
|
+
* the same base slug — bounded in practice by how many same-titled uploads exist in one workspace.
|
|
221
|
+
*/
|
|
222
|
+
async function deriveUniqueMediaSlug(mediaRepo, workspaceId, title) {
|
|
223
|
+
const base = capSlugCandidate(slugifyMediaTitle(title) || "untitled", MEDIA_MAX_SLUG_LENGTH);
|
|
224
|
+
let slug = base;
|
|
225
|
+
let suffix = 1;
|
|
226
|
+
while (await mediaRepo.findBySlug({ workspaceId, slug })) {
|
|
227
|
+
suffix += 1;
|
|
228
|
+
const suffixText = `-${suffix}`;
|
|
229
|
+
const truncatedBase = capSlugCandidate(base, MEDIA_MAX_SLUG_LENGTH - suffixText.length);
|
|
230
|
+
slug = `${truncatedBase}${suffixText}`;
|
|
231
|
+
}
|
|
232
|
+
return slug;
|
|
233
|
+
}
|
|
111
234
|
/**
|
|
112
235
|
* Validates, hashes, dedups by `(workspaceId, sha256)`, and writes a new media
|
|
113
236
|
* asset. Ordering (INV-1a): bytes are written (or found already
|
|
@@ -126,7 +249,8 @@ function deriveTitleFromFilename(filename) {
|
|
|
126
249
|
*
|
|
127
250
|
* @complexity O(1) — one hash, one blob lookup, at most one blob write, one
|
|
128
251
|
* media write, one rendition write (the lock itself adds O(1) scheduling
|
|
129
|
-
* overhead, not a scan)
|
|
252
|
+
* overhead, not a scan) — plus {@link deriveUniqueMediaSlug}'s own O(n) in the number of prior
|
|
253
|
+
* same-base-slug collisions (see that function's doc).
|
|
130
254
|
* @overallScore 100
|
|
131
255
|
*/
|
|
132
256
|
export async function uploadMedia(required, optional = {}) {
|
|
@@ -140,7 +264,7 @@ export async function uploadMedia(required, optional = {}) {
|
|
|
140
264
|
throw new MediaValidationError("uploaded file is empty");
|
|
141
265
|
}
|
|
142
266
|
if (input.bytes.byteLength > maxUploadBytes) {
|
|
143
|
-
throw new MediaValidationError(`uploaded file exceeds the ${maxUploadBytes}
|
|
267
|
+
throw new MediaValidationError(`uploaded file exceeds the ${formatMaxUploadBytesAsMb(maxUploadBytes)} size cap`);
|
|
144
268
|
}
|
|
145
269
|
const sha256 = createHash("sha256").update(input.bytes).digest("hex");
|
|
146
270
|
const nowIso = deps.clock.nowIso();
|
|
@@ -170,10 +294,13 @@ export async function uploadMedia(required, optional = {}) {
|
|
|
170
294
|
});
|
|
171
295
|
return written.storageKey;
|
|
172
296
|
});
|
|
297
|
+
const title = deriveTitleFromFilename(input.filename);
|
|
298
|
+
const slug = await deriveUniqueMediaSlug(deps.mediaRepo, input.workspaceId, title);
|
|
173
299
|
const media = {
|
|
174
300
|
id: deps.idGen.newId(),
|
|
175
301
|
workspaceId: input.workspaceId,
|
|
176
|
-
title
|
|
302
|
+
title,
|
|
303
|
+
slug,
|
|
177
304
|
alt: input.alt?.trim() ?? "",
|
|
178
305
|
caption: input.caption?.trim() ?? "",
|
|
179
306
|
credit: input.credit?.trim() ?? "",
|
|
@@ -187,6 +314,7 @@ export async function uploadMedia(required, optional = {}) {
|
|
|
187
314
|
width: null,
|
|
188
315
|
height: null,
|
|
189
316
|
cssClass: null,
|
|
317
|
+
htmlAttributes: null,
|
|
190
318
|
};
|
|
191
319
|
await deps.mediaRepo.save(media);
|
|
192
320
|
await deps.renditionRepo.save({
|
|
@@ -228,12 +356,45 @@ export async function getMediaById(required, _optional = {}) {
|
|
|
228
356
|
return { media };
|
|
229
357
|
}
|
|
230
358
|
/**
|
|
231
|
-
*
|
|
232
|
-
*
|
|
359
|
+
* Resolves a media asset by either its id or its slug — ID FIRST, slug second. The slug is the
|
|
360
|
+
* handle a human typed into an embed marker or a hand-authored `<img>`/`<video>` tag; the id is the
|
|
361
|
+
* opaque UUID every existing reference already uses, and every URL a host's renderer emits.
|
|
362
|
+
*
|
|
363
|
+
* THE ORDER IS A SECURITY PROPERTY, not a preference (2026-09-07). This resolved slug-first, on the
|
|
364
|
+
* documented assumption that "an id never collides with a slug in practice since slugs pass through
|
|
365
|
+
* {@link isValidMediaSlugFormat}". That assumption was false: a lowercase UUID matched the old slug
|
|
366
|
+
* pattern `[a-z0-9-]+` exactly, so setting asset B's slug to asset A's id made every `/m/{A.id}/…`
|
|
367
|
+
* URL — the ones a renderer emitted for A, already live in published pages — resolve to B instead.
|
|
368
|
+
* An id is minted by the system and is never a value a human chooses, so it must always win; the
|
|
369
|
+
* write path additionally refuses UUID-shaped slugs now (see
|
|
370
|
+
* {@link MEDIA_SLUG_UUID_SHAPE_PATTERN}), but ordering is what protects rows written before that
|
|
371
|
+
* rule existed. Id-first costs nothing on the slug path: a real slug simply misses the id lookup and
|
|
372
|
+
* falls through.
|
|
373
|
+
*
|
|
374
|
+
* Never throws (unlike {@link getMediaById}): every call site that needs this (an embed resolver, a
|
|
375
|
+
* public rendition route) already has its own REQ-27-style non-throwing-miss contract, so this
|
|
376
|
+
* returns `null` on failure and lets the caller apply its own not-found handling rather than forcing
|
|
377
|
+
* one shape on every caller.
|
|
378
|
+
*
|
|
379
|
+
* @complexity O(1) — at most two indexed repo lookups, short-circuited on the first hit.
|
|
380
|
+
*/
|
|
381
|
+
export async function findMediaByIdOrSlug(required, _optional = {}) {
|
|
382
|
+
const { deps, input } = required;
|
|
383
|
+
const byId = await deps.mediaRepo.findById({ workspaceId: input.workspaceId, id: input.idOrSlug });
|
|
384
|
+
if (byId)
|
|
385
|
+
return byId;
|
|
386
|
+
return deps.mediaRepo.findBySlug({ workspaceId: input.workspaceId, slug: input.idOrSlug });
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Updates editorial-only fields (title/alt/caption/credit/slug/width/height/cssClass).
|
|
390
|
+
* `source.sha256` is write-once — this function's input type has no `sha256` field,
|
|
233
391
|
* so there is no code path here that can touch it (see `resolveWriteOnceSource`
|
|
234
392
|
* doc for the directly-tested invariant this relies on).
|
|
235
393
|
*
|
|
236
|
-
*
|
|
394
|
+
* `slug` (2026-09-07) is validated and uniqueness-checked by {@link resolveSlugForUpdate} when
|
|
395
|
+
* provided; every other field keeps its pre-existing undefined-means-unchanged contract.
|
|
396
|
+
*
|
|
397
|
+
* @complexity O(1) plus {@link resolveSlugForUpdate}'s own O(1) when `input.slug` is provided.
|
|
237
398
|
* @overallScore 100
|
|
238
399
|
*/
|
|
239
400
|
/** Validates a `width`/`height` override: must be a positive integer. `null` (explicit clear) and
|
|
@@ -243,6 +404,54 @@ function assertPositiveIntegerOrThrow(value, field) {
|
|
|
243
404
|
throw new MediaValidationError(`media.${field} must be a positive integer, got ${value}`);
|
|
244
405
|
}
|
|
245
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* Validates and normalizes an explicit `slug` edit, then enforces per-workspace uniqueness against
|
|
409
|
+
* every OTHER row — an app-level courtesy check, not the enforcement itself (see `MediaRecord.slug`'s
|
|
410
|
+
* doc): the host's DB unique index is what actually prevents two rows from landing on the same slug
|
|
411
|
+
* under a concurrent write; this check only exists so a normal, non-racing caller sees a clear
|
|
412
|
+
* `MediaConflictError` naming the conflict instead of a raw constraint-violation message surfacing
|
|
413
|
+
* from whatever the host's repo adapter throws. Mirrors `post.ts`'s `assertSlugAvailableForUpdate`
|
|
414
|
+
* (same "claiming your own current slug is not a conflict" rule).
|
|
415
|
+
*
|
|
416
|
+
* @complexity O(1) — one format check, one uniqueness lookup.
|
|
417
|
+
*/
|
|
418
|
+
async function resolveSlugForUpdate(mediaRepo, workspaceId, id, rawSlug) {
|
|
419
|
+
const slug = rawSlug.trim().toLowerCase();
|
|
420
|
+
if (!slug || !isValidMediaSlugFormat(slug)) {
|
|
421
|
+
throw new MediaValidationError("slug must use lowercase letters, numbers, and dashes, and must not be shaped like a UUID");
|
|
422
|
+
}
|
|
423
|
+
// Same bound and rejection style as `post.ts`'s `resolveExplicitSlug` — a human typed this value,
|
|
424
|
+
// so it is refused with a clear reason rather than silently truncated (unlike the derived-on-
|
|
425
|
+
// upload path, see `MEDIA_MAX_SLUG_LENGTH`'s doc).
|
|
426
|
+
if (slug.length > MEDIA_MAX_SLUG_LENGTH) {
|
|
427
|
+
throw new MediaValidationError(`slug must be ${MEDIA_MAX_SLUG_LENGTH} characters or fewer`);
|
|
428
|
+
}
|
|
429
|
+
const duplicate = await mediaRepo.findBySlug({ workspaceId, slug });
|
|
430
|
+
if (duplicate && duplicate.id !== id) {
|
|
431
|
+
throw new MediaConflictError(`slug '${slug}' is already used by media '${duplicate.id}'`);
|
|
432
|
+
}
|
|
433
|
+
return slug;
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Validates an explicit `htmlAttributes` edit against `html-attributes.ts`'s allowlist — a stored-
|
|
437
|
+
* XSS boundary, not a syntax convenience (see `MediaRecord.htmlAttributes`'s own doc). Throws
|
|
438
|
+
* `MediaValidationError` naming the exact rejected attribute the moment `parseMediaHtmlAttributes`
|
|
439
|
+
* reports one; the caller (`updateMediaMetadata`) must never reach its own `save()` call when this
|
|
440
|
+
* throws, so an invalid value is never partially or fully persisted. A value that validates but
|
|
441
|
+
* trims to empty stores as `null`, matching `cssClass`'s identical "empty means unset" convention.
|
|
442
|
+
*
|
|
443
|
+
* @complexity O(n) in the input string's length (one `parseMediaHtmlAttributes` pass).
|
|
444
|
+
*/
|
|
445
|
+
function resolveHtmlAttributesForUpdate(rawValue) {
|
|
446
|
+
const trimmed = rawValue.trim();
|
|
447
|
+
if (trimmed === "")
|
|
448
|
+
return null;
|
|
449
|
+
const parsed = parseMediaHtmlAttributes(trimmed);
|
|
450
|
+
if (parsed.error) {
|
|
451
|
+
throw new MediaValidationError(`media.htmlAttributes: ${describeMediaHtmlAttributeError(parsed.error)}`);
|
|
452
|
+
}
|
|
453
|
+
return trimmed;
|
|
454
|
+
}
|
|
246
455
|
export async function updateMediaMetadata(required, _optional = {}) {
|
|
247
456
|
const { deps, input } = required;
|
|
248
457
|
const existing = await deps.mediaRepo.findById({ workspaceId: input.workspaceId, id: input.id });
|
|
@@ -252,15 +461,29 @@ export async function updateMediaMetadata(required, _optional = {}) {
|
|
|
252
461
|
assertPositiveIntegerOrThrow(input.width, "width");
|
|
253
462
|
if (input.height !== undefined && input.height !== null)
|
|
254
463
|
assertPositiveIntegerOrThrow(input.height, "height");
|
|
464
|
+
const slug = input.slug !== undefined
|
|
465
|
+
? await resolveSlugForUpdate(deps.mediaRepo, input.workspaceId, input.id, input.slug)
|
|
466
|
+
: existing.slug;
|
|
467
|
+
// Validated BEFORE the record is built (same "fail before any write" discipline `assertPositive
|
|
468
|
+
// IntegerOrThrow` above already follows) — an invalid value must never reach `save()`.
|
|
469
|
+
const htmlAttributes = input.htmlAttributes !== undefined
|
|
470
|
+
? input.htmlAttributes === null
|
|
471
|
+
? null
|
|
472
|
+
: resolveHtmlAttributesForUpdate(input.htmlAttributes)
|
|
473
|
+
: existing.htmlAttributes;
|
|
255
474
|
const media = {
|
|
256
475
|
...existing,
|
|
476
|
+
// `title` and `slug` are deliberately independent (see `MediaRecord.slug`'s doc): renaming the
|
|
477
|
+
// title never recomputes `slug`, and editing `slug` never touches `title`.
|
|
257
478
|
title: input.title !== undefined ? input.title.trim() || existing.title : existing.title,
|
|
479
|
+
slug,
|
|
258
480
|
alt: input.alt !== undefined ? input.alt.trim() : existing.alt,
|
|
259
481
|
caption: input.caption !== undefined ? input.caption.trim() : existing.caption,
|
|
260
482
|
credit: input.credit !== undefined ? input.credit.trim() : existing.credit,
|
|
261
483
|
width: input.width !== undefined ? input.width : existing.width,
|
|
262
484
|
height: input.height !== undefined ? input.height : existing.height,
|
|
263
485
|
cssClass: input.cssClass !== undefined ? (input.cssClass === null ? null : input.cssClass.trim() || null) : existing.cssClass,
|
|
486
|
+
htmlAttributes,
|
|
264
487
|
updatedAt: deps.clock.nowIso(),
|
|
265
488
|
version: existing.version + 1,
|
|
266
489
|
};
|
package/dist/media/ports.d.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface MediaRepoPort {
|
|
|
31
31
|
workspaceId: UUID;
|
|
32
32
|
id: UUID;
|
|
33
33
|
}): Promise<MediaRecord | null>;
|
|
34
|
+
/** Second lookup key (2026-09-07) — `MediaRecord.slug`'s own doc has the full identity-model
|
|
35
|
+
* rationale. Mirrors `AssetBlobRepoPort.findByHash`'s shape: one indexed field, one row or `null`. */
|
|
36
|
+
findBySlug(required: {
|
|
37
|
+
workspaceId: UUID;
|
|
38
|
+
slug: string;
|
|
39
|
+
}): Promise<MediaRecord | null>;
|
|
34
40
|
list(required: {
|
|
35
41
|
workspaceId: UUID;
|
|
36
42
|
}): Promise<MediaRecord[]>;
|
|
@@ -13,7 +13,13 @@ export interface ResolveMediaRenditionDeps {
|
|
|
13
13
|
}
|
|
14
14
|
export interface ResolveMediaRenditionInput {
|
|
15
15
|
workspaceId: UUID;
|
|
16
|
-
assetId
|
|
16
|
+
/** The `{assetId}` path segment, verbatim — despite the name, this may be either the asset's real
|
|
17
|
+
* `id` (a UUID) OR its `slug` (2026-09-07: `resolveMediaRendition` now resolves either via
|
|
18
|
+
* `findMediaByIdOrSlug`, so a hand-authored `<img src="/m/{slug}/...">` resolves the same asset a
|
|
19
|
+
* UUID-based one always has). Every rendition-table operation inside this function uses the
|
|
20
|
+
* RESOLVED record's own `media.id`, never this raw field, once the lookup succeeds — see that
|
|
21
|
+
* local rebinding's own comment for why that distinction matters. */
|
|
22
|
+
assetId: string;
|
|
17
23
|
/** The `{transformName}` path segment — cosmetic slug/ext are stripped by the caller (route). */
|
|
18
24
|
transformName: string;
|
|
19
25
|
/** The `{version}` parsed out of the `.v{version}` path segment. */
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
* - The real `entry_refs` "referenced by published content" check.
|
|
30
30
|
*/
|
|
31
31
|
import { createHash } from "node:crypto";
|
|
32
|
+
import { findMediaByIdOrSlug } from "./media-service.js";
|
|
32
33
|
import { mimeForTransformFormat } from "./transform-types.js";
|
|
33
34
|
import { isLatestTransformVersion, isReferencedByPublishedContent } from "./transform-registry.js";
|
|
34
35
|
import { withRenditionLock } from "./transform-lock.js";
|
|
@@ -56,11 +57,16 @@ import { withRenditionLock } from "./transform-lock.js";
|
|
|
56
57
|
*/
|
|
57
58
|
export async function resolveMediaRendition(required, _optional = {}) {
|
|
58
59
|
const { deps, input } = required;
|
|
59
|
-
const media = await deps.mediaRepo
|
|
60
|
+
const media = await findMediaByIdOrSlug({ deps: { mediaRepo: deps.mediaRepo }, input: { workspaceId: input.workspaceId, idOrSlug: input.assetId } });
|
|
60
61
|
if (!media)
|
|
61
62
|
return { outcome: "not-found" };
|
|
62
63
|
if (media.status === "trashed")
|
|
63
64
|
return { outcome: "gone" };
|
|
65
|
+
// From here on, every `asset_renditions`/blob operation keys on the RESOLVED record's own `id` —
|
|
66
|
+
// never the raw `input.assetId`, which may have been a slug. `asset_renditions.assetId` is a FK to
|
|
67
|
+
// `media.id`; keying it on the slug text instead would both miss every rendition already stored
|
|
68
|
+
// under the real id and, on the generation path below, write a second, slug-keyed row alongside it.
|
|
69
|
+
const assetId = media.id;
|
|
64
70
|
const definition = await deps.transformRepo.findByNameVersion({
|
|
65
71
|
workspaceId: input.workspaceId,
|
|
66
72
|
name: input.transformName,
|
|
@@ -70,7 +76,7 @@ export async function resolveMediaRendition(required, _optional = {}) {
|
|
|
70
76
|
return { outcome: "not-found" };
|
|
71
77
|
const existing = await deps.renditionRepo.findOne({
|
|
72
78
|
workspaceId: input.workspaceId,
|
|
73
|
-
assetId
|
|
79
|
+
assetId,
|
|
74
80
|
transformName: input.transformName,
|
|
75
81
|
version: input.version,
|
|
76
82
|
});
|
|
@@ -100,7 +106,7 @@ export async function resolveMediaRendition(required, _optional = {}) {
|
|
|
100
106
|
// `imageTransformer.transform` a second time.
|
|
101
107
|
const racedExisting = await deps.renditionRepo.findOne({
|
|
102
108
|
workspaceId: input.workspaceId,
|
|
103
|
-
assetId
|
|
109
|
+
assetId,
|
|
104
110
|
transformName: input.transformName,
|
|
105
111
|
version: input.version,
|
|
106
112
|
});
|
|
@@ -120,7 +126,7 @@ export async function resolveMediaRendition(required, _optional = {}) {
|
|
|
120
126
|
const row = {
|
|
121
127
|
id: deps.idGen.newId(),
|
|
122
128
|
workspaceId: input.workspaceId,
|
|
123
|
-
assetId
|
|
129
|
+
assetId,
|
|
124
130
|
transformName: input.transformName,
|
|
125
131
|
version: input.version,
|
|
126
132
|
storageKey,
|
|
@@ -20,6 +20,10 @@ export declare class InMemoryMediaRepo implements MediaRepoPort {
|
|
|
20
20
|
workspaceId: UUID;
|
|
21
21
|
id: UUID;
|
|
22
22
|
}): Promise<MediaRecord | null>;
|
|
23
|
+
findBySlug(required: {
|
|
24
|
+
workspaceId: UUID;
|
|
25
|
+
slug: string;
|
|
26
|
+
}): Promise<MediaRecord | null>;
|
|
23
27
|
list(required: {
|
|
24
28
|
workspaceId: UUID;
|
|
25
29
|
}): Promise<MediaRecord[]>;
|
|
@@ -6,6 +6,9 @@ export class InMemoryMediaRepo {
|
|
|
6
6
|
async findById(required) {
|
|
7
7
|
return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.id === required.id) ?? null);
|
|
8
8
|
}
|
|
9
|
+
async findBySlug(required) {
|
|
10
|
+
return (this.rows.find((row) => row.workspaceId === required.workspaceId && row.slug === required.slug) ?? null);
|
|
11
|
+
}
|
|
9
12
|
async list(required) {
|
|
10
13
|
return this.rows.filter((row) => row.workspaceId === required.workspaceId);
|
|
11
14
|
}
|
|
@@ -92,6 +92,18 @@ export interface MediaToolDeps {
|
|
|
92
92
|
media: MediaRecord;
|
|
93
93
|
bytes: Uint8Array;
|
|
94
94
|
}) => Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Optional host-supplied override of `uploadMedia`'s own `DEFAULT_MAX_UPLOAD_BYTES` (10 MiB,
|
|
97
|
+
* `media-service.ts`), forwarded verbatim as `uploadMedia`'s third (`optional`) argument in the
|
|
98
|
+
* `media_upload_asset` handler below (2026-09-21). Before this field existed, the handler called
|
|
99
|
+
* `uploadMedia({ deps, input })` with no third argument at all, so EVERY host's assistant upload
|
|
100
|
+
* tool was hard-capped at 10 MiB regardless of what limit that host's own HTTP upload route
|
|
101
|
+
* enforced — a host raising its own cap (e.g. to 50 MiB) had no way to raise this tool's cap to
|
|
102
|
+
* match. Omitted: `uploadMedia`'s own default applies unchanged, matching this field's own
|
|
103
|
+
* pre-2026-09-21 absence for every host that has not opted in yet — the same degrade-soft
|
|
104
|
+
* contract {@link resolvePublicUrls} and {@link recordUploadContentType} already established.
|
|
105
|
+
*/
|
|
106
|
+
maxUploadBytes?: number;
|
|
95
107
|
}
|
|
96
108
|
/**
|
|
97
109
|
* This wiring layer's OWN risk classification, authored from what each handler below actually
|
|
@@ -30,6 +30,7 @@ function isMediaShapeRejection(error) {
|
|
|
30
30
|
function toMediaToolView(record) {
|
|
31
31
|
return {
|
|
32
32
|
id: record.id,
|
|
33
|
+
slug: record.slug,
|
|
33
34
|
title: record.title,
|
|
34
35
|
alt: record.alt,
|
|
35
36
|
caption: record.caption,
|
|
@@ -37,6 +38,8 @@ function toMediaToolView(record) {
|
|
|
37
38
|
sha256: record.source.sha256,
|
|
38
39
|
status: record.status,
|
|
39
40
|
version: record.version,
|
|
41
|
+
cssClass: record.cssClass,
|
|
42
|
+
htmlAttributes: record.htmlAttributes,
|
|
40
43
|
};
|
|
41
44
|
}
|
|
42
45
|
/**
|
|
@@ -92,7 +95,7 @@ export function buildMediaRegistrations(routeDeps) {
|
|
|
92
95
|
credit: typeof input.credit === "string" ? input.credit : undefined,
|
|
93
96
|
createdByPrincipal: ctx.principal.id,
|
|
94
97
|
},
|
|
95
|
-
});
|
|
98
|
+
}, { maxUploadBytes: routeDeps.maxUploadBytes });
|
|
96
99
|
if (routeDeps.recordUploadContentType) {
|
|
97
100
|
try {
|
|
98
101
|
await routeDeps.recordUploadContentType({ media, bytes });
|
|
@@ -121,18 +124,23 @@ export function buildMediaRegistrations(routeDeps) {
|
|
|
121
124
|
const input = requireInputRecord(ctx.input);
|
|
122
125
|
const mediaId = requireString(input, "mediaId");
|
|
123
126
|
await requireToolPermission(routeDeps, { principalId: ctx.principal.id, permission: "media.update", entityType: "media", entityId: mediaId });
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
return withSchemaOnRejection({ toolId: "media_update_metadata", catalog: CATALOG_BY_ID, isShapeRejection: isMediaShapeRejection }, async () => {
|
|
128
|
+
const { media } = await updateMediaMetadata({
|
|
129
|
+
deps: { clock: routeDeps.clock, mediaRepo: routeDeps.mediaRepo },
|
|
130
|
+
input: {
|
|
131
|
+
workspaceId: routeDeps.workspaceId,
|
|
132
|
+
id: mediaId,
|
|
133
|
+
title: typeof input.title === "string" ? input.title : undefined,
|
|
134
|
+
alt: typeof input.alt === "string" ? input.alt : undefined,
|
|
135
|
+
caption: typeof input.caption === "string" ? input.caption : undefined,
|
|
136
|
+
credit: typeof input.credit === "string" ? input.credit : undefined,
|
|
137
|
+
slug: typeof input.slug === "string" ? input.slug : undefined,
|
|
138
|
+
cssClass: typeof input.cssClass === "string" ? input.cssClass : undefined,
|
|
139
|
+
htmlAttributes: typeof input.htmlAttributes === "string" ? input.htmlAttributes : undefined,
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return { media: toMediaToolView(media) };
|
|
134
143
|
});
|
|
135
|
-
return { media: toMediaToolView(media) };
|
|
136
144
|
},
|
|
137
145
|
media_trash_asset: async (ctx) => {
|
|
138
146
|
const mediaId = requireString(requireInputRecord(ctx.input), "mediaId");
|
package/dist/media/types.d.ts
CHANGED
|
@@ -42,6 +42,20 @@ export interface MediaRecord {
|
|
|
42
42
|
id: UUID;
|
|
43
43
|
workspaceId: UUID;
|
|
44
44
|
title: string;
|
|
45
|
+
/**
|
|
46
|
+
* Human-memorable, unique-per-workspace lookup key (owner-directed, 2026-09-07) — an ADDITIONAL
|
|
47
|
+
* key alongside `id`, never a replacement: `id` stays the canonical primary key every existing
|
|
48
|
+
* embed/reference resolves by, and this column only gives a second, human-typeable way to reach
|
|
49
|
+
* the same row (`findMediaByIdOrSlug` in `media-service.ts` tries this first, `id` second).
|
|
50
|
+
*
|
|
51
|
+
* Derived from `title` at upload time (`deriveMediaSlug`), then independently editable afterward
|
|
52
|
+
* — renaming `title` does NOT recompute this field; it changes only on an explicit `slug` write.
|
|
53
|
+
* Unique per `(workspaceId, slug)`, enforced by the host's own DB index (see the host's
|
|
54
|
+
* `idx_media_workspace_slug`) — `updateMediaMetadata`'s own `findBySlug` check is a friendly-error
|
|
55
|
+
* courtesy on top of that, not the enforcement itself, the same split `posts_workspace_slug_unique`
|
|
56
|
+
* already establishes for `post`'s identical `slug` field.
|
|
57
|
+
*/
|
|
58
|
+
slug: string;
|
|
45
59
|
alt: string;
|
|
46
60
|
caption: string;
|
|
47
61
|
credit: string;
|
|
@@ -63,6 +77,18 @@ export interface MediaRecord {
|
|
|
63
77
|
width: number | null;
|
|
64
78
|
height: number | null;
|
|
65
79
|
cssClass: string | null;
|
|
80
|
+
/**
|
|
81
|
+
* Owner-directed (2026-09-07) free-text HTML attributes threaded onto this asset's public
|
|
82
|
+
* `<img>`/`<video>` tag (stated uses: animations, custom WebMCP hooks) — raw, ALREADY-VALIDATED
|
|
83
|
+
* source text (e.g. `data-motion="fade-in" loading="lazy"`), the same "one string column, `null`
|
|
84
|
+
* means not set" shape {@link cssClass} already establishes; `null` by default. This is a stored-
|
|
85
|
+
* XSS boundary: `updateMediaMetadata` validates it against `html-attributes.ts`'s allowlist before
|
|
86
|
+
* ever writing it (`on*` handlers, `javascript:` values, and any name not on the allowlist are
|
|
87
|
+
* rejected outright, never sanitized), and a host's render path must re-validate before emitting
|
|
88
|
+
* it onto a real tag rather than trusting a stored value was never written by an older code path
|
|
89
|
+
* or a direct DB edit.
|
|
90
|
+
*/
|
|
91
|
+
htmlAttributes: string | null;
|
|
66
92
|
}
|
|
67
93
|
/**
|
|
68
94
|
* Blob-GC lifecycle status (INV-1's two-phase journaled protocol,
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
* published, there), so the target schema below is hand-authored against `types.ts`'s `NavTarget`
|
|
56
56
|
* union and cross-checked by this catalog's own contract test rather than imported.
|
|
57
57
|
*/
|
|
58
|
+
import { ALLOWED_HREF_SHAPES_DESCRIPTION } from "./menu-service.js";
|
|
58
59
|
const MENU_ID_SCHEMA = {
|
|
59
60
|
type: "string",
|
|
60
61
|
description: "The menu's id, as returned by menus_list_menus, menus_get_menu, or menus_create_menu.",
|
|
@@ -90,7 +91,13 @@ const NAV_TARGET_SCHEMA = {
|
|
|
90
91
|
required: ["kind", "href"],
|
|
91
92
|
properties: {
|
|
92
93
|
kind: { const: "url" },
|
|
93
|
-
href: {
|
|
94
|
+
href: {
|
|
95
|
+
type: "string",
|
|
96
|
+
// Derived from `menu-service.ts`'s own `ALLOWED_HREF_SHAPES_DESCRIPTION` (the single
|
|
97
|
+
// source of truth `isAllowedHref`'s rejection message also draws from) rather than
|
|
98
|
+
// hand-copied, so this schema description cannot drift out of sync with the real check.
|
|
99
|
+
description: `Accepted shapes: ${ALLOWED_HREF_SHAPES_DESCRIPTION}. Anything else — including 'javascript:', 'data:', 'vbscript:', 'file:', 'blob:', 'about:', a protocol-relative '//...' URL, or a bare relative path with no leading '/' — is rejected.`,
|
|
100
|
+
},
|
|
94
101
|
},
|
|
95
102
|
},
|
|
96
103
|
{
|
|
@@ -16,7 +16,16 @@ export { createNavMenuReadModel, type NavMenuReadModelDeps } from "./read-model.
|
|
|
16
16
|
* adapter implements it directly, the same way it implements `NavLocationBindingRepoPort`.
|
|
17
17
|
*/
|
|
18
18
|
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, type MenuRepoPort, } from "./repo.memory.js";
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `isAllowedHref`/`ALLOWED_HREF_SHAPES_DESCRIPTION` (2026-09-03): the canonical author-link
|
|
21
|
+
* allowlist, promoted here specifically so a HOST can import it rather than hand-copy it — the
|
|
22
|
+
* dependency direction a host -> `@jini-ai/cms` is the one that is actually legal (the reverse is
|
|
23
|
+
* not). The host's own render-time `safeHref` (`server/inbound/public-http/http/site/render.ts` and its
|
|
24
|
+
* `features/theme/static-render.ts` duplicate) have not yet been migrated to import this — see
|
|
25
|
+
* `menu-service.ts`'s own doc comment on `isAllowedHref` for the retirement plan and why nothing in
|
|
26
|
+
* `check:boundaries` blocks either host-side call site from doing so.
|
|
27
|
+
*/
|
|
28
|
+
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, type TreeValidationLimits, type CreateMenuDeps, type CreateMenuServiceInput, type CreateMenuRequired, type CreateMenuOptional, type UpdateMenuTreeDeps, type UpdateMenuTreeServiceInput, type UpdateMenuTreeRequired, type UpdateMenuTreeOptional, type AssignLocationDeps, type AssignLocationServiceInput, type AssignLocationRequired, type AssignLocationOptional, type DeleteMenuDeps, type DeleteMenuServiceInput, type DeleteMenuRequired, type DeleteMenuOptional, } from "./menu-service.js";
|
|
20
29
|
export { resolveForLocation, resolveMenuDoc, type ResolveTargetHrefFn, type ResolvedTargetHref, type ResolveForLocationDeps, type ResolveForLocationServiceInput, type ResolveForLocationRequired, type ResolveForLocationOptional, } from "./resolver.js";
|
|
21
30
|
export { rebuildNavLocationBindings, type RebuildNavLocationBindingsDeps, type RebuildNavLocationBindingsResult, } from "./reconcile.js";
|
|
22
31
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|
package/dist/navigation/index.js
CHANGED
|
@@ -7,7 +7,16 @@ export { createNavMenuReadModel } from "./read-model.js";
|
|
|
7
7
|
* adapter implements it directly, the same way it implements `NavLocationBindingRepoPort`.
|
|
8
8
|
*/
|
|
9
9
|
export { InMemoryMenuRepo, InMemoryNavLocationBindingRepo, } from "./repo.memory.js";
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* `isAllowedHref`/`ALLOWED_HREF_SHAPES_DESCRIPTION` (2026-09-03): the canonical author-link
|
|
12
|
+
* allowlist, promoted here specifically so a HOST can import it rather than hand-copy it — the
|
|
13
|
+
* dependency direction a host -> `@jini-ai/cms` is the one that is actually legal (the reverse is
|
|
14
|
+
* not). The host's own render-time `safeHref` (`server/inbound/public-http/http/site/render.ts` and its
|
|
15
|
+
* `features/theme/static-render.ts` duplicate) have not yet been migrated to import this — see
|
|
16
|
+
* `menu-service.ts`'s own doc comment on `isAllowedHref` for the retirement plan and why nothing in
|
|
17
|
+
* `check:boundaries` blocks either host-side call site from doing so.
|
|
18
|
+
*/
|
|
19
|
+
export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, isAllowedHref, ALLOWED_HREF_SHAPES_DESCRIPTION, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, } from "./menu-service.js";
|
|
11
20
|
export { resolveForLocation, resolveMenuDoc, } from "./resolver.js";
|
|
12
21
|
export { rebuildNavLocationBindings, } from "./reconcile.js";
|
|
13
22
|
/** The agent-tool surface for this domain (see `agent-tools.ts` for what is deliberately omitted). */
|