@velora-cms/server 0.11.0 → 0.12.0

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 (75) hide show
  1. package/dist/admin/sync-admin-bundle.d.ts +9 -0
  2. package/dist/admin/sync-admin-bundle.js +120 -0
  3. package/dist/admin-bundle/.velora-version +1 -0
  4. package/dist/admin-bundle/assets/{_virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DOKdWYyj.js → _virtual_mf-localSharedImportMap___mfe_internal__velora_admin__mf_owner__1-DlIgIuFP.js} +1 -1
  5. package/dist/admin-bundle/assets/{_virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-C1-ZAxwO.js → _virtual_mf___mfe_internal__velora_admin__mf_owner__1__loadShare___mf_0_velora_mf_2_cms_mf_1_plugin_mf_2_sdk__loadShare__.js-Bm9EahCG.js} +2 -2
  6. package/dist/admin-bundle/assets/{content-trash.schema-DfXHkC-w.js → content-trash.schema-tvJ4Lbos.js} +5 -5
  7. package/dist/admin-bundle/assets/{hostInit--C4xK6nj.js → hostInit-B-6-34XY.js} +1 -1
  8. package/dist/admin-bundle/assets/{index-DhMY-0sC.js → index-CHCGuSbb.js} +1 -1
  9. package/dist/admin-bundle/assets/{index-BIAF0ShP.js → index-DYO7-IK8.js} +33 -33
  10. package/dist/admin-bundle/assets/{mf-entry-bootstrap-0-aef015cd.js → mf-entry-bootstrap-0-7b0ce2fe.js} +2 -2
  11. package/dist/admin-bundle/assets/{remoteEntry-CNyN68bc.js → remoteEntry-J2yJXsGB.js} +2 -2
  12. package/dist/admin-bundle/index.html +4 -4
  13. package/dist/app.d.ts +16 -1
  14. package/dist/app.js +63 -4
  15. package/dist/auth/api-keys.d.ts +1 -0
  16. package/dist/auth/api-keys.js +5 -1
  17. package/dist/auth/route-rate-limits.d.ts +4 -0
  18. package/dist/auth/route-rate-limits.js +13 -0
  19. package/dist/bin-sync-admin.d.ts +2 -0
  20. package/dist/bin-sync-admin.js +39 -0
  21. package/dist/bundled-templates/com.velora.blog/package.tar.gz +0 -0
  22. package/dist/config/public-url.d.ts +3 -0
  23. package/dist/config/public-url.js +45 -0
  24. package/dist/db/core-migrations/steps.js +95 -16
  25. package/dist/docs-bundle/pagefind/fragment/en_42f5356.pf_fragment +0 -0
  26. package/dist/docs-bundle/pagefind/fragment/en_67dccf8.pf_fragment +0 -0
  27. package/dist/docs-bundle/pagefind/fragment/en_80941b8.pf_fragment +0 -0
  28. package/dist/docs-bundle/pagefind/fragment/en_a64440b.pf_fragment +0 -0
  29. package/dist/docs-bundle/pagefind/fragment/en_eb168aa.pf_fragment +0 -0
  30. package/dist/docs-bundle/pagefind/index/en_8450ec7.pf_index +0 -0
  31. package/dist/docs-bundle/pagefind/index/en_c285f7b.pf_index +0 -0
  32. package/dist/docs-bundle/pagefind/index/en_e8cada7.pf_index +0 -0
  33. package/dist/docs-bundle/pagefind/pagefind-entry.json +1 -1
  34. package/dist/docs-bundle/pagefind/pagefind.en_57d2f8922f.pf_meta +0 -0
  35. package/dist/docs-bundle/v1/using-velora/getting-started/index.html +5 -3
  36. package/dist/docs-bundle/v1/using-velora/headless-api/index.html +10 -4
  37. package/dist/docs-bundle/v1/using-velora/index.html +6 -4
  38. package/dist/docs-bundle/v1/using-velora/installation/index.html +73 -15
  39. package/dist/docs-bundle/v1/using-velora/media/index.html +118 -34
  40. package/dist/graphql/schema.d.ts +1 -0
  41. package/dist/graphql/schema.js +5 -2
  42. package/dist/lib.d.ts +1 -0
  43. package/dist/lib.js +45 -11
  44. package/dist/media/media-service.d.ts +14 -15
  45. package/dist/media/media-service.js +121 -59
  46. package/dist/media/media-tokens.d.ts +6 -0
  47. package/dist/media/media-tokens.js +88 -0
  48. package/dist/media/storage/local.d.ts +16 -0
  49. package/dist/media/storage/local.js +99 -0
  50. package/dist/media/storage/s3.d.ts +25 -0
  51. package/dist/media/storage/s3.js +134 -0
  52. package/dist/media/storage/types.d.ts +21 -0
  53. package/dist/media/storage/types.js +44 -0
  54. package/dist/routes/admin-static.d.ts +2 -0
  55. package/dist/routes/admin-static.js +42 -4
  56. package/dist/routes/media-public.d.ts +6 -0
  57. package/dist/routes/media-public.js +265 -0
  58. package/dist/routes/media.d.ts +2 -1
  59. package/dist/routes/media.js +95 -5
  60. package/dist/routes/plugin-storage.js +5 -1
  61. package/dist/routes/public-api.js +5 -3
  62. package/dist/routes/published-item.d.ts +2 -1
  63. package/dist/routes/published-item.js +28 -5
  64. package/dist/scripts/copy-admin-bundle.js +9 -1
  65. package/package.json +7 -6
  66. package/prisma/schema.prisma +16 -3
  67. package/dist/docs-bundle/pagefind/fragment/en_1d9eb24.pf_fragment +0 -0
  68. package/dist/docs-bundle/pagefind/fragment/en_3099406.pf_fragment +0 -0
  69. package/dist/docs-bundle/pagefind/fragment/en_9aa652e.pf_fragment +0 -0
  70. package/dist/docs-bundle/pagefind/fragment/en_d855550.pf_fragment +0 -0
  71. package/dist/docs-bundle/pagefind/fragment/en_e6d88d8.pf_fragment +0 -0
  72. package/dist/docs-bundle/pagefind/index/en_5feeed7.pf_index +0 -0
  73. package/dist/docs-bundle/pagefind/index/en_c6981f9.pf_index +0 -0
  74. package/dist/docs-bundle/pagefind/index/en_ce4f6bf.pf_index +0 -0
  75. package/dist/docs-bundle/pagefind/pagefind.en_1e94abd852.pf_meta +0 -0
@@ -70,11 +70,14 @@
70
70
  })();
71
71
  </script></sl-sidebar-state-persist><div class="md:sl-hidden"><div class="mobile-preferences sl-flex astro-yfet6llj"><div class="social-icons astro-yfet6llj"></div><starlight-theme-select><label style="--sl-select-width: 6.25em" class="astro-7uz3f5wr"><span class="sr-only astro-7uz3f5wr">Select theme</span><svg aria-hidden="true" class="icon label-icon astro-7uz3f5wr astro-24n5ceuf" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M21 14h-1V7a3 3 0 0 0-3-3H7a3 3 0 0 0-3 3v7H3a1 1 0 0 0-1 1v2a3 3 0 0 0 3 3h14a3 3 0 0 0 3-3v-2a1 1 0 0 0-1-1ZM6 7a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v7H6V7Zm14 10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-1h16v1Z"/></svg><select autocomplete="off" class="astro-7uz3f5wr"><option value="dark" class="astro-7uz3f5wr">Dark</option><option value="light" class="astro-7uz3f5wr">Light</option><option value="auto" selected class="astro-7uz3f5wr">Auto</option></select><svg aria-hidden="true" class="icon caret astro-7uz3f5wr astro-24n5ceuf" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1em;"><path d="M17 9.17a1 1 0 0 0-1.41 0L12 12.71 8.46 9.17a1 1 0 1 0-1.41 1.42l4.24 4.24a1.002 1.002 0 0 0 1.42 0L17 10.59a1.002 1.002 0 0 0 0-1.42Z"/></svg></label></starlight-theme-select><script>
72
72
  StarlightThemeProvider.updatePickers();
73
- </script></div></div></div></div></nav><div class="main-frame astro-5auw3bkx"><script type="module">var e=document.getElementById(`starlight__sidebar`),t=e?.querySelector(`sl-sidebar-state-persist`),n=`sl-sidebar-state`,r=()=>{let r=[],i=t?.dataset.hash||``;try{let e=sessionStorage.getItem(n),t=JSON.parse(e||`{}`);Array.isArray(t.open)&&t.hash===i&&(r=t.open)}catch{}return{hash:i,open:r,scroll:e?.scrollTop||0}},i=e=>{try{sessionStorage.setItem(n,JSON.stringify(e))}catch{}},a=()=>i(r()),o=(e,t)=>{let n=r();n.open[t]=e,i(n)};t?.addEventListener(`click`,e=>{if(!(e.target instanceof Element))return;let t=e.target.closest(`summary`)?.closest(`details`);if(!t)return;let n=t.querySelector(`sl-sidebar-restore`),r=parseInt(n?.dataset.index||``);isNaN(r)||o(!t.open,r)}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&a()});</script><div class="lg:sl-flex astro-perbdhd2"><aside class="right-sidebar-container print:hidden astro-perbdhd2"><div class="right-sidebar astro-perbdhd2"><div class="lg:sl-hidden astro-epm5tkrc"><mobile-starlight-toc data-min-h="2" data-max-h="3" class="astro-bnvebtr4"><nav aria-labelledby="starlight__on-this-page--mobile" class="astro-bnvebtr4"><details id="starlight__mobile-toc" class="astro-bnvebtr4"><summary id="starlight__on-this-page--mobile" class="sl-flex astro-bnvebtr4"><span class="toggle sl-flex astro-bnvebtr4">On this page<svg aria-hidden="true" class="caret astro-bnvebtr4 astro-24n5ceuf" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1rem;"><path d="m14.83 11.29-4.24-4.24a1 1 0 1 0-1.42 1.41L12.71 12l-3.54 3.54a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.24-4.24a1.002 1.002 0 0 0 0-1.42Z"/></svg></span><span class="display-current astro-bnvebtr4"></span></summary><div class="dropdown astro-bnvebtr4"><ul class="isMobile astro-lptfoybu" style="--depth: 0;"><li style="--depth: 0;" class="astro-lptfoybu"><a href="#_top" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Overview</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-media-library" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The media library</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#what-a-media-record-stores" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">What a media record stores</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#where-the-bytes-live" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Where the bytes live</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#limits-and-what-is-not-checked" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Limits, and what is not checked</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#using-media-in-content" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Using media in content</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-api" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The API</span></a><ul class="isMobile astro-lptfoybu" style="--depth: 1;"><li style="--depth: 1;" class="astro-lptfoybu"><a href="#upload-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Upload a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#browse-the-library" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Browse the library</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#delete-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Delete a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#reading-media-from-a-frontend" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Reading media from a frontend</span></a></li></ul></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#next" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Next</span></a></li></ul></div></details></nav></mobile-starlight-toc><script type="module" src="/docs/_astro/MobileTableOfContents.astro_astro_type_script_index_0_lang.BcSo_yiZ.js"></script></div><div class="right-sidebar-panel sl-hidden lg:sl-block astro-epm5tkrc"><div class="sl-container astro-epm5tkrc"><starlight-toc data-min-h="2" data-max-h="3"><nav aria-labelledby="starlight__on-this-page"><h2 id="starlight__on-this-page">On this page</h2><ul class="astro-lptfoybu" style="--depth: 0;"><li style="--depth: 0;" class="astro-lptfoybu"><a href="#_top" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Overview</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-media-library" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The media library</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#what-a-media-record-stores" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">What a media record stores</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#where-the-bytes-live" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Where the bytes live</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#limits-and-what-is-not-checked" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Limits, and what is not checked</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#using-media-in-content" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Using media in content</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-api" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The API</span></a><ul class="astro-lptfoybu" style="--depth: 1;"><li style="--depth: 1;" class="astro-lptfoybu"><a href="#upload-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Upload a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#browse-the-library" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Browse the library</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#delete-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Delete a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#reading-media-from-a-frontend" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Reading media from a frontend</span></a></li></ul></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#next" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Next</span></a></li></ul></nav></starlight-toc><script type="module" src="/docs/_astro/TableOfContents.astro_astro_type_script_index_0_lang.Csloo1VZ.js"></script></div></div></div></aside><div class="main-pane astro-perbdhd2"><main data-pagefind-body class="astro-ewhz5xxr" lang="en" dir="ltr"><div class="content-panel astro-ahkzlqop"><div class="sl-container astro-ahkzlqop"><h1 id="_top" class="astro-oef2efwn">Media</h1></div></div><div class="content-panel astro-ahkzlqop"><div class="sl-container astro-ahkzlqop"><div class="sl-markdown-content"><p>Velora splits every uploaded file in two: the <strong>bytes</strong> go to an
74
- S3-compatible object store, and a <strong>metadata row</strong> — filename, MIME type,
75
- size, uploader, and a key pointing at the object — goes into Velora’s own
76
- database. Nothing in the CMS ever serves file bytes itself; every download
77
- is a short-lived presigned URL minted against the object store on the spot.
73
+ </script></div></div></div></div></nav><div class="main-frame astro-5auw3bkx"><script type="module">var e=document.getElementById(`starlight__sidebar`),t=e?.querySelector(`sl-sidebar-state-persist`),n=`sl-sidebar-state`,r=()=>{let r=[],i=t?.dataset.hash||``;try{let e=sessionStorage.getItem(n),t=JSON.parse(e||`{}`);Array.isArray(t.open)&&t.hash===i&&(r=t.open)}catch{}return{hash:i,open:r,scroll:e?.scrollTop||0}},i=e=>{try{sessionStorage.setItem(n,JSON.stringify(e))}catch{}},a=()=>i(r()),o=(e,t)=>{let n=r();n.open[t]=e,i(n)};t?.addEventListener(`click`,e=>{if(!(e.target instanceof Element))return;let t=e.target.closest(`summary`)?.closest(`details`);if(!t)return;let n=t.querySelector(`sl-sidebar-restore`),r=parseInt(n?.dataset.index||``);isNaN(r)||o(!t.open,r)}),addEventListener(`visibilitychange`,()=>{document.visibilityState===`hidden`&&a()});</script><div class="lg:sl-flex astro-perbdhd2"><aside class="right-sidebar-container print:hidden astro-perbdhd2"><div class="right-sidebar astro-perbdhd2"><div class="lg:sl-hidden astro-epm5tkrc"><mobile-starlight-toc data-min-h="2" data-max-h="3" class="astro-bnvebtr4"><nav aria-labelledby="starlight__on-this-page--mobile" class="astro-bnvebtr4"><details id="starlight__mobile-toc" class="astro-bnvebtr4"><summary id="starlight__on-this-page--mobile" class="sl-flex astro-bnvebtr4"><span class="toggle sl-flex astro-bnvebtr4">On this page<svg aria-hidden="true" class="caret astro-bnvebtr4 astro-24n5ceuf" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="--sl-icon-size: 1rem;"><path d="m14.83 11.29-4.24-4.24a1 1 0 1 0-1.42 1.41L12.71 12l-3.54 3.54a1 1 0 0 0 0 1.41 1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.24-4.24a1.002 1.002 0 0 0 0-1.42Z"/></svg></span><span class="display-current astro-bnvebtr4"></span></summary><div class="dropdown astro-bnvebtr4"><ul class="isMobile astro-lptfoybu" style="--depth: 0;"><li style="--depth: 0;" class="astro-lptfoybu"><a href="#_top" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Overview</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-media-library" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The media library</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#what-a-media-record-stores" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">What a media record stores</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#where-the-bytes-live" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Where the bytes live</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#visibility-public-and-private" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Visibility: public and private</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#temporary-links-for-private-files" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Temporary links for private files</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#renditions-reserved" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Renditions (reserved)</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#limits-and-what-is-not-checked" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Limits, and what is not checked</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#using-media-in-content" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Using media in content</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-api" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The API</span></a><ul class="isMobile astro-lptfoybu" style="--depth: 1;"><li style="--depth: 1;" class="astro-lptfoybu"><a href="#upload-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Upload a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#browse-the-library" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Browse the library</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#delete-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Delete a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#reading-media-from-a-frontend" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Reading media from a frontend</span></a></li></ul></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#next" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Next</span></a></li></ul></div></details></nav></mobile-starlight-toc><script type="module" src="/docs/_astro/MobileTableOfContents.astro_astro_type_script_index_0_lang.BcSo_yiZ.js"></script></div><div class="right-sidebar-panel sl-hidden lg:sl-block astro-epm5tkrc"><div class="sl-container astro-epm5tkrc"><starlight-toc data-min-h="2" data-max-h="3"><nav aria-labelledby="starlight__on-this-page"><h2 id="starlight__on-this-page">On this page</h2><ul class="astro-lptfoybu" style="--depth: 0;"><li style="--depth: 0;" class="astro-lptfoybu"><a href="#_top" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Overview</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-media-library" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The media library</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#what-a-media-record-stores" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">What a media record stores</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#where-the-bytes-live" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Where the bytes live</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#visibility-public-and-private" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Visibility: public and private</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#temporary-links-for-private-files" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Temporary links for private files</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#renditions-reserved" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Renditions (reserved)</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#limits-and-what-is-not-checked" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Limits, and what is not checked</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#using-media-in-content" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Using media in content</span></a></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#the-api" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">The API</span></a><ul class="astro-lptfoybu" style="--depth: 1;"><li style="--depth: 1;" class="astro-lptfoybu"><a href="#upload-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Upload a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#browse-the-library" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Browse the library</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#delete-a-file" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Delete a file</span></a></li><li style="--depth: 1;" class="astro-lptfoybu"><a href="#reading-media-from-a-frontend" style="--depth: 1;" class="astro-lptfoybu"><span style="--depth: 1;" class="astro-lptfoybu">Reading media from a frontend</span></a></li></ul></li><li style="--depth: 0;" class="astro-lptfoybu"><a href="#next" style="--depth: 0;" class="astro-lptfoybu"><span style="--depth: 0;" class="astro-lptfoybu">Next</span></a></li></ul></nav></starlight-toc><script type="module" src="/docs/_astro/TableOfContents.astro_astro_type_script_index_0_lang.Csloo1VZ.js"></script></div></div></div></aside><div class="main-pane astro-perbdhd2"><main data-pagefind-body class="astro-ewhz5xxr" lang="en" dir="ltr"><div class="content-panel astro-ahkzlqop"><div class="sl-container astro-ahkzlqop"><h1 id="_top" class="astro-oef2efwn">Media</h1></div></div><div class="content-panel astro-ahkzlqop"><div class="sl-container astro-ahkzlqop"><div class="sl-markdown-content"><p>Velora splits every uploaded file in two: the <strong>bytes</strong> go to a storage
74
+ driver (local disk by default, or an S3-compatible object store), and a
75
+ <strong>metadata row</strong> filename, MIME type, size, uploader, a public/private
76
+ flag, and a key pointing at the object goes into Velora’s own database.
77
+ The CMS itself serves the bytes, at one stable, permanent URL per file:
78
+ <code dir="auto">GET /media/:id/:filename</code>. A <strong>public</strong> file’s URL never changes and never
79
+ expires — safe to embed in content. A <strong>private</strong> file’s URL 404s for
80
+ anyone without a session, an API key, or a short-lived temporary link.
78
81
  Everything on this page follows from that split.</p>
79
82
  <div class="sl-heading-wrapper level-h2"><h2 id="the-media-library">The media library</h2><a class="sl-anchor-link" href="#the-media-library"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “The media library”</span></a></div>
80
83
  <p>The <strong>Media</strong> section is a flat, searchable library of every file this
@@ -154,15 +157,37 @@ that confirmation really is final.</p>
154
157
 
155
158
 
156
159
 
157
- <table><thead><tr><th>Name</th><th>Type</th><th>Meaning</th><th>Example</th></tr></thead><tbody><tr><td><code dir="auto">id</code></td><td>string (uuid)</td><td>The file’s identity. This is the value an Image field stores, and the <code dir="auto">:id</code> in every media route.</td><td><code dir="auto">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</code></td></tr><tr><td><code dir="auto">filename</code></td><td>string, non-empty</td><td>The original filename, kept verbatim — spaces and non-ASCII characters included. Only the storage key is sanitised, never this.</td><td><code dir="auto">route test file.txt</code></td></tr><tr><td><code dir="auto">mimeType</code></td><td>string, non-empty</td><td>The content type the browser sent with the upload part. Stored as given and replayed as the object’s <code dir="auto">Content-Type</code>.</td><td><code dir="auto">text/plain</code></td></tr><tr><td><code dir="auto">size</code></td><td>integer ≥ 0</td><td>Byte length of the uploaded buffer, measured server-side — not a client-declared value.</td><td><code dir="auto">42</code></td></tr><tr><td><code dir="auto">url</code></td><td>string (URL)</td><td>The <strong>canonical</strong> object URL, <code dir="auto">&#x3C;S3_ENDPOINT>/&#x3C;S3_BUCKET>/&#x3C;id>/&#x3C;sanitised filename></code>. Persisted, but not usable on a private bucket — it is a stable identity, not a download link. Admin responses only.</td><td><code dir="auto">http://localhost:9000/velora-media/9f1d3b7c…/route_test_file.txt</code></td></tr><tr><td><code dir="auto">uploadedBy</code></td><td>string (uuid)</td><td>The user who uploaded it, taken from the session token, never from the request. Admin responses only.</td><td><code dir="auto">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</code></td></tr><tr><td><code dir="auto">uploadedAt</code></td><td>ISO datetime string</td><td>When the row was written. The library sorts on this, newest first, with <code dir="auto">id</code> breaking ties.</td><td><code dir="auto">2026-08-31T09:12:04.006Z</code></td></tr><tr><td><code dir="auto">downloadUrl</code></td><td>string (URL)</td><td>A <strong>presigned GET</strong>, generated per response and valid for 900 seconds (15 minutes). Never persisted, never the same twice. Refetch the record when it expires.</td><td><code dir="auto">http://localhost:9000/velora-media/9f1d3b7c…?X-Amz-Expires=900&#x26;…</code></td></tr></tbody></table>
158
- <p><code dir="auto">downloadUrl</code> is the only property here that is computed rather than read.
159
- That is deliberate: it works against a private bucket, so no media has to
160
- be world-readable for the admin to show a thumbnail.</p>
160
+
161
+
162
+
163
+
164
+
165
+
166
+ <table><thead><tr><th>Name</th><th>Type</th><th>Meaning</th><th>Example</th></tr></thead><tbody><tr><td><code dir="auto">id</code></td><td>string (uuid)</td><td>The file’s identity. This is the value an Image field stores, and the <code dir="auto">:id</code> in every media route.</td><td><code dir="auto">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</code></td></tr><tr><td><code dir="auto">filename</code></td><td>string, non-empty</td><td>The original filename, kept verbatim — spaces and non-ASCII characters included. Only the storage key is sanitised, never this.</td><td><code dir="auto">route test file.txt</code></td></tr><tr><td><code dir="auto">mimeType</code></td><td>string, non-empty</td><td>The content type the browser sent with the upload part. Stored as given and replayed as the object’s <code dir="auto">Content-Type</code>.</td><td><code dir="auto">text/plain</code></td></tr><tr><td><code dir="auto">size</code></td><td>integer ≥ 0</td><td>Byte length of the uploaded buffer, measured server-side — not a client-declared value.</td><td><code dir="auto">42</code></td></tr><tr><td><code dir="auto">url</code></td><td>string (path)</td><td>The <strong>stable app path</strong>, <code dir="auto">/media/&#x3C;id>/&#x3C;filename></code> — computed from the row, never stored as an absolute host/port/endpoint that could go stale. Identical to <code dir="auto">downloadUrl</code> for a public item. Admin responses only.</td><td><code dir="auto">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/route_test_file.txt</code></td></tr><tr><td><code dir="auto">visibility</code></td><td><code dir="auto">"public"</code> | <code dir="auto">"private"</code></td><td>Whether the stable URL above serves anonymously. Default <code dir="auto">public</code>. See <a href="#visibility-public-and-private">Visibility</a> below.</td><td><code dir="auto">public</code></td></tr><tr><td><code dir="auto">uploadedBy</code></td><td>string (uuid)</td><td>The user who uploaded it, taken from the session token, never from the request. Admin responses only.</td><td><code dir="auto">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</code></td></tr><tr><td><code dir="auto">uploadedAt</code></td><td>ISO datetime string</td><td>When the row was written. The library sorts on this, newest first, with <code dir="auto">id</code> breaking ties.</td><td><code dir="auto">2026-08-31T09:12:04.006Z</code></td></tr><tr><td><code dir="auto">downloadUrl</code></td><td>string (path)</td><td>The same stable path as <code dir="auto">url</code> for a <strong>public</strong> item — permanent, safe to persist or embed. For a <strong>private</strong> item, that path plus a freshly minted <code dir="auto">?token=</code> good for 15 minutes: a new one on every response, meant for immediate use (rendering a thumbnail now), not for saving.</td><td><code dir="auto">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/route_test_file.txt</code></td></tr></tbody></table>
167
+ <p><code dir="auto">downloadUrl</code> is the only property here that is computed rather than read
168
+ straight from the row: it’s what adds the temporary token for a private
169
+ item, so no media has to be world-readable for the admin to show a
170
+ thumbnail.</p>
161
171
  <div class="sl-heading-wrapper level-h2"><h2 id="where-the-bytes-live">Where the bytes live</h2><a class="sl-anchor-link" href="#where-the-bytes-live"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “Where the bytes live”</span></a></div>
162
- <p>Storage is any S3-compatible endpoint. The defaults match the <code dir="auto">minio</code>
163
- service in the repository’s <code dir="auto">docker-compose.yml</code>, so local development
164
- needs no configuration at all; production points the same five variables at
165
- a real provider.</p>
172
+ <p>Velora talks to storage through one interface with two implementations, so
173
+ neither the CMS nor a plugin ever has to know which is active:</p>
174
+ <ul>
175
+ <li><strong>Local disk</strong> — the scaffold default. Uploads land under <code dir="auto">MEDIA_DIR</code>
176
+ (default <code dir="auto">./media</code>, resolved against the working directory), right next
177
+ to <code dir="auto">velora.db</code>. There is no object store to run for a simple site: back
178
+ up the project folder and you’ve backed up the uploads too.</li>
179
+ <li><strong>S3-compatible object storage</strong> — for a deployment that wants to scale
180
+ storage independently, or already runs S3/R2/MinIO. One private bucket,
181
+ same keys as the local driver; the server is still the only thing that
182
+ ever serves the bytes.</li>
183
+ </ul>
184
+ <p>Which one runs is decided at boot, in order: <code dir="auto">MEDIA_STORAGE=local</code> or
185
+ <code dir="auto">MEDIA_STORAGE=s3</code> wins outright if set; otherwise, any <code dir="auto">S3_*</code> variable
186
+ being set (non-empty) selects <code dir="auto">s3</code>; with nothing set at all, a fresh
187
+ scaffold gets local disk automatically. See <code dir="auto">MEDIA_STORAGE</code> and <code dir="auto">MEDIA_DIR</code>
188
+ in <a href="/docs/v1/using-velora/installation/#media-storage">Installation</a> for the
189
+ full variable reference, alongside the five <code dir="auto">S3_*</code> variables an S3
190
+ deployment needs:</p>
166
191
 
167
192
 
168
193
 
@@ -205,14 +230,62 @@ a real provider.</p>
205
230
 
206
231
 
207
232
  <table><thead><tr><th>Name</th><th>Type</th><th>Meaning</th><th>Example</th></tr></thead><tbody><tr><td><code dir="auto">S3_ENDPOINT</code></td><td>string (URL)</td><td>Object storage endpoint. Requests are path-style, so a <code dir="auto">localhost</code> endpoint works. Default <code dir="auto">http://localhost:9000</code>.</td><td><code dir="auto">https://s3.eu-central-1.amazonaws.com</code></td></tr><tr><td><code dir="auto">S3_REGION</code></td><td>string</td><td>Region passed to the S3 client. Default <code dir="auto">us-east-1</code>.</td><td><code dir="auto">eu-central-1</code></td></tr><tr><td><code dir="auto">S3_ACCESS_KEY_ID</code></td><td>string</td><td>Access key id. Default <code dir="auto">velora</code>.</td><td><code dir="auto">AKIA…</code></td></tr><tr><td><code dir="auto">S3_SECRET_ACCESS_KEY</code></td><td>string</td><td>Secret access key. Default <code dir="auto">velora-secret</code>.</td><td><code dir="auto">wJalr…</code></td></tr><tr><td><code dir="auto">S3_BUCKET</code></td><td>string</td><td>Bucket the objects land in. Created on startup if missing. Default <code dir="auto">velora-media</code>.</td><td><code dir="auto">velora-media</code></td></tr></tbody></table>
208
- <p>The object key is <code dir="auto">&#x3C;media id>/&#x3C;sanitised filename></code>, where sanitising
209
- replaces every character outside <code dir="auto">A–Z a–z 0–9 . _ -</code> with <code dir="auto">_</code>. Prefixing
210
- with the id means two uploads of <code dir="auto">logo.png</code> never collide, and the original
211
- filename still survives untouched in the metadata row.</p>
233
+ <p>In both drivers the object key is <code dir="auto">&#x3C;media id>/&#x3C;sanitised filename></code>, where
234
+ sanitising replaces every character outside <code dir="auto">A–Z a–z 0–9 . _ -</code> with <code dir="auto">_</code>.
235
+ Prefixing with the id means two uploads of <code dir="auto">logo.png</code> never collide, and
236
+ the original filename still survives untouched in the metadata row.</p>
212
237
  <p>The two writes are ordered so metadata stays the source of truth: the
213
238
  object is stored first, then the row; if the row insert fails, the
214
239
  just-written object is deleted again. There is no path that leaves an
215
240
  object no row points at.</p>
241
+ <div class="sl-heading-wrapper level-h2"><h2 id="visibility-public-and-private">Visibility: public and private</h2><a class="sl-anchor-link" href="#visibility-public-and-private"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “Visibility: public and private”</span></a></div>
242
+ <p>Every media item carries a <code dir="auto">visibility</code> flag, <code dir="auto">public</code> or <code dir="auto">private</code> —
243
+ defaulting to <code dir="auto">public</code>, the normal case for images embedded in content.
244
+ It’s a checkbox on each item in the media library (also shown as a
245
+ <strong>Private</strong> badge on the thumbnail), and flipping it only ever updates that
246
+ one row: no file move, no key change, and — deliberately — no URL change,
247
+ in either driver.</p>
248
+ <ul>
249
+ <li><strong>Public</strong> items serve from <code dir="auto">GET /media/:id/:filename</code> with no
250
+ credentials, and the response carries a permanent, cacheable header
251
+ (<code dir="auto">Cache-Control: public, max-age=31536000, immutable</code>) — safe to embed
252
+ directly in published content.</li>
253
+ <li><strong>Private</strong> items answer that same URL only for a signed-in admin
254
+ session, a read-scoped API key, or a valid <code dir="auto">?token=</code> (see <a href="#temporary-links-for-private-files">Temporary
255
+ links</a> below). Everyone else gets a
256
+ <code dir="auto">404</code> — the same body an unknown id returns, so the response never
257
+ reveals whether a private id exists at all.</li>
258
+ </ul>
259
+ <p>One caveat worth knowing before you rely on flipping a file private: a
260
+ browser or CDN that already cached a public copy under that permanent
261
+ header can keep serving it — to whoever holds that copy — for up to a
262
+ year after you check the box. The visibility check only governs <em>new</em>
263
+ requests; there’s no cache-purge step on flip, since the URL is
264
+ deliberately stable across the change. The admin’s help text under the
265
+ checkbox says the same thing.</p>
266
+ <p>Certain file types always download instead of rendering inline — <code dir="auto">text/html</code>,
267
+ <code dir="auto">image/svg+xml</code>, and other markup/script-capable types — even for a public
268
+ item, so a hostile upload can never execute script on the CMS’s own origin.
269
+ Ordinary images, video, audio, PDF, and plain text still render inline as
270
+ you’d expect.</p>
271
+ <div class="sl-heading-wrapper level-h2"><h2 id="temporary-links-for-private-files">Temporary links for private files</h2><a class="sl-anchor-link" href="#temporary-links-for-private-files"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “Temporary links for private files”</span></a></div>
272
+ <p>A private item needs a way to hand out a link without opening a session —
273
+ a review link, an email attachment. The admin’s <strong>Copy temporary link</strong>
274
+ button (shown next to a private item only) mints one:</p>
275
+ <div class="expressive-code"><link rel="stylesheet" href="/docs/_astro/ec.w36nc.css"><script type="module" src="/docs/_astro/ec.0vx5m.js"></script><figure class="frame is-terminal not-content"><figcaption class="header"><span class="title"></span><span class="sr-only">Terminal window</span></figcaption><pre data-language="sh"><code><div class="ec-line"><div class="code"><span style="--0:#82AAFF;--1:#3B61B0">curl</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-s</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-X</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#ECC48D;--1:#3B61B0">POST</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#ECC48D;--1:#3B61B0">http://localhost:3000/api/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/link</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#F78C6C;--1:#AA0982">\</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#82AAFF;--1:#3B61B0">-H</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">Authorization: Bearer </span><span style="--0:#C5E478;--1:#3B61B0">$TOKEN</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="curl -s -X POST http://localhost:3000/api/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/link \ -H &#x22;Authorization: Bearer $TOKEN&#x22;"><div></div></button></div></figure></div>
276
+ <p>The response is the stable URL with a signed <code dir="auto">?token=</code> appended, and when
277
+ it stops working:</p>
278
+ <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"url"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png?token=eyJpZCI6…</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"expiresAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-09-02T09:27:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;url&#x22;: &#x22;/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png?token=eyJpZCI6…&#x22;, &#x22;expiresAt&#x22;: &#x22;2026-09-02T09:27:04.006Z&#x22;}"><div></div></button></div></figure></div>
279
+ <p>The token defaults to 15 minutes and is scoped to that one media id — it
280
+ does nothing against any other item’s URL, and it stops working outright
281
+ past <code dir="auto">expiresAt</code> (a <code dir="auto">404</code>, same as every other rejection this route makes).
282
+ Minting a new one doesn’t invalidate an older still-live token; there’s no
283
+ revoke, so treat a copied link as good until it naturally expires.</p>
284
+ <div class="sl-heading-wrapper level-h2"><h2 id="renditions-reserved">Renditions (reserved)</h2><a class="sl-anchor-link" href="#renditions-reserved"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “Renditions (reserved)”</span></a></div>
285
+ <p><code dir="auto">?preset=&#x3C;name></code> and <code dir="auto">?w=&#x3C;px></code> are reserved on <code dir="auto">GET /media/:id/:filename</code>
286
+ for a future crop/scale feature — the route rejects both with a <code dir="auto">400</code>
287
+ today rather than silently ignoring them, so that a future implementation
288
+ isn’t a silent behavior change for anyone already using them.</p>
216
289
  <div class="sl-heading-wrapper level-h2"><h2 id="limits-and-what-is-not-checked">Limits, and what is not checked</h2><a class="sl-anchor-link" href="#limits-and-what-is-not-checked"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “Limits, and what is not checked”</span></a></div>
217
290
 
218
291
 
@@ -254,11 +327,13 @@ your proxy or object store.</p>
254
327
  <p>The <strong>Image</strong> data type (editor id <code dir="auto">com.velora.image</code>) does not embed a
255
328
  copy of the file. Its stored value is the media id — one string — so a
256
329
  content version’s <code dir="auto">data</code> for an Image field looks like this:</p>
257
- <div class="expressive-code"><link rel="stylesheet" href="/docs/_astro/ec.w36nc.css"><script type="module" src="/docs/_astro/ec.0vx5m.js"></script><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{ </span><span style="--0:#7FDBCA;--1:#096E72">"coverImage"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53"> }</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;coverImage&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22; }"><div></div></button></div></figure></div>
330
+ <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{ </span><span style="--0:#7FDBCA;--1:#096E72">"coverImage"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53"> }</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;coverImage&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22; }"><div></div></button></div></figure></div>
258
331
  <p>That is the whole value: no filename, no URL, no size. The field fetches
259
- <code dir="auto">GET /api/media/:id</code> whenever the id changes and renders the presigned
260
- <code dir="auto">downloadUrl</code> it gets back, which is why a thumbnail never goes stale and
261
- why nothing in your content has to be rewritten when storage moves.</p>
332
+ <code dir="auto">GET /api/media/:id</code> whenever the id changes and renders the <code dir="auto">downloadUrl</code>
333
+ it gets back the permanent stable path for a public item, or a
334
+ freshly tokened one for a private item which is why nothing in your
335
+ content has to be rewritten when a file’s visibility flips or storage
336
+ moves.</p>
262
337
 
263
338
 
264
339
 
@@ -288,8 +363,8 @@ the thumbnail reports that the media could not be loaded. Treat “delete” as
288
363
  <div class="sl-heading-wrapper level-h2"><h2 id="the-api">The API</h2><a class="sl-anchor-link" href="#the-api"><span aria-hidden="true" class="sl-anchor-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="m12.11 15.39-3.88 3.88a2.52 2.52 0 0 1-3.5 0 2.47 2.47 0 0 1 0-3.5l3.88-3.88a1 1 0 1 0-1.42-1.42l-3.88 3.89a4.48 4.48 0 0 0 6.33 6.33l3.89-3.88a1 1 0 0 0-1.42-1.42m8.58-12.08a4.49 4.49 0 0 0-6.33 0l-3.89 3.88a1 1 0 1 0 1.42 1.42l3.88-3.88a2.52 2.52 0 0 1 3.5 0 2.47 2.47 0 0 1 0 3.5l-3.88 3.88a1 1 0 0 0 0 1.42 1 1 0 0 0 1.42 0l3.88-3.89a4.49 4.49 0 0 0 0-6.33M8.83 15.17a1 1 0 0 0 .71.29 1 1 0 0 0 .71-.29l4.92-4.92a1 1 0 1 0-1.42-1.42l-4.92 4.92a1 1 0 0 0 0 1.42"></path></svg></span><span class="sr-only" data-pagefind-ignore="">Section titled “The API”</span></a></div>
289
364
  <p>The JSON in this section is derived from the Zod schemas in
290
365
  <code dir="auto">packages/api-schemas</code> and the shapes the route integration tests assert —
291
- ids, timestamps and signed URLs are illustrative. The two live transcripts
292
- against the public demo are labelled where they appear.</p>
366
+ ids and timestamps are illustrative. The two live transcripts against the
367
+ public demo are labelled where they appear.</p>
293
368
  <p>Every <code dir="auto">/api/media</code> route needs a signed-in session: send
294
369
  <code dir="auto">Authorization: Bearer &#x3C;accessToken></code> from <code dir="auto">POST /api/auth/login</code>. Without
295
370
  it each one answers <code dir="auto">401</code>.</p>
@@ -298,9 +373,9 @@ it each one answers <code dir="auto">401</code>.</p>
298
373
  name is not significant — the route takes the first file part it finds —
299
374
  but it must carry a filename:</p>
300
375
  <div class="expressive-code"><figure class="frame is-terminal not-content"><figcaption class="header"><span class="title"></span><span class="sr-only">Terminal window</span></figcaption><pre data-language="sh"><code><div class="ec-line"><div class="code"><span style="--0:#82AAFF;--1:#3B61B0">curl</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-s</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-X</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#ECC48D;--1:#3B61B0">POST</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#ECC48D;--1:#3B61B0">http://localhost:3000/api/media</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#F78C6C;--1:#AA0982">\</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#82AAFF;--1:#3B61B0">-H</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">Authorization: Bearer </span><span style="--0:#C5E478;--1:#3B61B0">$TOKEN</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#F78C6C;--1:#AA0982">\</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#82AAFF;--1:#3B61B0">-F</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">file=@./cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="curl -s -X POST http://localhost:3000/api/media \ -H &#x22;Authorization: Bearer $TOKEN&#x22; \ -F &#x22;file=@./cover.png&#x22;"><div></div></button></div></figure></div>
301
- <p>A success is <code dir="auto">201</code> with the full record, including the first presigned
302
- <code dir="auto">downloadUrl</code>:</p>
303
- <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"url"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedBy"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&#x26;X-Amz-Expires=900&#x26;X-Amz-Signature=8f2c…</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;url&#x22;: &#x22;http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;, &#x22;uploadedBy&#x22;: &#x22;0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;, &#x22;downloadUrl&#x22;: &#x22;http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&#x26;X-Amz-Expires=900&#x26;X-Amz-Signature=8f2c…&#x22;}"><div></div></button></div></figure></div>
376
+ <p>A success is <code dir="auto">201</code> with the full record, <code dir="auto">visibility</code> defaulted to
377
+ <code dir="auto">public</code> and <code dir="auto">downloadUrl</code> equal to the permanent stable path:</p>
378
+ <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"url"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"visibility"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">public</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedBy"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;url&#x22;: &#x22;/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;, &#x22;visibility&#x22;: &#x22;public&#x22;, &#x22;uploadedBy&#x22;: &#x22;0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;, &#x22;downloadUrl&#x22;: &#x22;/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;}"><div></div></button></div></figure></div>
304
379
  <p>A request with no file part is a <code dir="auto">400</code> with
305
380
  <code dir="auto">{"message":"A multipart file field is required"}</code> — that, and the 50 MiB
306
381
  ceiling, are the only two ways an upload is refused.</p>
@@ -309,8 +384,8 @@ ceiling, are the only two ways an upload is refused.</p>
309
384
  case-insensitive filename filter:</p>
310
385
  <div class="expressive-code"><figure class="frame is-terminal not-content"><figcaption class="header"><span class="title"></span><span class="sr-only">Terminal window</span></figcaption><pre data-language="sh"><code><div class="ec-line"><div class="code"><span style="--0:#82AAFF;--1:#3B61B0">curl</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-s</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">http://localhost:3000/api/media?limit=2&#x26;search=cover</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#F78C6C;--1:#AA0982">\</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#82AAFF;--1:#3B61B0">-H</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">Authorization: Bearer </span><span style="--0:#C5E478;--1:#3B61B0">$TOKEN</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="curl -s &#x22;http://localhost:3000/api/media?limit=2&#x26;search=cover&#x22; \ -H &#x22;Authorization: Bearer $TOKEN&#x22;"><div></div></button></div></figure></div>
311
386
  <p>It answers <code dir="auto">200</code> with a page of full records — each one carrying its own
312
- freshly minted <code dir="auto">downloadUrl</code> — and the cursor for the next page:</p>
313
- <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"items"</span><span style="--0:#D6DEEB;--1:#403F53">: [</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"url"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedBy"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">http://localhost:9000/velora-media/9f1d3b7c…?X-Amz-Expires=900&#x26;…</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">],</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"nextCursor"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;items&#x22;: [ { &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;url&#x22;: &#x22;http://localhost:9000/velora-media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;, &#x22;uploadedBy&#x22;: &#x22;0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;, &#x22;downloadUrl&#x22;: &#x22;http://localhost:9000/velora-media/9f1d3b7c…?X-Amz-Expires=900&#x26;…&#x22; } ], &#x22;nextCursor&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;}"><div></div></button></div></figure></div>
387
+ freshly computed <code dir="auto">downloadUrl</code> — and the cursor for the next page:</p>
388
+ <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"items"</span><span style="--0:#D6DEEB;--1:#403F53">: [</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"url"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"visibility"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">public</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedBy"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div><div class="ec-line"><div class="code"><span class="indent"><span style="--0:#D6DEEB;--1:#403F53"> </span></span><span style="--0:#D6DEEB;--1:#403F53">],</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"nextCursor"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;items&#x22;: [ { &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;url&#x22;: &#x22;/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;, &#x22;visibility&#x22;: &#x22;public&#x22;, &#x22;uploadedBy&#x22;: &#x22;0b6d4e21-8c3a-4f57-9d02-6a1e5b7c8d90&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;, &#x22;downloadUrl&#x22;: &#x22;/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22; } ], &#x22;nextCursor&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;}"><div></div></button></div></figure></div>
314
389
 
315
390
 
316
391
 
@@ -390,8 +465,11 @@ transcript rather than a derived one:</p>
390
465
  has nothing to fetch, so this one is derived from <code dir="auto">PublicMediaFileSchema</code>
391
466
  rather than a live transcript:</p>
392
467
  <div class="expressive-code"><figure class="frame is-terminal not-content"><figcaption class="header"><span class="title"></span><span class="sr-only">Terminal window</span></figcaption><pre data-language="sh"><code><div class="ec-line"><div class="code"><span style="--0:#82AAFF;--1:#3B61B0">curl</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-s</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#82AAFF;--1:#3B61B0">-H</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">Authorization: Bearer vk_4ac0a6b78b953a13b8646e6379fcbb6bee1a269af253a14eebce668572019328</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53"> </span><span style="--0:#F78C6C;--1:#AA0982">\</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#ECC48D;--1:#984E4D">https://demo.velora-cms.com/api/v1/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="curl -s -H &#x22;Authorization: Bearer vk_4ac0a6b78b953a13b8646e6379fcbb6bee1a269af253a14eebce668572019328&#x22; \ &#x22;https://demo.velora-cms.com/api/v1/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;"><div></div></button></div></figure></div>
393
- <p>A hit looks like this:</p>
394
- <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">https://…?X-Amz-Expires=900&#x26;…</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;downloadUrl&#x22;: &#x22;https://…?X-Amz-Expires=900&#x26;…&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;}"><div></div></button></div></figure></div>
468
+ <p>A hit looks like this — <code dir="auto">downloadUrl</code> here is <strong>absolute</strong>, unlike the
469
+ admin surface’s path-relative <code dir="auto">url</code>, because a headless frontend fetches it
470
+ from its own origin (see <a href="/docs/v1/using-velora/headless-api/">Headless API</a> for
471
+ how the base is chosen):</p>
472
+ <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"id"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"filename"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"mimeType"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">image/png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"size"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#F78C6C;--1:#AA0982">184320</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"downloadUrl"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">https://demo.velora-cms.com/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"visibility"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">public</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">,</span></div></div><div class="ec-line"><div class="code"><span class="indent"> </span><span style="--0:#7FDBCA;--1:#096E72">"uploadedAt"</span><span style="--0:#D6DEEB;--1:#403F53">: </span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">2026-08-31T09:12:04.006Z</span><span style="--0:#D9F5DD;--1:#111111">"</span></div></div><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{ &#x22;id&#x22;: &#x22;9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218&#x22;, &#x22;filename&#x22;: &#x22;cover.png&#x22;, &#x22;mimeType&#x22;: &#x22;image/png&#x22;, &#x22;size&#x22;: 184320, &#x22;downloadUrl&#x22;: &#x22;https://demo.velora-cms.com/media/9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218/cover.png&#x22;, &#x22;visibility&#x22;: &#x22;public&#x22;, &#x22;uploadedAt&#x22;: &#x22;2026-08-31T09:12:04.006Z&#x22;}"><div></div></button></div></figure></div>
395
473
  <p>Asking for an id that does not exist returns <code dir="auto">404</code> and the same message
396
474
  shape as the admin route — also a live response:</p>
397
475
  <div class="expressive-code"><figure class="frame not-content"><figcaption class="header"></figcaption><pre data-language="json"><code><div class="ec-line"><div class="code"><span style="--0:#D6DEEB;--1:#403F53">{</span><span style="--0:#7FDBCA;--1:#096E72">"message"</span><span style="--0:#D6DEEB;--1:#403F53">:</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#C789D6;--1:#7C5686">No media file found for id 00000000-0000-4000-8000-000000000000</span><span style="--0:#D9F5DD;--1:#111111">"</span><span style="--0:#D6DEEB;--1:#403F53">}</span></div></div></code></pre><div class="copy"><div aria-live="polite"></div><button title="Copy to clipboard" data-copied="Copied!" data-code="{&#x22;message&#x22;:&#x22;No media file found for id 00000000-0000-4000-8000-000000000000&#x22;}"><div></div></button></div></figure></div>
@@ -447,10 +525,16 @@ than the admin one:</p>
447
525
 
448
526
 
449
527
 
450
- <table><thead><tr><th>Name</th><th>Type</th><th>Meaning</th><th>Example</th></tr></thead><tbody><tr><td><code dir="auto">id</code></td><td>string (uuid)</td><td>Same id as the admin surface — the value your content’s Image fields contain.</td><td><code dir="auto">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</code></td></tr><tr><td><code dir="auto">filename</code></td><td>string, non-empty</td><td>Original filename. Useful as <code dir="auto">alt</code> text fallback.</td><td><code dir="auto">cover.png</code></td></tr><tr><td><code dir="auto">mimeType</code></td><td>string, non-empty</td><td>Content type, for picking a renderer.</td><td><code dir="auto">image/png</code></td></tr><tr><td><code dir="auto">size</code></td><td>integer ≥ 0</td><td>Byte length.</td><td><code dir="auto">184320</code></td></tr><tr><td><code dir="auto">downloadUrl</code></td><td>string (URL)</td><td>Freshly presigned per response, 900 seconds. Fetch the record at render time; do not cache this in your frontend’s own database.</td><td><code dir="auto">https://…?X-Amz-Expires=900&#x26;…</code></td></tr><tr><td><code dir="auto">uploadedAt</code></td><td>ISO datetime string</td><td>When it was uploaded.</td><td><code dir="auto">2026-08-31T09:12:04.006Z</code></td></tr></tbody></table>
451
- <p><code dir="auto">uploadedBy</code> and the canonical <code dir="auto">url</code> are omitted on purpose: one is an
452
- internal user id, the other is meaningless to a client that cannot reach
453
- the bucket directly.</p>
528
+
529
+
530
+
531
+
532
+
533
+
534
+ <table><thead><tr><th>Name</th><th>Type</th><th>Meaning</th><th>Example</th></tr></thead><tbody><tr><td><code dir="auto">id</code></td><td>string (uuid)</td><td>Same id as the admin surface — the value your content’s Image fields contain.</td><td><code dir="auto">9f1d3b7c-52ae-4d08-9b31-6c74a0f5e218</code></td></tr><tr><td><code dir="auto">filename</code></td><td>string, non-empty</td><td>Original filename. Useful as <code dir="auto">alt</code> text fallback.</td><td><code dir="auto">cover.png</code></td></tr><tr><td><code dir="auto">mimeType</code></td><td>string, non-empty</td><td>Content type, for picking a renderer.</td><td><code dir="auto">image/png</code></td></tr><tr><td><code dir="auto">size</code></td><td>integer ≥ 0</td><td>Byte length.</td><td><code dir="auto">184320</code></td></tr><tr><td><code dir="auto">downloadUrl</code></td><td>string (absolute URL)</td><td>The stable path made absolute (<code dir="auto">VELORA_PUBLIC_URL</code>, or derived from the request). Permanent for a public item. For a private one it’s minted with a fresh short-lived <code dir="auto">?token=</code> right here — your API key is enough to get a working link back — but that link stops working once the token expires, and <em>any other</em> fetch of the bare path 404s exactly like an unknown id (no existence oracle). Fetch the record at render time rather than persisting this.</td><td><code dir="auto">https://cms.example.com/media/9f1d3b7c…/cover.png</code></td></tr><tr><td><code dir="auto">visibility</code></td><td><code dir="auto">"public"</code> | <code dir="auto">"private"</code></td><td>Whether the URL above needed a token.</td><td><code dir="auto">public</code></td></tr><tr><td><code dir="auto">uploadedAt</code></td><td>ISO datetime string</td><td>When it was uploaded.</td><td><code dir="auto">2026-08-31T09:12:04.006Z</code></td></tr></tbody></table>
535
+ <p><code dir="auto">uploadedBy</code> and the path-relative <code dir="auto">url</code> are omitted on purpose: one is an
536
+ internal user id, the other is meaningless to a client on a different
537
+ origin.</p>
454
538
  <p>One thing to know before you publish a key: unlike content, <strong>media has no
455
539
  published state</strong>, so <code dir="auto">/api/v1/media</code> lists every uploaded file, including
456
540
  ones no content references yet. <code dir="auto">?limit=</code> and <code dir="auto">?cursor=</code> behave exactly as
@@ -8,5 +8,6 @@ export interface GraphqlContext {
8
8
  contentService: ContentService;
9
9
  mediaService: MediaService;
10
10
  getContentNode: (id: string) => Promise<ContentNode | null>;
11
+ mediaBaseUrl: string;
11
12
  }
12
13
  export declare function buildGraphqlSchema(): GraphQLSchema;
@@ -86,6 +86,9 @@ const PublicMediaFileRef = builder.objectRef("PublicMediaFile").implement({
86
86
  mimeType: t.exposeString("mimeType"),
87
87
  size: t.exposeInt("size"),
88
88
  downloadUrl: t.exposeString("downloadUrl"),
89
+ // S186c §2 — public/private, same value REST's PublicMediaFileSchema
90
+ // carries; a headless consumer needs to know before downloadUrl 401s.
91
+ visibility: t.exposeString("visibility"),
89
92
  uploadedAt: t.exposeString("uploadedAt"),
90
93
  }),
91
94
  });
@@ -307,7 +310,7 @@ builder.queryType({
307
310
  args: { id: t.arg.id({ required: true }) },
308
311
  resolve: async (_root, args, ctx) => {
309
312
  const media = await ctx.mediaService.getById(String(args.id));
310
- return media ? toPublicMediaFile(ctx.mediaService, media) : null;
313
+ return media ? toPublicMediaFile(ctx.mediaService, media, ctx.mediaBaseUrl) : null;
311
314
  },
312
315
  }),
313
316
  // GET /api/v1/media analog — never null, same as contentRoots.
@@ -318,7 +321,7 @@ builder.queryType({
318
321
  resolve: async (_root, args, ctx) => {
319
322
  const { cursor, limit, search } = parseArgs(PublicMediaListQuerySchema, { cursor: args.cursor ?? undefined, limit: args.limit ?? undefined, search: args.search ?? undefined }, "media arguments");
320
323
  const files = await ctx.mediaService.list({ cursor, limit, search });
321
- const items = await Promise.all(files.map((file) => toPublicMediaFile(ctx.mediaService, file)));
324
+ const items = await Promise.all(files.map((file) => toPublicMediaFile(ctx.mediaService, file, ctx.mediaBaseUrl)));
322
325
  const nextCursor = files.length === limit ? (files[files.length - 1]?.id ?? null) : null;
323
326
  return { items, nextCursor };
324
327
  },
package/dist/lib.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export declare function createApp(): Promise<import("fastify").FastifyInstance<import("fastify").RawServerDefault, import("http").IncomingMessage, import("http").ServerResponse<import("http").IncomingMessage>, import("fastify").FastifyBaseLogger, import("fastify").FastifyTypeProviderDefault>>;
2
2
  export declare const buildApp: typeof createApp;
3
+ export { syncAdminBundle } from "./admin/sync-admin-bundle.js";
package/dist/lib.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // The embed surface (S185). createApp() mirrors the preparation the
2
2
  // `velora-server` bin (index.ts) performs before building the app — env
3
3
  // file load, DATABASE_URL normalization, Prisma-client self-heal, core
4
- // migrations — then returns the built (not-listening) Fastify instance for
5
- // the caller to mount or listen on themselves.
4
+ // migrations, boot-mode resolution — then returns the built (not-listening)
5
+ // Fastify instance for the caller to mount or listen on themselves.
6
6
  //
7
7
  // Everything below is imported DYNAMICALLY and only inside the call:
8
8
  // db/prisma.ts holds a top-level-await Prisma client that must not be
@@ -10,13 +10,27 @@
10
10
  // point), and a consumer must be able to set process.env AFTER importing
11
11
  // this module and BEFORE calling createApp().
12
12
  //
13
- // Unlike index.ts, this does NOT reproduce the bin's boot-mode gate
14
- // (resolveBootMode / judgeCoreMigrationFailure) or its process.exit
15
- // refusal behaviorthose belong to the standalone server process, not to
16
- // a library call. Here, any failure (env parsing, client generation,
17
- // migrations, or buildApp itself) simply propagates as a rejected promise
18
- // for the caller to catch. That IS the right embed behavior: a library
19
- // function must never call process.exit or decide a host process's fate.
13
+ // Rehearsal finding (S186b follow-up): createApp() used to unconditionally
14
+ // dynamic-import ./app.js, whose import graph reaches db/prisma.ts's
15
+ // top-level-await Prisma singleton so on a fresh scaffold (no
16
+ // DATABASE_URL, no generated client) it threw "@prisma/client did not
17
+ // initialize" before the setup wizard could ever be served. The bin
18
+ // (index.ts) never had this problem because it resolves the boot mode
19
+ // FIRST and only imports app.js for "full". createApp() now does the same:
20
+ // resolveBootMode decides whether this process is unconfigured
21
+ // (setup-full), configured-but-adminless (setup-admin-only), or ready
22
+ // (full), and only the "full" branch touches app.js/the Prisma singleton
23
+ // at all — the two setup-* branches build the same setup-only app index.ts
24
+ // does, via buildSetupOnlyApp(), and never import app.js.
25
+ //
26
+ // Unlike index.ts, this does NOT reproduce the bin's process.exit refusal
27
+ // behavior around either resolveBootMode or judgeCoreMigrationFailure —
28
+ // those belong to the standalone server process, not to a library call.
29
+ // Here, any failure (env parsing, client generation, migrations,
30
+ // resolveBootMode itself, or buildApp/buildSetupOnlyApp) simply propagates
31
+ // as a rejected promise for the caller to catch. That IS the right embed
32
+ // behavior: a library function must never call process.exit or decide a
33
+ // host process's fate.
20
34
  export async function createApp() {
21
35
  const { loadEnvFile } = await import("./config/load-env.js");
22
36
  const { normalizeDatabaseUrl } = await import("./setup/normalize-database-url.js");
@@ -32,8 +46,28 @@ export async function createApp() {
32
46
  await applyCoreMigrations({ dialect, databaseUrl: process.env.DATABASE_URL, mode: "boot" });
33
47
  }
34
48
  }
35
- const { buildApp } = await import("./app.js");
36
- return buildApp();
49
+ // Deferred until after the preparation above for the same reason index.ts
50
+ // defers it: countAdminUsers builds its own short-lived Prisma client and
51
+ // must see the settled, normalized DATABASE_URL and a self-healed client
52
+ // directory, not whatever was there before loadEnvFile()/
53
+ // normalizeDatabaseUrl() ran. A rejection here (e.g. the admin probe
54
+ // failing or timing out) is not caught — it propagates, same as every
55
+ // other failure in this function.
56
+ const { resolveBootMode, countAdminUsers } = await import("./boot/resolve-boot-mode.js");
57
+ const mode = await resolveBootMode(process.env, countAdminUsers);
58
+ if (mode === "full") {
59
+ const { buildApp } = await import("./app.js");
60
+ return buildApp();
61
+ }
62
+ const { buildSetupOnlyApp } = await import("./setup-app.js");
63
+ return buildSetupOnlyApp({ databaseConfigured: mode === "setup-admin-only" });
37
64
  }
38
65
  // Alias kept for symmetry with app.ts's internal name.
39
66
  export const buildApp = createApp;
67
+ // Review finding I3: spec §4 promises this export. Safe as a static
68
+ // (non-dynamic) re-export — verified sync-admin-bundle.ts imports only
69
+ // node:fs/node:path/node:url, has no top-level await, and touches nothing
70
+ // Prisma-shaped, so unlike createApp's dynamic imports above, importing it
71
+ // eagerly at module load can never construct a Prisma client or otherwise
72
+ // run side effects before a caller is ready for them.
73
+ export { syncAdminBundle } from "./admin/sync-admin-bundle.js";