@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.
Files changed (325) hide show
  1. package/README.md +22 -17
  2. package/dist/content-types/agent-tools.js +1 -1
  3. package/dist/content-types/errors.d.ts +19 -1
  4. package/dist/content-types/errors.js +22 -1
  5. package/dist/content-types/index-provisioning.d.ts +56 -10
  6. package/dist/content-types/index-provisioning.js +86 -13
  7. package/dist/content-types/index.d.ts +15 -3
  8. package/dist/content-types/index.js +14 -2
  9. package/dist/content-types/lifecycle.js +3 -3
  10. package/dist/content-types/types.d.ts +64 -3
  11. package/dist/content-types/types.js +67 -3
  12. package/dist/content-types/write-service.d.ts +18 -3
  13. package/dist/content-types/write-service.js +28 -2
  14. package/dist/core/ports.d.ts +12 -2
  15. package/dist/entries/field-validation.js +96 -1
  16. package/dist/entries/write-service.d.ts +14 -0
  17. package/dist/entries/write-service.js +2 -2
  18. package/dist/identity/admin-crud-service.d.ts +28 -0
  19. package/dist/identity/admin-crud-service.js +30 -0
  20. package/dist/identity/auth-service.d.ts +32 -1
  21. package/dist/identity/auth-service.js +44 -15
  22. package/dist/identity/index.d.ts +4 -1
  23. package/dist/identity/index.js +4 -1
  24. package/dist/identity/seed.d.ts +14 -2
  25. package/dist/identity/seed.js +197 -45
  26. package/dist/media/agent-tools.d.ts +13 -3
  27. package/dist/media/agent-tools.js +45 -10
  28. package/dist/media/content-type-sniffer.d.ts +1 -1
  29. package/dist/media/content-type-sniffer.js +52 -4
  30. package/dist/media/html-attributes.d.ts +90 -0
  31. package/dist/media/html-attributes.js +161 -0
  32. package/dist/media/image-transformer.sharp.d.ts +35 -1
  33. package/dist/media/image-transformer.sharp.js +44 -2
  34. package/dist/media/index.d.ts +2 -1
  35. package/dist/media/index.js +2 -1
  36. package/dist/media/media-service.d.ts +62 -2
  37. package/dist/media/media-service.js +230 -7
  38. package/dist/media/ports.d.ts +6 -0
  39. package/dist/media/rendition-service.d.ts +7 -1
  40. package/dist/media/rendition-service.js +10 -4
  41. package/dist/media/repo.memory.d.ts +4 -0
  42. package/dist/media/repo.memory.js +3 -0
  43. package/dist/media/tool-registrations.d.ts +12 -0
  44. package/dist/media/tool-registrations.js +20 -12
  45. package/dist/media/types.d.ts +26 -0
  46. package/dist/navigation/agent-tools.js +8 -1
  47. package/dist/navigation/index.d.ts +10 -1
  48. package/dist/navigation/index.js +10 -1
  49. package/dist/navigation/menu-service.d.ts +60 -4
  50. package/dist/navigation/menu-service.js +106 -8
  51. package/dist/settings/dictionaries/index.d.ts +3 -3
  52. package/dist/settings/dictionaries/index.js +3 -3
  53. package/dist/taxonomy/agent-tools.d.ts +5 -4
  54. package/dist/taxonomy/agent-tools.js +33 -10
  55. package/dist/taxonomy/content-lookup.d.ts +35 -0
  56. package/dist/taxonomy/content-lookup.js +28 -0
  57. package/dist/taxonomy/index.d.ts +2 -2
  58. package/dist/taxonomy/index.js +2 -2
  59. package/dist/taxonomy/repo.memory.d.ts +11 -2
  60. package/dist/taxonomy/repo.memory.js +11 -0
  61. package/dist/taxonomy/write-service.d.ts +44 -15
  62. package/dist/taxonomy/write-service.js +60 -8
  63. package/package.json +3 -2
  64. package/dist/content-types/agent-tools.d.ts.map +0 -1
  65. package/dist/content-types/agent-tools.js.map +0 -1
  66. package/dist/content-types/cleanup.d.ts.map +0 -1
  67. package/dist/content-types/cleanup.js.map +0 -1
  68. package/dist/content-types/errors.d.ts.map +0 -1
  69. package/dist/content-types/errors.js.map +0 -1
  70. package/dist/content-types/field-defs.d.ts.map +0 -1
  71. package/dist/content-types/field-defs.js.map +0 -1
  72. package/dist/content-types/index-provisioning.d.ts.map +0 -1
  73. package/dist/content-types/index-provisioning.js.map +0 -1
  74. package/dist/content-types/index.d.ts.map +0 -1
  75. package/dist/content-types/index.js.map +0 -1
  76. package/dist/content-types/lifecycle-dispatch.d.ts.map +0 -1
  77. package/dist/content-types/lifecycle-dispatch.js.map +0 -1
  78. package/dist/content-types/lifecycle.d.ts.map +0 -1
  79. package/dist/content-types/lifecycle.js.map +0 -1
  80. package/dist/content-types/list.d.ts.map +0 -1
  81. package/dist/content-types/list.js.map +0 -1
  82. package/dist/content-types/repo.memory.d.ts.map +0 -1
  83. package/dist/content-types/repo.memory.js.map +0 -1
  84. package/dist/content-types/tool-registrations.d.ts.map +0 -1
  85. package/dist/content-types/tool-registrations.js.map +0 -1
  86. package/dist/content-types/types.d.ts.map +0 -1
  87. package/dist/content-types/types.js.map +0 -1
  88. package/dist/content-types/write-service.d.ts.map +0 -1
  89. package/dist/content-types/write-service.js.map +0 -1
  90. package/dist/core/commands/change-set.d.ts.map +0 -1
  91. package/dist/core/commands/change-set.js.map +0 -1
  92. package/dist/core/commands/command.d.ts.map +0 -1
  93. package/dist/core/commands/command.js.map +0 -1
  94. package/dist/core/index.d.ts.map +0 -1
  95. package/dist/core/index.js.map +0 -1
  96. package/dist/core/ports.d.ts.map +0 -1
  97. package/dist/core/ports.js.map +0 -1
  98. package/dist/core/tools/registration-kit.d.ts.map +0 -1
  99. package/dist/core/tools/registration-kit.js.map +0 -1
  100. package/dist/entries/agent-tools.d.ts.map +0 -1
  101. package/dist/entries/agent-tools.js.map +0 -1
  102. package/dist/entries/errors.d.ts.map +0 -1
  103. package/dist/entries/errors.js.map +0 -1
  104. package/dist/entries/field-validation.d.ts.map +0 -1
  105. package/dist/entries/field-validation.js.map +0 -1
  106. package/dist/entries/index.d.ts.map +0 -1
  107. package/dist/entries/index.js.map +0 -1
  108. package/dist/entries/lifecycle-dispatch.d.ts.map +0 -1
  109. package/dist/entries/lifecycle-dispatch.js.map +0 -1
  110. package/dist/entries/list.d.ts.map +0 -1
  111. package/dist/entries/list.js.map +0 -1
  112. package/dist/entries/repo.memory.d.ts.map +0 -1
  113. package/dist/entries/repo.memory.js.map +0 -1
  114. package/dist/entries/tool-registrations.d.ts.map +0 -1
  115. package/dist/entries/tool-registrations.js.map +0 -1
  116. package/dist/entries/types.d.ts.map +0 -1
  117. package/dist/entries/types.js.map +0 -1
  118. package/dist/entries/write-service.d.ts.map +0 -1
  119. package/dist/entries/write-service.js.map +0 -1
  120. package/dist/identity/admin-crud-service.d.ts.map +0 -1
  121. package/dist/identity/admin-crud-service.js.map +0 -1
  122. package/dist/identity/agent-tool-input.d.ts.map +0 -1
  123. package/dist/identity/agent-tool-input.js.map +0 -1
  124. package/dist/identity/agent-tools.d.ts.map +0 -1
  125. package/dist/identity/agent-tools.js.map +0 -1
  126. package/dist/identity/auth-service.d.ts.map +0 -1
  127. package/dist/identity/auth-service.js.map +0 -1
  128. package/dist/identity/authorize.d.ts.map +0 -1
  129. package/dist/identity/authorize.js.map +0 -1
  130. package/dist/identity/grant-service.d.ts.map +0 -1
  131. package/dist/identity/grant-service.js.map +0 -1
  132. package/dist/identity/hasher.d.ts.map +0 -1
  133. package/dist/identity/hasher.js.map +0 -1
  134. package/dist/identity/index.d.ts.map +0 -1
  135. package/dist/identity/index.js.map +0 -1
  136. package/dist/identity/password-policy.d.ts.map +0 -1
  137. package/dist/identity/password-policy.js.map +0 -1
  138. package/dist/identity/permission-migrations.d.ts.map +0 -1
  139. package/dist/identity/permission-migrations.js.map +0 -1
  140. package/dist/identity/permissions.d.ts.map +0 -1
  141. package/dist/identity/permissions.js.map +0 -1
  142. package/dist/identity/ports.d.ts.map +0 -1
  143. package/dist/identity/ports.js.map +0 -1
  144. package/dist/identity/repo.memory.d.ts.map +0 -1
  145. package/dist/identity/repo.memory.js.map +0 -1
  146. package/dist/identity/seed.d.ts.map +0 -1
  147. package/dist/identity/seed.js.map +0 -1
  148. package/dist/identity/tool-registrations.d.ts.map +0 -1
  149. package/dist/identity/tool-registrations.js.map +0 -1
  150. package/dist/identity/types.d.ts.map +0 -1
  151. package/dist/identity/types.js.map +0 -1
  152. package/dist/identity/username.d.ts.map +0 -1
  153. package/dist/identity/username.js.map +0 -1
  154. package/dist/media/agent-tools.d.ts.map +0 -1
  155. package/dist/media/agent-tools.js.map +0 -1
  156. package/dist/media/blob-gc-lock.d.ts.map +0 -1
  157. package/dist/media/blob-gc-lock.js.map +0 -1
  158. package/dist/media/blob-gc.d.ts.map +0 -1
  159. package/dist/media/blob-gc.js.map +0 -1
  160. package/dist/media/blob-key.d.ts.map +0 -1
  161. package/dist/media/blob-key.js.map +0 -1
  162. package/dist/media/blob-store.fs.d.ts.map +0 -1
  163. package/dist/media/blob-store.fs.js.map +0 -1
  164. package/dist/media/blob-store.memory.d.ts.map +0 -1
  165. package/dist/media/blob-store.memory.js.map +0 -1
  166. package/dist/media/content-type-sniffer.d.ts.map +0 -1
  167. package/dist/media/content-type-sniffer.js.map +0 -1
  168. package/dist/media/image-transformer.d.ts.map +0 -1
  169. package/dist/media/image-transformer.js.map +0 -1
  170. package/dist/media/image-transformer.sharp.d.ts.map +0 -1
  171. package/dist/media/image-transformer.sharp.js.map +0 -1
  172. package/dist/media/index.d.ts.map +0 -1
  173. package/dist/media/index.js.map +0 -1
  174. package/dist/media/media-service.d.ts.map +0 -1
  175. package/dist/media/media-service.js.map +0 -1
  176. package/dist/media/ports.d.ts.map +0 -1
  177. package/dist/media/ports.js.map +0 -1
  178. package/dist/media/rendition-service.d.ts.map +0 -1
  179. package/dist/media/rendition-service.js.map +0 -1
  180. package/dist/media/repo.memory.d.ts.map +0 -1
  181. package/dist/media/repo.memory.js.map +0 -1
  182. package/dist/media/tool-registrations.d.ts.map +0 -1
  183. package/dist/media/tool-registrations.js.map +0 -1
  184. package/dist/media/transform-lock.d.ts.map +0 -1
  185. package/dist/media/transform-lock.js.map +0 -1
  186. package/dist/media/transform-registry.d.ts.map +0 -1
  187. package/dist/media/transform-registry.js.map +0 -1
  188. package/dist/media/transform-types.d.ts.map +0 -1
  189. package/dist/media/transform-types.js.map +0 -1
  190. package/dist/media/types.d.ts.map +0 -1
  191. package/dist/media/types.js.map +0 -1
  192. package/dist/navigation/agent-tools.d.ts.map +0 -1
  193. package/dist/navigation/agent-tools.js.map +0 -1
  194. package/dist/navigation/contracts.d.ts.map +0 -1
  195. package/dist/navigation/contracts.js.map +0 -1
  196. package/dist/navigation/index.d.ts.map +0 -1
  197. package/dist/navigation/index.js.map +0 -1
  198. package/dist/navigation/menu-service.d.ts.map +0 -1
  199. package/dist/navigation/menu-service.js.map +0 -1
  200. package/dist/navigation/ports.d.ts.map +0 -1
  201. package/dist/navigation/ports.js.map +0 -1
  202. package/dist/navigation/read-model.d.ts.map +0 -1
  203. package/dist/navigation/read-model.js.map +0 -1
  204. package/dist/navigation/reconcile.d.ts.map +0 -1
  205. package/dist/navigation/reconcile.js.map +0 -1
  206. package/dist/navigation/repo.memory.d.ts.map +0 -1
  207. package/dist/navigation/repo.memory.js.map +0 -1
  208. package/dist/navigation/resolver.d.ts.map +0 -1
  209. package/dist/navigation/resolver.js.map +0 -1
  210. package/dist/navigation/tool-registrations.d.ts.map +0 -1
  211. package/dist/navigation/tool-registrations.js.map +0 -1
  212. package/dist/navigation/types.d.ts.map +0 -1
  213. package/dist/navigation/types.js.map +0 -1
  214. package/dist/presentation/index.d.ts.map +0 -1
  215. package/dist/presentation/index.js.map +0 -1
  216. package/dist/presentation/presentation.d.ts.map +0 -1
  217. package/dist/presentation/presentation.js.map +0 -1
  218. package/dist/presentation/repo.memory.d.ts.map +0 -1
  219. package/dist/presentation/repo.memory.js.map +0 -1
  220. package/dist/server/index.d.ts.map +0 -1
  221. package/dist/server/index.js.map +0 -1
  222. package/dist/settings/agent-tools.d.ts.map +0 -1
  223. package/dist/settings/agent-tools.js.map +0 -1
  224. package/dist/settings/agent-writable-preferences.d.ts.map +0 -1
  225. package/dist/settings/agent-writable-preferences.js.map +0 -1
  226. package/dist/settings/change-feed.d.ts.map +0 -1
  227. package/dist/settings/change-feed.js.map +0 -1
  228. package/dist/settings/definitions-dispatch.d.ts.map +0 -1
  229. package/dist/settings/definitions-dispatch.js.map +0 -1
  230. package/dist/settings/dictionaries/index.d.ts.map +0 -1
  231. package/dist/settings/dictionaries/index.js.map +0 -1
  232. package/dist/settings/dictionaries/settings-dialog.ar.d.ts.map +0 -1
  233. package/dist/settings/dictionaries/settings-dialog.ar.js.map +0 -1
  234. package/dist/settings/dictionaries/settings-dialog.bn.d.ts.map +0 -1
  235. package/dist/settings/dictionaries/settings-dialog.bn.js.map +0 -1
  236. package/dist/settings/dictionaries/settings-dialog.de.d.ts.map +0 -1
  237. package/dist/settings/dictionaries/settings-dialog.de.js.map +0 -1
  238. package/dist/settings/dictionaries/settings-dialog.es.d.ts.map +0 -1
  239. package/dist/settings/dictionaries/settings-dialog.es.js.map +0 -1
  240. package/dist/settings/dictionaries/settings-dialog.fa.d.ts.map +0 -1
  241. package/dist/settings/dictionaries/settings-dialog.fa.js.map +0 -1
  242. package/dist/settings/dictionaries/settings-dialog.fr.d.ts.map +0 -1
  243. package/dist/settings/dictionaries/settings-dialog.fr.js.map +0 -1
  244. package/dist/settings/dictionaries/settings-dialog.hi.d.ts.map +0 -1
  245. package/dist/settings/dictionaries/settings-dialog.hi.js.map +0 -1
  246. package/dist/settings/dictionaries/settings-dialog.hu.d.ts.map +0 -1
  247. package/dist/settings/dictionaries/settings-dialog.hu.js.map +0 -1
  248. package/dist/settings/dictionaries/settings-dialog.id.d.ts.map +0 -1
  249. package/dist/settings/dictionaries/settings-dialog.id.js.map +0 -1
  250. package/dist/settings/dictionaries/settings-dialog.it.d.ts.map +0 -1
  251. package/dist/settings/dictionaries/settings-dialog.it.js.map +0 -1
  252. package/dist/settings/dictionaries/settings-dialog.ja.d.ts.map +0 -1
  253. package/dist/settings/dictionaries/settings-dialog.ja.js.map +0 -1
  254. package/dist/settings/dictionaries/settings-dialog.ko.d.ts.map +0 -1
  255. package/dist/settings/dictionaries/settings-dialog.ko.js.map +0 -1
  256. package/dist/settings/dictionaries/settings-dialog.pl.d.ts.map +0 -1
  257. package/dist/settings/dictionaries/settings-dialog.pl.js.map +0 -1
  258. package/dist/settings/dictionaries/settings-dialog.pt-BR.d.ts.map +0 -1
  259. package/dist/settings/dictionaries/settings-dialog.pt-BR.js.map +0 -1
  260. package/dist/settings/dictionaries/settings-dialog.ru.d.ts.map +0 -1
  261. package/dist/settings/dictionaries/settings-dialog.ru.js.map +0 -1
  262. package/dist/settings/dictionaries/settings-dialog.th.d.ts.map +0 -1
  263. package/dist/settings/dictionaries/settings-dialog.th.js.map +0 -1
  264. package/dist/settings/dictionaries/settings-dialog.tr.d.ts.map +0 -1
  265. package/dist/settings/dictionaries/settings-dialog.tr.js.map +0 -1
  266. package/dist/settings/dictionaries/settings-dialog.uk.d.ts.map +0 -1
  267. package/dist/settings/dictionaries/settings-dialog.uk.js.map +0 -1
  268. package/dist/settings/dictionaries/settings-dialog.ur.d.ts.map +0 -1
  269. package/dist/settings/dictionaries/settings-dialog.ur.js.map +0 -1
  270. package/dist/settings/dictionaries/settings-dialog.zh-CN.d.ts.map +0 -1
  271. package/dist/settings/dictionaries/settings-dialog.zh-CN.js.map +0 -1
  272. package/dist/settings/dictionaries/settings-dialog.zh-TW.d.ts.map +0 -1
  273. package/dist/settings/dictionaries/settings-dialog.zh-TW.js.map +0 -1
  274. package/dist/settings/ensure-definitions.d.ts.map +0 -1
  275. package/dist/settings/ensure-definitions.js.map +0 -1
  276. package/dist/settings/errors.d.ts.map +0 -1
  277. package/dist/settings/errors.js.map +0 -1
  278. package/dist/settings/index.d.ts.map +0 -1
  279. package/dist/settings/index.js.map +0 -1
  280. package/dist/settings/ports.d.ts.map +0 -1
  281. package/dist/settings/ports.js.map +0 -1
  282. package/dist/settings/purge-service.d.ts.map +0 -1
  283. package/dist/settings/purge-service.js.map +0 -1
  284. package/dist/settings/repo.memory.d.ts.map +0 -1
  285. package/dist/settings/repo.memory.js.map +0 -1
  286. package/dist/settings/settings.d.ts.map +0 -1
  287. package/dist/settings/settings.js.map +0 -1
  288. package/dist/settings/tool-registrations.d.ts.map +0 -1
  289. package/dist/settings/tool-registrations.js.map +0 -1
  290. package/dist/settings/types.d.ts.map +0 -1
  291. package/dist/settings/types.js.map +0 -1
  292. package/dist/settings/ui-tab-definitions.d.ts.map +0 -1
  293. package/dist/settings/ui-tab-definitions.js.map +0 -1
  294. package/dist/settings/write-service.d.ts.map +0 -1
  295. package/dist/settings/write-service.js.map +0 -1
  296. package/dist/taxonomy/agent-tools.d.ts.map +0 -1
  297. package/dist/taxonomy/agent-tools.js.map +0 -1
  298. package/dist/taxonomy/content-lookup.d.ts.map +0 -1
  299. package/dist/taxonomy/content-lookup.js.map +0 -1
  300. package/dist/taxonomy/index.d.ts.map +0 -1
  301. package/dist/taxonomy/index.js.map +0 -1
  302. package/dist/taxonomy/list.d.ts.map +0 -1
  303. package/dist/taxonomy/list.js.map +0 -1
  304. package/dist/taxonomy/merge-term.d.ts.map +0 -1
  305. package/dist/taxonomy/merge-term.js.map +0 -1
  306. package/dist/taxonomy/repo.memory.d.ts.map +0 -1
  307. package/dist/taxonomy/repo.memory.js.map +0 -1
  308. package/dist/taxonomy/validation-chain.d.ts.map +0 -1
  309. package/dist/taxonomy/validation-chain.js.map +0 -1
  310. package/dist/taxonomy/write-service.d.ts.map +0 -1
  311. package/dist/taxonomy/write-service.js.map +0 -1
  312. package/dist/workspace/agent-tools.d.ts.map +0 -1
  313. package/dist/workspace/agent-tools.js.map +0 -1
  314. package/dist/workspace/create.d.ts.map +0 -1
  315. package/dist/workspace/create.js.map +0 -1
  316. package/dist/workspace/delete.d.ts.map +0 -1
  317. package/dist/workspace/delete.js.map +0 -1
  318. package/dist/workspace/index.d.ts.map +0 -1
  319. package/dist/workspace/index.js.map +0 -1
  320. package/dist/workspace/repo.memory.d.ts.map +0 -1
  321. package/dist/workspace/repo.memory.js.map +0 -1
  322. package/dist/workspace/tool-registrations.d.ts.map +0 -1
  323. package/dist/workspace/tool-registrations.js.map +0 -1
  324. package/dist/workspace/update.d.ts.map +0 -1
  325. 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 Tovu's
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}-byte size cap`);
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: deriveTitleFromFilename(input.filename),
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
- * Updates editorial-only fields (title/alt/caption/credit). `source.sha256` is
232
- * write-once — this function's input type has no `sha256` field,
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
- * @complexity O(1).
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
  };
@@ -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: UUID;
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.findById({ workspaceId: input.workspaceId, id: input.assetId });
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: input.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: input.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: input.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
- const { media } = await updateMediaMetadata({
125
- deps: { clock: routeDeps.clock, mediaRepo: routeDeps.mediaRepo },
126
- input: {
127
- workspaceId: routeDeps.workspaceId,
128
- id: mediaId,
129
- title: typeof input.title === "string" ? input.title : undefined,
130
- alt: typeof input.alt === "string" ? input.alt : undefined,
131
- caption: typeof input.caption === "string" ? input.caption : undefined,
132
- credit: typeof input.credit === "string" ? input.credit : undefined,
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");
@@ -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: { type: "string", description: "An absolute or site-relative URL. 'javascript:', 'data:', and 'vbscript:' schemes are rejected." },
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
- export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, 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";
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). */
@@ -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
- export { createMenu, updateMenuTree, assignLocation, deleteMenu, validateAndCloneTree, MenuNotFoundError, MenuValidationError, MenuConflictError, MenuLocationBoundError, DEFAULT_MAX_TREE_DEPTH, DEFAULT_MAX_ITEM_COUNT, } from "./menu-service.js";
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). */