@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
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file Server-side allowlist for `MediaRecord.htmlAttributes` (2026-09-07) — the enforcement half
3
+ * of a validator that ALSO has a browser-side copy in the consuming product's admin
4
+ * (`apps/admin/src/features/media/rules.ts`, `parseMediaHtmlAttributes` and friends).
5
+ *
6
+ * Deliberately duplicated, not shared via one imported module, for the same reason this codebase
7
+ * already hand-copies `DEFAULT_ALLOWED_MIME_TYPES` into that admin's `FILE_HANDLER_ALLOWED_MIME_TYPES`/
8
+ * `IMPORTABLE_CONTENT_TYPES`: `@jini-ai/cms/media`'s barrel (`index.ts`) re-exports files that
9
+ * import real Node built-ins (`node:crypto` in `media-service.ts`, `node:fs` in
10
+ * `blob-store.fs.ts`, native `sharp` bindings in `image-transformer.sharp.ts`) — importing this
11
+ * subpath from a browser-bundled Vite app (that product's `apps/admin`) risks pulling those into the admin
12
+ * SPA's bundle even if only this one pure function is actually used, and this admin's own copy
13
+ * already exists, tested, with no such risk. This file is the copy every Node-side consumer
14
+ * (`media-service.ts`'s own `updateMediaMetadata`, and that product's `apps/website` render path, both pure
15
+ * backend code) can safely import from `@jini-ai/cms/media` instead of re-deriving the rules a
16
+ * third time.
17
+ *
18
+ * THE RULES THEMSELVES ARE NOT REDESIGNED HERE — this is a verbatim port of the admin copy's logic
19
+ * (allowlist membership, `on*`/`javascript:` precedence, the tokenizer, the four rejection reasons).
20
+ * Extend `MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES` in BOTH copies together if a new attribute is ever
21
+ * needed — that is the one piece of drift risk this duplication accepts, the same risk every other
22
+ * hand-copied list in this codebase already accepts.
23
+ *
24
+ * This is a SECURITY boundary, not a syntax convenience: media metadata is authored in the consuming product's admin
25
+ * but rendered on the public site, so a free-text HTML-attribute passthrough is a stored-XSS vector
26
+ * (`onerror`, `onclick`, `style`, `href="javascript:"`, any `on*` handler) the moment it reaches a
27
+ * public page. A client-side-only check is not a control, since the API accepts whatever a caller
28
+ * sends — this file is what makes the write path (`updateMediaMetadata`) and the render path (a
29
+ * host's own tag-building code) actually enforce it, not just the admin form.
30
+ */
31
+ /**
32
+ * Exact-match attribute names the allowlist accepts beyond the open-ended `data-`/`aria-` prefix
33
+ * families (checked separately in {@link isAllowedMediaHtmlAttributeName}). Picked for the owner's
34
+ * stated near-term uses (animations, custom WebMCP hooks) plus the standard `<img>`/`<video>`
35
+ * attributes those uses actually need; extend this list, not the parser, when a new one is needed —
36
+ * and keep it in sync with the identical constant in that admin's copy (see this file's header).
37
+ */
38
+ export const MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES = [
39
+ "loading",
40
+ "decoding",
41
+ "playsinline",
42
+ "muted",
43
+ "loop",
44
+ "autoplay",
45
+ "poster",
46
+ ];
47
+ /**
48
+ * The shape EVERY accepted attribute name must have, checked before allowlist membership
49
+ * (2026-09-07 stored-XSS fix; keep identical in the admin copy).
50
+ *
51
+ * The `data-`/`aria-` families below are open-ended by design — no fixed suffix list — so the
52
+ * prefix check alone accepted whatever characters {@link HTML_ATTRIBUTE_TOKEN}'s name class
53
+ * (`[^\s="']+`, which excludes only whitespace, `=` and quotes) let through. `<`, `>` and `/` are
54
+ * all legal in that class, and a renderer templating a name into ` name="value"` escapes only the
55
+ * VALUE — so a stored `data-x><svg/onload=alert(1)` closed the `<img>` and opened a live
56
+ * `<svg onload>` on the public page. Constraining the NAME to the characters a real HTML attribute
57
+ * name can contain is what makes "the value is escaped" sufficient: no accepted name can carry a
58
+ * character that terminates a tag or starts an attribute.
59
+ *
60
+ * Deliberately narrower than the HTML spec's own name production (which permits far more): this
61
+ * field exists for `data-*`/`aria-*` hooks and a short list of standard `<img>`/`<video>`
62
+ * attributes, every one of which fits `[a-z][a-z0-9-]*`. Widening this is a deliberate decision,
63
+ * not something a new allowlist entry should need.
64
+ */
65
+ const MEDIA_HTML_ATTRIBUTE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
66
+ /** Whether `name` is on the allowlist — a well-formed attribute name
67
+ * ({@link MEDIA_HTML_ATTRIBUTE_NAME_PATTERN}) that is either an exact match against
68
+ * {@link MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES} or carries a `data-`/`aria-` prefix (both open-ended
69
+ * families with no fixed suffix list). Case-insensitive: HTML attribute names are themselves
70
+ * case-insensitive, and an operator typing `DATA-FOO` should not slip past a lowercase-only check.
71
+ *
72
+ * @complexity O(n) in the name's length (one anchored regex test), then O(1) — one prefix check,
73
+ * one fixed-length array lookup.
74
+ */
75
+ export function isAllowedMediaHtmlAttributeName(name) {
76
+ const lower = name.toLowerCase();
77
+ if (!MEDIA_HTML_ATTRIBUTE_NAME_PATTERN.test(lower))
78
+ return false;
79
+ if (lower.startsWith("data-") || lower.startsWith("aria-"))
80
+ return true;
81
+ return MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES.includes(lower);
82
+ }
83
+ /** Matches one `name`, or one `name="value"`/`name='value'`/`name=value` pair — the same loose
84
+ * shape real HTML attribute syntax allows, since that is the syntax an operator typing this field
85
+ * would naturally reach for. */
86
+ const HTML_ATTRIBUTE_TOKEN = /([^\s="']+)(?:=(?:"([^"]*)"|'([^']*)'|([^\s"']+)))?/g;
87
+ /** Classifies one already-tokenized `name`/`value` pair. Split out of {@link parseMediaHtmlAttributes}
88
+ * so each rejection reason is its own directly testable branch.
89
+ *
90
+ * Order matters: `on*`/`javascript:` are checked BEFORE allowlist membership, so a rejected
91
+ * `onerror="..."` always reports as `event-handler` (the more specific, more actionable reason)
92
+ * rather than the generic `disallowed-name`.
93
+ *
94
+ * @complexity O(1) — three fixed checks against one already-extracted token.
95
+ */
96
+ function classifyMediaHtmlAttributeToken(name, value) {
97
+ if (name.startsWith("on"))
98
+ return { reason: "event-handler", attribute: name };
99
+ if (value.trim().toLowerCase().startsWith("javascript:"))
100
+ return { reason: "javascript-url", attribute: name };
101
+ if (!isAllowedMediaHtmlAttributeName(name))
102
+ return { reason: "disallowed-name", attribute: name };
103
+ return null;
104
+ }
105
+ /**
106
+ * Parses the `htmlAttributes` field's free text (`name="value" name2="value2"`, or a bare boolean
107
+ * `name`) into a validated attribute map, rejecting anything not on the allowlist. Nothing here
108
+ * tries to sanitize or escape an otherwise-disallowed name into something safe — it is rejected
109
+ * outright, and the caller must show the reason (not silently drop it) so an operator can tell a
110
+ * typo from a hard "no".
111
+ *
112
+ * @complexity Time O(n) in `text`'s length (one regex pass over it), space O(k) for k parsed
113
+ * attributes.
114
+ */
115
+ export function parseMediaHtmlAttributes(text) {
116
+ const trimmed = text.trim();
117
+ if (trimmed === "")
118
+ return { attributes: {}, error: null };
119
+ const attributes = {};
120
+ HTML_ATTRIBUTE_TOKEN.lastIndex = 0;
121
+ let consumed = 0;
122
+ let match;
123
+ while ((match = HTML_ATTRIBUTE_TOKEN.exec(trimmed)) !== null) {
124
+ // Non-whitespace text between the previous match and this one is a fragment the token pattern
125
+ // could not parse as a name (e.g. a stray quote) — reported once, at the first gap, rather
126
+ // than silently skipped.
127
+ const skipped = trimmed.slice(consumed, match.index);
128
+ if (skipped.trim() !== "")
129
+ return { attributes: {}, error: { reason: "malformed", attribute: skipped.trim() } };
130
+ consumed = match.index + match[0].length;
131
+ const name = match[1].toLowerCase();
132
+ const value = match[2] ?? match[3] ?? match[4] ?? "";
133
+ const rejection = classifyMediaHtmlAttributeToken(name, value);
134
+ if (rejection)
135
+ return { attributes: {}, error: rejection };
136
+ attributes[name] = value;
137
+ }
138
+ const trailing = trimmed.slice(consumed);
139
+ if (trailing.trim() !== "")
140
+ return { attributes: {}, error: { reason: "malformed", attribute: trailing.trim() } };
141
+ return { attributes, error: null };
142
+ }
143
+ /** Formats a {@link MediaHtmlAttributeError} into a plain-English message naming the rejected
144
+ * attribute (owner requirement) — the server-side, non-localized counterpart to the admin's own
145
+ * `describeMediaHtmlAttributeError` (which additionally runs the message through `MEDIA_DICT` for
146
+ * the operator's locale). Domain errors elsewhere in this file (`MediaValidationError`'s other
147
+ * messages) are likewise always plain English, so this matches that existing convention rather
148
+ * than inventing a localized-error contract this service has never had.
149
+ *
150
+ * @complexity O(1).
151
+ */
152
+ export function describeMediaHtmlAttributeError(error) {
153
+ if (error.reason === "event-handler")
154
+ return `event handler attributes like '${error.attribute}' are not allowed`;
155
+ if (error.reason === "javascript-url")
156
+ return `'${error.attribute}' cannot use a javascript: value`;
157
+ if (error.reason === "malformed")
158
+ return `could not parse HTML attributes near '${error.attribute}'`;
159
+ return `'${error.attribute}' is not an allowed HTML attribute`;
160
+ }
161
+ //# sourceMappingURL=html-attributes.js.map
@@ -24,8 +24,42 @@ export declare class ImageSourceCorruptError extends Error {
24
24
  * `params.format` (via `sharp().toFormat(...)`, always — re-encode is
25
25
  * unconditional).
26
26
  *
27
+ * Animated sources (multi-frame GIF/WebP/APNG): loaded with `{ animated: true }` whenever the
28
+ * target format can carry animation (`ANIMATION_CAPABLE_FORMATS`), so `.resize()` and `.toFormat()`
29
+ * operate on every frame instead of silently decoding only frame 0. This is safe to request
30
+ * unconditionally for a genuinely single-frame source too — verified empirically against this
31
+ * package's pinned `sharp@0.35.3`: a static image loaded with `{ animated: true }` produces
32
+ * byte-identical dimensions/output to loading it without the option (sharp resolves `nPages` to 1
33
+ * from the format's own page count, there is no multi-page metadata to read all of).
34
+ *
35
+ * When the target format CANNOT carry animation (jpeg/png), `{ animated: true }` is deliberately
36
+ * NOT passed — verified empirically that doing so anyway is worse than a plain flatten: sharp
37
+ * writes the whole multi-frame "toilet roll" (all frames stacked vertically) out as one tall,
38
+ * visibly-corrupted static image rather than either a clean single frame or a real animated
39
+ * output. Loading without the option keeps sharp's default single-page decode, which already
40
+ * yields a normal frame-0 still — a deliberate, now-documented flatten instead of an accident.
41
+ *
42
+ * Multi-frame resize correctness: `sharp`'s native pipeline is already page-height-aware — the
43
+ * `width`/`height` passed to `.resize()` are resolved against the PER-FRAME `pageHeight`, not the
44
+ * full multi-frame canvas, and the crop/scale math is applied identically per frame (verified with
45
+ * a 4-frame fixture and an aggressive mismatched-aspect crop — each frame's content stayed inside
46
+ * its own frame boundary). No special-cased width-only or manual `pageHeight` recomputation is
47
+ * needed here; adding one would be redundant with (and could conflict with) sharp's own handling.
48
+ *
49
+ * Resource bounds: sharp's default `limitInputPixels` (~268M px, unconditional, not raised by
50
+ * `{ animated: true }`) is checked against the FULL decoded canvas — for a multi-page load that
51
+ * means `width * (pageHeight * pages)`, i.e. total pixels across all frames combined, not per
52
+ * frame. Turning on `{ animated: true }` therefore does not remove or widen that ceiling. It does
53
+ * mean legitimate large animated files now do proportionally more decode/re-encode work than the
54
+ * previous (buggy) frame-0-only path — expected, since fully processing an animated file is the
55
+ * point of this fix — bounded on the input side by `DEFAULT_MAX_UPLOAD_BYTES` (10 MiB compressed)
56
+ * before any of these bytes ever reach this transformer.
57
+ *
27
58
  * @complexity O(pixels) — dominated by `sharp`'s native resize/encode work,
28
- * outside this function's control. Runs IN-PROCESS in this build (the
59
+ * outside this function's control. For an animated source this now scales with total pixels across
60
+ * ALL frames combined (previously just frame 0) — see "Resource bounds" above for why this widened
61
+ * cost is bounded by the same pre-existing `limitInputPixels`/upload-size caps rather than being an
62
+ * unbounded new surface. Runs IN-PROCESS in this build (the
29
63
  * out-of-process worker the original design calls for to protect the host from
30
64
  * `sharp` OOMing is explicitly out of scope for this task — see
31
65
  * `rendition-service.ts`'s file header).
@@ -49,6 +49,13 @@ export class ImageTransformUnavailableError extends Error {
49
49
  */
50
50
  export class ImageSourceCorruptError extends Error {
51
51
  }
52
+ /**
53
+ * {@link TransformFormat} values that can actually carry multiple frames on output. GIF and WebP
54
+ * both support animated re-encoding; JPEG and PNG do not (this build's plain libvips PNG writer
55
+ * does not emit APNG). Gates the `{ animated: true }` load option below — see that call site's
56
+ * comment for why loading animated but encoding to a non-animated format must NOT happen.
57
+ */
58
+ const ANIMATION_CAPABLE_FORMATS = new Set(["webp", "gif"]);
52
59
  /**
53
60
  * Lazily resolves the `sharp` module. See file header for why this is a
54
61
  * deferred, untyped `require()` (via `createRequire`) rather than a
@@ -75,8 +82,42 @@ function loadSharpFactory() {
75
82
  * `params.format` (via `sharp().toFormat(...)`, always — re-encode is
76
83
  * unconditional).
77
84
  *
85
+ * Animated sources (multi-frame GIF/WebP/APNG): loaded with `{ animated: true }` whenever the
86
+ * target format can carry animation (`ANIMATION_CAPABLE_FORMATS`), so `.resize()` and `.toFormat()`
87
+ * operate on every frame instead of silently decoding only frame 0. This is safe to request
88
+ * unconditionally for a genuinely single-frame source too — verified empirically against this
89
+ * package's pinned `sharp@0.35.3`: a static image loaded with `{ animated: true }` produces
90
+ * byte-identical dimensions/output to loading it without the option (sharp resolves `nPages` to 1
91
+ * from the format's own page count, there is no multi-page metadata to read all of).
92
+ *
93
+ * When the target format CANNOT carry animation (jpeg/png), `{ animated: true }` is deliberately
94
+ * NOT passed — verified empirically that doing so anyway is worse than a plain flatten: sharp
95
+ * writes the whole multi-frame "toilet roll" (all frames stacked vertically) out as one tall,
96
+ * visibly-corrupted static image rather than either a clean single frame or a real animated
97
+ * output. Loading without the option keeps sharp's default single-page decode, which already
98
+ * yields a normal frame-0 still — a deliberate, now-documented flatten instead of an accident.
99
+ *
100
+ * Multi-frame resize correctness: `sharp`'s native pipeline is already page-height-aware — the
101
+ * `width`/`height` passed to `.resize()` are resolved against the PER-FRAME `pageHeight`, not the
102
+ * full multi-frame canvas, and the crop/scale math is applied identically per frame (verified with
103
+ * a 4-frame fixture and an aggressive mismatched-aspect crop — each frame's content stayed inside
104
+ * its own frame boundary). No special-cased width-only or manual `pageHeight` recomputation is
105
+ * needed here; adding one would be redundant with (and could conflict with) sharp's own handling.
106
+ *
107
+ * Resource bounds: sharp's default `limitInputPixels` (~268M px, unconditional, not raised by
108
+ * `{ animated: true }`) is checked against the FULL decoded canvas — for a multi-page load that
109
+ * means `width * (pageHeight * pages)`, i.e. total pixels across all frames combined, not per
110
+ * frame. Turning on `{ animated: true }` therefore does not remove or widen that ceiling. It does
111
+ * mean legitimate large animated files now do proportionally more decode/re-encode work than the
112
+ * previous (buggy) frame-0-only path — expected, since fully processing an animated file is the
113
+ * point of this fix — bounded on the input side by `DEFAULT_MAX_UPLOAD_BYTES` (10 MiB compressed)
114
+ * before any of these bytes ever reach this transformer.
115
+ *
78
116
  * @complexity O(pixels) — dominated by `sharp`'s native resize/encode work,
79
- * outside this function's control. Runs IN-PROCESS in this build (the
117
+ * outside this function's control. For an animated source this now scales with total pixels across
118
+ * ALL frames combined (previously just frame 0) — see "Resource bounds" above for why this widened
119
+ * cost is bounded by the same pre-existing `limitInputPixels`/upload-size caps rather than being an
120
+ * unbounded new surface. Runs IN-PROCESS in this build (the
80
121
  * out-of-process worker the original design calls for to protect the host from
81
122
  * `sharp` OOMing is explicitly out of scope for this task — see
82
123
  * `rendition-service.ts`'s file header).
@@ -90,6 +131,7 @@ function loadSharpFactory() {
90
131
  export class SharpImageTransformer {
91
132
  async transform(input) {
92
133
  const sharpFactory = loadSharpFactory();
134
+ const preserveAnimation = ANIMATION_CAPABLE_FORMATS.has(input.params.format);
93
135
  // Pipeline construction through `toBuffer()` all live in one try/catch: any of these steps can
94
136
  // reject on bad SOURCE bytes (`sharp`'s decode is lazy — a malformed file often only surfaces
95
137
  // once a downstream operation like `toBuffer()` actually reads it), and every such rejection
@@ -97,7 +139,7 @@ export class SharpImageTransformer {
97
139
  // call in the chain actually threw. See {@link ImageSourceCorruptError}'s own doc for why this is
98
140
  // named/rethrown rather than left as an undifferentiated `sharp` error.
99
141
  try {
100
- let pipeline = sharpFactory(Buffer.from(input.bytes));
142
+ let pipeline = sharpFactory(Buffer.from(input.bytes), preserveAnimation ? { animated: true } : undefined);
101
143
  if (input.params.width !== undefined || input.params.height !== undefined) {
102
144
  pipeline = pipeline.resize(input.params.width, input.params.height, {
103
145
  fit: input.params.fit ?? "cover",
@@ -15,12 +15,13 @@ export type { MediaStatus, MediaSource, MediaRecord, AssetBlobStatus, AssetBlobR
15
15
  export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
16
16
  export type { MediaRepoPort, AssetBlobRepoPort, AssetRenditionRepoPort, BlobStorePort, BlobGcJournalRepoPort, PutBlobInput, TransformDefinitionRepoPort, } from "./ports.js";
17
17
  export { computeBlobStorageKey } from "./blob-key.js";
18
+ export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, type MediaHtmlAttributeRejectionReason, type MediaHtmlAttributeError, type ParsedMediaHtmlAttributes, } from "./html-attributes.js";
18
19
  export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
19
20
  export { withSha256Lock } from "./blob-gc-lock.js";
20
21
  export { DEFAULT_GC_GRACE_MS, resolveGcGraceMs, isBlobUnreferenced, tombstoneBlobIfUnreferenced, runBlobGcDeletePass, runBlobGcUnlinkPass, runBlobGcCycle, runMonthlyOrphanSweepStub, } from "./blob-gc.js";
21
22
  export { InMemoryBlobStore } from "./blob-store.memory.js";
22
23
  export { LocalFsBlobStore, type LocalFsBlobStoreDeps } from "./blob-store.fs.js";
23
- export { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_ALLOWED_MIME_TYPES, resolveWriteOnceSource, uploadMedia, listMedia, getMediaById, updateMediaMetadata, trashMedia, purgeMedia, type UploadMediaInput, type UploadMediaDeps, type UpdateMediaMetadataInput, } from "./media-service.js";
24
+ export { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_ALLOWED_MIME_TYPES, resolveWriteOnceSource, uploadMedia, listMedia, getMediaById, findMediaByIdOrSlug, updateMediaMetadata, trashMedia, purgeMedia, isValidMediaSlugFormat, type UploadMediaInput, type UploadMediaDeps, type UpdateMediaMetadataInput, type FindMediaByIdOrSlugRequired, } from "./media-service.js";
24
25
  export type { TransformFit, TransformFormat, TransformParams, TransformDefinitionRecord, } from "./transform-types.js";
25
26
  export { TransformValidationError, mimeForTransformFormat, MAX_TRANSFORM_DIMENSION_PX } from "./transform-types.js";
26
27
  export { withRenditionLock, withTransformRegistryLock } from "./transform-lock.js";
@@ -1,11 +1,12 @@
1
1
  export { MediaNotFoundError, MediaValidationError, MediaConflictError, MediaSourceImmutableError, MediaStillReferencedError, } from "./types.js";
2
2
  export { computeBlobStorageKey } from "./blob-key.js";
3
+ export { MEDIA_HTML_ATTRIBUTE_ALLOWED_NAMES, isAllowedMediaHtmlAttributeName, parseMediaHtmlAttributes, describeMediaHtmlAttributeError, } from "./html-attributes.js";
3
4
  export { InMemoryMediaRepo, InMemoryAssetBlobRepo, InMemoryAssetRenditionRepo, InMemoryBlobGcJournalRepo, InMemoryTransformDefinitionRepo, } from "./repo.memory.js";
4
5
  export { withSha256Lock } from "./blob-gc-lock.js";
5
6
  export { DEFAULT_GC_GRACE_MS, resolveGcGraceMs, isBlobUnreferenced, tombstoneBlobIfUnreferenced, runBlobGcDeletePass, runBlobGcUnlinkPass, runBlobGcCycle, runMonthlyOrphanSweepStub, } from "./blob-gc.js";
6
7
  export { InMemoryBlobStore } from "./blob-store.memory.js";
7
8
  export { LocalFsBlobStore } from "./blob-store.fs.js";
8
- export { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_ALLOWED_MIME_TYPES, resolveWriteOnceSource, uploadMedia, listMedia, getMediaById, updateMediaMetadata, trashMedia, purgeMedia, } from "./media-service.js";
9
+ export { DEFAULT_MAX_UPLOAD_BYTES, DEFAULT_ALLOWED_MIME_TYPES, resolveWriteOnceSource, uploadMedia, listMedia, getMediaById, findMediaByIdOrSlug, updateMediaMetadata, trashMedia, purgeMedia, isValidMediaSlugFormat, } from "./media-service.js";
9
10
  export { TransformValidationError, mimeForTransformFormat, MAX_TRANSFORM_DIMENSION_PX } from "./transform-types.js";
10
11
  export { withRenditionLock, withTransformRegistryLock } from "./transform-lock.js";
11
12
  export { registerTransform, getLatestTransformDefinition, isLatestTransformVersion, isReferencedByPublishedContent, } from "./transform-registry.js";
@@ -10,12 +10,20 @@ export declare const DEFAULT_MAX_UPLOAD_BYTES: number;
10
10
  * that sanitizer is not built in this pass, so SVG upload is rejected rather
11
11
  * than accepted unsanitized.
12
12
  *
13
+ * `image/avif` (owner-directed, 2026-09-06): a fifth still-image type, on the
14
+ * same footing as the four above — `content-type-sniffer.ts` identifies it by
15
+ * ISO-BMFF brand and the installed `sharp`/libvips build decodes it, so it
16
+ * flows through the ordinary transform/rendition pipeline and is re-encoded
17
+ * like any other image. Adding it required a matching sniffer fix, not just
18
+ * this line: AVIF shares MP4's `ftyp` container tag, so before that fix an
19
+ * AVIF was sniffed as `video/mp4` and served as an unplayable video.
20
+ *
13
21
  * `video/mp4`/`video/webm` (owner-directed, 2026-08-24): the two formats
14
22
  * `content-type-sniffer.ts` already recognizes by magic bytes. Unlike the four
15
23
  * image types above, an accepted video is never re-encoded — there is no
16
24
  * `TransformFormat` for video (`transform-types.ts`'s union is image-only), so
17
25
  * a video asset's public URL bypasses the transform/rendition pipeline
18
- * entirely and serves the original bytes as-is (host concern; see Tovu's
26
+ * entirely and serves the original bytes as-is (host concern; see the host's
19
27
  * `routes/site/media-rendition.ts`). This widens the SAME advisory,
20
28
  * client-declared-string check item 4 above already discloses as untrusted —
21
29
  * no new ingress hardening was added for video.
@@ -41,6 +49,7 @@ export declare function resolveWriteOnceSource(required: {
41
49
  existing: MediaSource | undefined;
42
50
  requestedSha256: string;
43
51
  }, _optional?: Record<string, never>): MediaSource;
52
+ export declare function isValidMediaSlugFormat(slug: string): boolean;
44
53
  export interface UploadMediaInput {
45
54
  workspaceId: UUID;
46
55
  bytes: Uint8Array;
@@ -86,7 +95,8 @@ export interface UploadMediaOptional {
86
95
  *
87
96
  * @complexity O(1) — one hash, one blob lookup, at most one blob write, one
88
97
  * media write, one rendition write (the lock itself adds O(1) scheduling
89
- * overhead, not a scan).
98
+ * overhead, not a scan) — plus {@link deriveUniqueMediaSlug}'s own O(n) in the number of prior
99
+ * same-base-slug collisions (see that function's doc).
90
100
  * @overallScore 100
91
101
  */
92
102
  export declare function uploadMedia(required: UploadMediaRequired, optional?: UploadMediaOptional): Promise<{
@@ -131,6 +141,39 @@ export interface GetMediaByIdRequired {
131
141
  export declare function getMediaById(required: GetMediaByIdRequired, _optional?: Record<string, never>): Promise<{
132
142
  media: MediaRecord;
133
143
  }>;
144
+ export interface FindMediaByIdOrSlugRequired {
145
+ deps: {
146
+ mediaRepo: MediaRepoPort;
147
+ };
148
+ input: {
149
+ workspaceId: UUID;
150
+ idOrSlug: string;
151
+ };
152
+ }
153
+ /**
154
+ * Resolves a media asset by either its id or its slug — ID FIRST, slug second. The slug is the
155
+ * handle a human typed into an embed marker or a hand-authored `<img>`/`<video>` tag; the id is the
156
+ * opaque UUID every existing reference already uses, and every URL a host's renderer emits.
157
+ *
158
+ * THE ORDER IS A SECURITY PROPERTY, not a preference (2026-09-07). This resolved slug-first, on the
159
+ * documented assumption that "an id never collides with a slug in practice since slugs pass through
160
+ * {@link isValidMediaSlugFormat}". That assumption was false: a lowercase UUID matched the old slug
161
+ * pattern `[a-z0-9-]+` exactly, so setting asset B's slug to asset A's id made every `/m/{A.id}/…`
162
+ * URL — the ones a renderer emitted for A, already live in published pages — resolve to B instead.
163
+ * An id is minted by the system and is never a value a human chooses, so it must always win; the
164
+ * write path additionally refuses UUID-shaped slugs now (see
165
+ * {@link MEDIA_SLUG_UUID_SHAPE_PATTERN}), but ordering is what protects rows written before that
166
+ * rule existed. Id-first costs nothing on the slug path: a real slug simply misses the id lookup and
167
+ * falls through.
168
+ *
169
+ * Never throws (unlike {@link getMediaById}): every call site that needs this (an embed resolver, a
170
+ * public rendition route) already has its own REQ-27-style non-throwing-miss contract, so this
171
+ * returns `null` on failure and lets the caller apply its own not-found handling rather than forcing
172
+ * one shape on every caller.
173
+ *
174
+ * @complexity O(1) — at most two indexed repo lookups, short-circuited on the first hit.
175
+ */
176
+ export declare function findMediaByIdOrSlug(required: FindMediaByIdOrSlugRequired, _optional?: Record<string, never>): Promise<MediaRecord | null>;
134
177
  export interface UpdateMediaMetadataInput {
135
178
  workspaceId: UUID;
136
179
  id: UUID;
@@ -152,6 +195,23 @@ export interface UpdateMediaMetadataInput {
152
195
  * a string that trims to empty is stored as `null` (equivalent to clearing it), matching the
153
196
  * "empty means unset" convention `title`/`alt`/etc. already follow via `.trim()`. */
154
197
  cssClass?: string | null | undefined;
198
+ /**
199
+ * Explicit slug edit (2026-09-07). `undefined` (key omitted) leaves the stored slug unchanged —
200
+ * in particular, changing `title` on the SAME call never touches `slug`; they are independent
201
+ * fields by design (see `MediaRecord.slug`'s doc). Unlike `title`/`cssClass`, `slug` has no
202
+ * "empty means unset" fallback: a media asset's slug is never null once assigned, so a caller
203
+ * cannot clear it back to absent, only replace it with a different valid slug.
204
+ */
205
+ slug?: string | undefined;
206
+ /**
207
+ * Same undefined/null/value contract as `cssClass` above (2026-09-07). A provided non-empty string
208
+ * is validated against `html-attributes.ts`'s allowlist BEFORE it is trimmed and stored — an `on*`
209
+ * handler, a `javascript:` value, or any disallowed name throws `MediaValidationError` naming the
210
+ * exact rejected attribute, and NOTHING is written (this field included) when that happens. A
211
+ * string that validates but trims to empty is stored as `null`, matching `cssClass`'s identical
212
+ * "empty means unset" convention.
213
+ */
214
+ htmlAttributes?: string | null | undefined;
155
215
  }
156
216
  export interface UpdateMediaMetadataDeps {
157
217
  clock: ClockPort;