@shapio/cms 0.3.1 → 0.4.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 (317) hide show
  1. package/dist/admin/assets/AssetTile-C-Mu0BRF.js +2 -0
  2. package/dist/admin/assets/AssetTile-C-Mu0BRF.js.map +1 -0
  3. package/dist/admin/assets/{AssistError-BYR085vj.js → AssistError-B-bnaHhJ.js} +2 -2
  4. package/dist/admin/assets/{AssistError-BYR085vj.js.map → AssistError-B-bnaHhJ.js.map} +1 -1
  5. package/dist/admin/assets/{BlockMenuItems-BnLF3Efc.js → BlockMenuItems-Dn9WULjL.js} +3 -3
  6. package/dist/admin/assets/{BlockMenuItems-BnLF3Efc.js.map → BlockMenuItems-Dn9WULjL.js.map} +1 -1
  7. package/dist/admin/assets/Body-C0SwduLi.js +2 -0
  8. package/dist/admin/assets/{Body-CCOZq2q9.js.map → Body-C0SwduLi.js.map} +1 -1
  9. package/dist/admin/assets/Builder-C11yUgu9.js +2 -0
  10. package/dist/admin/assets/Builder-C11yUgu9.js.map +1 -0
  11. package/dist/admin/assets/{ConfirmDialog-Tu-gjEwm.js → ConfirmDialog-BfpN2MQx.js} +2 -2
  12. package/dist/admin/assets/{ConfirmDialog-Tu-gjEwm.js.map → ConfirmDialog-BfpN2MQx.js.map} +1 -1
  13. package/dist/admin/assets/{CopyButton-BuBaIlCk.js → CopyButton-Dxbzp7hC.js} +2 -2
  14. package/dist/admin/assets/{CopyButton-BuBaIlCk.js.map → CopyButton-Dxbzp7hC.js.map} +1 -1
  15. package/dist/admin/assets/{CursorPager-Bvu3CXpD.js → CursorPager-DkZs6rPg.js} +2 -2
  16. package/dist/admin/assets/{CursorPager-Bvu3CXpD.js.map → CursorPager-DkZs6rPg.js.map} +1 -1
  17. package/dist/admin/assets/{ExternalLink-CCr5oRXI.js → ExternalLink-KdO72pTj.js} +2 -2
  18. package/dist/admin/assets/{ExternalLink-CCr5oRXI.js.map → ExternalLink-KdO72pTj.js.map} +1 -1
  19. package/dist/admin/assets/FocalPoint-r1MG_y-r.js +2 -0
  20. package/dist/admin/assets/FocalPoint-r1MG_y-r.js.map +1 -0
  21. package/dist/admin/assets/Form-D8vp4Kd_.js +2 -0
  22. package/dist/admin/assets/{Form-Doc0JEzF.js.map → Form-D8vp4Kd_.js.map} +1 -1
  23. package/dist/admin/assets/{FormCheckboxGroup-eg39IJvf.js → FormCheckboxGroup-8ObPh9cH.js} +2 -2
  24. package/dist/admin/assets/{FormCheckboxGroup-eg39IJvf.js.map → FormCheckboxGroup-8ObPh9cH.js.map} +1 -1
  25. package/dist/admin/assets/{FormRadioGroup-ZppSTZK6.js → FormRadioGroup-CAB3qzVS.js} +2 -2
  26. package/dist/admin/assets/{FormRadioGroup-ZppSTZK6.js.map → FormRadioGroup-CAB3qzVS.js.map} +1 -1
  27. package/dist/admin/assets/{FormSelectField-Cosnxiz-.js → FormSelectField-C7hE05Wq.js} +2 -2
  28. package/dist/admin/assets/{FormSelectField-Cosnxiz-.js.map → FormSelectField-C7hE05Wq.js.map} +1 -1
  29. package/dist/admin/assets/{FormSwitchField-DsKQEZw6.js → FormSwitchField-7EEYkGOH.js} +2 -2
  30. package/dist/admin/assets/{FormSwitchField-DsKQEZw6.js.map → FormSwitchField-7EEYkGOH.js.map} +1 -1
  31. package/dist/admin/assets/{FormTextareaField-BXidSgIE.js → FormTextareaField-BasuRoXW.js} +2 -2
  32. package/dist/admin/assets/{FormTextareaField-BXidSgIE.js.map → FormTextareaField-BasuRoXW.js.map} +1 -1
  33. package/dist/admin/assets/{HintedLabel-USQku9Yt.js → HintedLabel-BDV81kIT.js} +2 -2
  34. package/dist/admin/assets/{HintedLabel-USQku9Yt.js.map → HintedLabel-BDV81kIT.js.map} +1 -1
  35. package/dist/admin/assets/{InfoHint-Bz_yvehr.js → InfoHint-DWn9kLf1.js} +2 -2
  36. package/dist/admin/assets/{InfoHint-Bz_yvehr.js.map → InfoHint-DWn9kLf1.js.map} +1 -1
  37. package/dist/admin/assets/InsertMenuItem-DhJk5BeH.js +2 -0
  38. package/dist/admin/assets/InsertMenuItem-DhJk5BeH.js.map +1 -0
  39. package/dist/admin/assets/{LinkTabs-D4U-rJyK.js → LinkTabs-stM3j4aH.js} +2 -2
  40. package/dist/admin/assets/{LinkTabs-D4U-rJyK.js.map → LinkTabs-stM3j4aH.js.map} +1 -1
  41. package/dist/admin/assets/{Match-UAw1Ss8a.js → Match-Zu4pwfCC.js} +2 -2
  42. package/dist/admin/assets/{Match-UAw1Ss8a.js.map → Match-Zu4pwfCC.js.map} +1 -1
  43. package/dist/admin/assets/{Panel-BdjqCkKs.js → Panel-ClY9b33D.js} +2 -2
  44. package/dist/admin/assets/{Panel-BdjqCkKs.js.map → Panel-ClY9b33D.js.map} +1 -1
  45. package/dist/admin/assets/{QueryView-Bkyizh1P.js → QueryView-Cu-KPtQI.js} +2 -2
  46. package/dist/admin/assets/{QueryView-Bkyizh1P.js.map → QueryView-Cu-KPtQI.js.map} +1 -1
  47. package/dist/admin/assets/{RadioTile-VHTPl88w.js → RadioTile-Ci0LucKI.js} +2 -2
  48. package/dist/admin/assets/{RadioTile-VHTPl88w.js.map → RadioTile-Ci0LucKI.js.map} +1 -1
  49. package/dist/admin/assets/RichTextField-BcYOFkeW.js +144 -0
  50. package/dist/admin/assets/{RichTextField-B21FqrT5.js.map → RichTextField-BcYOFkeW.js.map} +1 -1
  51. package/dist/admin/assets/{RowTitle-BD8QPs_v.js → RowTitle-BdDgA5Lb.js} +2 -2
  52. package/dist/admin/assets/{RowTitle-BD8QPs_v.js.map → RowTitle-BdDgA5Lb.js.map} +1 -1
  53. package/dist/admin/assets/{SecretReveal-BfJKUxXW.js → SecretReveal-ai1uMW_b.js} +2 -2
  54. package/dist/admin/assets/{SecretReveal-BfJKUxXW.js.map → SecretReveal-ai1uMW_b.js.map} +1 -1
  55. package/dist/admin/assets/{SharedGlyph-DDPdvsm_.js → SharedGlyph-y6ZsA_gz.js} +2 -2
  56. package/dist/admin/assets/{SharedGlyph-DDPdvsm_.js.map → SharedGlyph-y6ZsA_gz.js.map} +1 -1
  57. package/dist/admin/assets/{StepList-DKhkw1hN.js → StepList-xmD9CPZl.js} +2 -2
  58. package/dist/admin/assets/{StepList-DKhkw1hN.js.map → StepList-xmD9CPZl.js.map} +1 -1
  59. package/dist/admin/assets/Structure-DGt-TDk5.js +2 -0
  60. package/dist/admin/assets/{Structure-C7dr00Bp.js.map → Structure-DGt-TDk5.js.map} +1 -1
  61. package/dist/admin/assets/{SubNavLink-BAF2-wuF.js → SubNavLink-YjjaDN40.js} +2 -2
  62. package/dist/admin/assets/{SubNavLink-BAF2-wuF.js.map → SubNavLink-YjjaDN40.js.map} +1 -1
  63. package/dist/admin/assets/{SuggestAltButton-DEsbHvs4.js → SuggestAltButton-D1-v_0wB.js} +2 -2
  64. package/dist/admin/assets/{SuggestAltButton-DEsbHvs4.js.map → SuggestAltButton-D1-v_0wB.js.map} +1 -1
  65. package/dist/admin/assets/{TextLink-CegO3jom.js → TextLink-DIqr5zkJ.js} +2 -2
  66. package/dist/admin/assets/{TextLink-CegO3jom.js.map → TextLink-DIqr5zkJ.js.map} +1 -1
  67. package/dist/admin/assets/Thumbnail-cjA7fSWh.js +2 -0
  68. package/dist/admin/assets/Thumbnail-cjA7fSWh.js.map +1 -0
  69. package/dist/admin/assets/{UnsavedChangesGuard-Cbfg-Bgj.js → UnsavedChangesGuard-B57D0278.js} +2 -2
  70. package/dist/admin/assets/{UnsavedChangesGuard-Cbfg-Bgj.js.map → UnsavedChangesGuard-B57D0278.js.map} +1 -1
  71. package/dist/admin/assets/{UserAvatar-CiZ00-cr.js → UserAvatar--pMv2i3c.js} +2 -2
  72. package/dist/admin/assets/{UserAvatar-CiZ00-cr.js.map → UserAvatar--pMv2i3c.js.map} +1 -1
  73. package/dist/admin/assets/{auth-BaVAo0u_.js → auth-YVfK3SxF.js} +2 -2
  74. package/dist/admin/assets/{auth-BaVAo0u_.js.map → auth-YVfK3SxF.js.map} +1 -1
  75. package/dist/admin/assets/{badge-IBeVOhKd.js → badge-C5oMRtwJ.js} +2 -2
  76. package/dist/admin/assets/{badge-IBeVOhKd.js.map → badge-C5oMRtwJ.js.map} +1 -1
  77. package/dist/admin/assets/{boxes-BH0MDegO.js → boxes-DRvd25_u.js} +2 -2
  78. package/dist/admin/assets/{boxes-BH0MDegO.js.map → boxes-DRvd25_u.js.map} +1 -1
  79. package/dist/admin/assets/{calendar-clock-DKUk-w81.js → calendar-clock-VENf4s1R.js} +2 -2
  80. package/dist/admin/assets/{calendar-clock-DKUk-w81.js.map → calendar-clock-VENf4s1R.js.map} +1 -1
  81. package/dist/admin/assets/{changeSets-o_G67qPk.js → changeSets-CObZV8H2.js} +2 -2
  82. package/dist/admin/assets/{changeSets-o_G67qPk.js.map → changeSets-CObZV8H2.js.map} +1 -1
  83. package/dist/admin/assets/chevron-left-D2DnQyeV.js +2 -0
  84. package/dist/admin/assets/{chevron-left-D259hWJn.js.map → chevron-left-D2DnQyeV.js.map} +1 -1
  85. package/dist/admin/assets/{circle-check-Cidzt1Ww.js → circle-check-jmRQz1Jq.js} +2 -2
  86. package/dist/admin/assets/{circle-check-Cidzt1Ww.js.map → circle-check-jmRQz1Jq.js.map} +1 -1
  87. package/dist/admin/assets/{clock-BOtPNp9-.js → clock-DrSTMGjp.js} +2 -2
  88. package/dist/admin/assets/{clock-BOtPNp9-.js.map → clock-DrSTMGjp.js.map} +1 -1
  89. package/dist/admin/assets/cn-CL1YHRrS.js +2 -0
  90. package/dist/admin/assets/cn-CL1YHRrS.js.map +1 -0
  91. package/dist/admin/assets/coerce-B4MJTGg1.js +2 -0
  92. package/dist/admin/assets/{coerce-DWI5sgLW.js.map → coerce-B4MJTGg1.js.map} +1 -1
  93. package/dist/admin/assets/constants-DT4WPUvz.js +2 -0
  94. package/dist/admin/assets/{constants-CgUdkWmf.js.map → constants-DT4WPUvz.js.map} +1 -1
  95. package/dist/admin/assets/content-HvisCB5m.js +3 -0
  96. package/dist/admin/assets/content-HvisCB5m.js.map +1 -0
  97. package/dist/admin/assets/{contentCounts-T-LmvUf5.js → contentCounts-DzIvdeXR.js} +2 -2
  98. package/dist/admin/assets/{contentCounts-T-LmvUf5.js.map → contentCounts-DzIvdeXR.js.map} +1 -1
  99. package/dist/admin/assets/{contentTypes-BSlp81l1.js → contentTypes-ArLEfM29.js} +2 -2
  100. package/dist/admin/assets/{contentTypes-BSlp81l1.js.map → contentTypes-ArLEfM29.js.map} +1 -1
  101. package/dist/admin/assets/context-4Jl20mYc.js +2 -0
  102. package/dist/admin/assets/context-4Jl20mYc.js.map +1 -0
  103. package/dist/admin/assets/{copy-DT7dAzVm.js → copy-t6kAFw-O.js} +2 -2
  104. package/dist/admin/assets/{copy-DT7dAzVm.js.map → copy-t6kAFw-O.js.map} +1 -1
  105. package/dist/admin/assets/{dateTimeLocal-DRq7DuYO.js → dateTimeLocal-IDUOKulz.js} +2 -2
  106. package/dist/admin/assets/{dateTimeLocal-DRq7DuYO.js.map → dateTimeLocal-IDUOKulz.js.map} +1 -1
  107. package/dist/admin/assets/develop-DvKYqVeK.js +11 -0
  108. package/dist/admin/assets/develop-DvKYqVeK.js.map +1 -0
  109. package/dist/admin/assets/dropdown-menu-DPzDthV7.js +2 -0
  110. package/dist/admin/assets/dropdown-menu-DPzDthV7.js.map +1 -0
  111. package/dist/admin/assets/{ellipsis-xDmHl1SK.js → ellipsis-DEHUaCkn.js} +2 -2
  112. package/dist/admin/assets/{ellipsis-xDmHl1SK.js.map → ellipsis-DEHUaCkn.js.map} +1 -1
  113. package/dist/admin/assets/external-link-B8UFREQ_.js +2 -0
  114. package/dist/admin/assets/external-link-B8UFREQ_.js.map +1 -0
  115. package/dist/admin/assets/{fileRoute-Buucm0CU.js → fileRoute-sJvv4Ipn.js} +2 -2
  116. package/dist/admin/assets/{fileRoute-Buucm0CU.js.map → fileRoute-sJvv4Ipn.js.map} +1 -1
  117. package/dist/admin/assets/folder-plus-iDg1hhew.js +2 -0
  118. package/dist/admin/assets/folder-plus-iDg1hhew.js.map +1 -0
  119. package/dist/admin/assets/followPlural-tOh6gDgT.js +2 -0
  120. package/dist/admin/assets/{followPlural-Cuocm4Sv.js.map → followPlural-tOh6gDgT.js.map} +1 -1
  121. package/dist/admin/assets/{globe-CAhrwBgv.js → globe-TooNSp9V.js} +2 -2
  122. package/dist/admin/assets/{globe-CAhrwBgv.js.map → globe-TooNSp9V.js.map} +1 -1
  123. package/dist/admin/assets/{image-CGN6ffEu.js → image-CXZXXH58.js} +2 -2
  124. package/dist/admin/assets/{image-CGN6ffEu.js.map → image-CXZXXH58.js.map} +1 -1
  125. package/dist/admin/assets/{image-off-0zX_kODH.js → image-off-cxva5G-Y.js} +2 -2
  126. package/dist/admin/assets/{image-off-0zX_kODH.js.map → image-off-cxva5G-Y.js.map} +1 -1
  127. package/dist/admin/assets/{inbox-CukkApHK.js → inbox-B8c9Kj_z.js} +2 -2
  128. package/dist/admin/assets/{inbox-CukkApHK.js.map → inbox-B8c9Kj_z.js.map} +1 -1
  129. package/dist/admin/assets/{inbox-CWq1hHqK.js → inbox-nfR2K7f6.js} +2 -2
  130. package/dist/admin/assets/{inbox-CWq1hHqK.js.map → inbox-nfR2K7f6.js.map} +1 -1
  131. package/dist/admin/assets/index-Bbb2ER3l.css +2 -0
  132. package/dist/admin/assets/index-C6FrF34G.js +4 -0
  133. package/dist/admin/assets/index-C6FrF34G.js.map +1 -0
  134. package/dist/admin/assets/{key-round-DnE-2gr0.js → key-round-CFJ61iCd.js} +2 -2
  135. package/dist/admin/assets/{key-round-DnE-2gr0.js.map → key-round-CFJ61iCd.js.map} +1 -1
  136. package/dist/admin/assets/languages-eh7U4anI.js +2 -0
  137. package/dist/admin/assets/languages-eh7U4anI.js.map +1 -0
  138. package/dist/admin/assets/layers-mGfmMsKE.js +2 -0
  139. package/dist/admin/assets/layers-mGfmMsKE.js.map +1 -0
  140. package/dist/admin/assets/link-2-off-DbZzRN_w.js +2 -0
  141. package/dist/admin/assets/link-2-off-DbZzRN_w.js.map +1 -0
  142. package/dist/admin/assets/{link-DPniuk00.js → link-b2pFaN-A.js} +2 -2
  143. package/dist/admin/assets/{link-DPniuk00.js.map → link-b2pFaN-A.js.map} +1 -1
  144. package/dist/admin/assets/{locales-DFLQ17ow.js → locales-oBD0q1pN.js} +2 -2
  145. package/dist/admin/assets/{locales-DFLQ17ow.js.map → locales-oBD0q1pN.js.map} +1 -1
  146. package/dist/admin/assets/{media-CKTs3j83.js → media-5ks_0Rre.js} +2 -2
  147. package/dist/admin/assets/{media-CKTs3j83.js.map → media-5ks_0Rre.js.map} +1 -1
  148. package/dist/admin/assets/media-BzivlEh2.js +2 -0
  149. package/dist/admin/assets/{media-CUfV74JZ.js.map → media-BzivlEh2.js.map} +1 -1
  150. package/dist/admin/assets/network-Cj04CpFi.js +2 -0
  151. package/dist/admin/assets/network-Cj04CpFi.js.map +1 -0
  152. package/dist/admin/assets/{pencil-BqAgKC9v.js → pencil-C7eD5mlZ.js} +2 -2
  153. package/dist/admin/assets/{pencil-BqAgKC9v.js.map → pencil-C7eD5mlZ.js.map} +1 -1
  154. package/dist/admin/assets/{plus-DLRcpVhJ.js → plus-BDZHQBlU.js} +2 -2
  155. package/dist/admin/assets/{plus-DLRcpVhJ.js.map → plus-BDZHQBlU.js.map} +1 -1
  156. package/dist/admin/assets/{preload-helper-BHNBIF88.js → preload-helper-CJNJ4S3k.js} +2 -2
  157. package/dist/admin/assets/{preload-helper-BHNBIF88.js.map → preload-helper-CJNJ4S3k.js.map} +1 -1
  158. package/dist/admin/assets/publishing-ChHkrwvr.js +2 -0
  159. package/dist/admin/assets/{publishing-DaaE8PaU.js.map → publishing-ChHkrwvr.js.map} +1 -1
  160. package/dist/admin/assets/{radio-group-CA3XlQcc.js → radio-group-DSK5LPaM.js} +2 -2
  161. package/dist/admin/assets/{radio-group-CA3XlQcc.js.map → radio-group-DSK5LPaM.js.map} +1 -1
  162. package/dist/admin/assets/{rotate-ccw-CfrkcgYa.js → rotate-ccw-DCWwb4Ga.js} +2 -2
  163. package/dist/admin/assets/{rotate-ccw-CfrkcgYa.js.map → rotate-ccw-DCWwb4Ga.js.map} +1 -1
  164. package/dist/admin/assets/{schedules-hJQkjEhN.js → schedules-DXrBSypK.js} +2 -2
  165. package/dist/admin/assets/{schedules-hJQkjEhN.js.map → schedules-DXrBSypK.js.map} +1 -1
  166. package/dist/admin/assets/{schema--xSR7cf9.js → schema-B7L9OJrX.js} +2 -2
  167. package/dist/admin/assets/{schema--xSR7cf9.js.map → schema-B7L9OJrX.js.map} +1 -1
  168. package/dist/admin/assets/{scroll-text-BA9yh7Za.js → scroll-text-BLThe4Ki.js} +2 -2
  169. package/dist/admin/assets/{scroll-text-BA9yh7Za.js.map → scroll-text-BLThe4Ki.js.map} +1 -1
  170. package/dist/admin/assets/{file-text-BJkQMeQO.js → search-CcJQqtRw.js} +2 -2
  171. package/dist/admin/assets/search-CcJQqtRw.js.map +1 -0
  172. package/dist/admin/assets/{searchSchemas-C-nQbyhd.js → searchSchemas-BdwAFaVO.js} +2 -2
  173. package/dist/admin/assets/{searchSchemas-C-nQbyhd.js.map → searchSchemas-BdwAFaVO.js.map} +1 -1
  174. package/dist/admin/assets/{sections-D0OZozD2.js → sections-TIN_Nl1b.js} +2 -2
  175. package/dist/admin/assets/{sections-D0OZozD2.js.map → sections-TIN_Nl1b.js.map} +1 -1
  176. package/dist/admin/assets/sections-iNOzo7pL.js +2 -0
  177. package/dist/admin/assets/sections-iNOzo7pL.js.map +1 -0
  178. package/dist/admin/assets/{select-De48SQdZ.js → select-DyD7qIkJ.js} +2 -2
  179. package/dist/admin/assets/{select-De48SQdZ.js.map → select-DyD7qIkJ.js.map} +1 -1
  180. package/dist/admin/assets/{send-NuSmLVSD.js → send-CbP0Rc9_.js} +2 -2
  181. package/dist/admin/assets/{send-NuSmLVSD.js.map → send-CbP0Rc9_.js.map} +1 -1
  182. package/dist/admin/assets/seo-uMm2fGPB.js +2 -0
  183. package/dist/admin/assets/seo-uMm2fGPB.js.map +1 -0
  184. package/dist/admin/assets/settings-CJVORrIV.js +2 -0
  185. package/dist/admin/assets/settings-CJVORrIV.js.map +1 -0
  186. package/dist/admin/assets/skeleton-B5GJgv7y.js +42 -0
  187. package/dist/admin/assets/skeleton-B5GJgv7y.js.map +1 -0
  188. package/dist/admin/assets/snapshots-DkyyrWX3.js +2 -0
  189. package/dist/admin/assets/{snapshots-BQeFK5cj.js.map → snapshots-DkyyrWX3.js.map} +1 -1
  190. package/dist/admin/assets/{sparkles-B-0I44es.js → sparkles-D88gf3vX.js} +2 -2
  191. package/dist/admin/assets/{sparkles-B-0I44es.js.map → sparkles-D88gf3vX.js.map} +1 -1
  192. package/dist/admin/assets/src-B2qcqjNM.js +4 -0
  193. package/dist/admin/assets/src-B2qcqjNM.js.map +1 -0
  194. package/dist/admin/assets/{stableId-rPnYVF-A.js → stableId-DlNKh3HM.js} +2 -2
  195. package/dist/admin/assets/{stableId-rPnYVF-A.js.map → stableId-DlNKh3HM.js.map} +1 -1
  196. package/dist/admin/assets/{store-Bqd9L9DK.js → store-jUWyOXDG.js} +2 -2
  197. package/dist/admin/assets/{store-Bqd9L9DK.js.map → store-jUWyOXDG.js.map} +1 -1
  198. package/dist/admin/assets/{switch-JmfyFAQG.js → switch-DbIrjP3h.js} +2 -2
  199. package/dist/admin/assets/{switch-JmfyFAQG.js.map → switch-DbIrjP3h.js.map} +1 -1
  200. package/dist/admin/assets/table-DQ5ILlSh.js +56 -0
  201. package/dist/admin/assets/table-DQ5ILlSh.js.map +1 -0
  202. package/dist/admin/assets/tabs-BJhfL-lw.js +5 -0
  203. package/dist/admin/assets/tabs-BJhfL-lw.js.map +1 -0
  204. package/dist/admin/assets/{textarea-eatLAWE_.js → textarea-FwSvwmyT.js} +2 -2
  205. package/dist/admin/assets/{textarea-eatLAWE_.js.map → textarea-FwSvwmyT.js.map} +1 -1
  206. package/dist/admin/assets/theme-C0Tv6M38.js +2 -0
  207. package/dist/admin/assets/theme-C0Tv6M38.js.map +1 -0
  208. package/dist/admin/assets/{titles-Bi2fWNN4.js → titles-uL13bpi8.js} +2 -2
  209. package/dist/admin/assets/{titles-Bi2fWNN4.js.map → titles-uL13bpi8.js.map} +1 -1
  210. package/dist/admin/assets/{tokens-C2E7swse.js → tokens-C-20gLAT.js} +2 -2
  211. package/dist/admin/assets/{tokens-C2E7swse.js.map → tokens-C-20gLAT.js.map} +1 -1
  212. package/dist/admin/assets/{tooltip-Bji9fmSr.js → tooltip-BK5FqmJ0.js} +2 -2
  213. package/dist/admin/assets/{tooltip-Bji9fmSr.js.map → tooltip-BK5FqmJ0.js.map} +1 -1
  214. package/dist/admin/assets/{undo-2-BU-uqm7e.js → undo-2-CLy2zklL.js} +2 -2
  215. package/dist/admin/assets/{undo-2-BU-uqm7e.js.map → undo-2-CLy2zklL.js.map} +1 -1
  216. package/dist/admin/assets/{useAuditLabels-D5nmbXqN.js → useAuditLabels-BJF7M5RY.js} +2 -2
  217. package/dist/admin/assets/{useAuditLabels-D5nmbXqN.js.map → useAuditLabels-BJF7M5RY.js.map} +1 -1
  218. package/dist/admin/assets/{useConfirmTarget-Y7_8kVX5.js → useConfirmTarget-BUvlrGSW.js} +2 -2
  219. package/dist/admin/assets/{useConfirmTarget-Y7_8kVX5.js.map → useConfirmTarget-BUvlrGSW.js.map} +1 -1
  220. package/dist/admin/assets/{useContentSchema-DJTqjlUk.js → useContentSchema-BCdLYsKa.js} +2 -2
  221. package/dist/admin/assets/{useContentSchema-DJTqjlUk.js.map → useContentSchema-BCdLYsKa.js.map} +1 -1
  222. package/dist/admin/assets/{useCursorPager-CiJroOo7.js → useCursorPager-CzB6NDOB.js} +2 -2
  223. package/dist/admin/assets/{useCursorPager-CiJroOo7.js.map → useCursorPager-CzB6NDOB.js.map} +1 -1
  224. package/dist/admin/assets/{useDiscardGuard-t_N3l9TQ.js → useDiscardGuard-CGFgC3xY.js} +2 -2
  225. package/dist/admin/assets/{useDiscardGuard-t_N3l9TQ.js.map → useDiscardGuard-CGFgC3xY.js.map} +1 -1
  226. package/dist/admin/assets/useMediaPickerDialog-BOkGEyG7.js +2 -0
  227. package/dist/admin/assets/useMediaPickerDialog-BOkGEyG7.js.map +1 -0
  228. package/dist/admin/assets/useMediaUploader-D1ouV8jc.js +2 -0
  229. package/dist/admin/assets/useMediaUploader-D1ouV8jc.js.map +1 -0
  230. package/dist/admin/assets/{usePaletteItems-BVjWCdow.js → usePaletteItems-neKzBlys.js} +2 -2
  231. package/dist/admin/assets/{usePaletteItems-BVjWCdow.js.map → usePaletteItems-neKzBlys.js.map} +1 -1
  232. package/dist/admin/assets/{usePlaces-CnSOZwtO.js → usePlaces-6iTN15Xy.js} +2 -2
  233. package/dist/admin/assets/{usePlaces-CnSOZwtO.js.map → usePlaces-6iTN15Xy.js.map} +1 -1
  234. package/dist/admin/assets/{useQueries-BRolGUiw.js → useQueries-CYfcxI0Q.js} +2 -2
  235. package/dist/admin/assets/{useQueries-BRolGUiw.js.map → useQueries-CYfcxI0Q.js.map} +1 -1
  236. package/dist/admin/assets/useResolvedScheme-D0moXjaP.js +2 -0
  237. package/dist/admin/assets/{searchSchema-Ypl6gq9g.js.map → useResolvedScheme-D0moXjaP.js.map} +1 -1
  238. package/dist/admin/assets/useSchemaScopeAccess-WQKmj8_3.js +2 -0
  239. package/dist/admin/assets/useSchemaScopeAccess-WQKmj8_3.js.map +1 -0
  240. package/dist/admin/assets/{users-CbErtM7w.js → users-B01EH47o.js} +2 -2
  241. package/dist/admin/assets/{users-CbErtM7w.js.map → users-B01EH47o.js.map} +1 -1
  242. package/dist/admin/assets/{users-bDGYpSXg.js → users-BgTNDPXx.js} +2 -2
  243. package/dist/admin/assets/{users-bDGYpSXg.js.map → users-BgTNDPXx.js.map} +1 -1
  244. package/dist/admin/assets/{zod-Gtw4e0qi.js → zod-CS0EvbwG.js} +2 -2
  245. package/dist/admin/assets/{zod-Gtw4e0qi.js.map → zod-CS0EvbwG.js.map} +1 -1
  246. package/dist/admin/index.html +57 -57
  247. package/dist/admin/theme-init.js +23 -21
  248. package/dist/cli.js +19 -9
  249. package/dist/cli.js.map +1 -1
  250. package/dist/config.d.ts +1 -1
  251. package/dist/index.d.ts +4 -4
  252. package/dist/index.js +1 -1
  253. package/dist/{public-B7fFHMNr.d.ts → public-BSbOGbK-.d.ts} +19 -3
  254. package/dist/{worker-BAmQPoTJ.js → worker-i1rY2TIZ.js} +1894 -612
  255. package/dist/worker-i1rY2TIZ.js.map +1 -0
  256. package/package.json +5 -5
  257. package/dist/admin/assets/Body-CCOZq2q9.js +0 -2
  258. package/dist/admin/assets/Builder-BZy9mphs.js +0 -2
  259. package/dist/admin/assets/Builder-BZy9mphs.js.map +0 -1
  260. package/dist/admin/assets/Form-Doc0JEzF.js +0 -2
  261. package/dist/admin/assets/InsertMenuItem-DJXglsEd.js +0 -2
  262. package/dist/admin/assets/InsertMenuItem-DJXglsEd.js.map +0 -1
  263. package/dist/admin/assets/RichTextField-B21FqrT5.js +0 -144
  264. package/dist/admin/assets/Structure-C7dr00Bp.js +0 -2
  265. package/dist/admin/assets/Thumbnail-CTpKlrxR.js +0 -2
  266. package/dist/admin/assets/Thumbnail-CTpKlrxR.js.map +0 -1
  267. package/dist/admin/assets/arrow-up-DwMJqZVV.js +0 -2
  268. package/dist/admin/assets/arrow-up-DwMJqZVV.js.map +0 -1
  269. package/dist/admin/assets/chevron-left-D259hWJn.js +0 -2
  270. package/dist/admin/assets/circle-x-c1RwmC6j.js +0 -2
  271. package/dist/admin/assets/circle-x-c1RwmC6j.js.map +0 -1
  272. package/dist/admin/assets/cn-M4_WHiur.js +0 -2
  273. package/dist/admin/assets/cn-M4_WHiur.js.map +0 -1
  274. package/dist/admin/assets/coerce-DWI5sgLW.js +0 -2
  275. package/dist/admin/assets/constants-CgUdkWmf.js +0 -2
  276. package/dist/admin/assets/content-D25ainqV.js +0 -3
  277. package/dist/admin/assets/content-D25ainqV.js.map +0 -1
  278. package/dist/admin/assets/develop-qF10t9tM.js +0 -11
  279. package/dist/admin/assets/develop-qF10t9tM.js.map +0 -1
  280. package/dist/admin/assets/dropdown-menu-Cl20LCvY.js +0 -2
  281. package/dist/admin/assets/dropdown-menu-Cl20LCvY.js.map +0 -1
  282. package/dist/admin/assets/external-link-DtJAaOWk.js +0 -2
  283. package/dist/admin/assets/external-link-DtJAaOWk.js.map +0 -1
  284. package/dist/admin/assets/file-text-BJkQMeQO.js.map +0 -1
  285. package/dist/admin/assets/followPlural-Cuocm4Sv.js +0 -2
  286. package/dist/admin/assets/index-C9uJxg4J.js +0 -4
  287. package/dist/admin/assets/index-C9uJxg4J.js.map +0 -1
  288. package/dist/admin/assets/index-Cz3C-LxB.css +0 -2
  289. package/dist/admin/assets/link-2-off-Cbpvb3w4.js +0 -2
  290. package/dist/admin/assets/link-2-off-Cbpvb3w4.js.map +0 -1
  291. package/dist/admin/assets/list-CkHF_4xh.js +0 -2
  292. package/dist/admin/assets/list-CkHF_4xh.js.map +0 -1
  293. package/dist/admin/assets/media-CUfV74JZ.js +0 -2
  294. package/dist/admin/assets/network-Fri1cjMt.js +0 -2
  295. package/dist/admin/assets/network-Fri1cjMt.js.map +0 -1
  296. package/dist/admin/assets/publishing-DaaE8PaU.js +0 -2
  297. package/dist/admin/assets/search--71HzPvh.js +0 -2
  298. package/dist/admin/assets/search--71HzPvh.js.map +0 -1
  299. package/dist/admin/assets/searchSchema-Ypl6gq9g.js +0 -2
  300. package/dist/admin/assets/sections-BBN2JErq.js +0 -2
  301. package/dist/admin/assets/sections-BBN2JErq.js.map +0 -1
  302. package/dist/admin/assets/settings-CYXU73dP.js +0 -2
  303. package/dist/admin/assets/settings-CYXU73dP.js.map +0 -1
  304. package/dist/admin/assets/skeleton-CCwdO9oq.js +0 -42
  305. package/dist/admin/assets/skeleton-CCwdO9oq.js.map +0 -1
  306. package/dist/admin/assets/snapshots-BQeFK5cj.js +0 -2
  307. package/dist/admin/assets/src-CQu-YxUM.js +0 -4
  308. package/dist/admin/assets/src-CQu-YxUM.js.map +0 -1
  309. package/dist/admin/assets/table-BEDv9IRM.js +0 -56
  310. package/dist/admin/assets/table-BEDv9IRM.js.map +0 -1
  311. package/dist/admin/assets/tabs-DSS1c-Kf.js +0 -5
  312. package/dist/admin/assets/tabs-DSS1c-Kf.js.map +0 -1
  313. package/dist/admin/assets/useMediaUploader-CLHmKCwq.js +0 -2
  314. package/dist/admin/assets/useMediaUploader-CLHmKCwq.js.map +0 -1
  315. package/dist/admin/assets/useSchemaScopeAccess-bd8RQxuu.js +0 -2
  316. package/dist/admin/assets/useSchemaScopeAccess-bd8RQxuu.js.map +0 -1
  317. package/dist/worker-BAmQPoTJ.js.map +0 -1
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { createCipheriv, createDecipheriv, createHash, createHmac, hkdfSync, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
4
4
  import { createReadStream, createWriteStream, existsSync, readFileSync, statSync } from "node:fs";
5
5
  import { dirname, join, relative, resolve, sep } from "node:path";
6
- import Fastify from "fastify";
6
+ import Fastify, { LogController } from "fastify";
7
7
  import { AliasNode, AndNode, BinaryOperationNode, ColumnNode, ColumnUpdateNode, IdentifierNode, InsertQueryNode, Kysely, ListNode, MysqlAdapter, MysqlIntrospector, MysqlQueryCompiler, OnDuplicateKeyNode, OperationNodeTransformer, OperatorNode, OrNode, OrderByItemNode, ParensNode, PostgresDialect, PrimitiveValueListNode, RawNode, ReferenceNode, SelectAllNode, SelectModifierNode, SelectQueryNode, SelectionNode, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler, TableNode, UpdateQueryNode, ValueListNode, ValueNode, ValuesNode, WhereNode, sql } from "kysely";
8
8
  import pg from "pg";
9
9
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -1857,9 +1857,10 @@ const MYSQL_TABLES = {
1857
1857
  "is_primary",
1858
1858
  "version",
1859
1859
  "created_at",
1860
- "updated_at"
1860
+ "updated_at",
1861
+ "seo_defaults"
1861
1862
  ],
1862
- nullable: [],
1863
+ nullable: ["seo_defaults"],
1863
1864
  timestamps: ["created_at", "updated_at"],
1864
1865
  uniqueKeys: [
1865
1866
  {
@@ -2401,7 +2402,7 @@ const lockingKeySelect = (table, key, node, extra) => {
2401
2402
  limit: node.limit
2402
2403
  }));
2403
2404
  };
2404
- const keysOf$1 = (rows, key, bySubquery) => rows.map((row) => bySubquery ? [Object.values(row)[0]] : key.map((_, index) => row[`__shapio_key_${index}`]));
2405
+ const keysOf$2 = (rows, key, bySubquery) => rows.map((row) => bySubquery ? [Object.values(row)[0]] : key.map((_, index) => row[`__shapio_key_${index}`]));
2405
2406
  const updatePlan = (node, compile) => {
2406
2407
  const table = tableOf(node.table, "UPDATE");
2407
2408
  if (node.from || node.joins?.length) throw new Error(`MySQL: UPDATE … FROM/JOIN on ${table} cannot use RETURNING`);
@@ -2409,7 +2410,7 @@ const updatePlan = (node, compile) => {
2409
2410
  const bySubquery = keySubquery(node.where, key) !== void 0;
2410
2411
  const lock = compile(lockingKeySelect(table, key, node, []));
2411
2412
  return (context) => context.atomically(async () => {
2412
- const keys = keysOf$1((await context.run(lock)).rows, key, bySubquery);
2413
+ const keys = keysOf$2((await context.run(lock)).rows, key, bySubquery);
2413
2414
  if (keys.length === 0) return resultOf$2([], 0);
2414
2415
  await context.run(compile(Object.freeze({
2415
2416
  ...node,
@@ -2429,7 +2430,7 @@ const deletePlan = (node, compile) => {
2429
2430
  const lock = compile(lockingKeySelect(table, key, node, node.returning.selections));
2430
2431
  return (context) => context.atomically(async () => {
2431
2432
  const locked = (await context.run(lock)).rows;
2432
- const keys = keysOf$1(locked, key, false);
2433
+ const keys = keysOf$2(locked, key, false);
2433
2434
  if (keys.length === 0) return resultOf$2([], 0);
2434
2435
  await context.run(compile(Object.freeze({
2435
2436
  ...node,
@@ -5150,7 +5151,7 @@ const NODE_KEYS = /* @__PURE__ */ new Set([
5150
5151
  "marks"
5151
5152
  ]);
5152
5153
  const MAX_PROBLEMS = 20;
5153
- const isRecord$13 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5154
+ const isRecord$14 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5154
5155
  const fail = (walk, path, message) => {
5155
5156
  if (walk.problems.length < MAX_PROBLEMS) walk.problems.push({
5156
5157
  path,
@@ -5158,7 +5159,7 @@ const fail = (walk, path, message) => {
5158
5159
  });
5159
5160
  };
5160
5161
  const checkAttrs = (walk, path, rules, raw) => {
5161
- if (raw !== void 0 && !isRecord$13(raw)) return fail(walk, `${path}/attrs`, "must be an object");
5162
+ if (raw !== void 0 && !isRecord$14(raw)) return fail(walk, `${path}/attrs`, "must be an object");
5162
5163
  const given = raw ?? {};
5163
5164
  const attrs = {};
5164
5165
  for (const key of Object.keys(given)) if (!rules || !Object.hasOwn(rules, key)) fail(walk, `${path}/attrs/${key}`, "is not an allowed attribute");
@@ -5180,7 +5181,7 @@ const checkMarks = (walk, path, raw, allowed) => {
5180
5181
  const marks = [];
5181
5182
  raw.forEach((mark, index) => {
5182
5183
  const at = `${path}/marks/${index}`;
5183
- if (!isRecord$13(mark) || typeof mark.type !== "string" || !Object.hasOwn(MARK_RULES, mark.type)) {
5184
+ if (!isRecord$14(mark) || typeof mark.type !== "string" || !Object.hasOwn(MARK_RULES, mark.type)) {
5184
5185
  fail(walk, at, "is not an allowed mark");
5185
5186
  return;
5186
5187
  }
@@ -5201,7 +5202,7 @@ const matchesAllow = (allow, type) => {
5201
5202
  };
5202
5203
  const checkNode = (walk, raw, path, depth, context) => {
5203
5204
  if (depth > 40) return fail(walk, path, "is nested too deeply");
5204
- if (!isRecord$13(raw) || typeof raw.type !== "string") return fail(walk, path, "must be a node with a type");
5205
+ if (!isRecord$14(raw) || typeof raw.type !== "string") return fail(walk, path, "must be a node with a type");
5205
5206
  const { type } = raw;
5206
5207
  const rule = Object.hasOwn(NODE_RULES, type) ? NODE_RULES[type] : void 0;
5207
5208
  if (!rule) return fail(walk, path, `"${type}" is not an allowed node`);
@@ -5254,7 +5255,7 @@ const validateRichText = (value) => {
5254
5255
  problems: [],
5255
5256
  textLength: 0
5256
5257
  };
5257
- if (!isRecord$13(value)) return {
5258
+ if (!isRecord$14(value)) return {
5258
5259
  ok: false,
5259
5260
  problems: [{
5260
5261
  path: "",
@@ -5305,6 +5306,83 @@ const richTextMediaIds = (document) => {
5305
5306
  return [...ids];
5306
5307
  };
5307
5308
  //#endregion
5309
+ //#region ../../packages/schema/dist/resolve-viddCEYd.js
5310
+ /**
5311
+ * The built-in SEO component (plan seo-fields). It is ordinary schema data, created through the change planner
5312
+ * and always shared with every site; what makes it "the" SEO component is its stable ID, never its API ID, so
5313
+ * a renamed or extended copy is still recognised and an unrelated `seo` component (an import) never is.
5314
+ */
5315
+ const SEO_COMPONENT_ID = "f3905a00-d2a3-4c62-899a-c894bfd6cb9b";
5316
+ /** Stable field IDs of the built-in's fields. Resolver and admin look fields up by these. */
5317
+ const SEO_FIELD_IDS = {
5318
+ title: "3fe294a9-6af9-4019-8b32-43010822e45a",
5319
+ description: "6d1917f3-94a2-4391-ba1f-a98d14577b93",
5320
+ image: "d27c9354-4afc-4583-93b4-367214a7714a",
5321
+ canonical: "17b374b6-cd85-444e-8403-20c7458ed29e",
5322
+ noindex: "e8346f99-dc3c-4b5c-906f-4854e03c9a57"
5323
+ };
5324
+ /** The built-in editor for a field holding the SEO component (preview and counters in the admin). */
5325
+ const SEO_EDITOR_ID = "seoEditor";
5326
+ /** A title template holds the placeholder exactly once, e.g. `%s · Acme`. */
5327
+ const SEO_TITLE_TEMPLATE_PATTERN = /^(?:(?!%s)[\s\S])*%s(?:(?!%s)[\s\S])*$/;
5328
+ /** A Twitter/X handle with its `@`. */
5329
+ const SEO_TWITTER_HANDLE_PATTERN = /^@[A-Za-z0-9_]{1,15}$/;
5330
+ /** No defaults saved yet. */
5331
+ const EMPTY_SEO_DEFAULTS = {
5332
+ locales: {},
5333
+ imageId: null,
5334
+ twitterHandle: null
5335
+ };
5336
+ const isValidTitleTemplate = (template) => SEO_TITLE_TEMPLATE_PATTERN.test(template);
5337
+ /**
5338
+ * One locale's effective default texts: each key from the first locale of the chain that sets it (the
5339
+ * requested locale, then its fallbacks, then the default locale).
5340
+ */
5341
+ const seoDefaultsForLocale = (defaults, localeChain) => {
5342
+ const pick = (key) => localeChain.map((code) => defaults.locales[code]?.[key]).find((value) => value !== void 0);
5343
+ const siteName = pick("siteName");
5344
+ const titleTemplate = pick("titleTemplate");
5345
+ const description = pick("description");
5346
+ return {
5347
+ ...siteName !== void 0 ? { siteName } : {},
5348
+ ...titleTemplate !== void 0 ? { titleTemplate } : {},
5349
+ ...description !== void 0 ? { description } : {}
5350
+ };
5351
+ };
5352
+ /** Applies a title template (`%s` replaced once); an invalid template leaves the title as is. */
5353
+ const applyTitleTemplate = (title, template) => template !== void 0 && isValidTitleTemplate(template) ? template.replace("%s", () => title) : title;
5354
+ /** The API IDs the built-in component is created with. */
5355
+ const DEFAULT_SEO_KEYS = {
5356
+ title: "title",
5357
+ description: "description",
5358
+ image: "image",
5359
+ canonical: "canonical",
5360
+ noindex: "noindex"
5361
+ };
5362
+ const isRecord$13 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
5363
+ const text = (value) => typeof value === "string" && value.trim() !== "" ? value : null;
5364
+ /**
5365
+ * The effective SEO values of one delivered entry, in the same shape as the raw value (plan seo-fields): the
5366
+ * title is the entry's SEO title, else its own title, through the site's template (the bare site name when
5367
+ * neither exists, never templated); description and image fall back to the site's defaults; canonical stays
5368
+ * the entry's value or null; noindex is a boolean. Other keys (fields a user added) pass through. Pure.
5369
+ */
5370
+ const resolveSeo = (raw, context) => {
5371
+ const keys = context.keys ?? DEFAULT_SEO_KEYS;
5372
+ const output = isRecord$13(raw) ? { ...raw } : {};
5373
+ const value = (key) => keys[key] === void 0 ? void 0 : output[keys[key]];
5374
+ const set = (key, next) => {
5375
+ if (keys[key] !== void 0) output[keys[key]] = next;
5376
+ };
5377
+ const base = text(value("title")) ?? text(context.fallbackTitle);
5378
+ set("title", base !== null ? applyTitleTemplate(base, context.defaults.titleTemplate) : context.defaults.siteName ?? null);
5379
+ set("description", text(value("description")) ?? context.defaults.description ?? null);
5380
+ set("image", value("image") ?? context.image ?? null);
5381
+ set("canonical", text(value("canonical")));
5382
+ set("noindex", value("noindex") === true);
5383
+ return output;
5384
+ };
5385
+ //#endregion
5308
5386
  //#region ../../packages/schema/dist/index.js
5309
5387
  var import_pluralize = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
5310
5388
  (function(root, pluralize) {
@@ -5811,7 +5889,7 @@ const STORAGE_FAMILY = {
5811
5889
  * defaulted properties may be omitted. `normalizeDefinition` turns input into the normalized types below,
5812
5890
  * which are what Shapio stores, hashes and diffs.
5813
5891
  */
5814
- const closed$2$1 = { additionalProperties: false };
5892
+ const closed$3$1 = { additionalProperties: false };
5815
5893
  const MODEL_KINDS = ["collection", "singleton"];
5816
5894
  const DEFINITION_KINDS = [...MODEL_KINDS, "component"];
5817
5895
  const SORT_DIRECTIONS = ["asc", "desc"];
@@ -5827,7 +5905,7 @@ const EditorChoiceInputSchema = Type.Object({
5827
5905
  maxLength: 128
5828
5906
  }),
5829
5907
  options: Type.Optional(Type.Record(Type.String(), Type.Unknown()))
5830
- }, closed$2$1);
5908
+ }, closed$3$1);
5831
5909
  const FieldInputSchema = Type.Object({
5832
5910
  id: Type.Optional(Type.String()),
5833
5911
  apiKey: Type.String(),
@@ -5850,7 +5928,7 @@ const FieldInputSchema = Type.Object({
5850
5928
  /** Type-specific settings, validated against SETTINGS_SCHEMAS[type]. */
5851
5929
  settings: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
5852
5930
  editor: Type.Optional(EditorChoiceInputSchema)
5853
- }, closed$2$1);
5931
+ }, closed$3$1);
5854
5932
  const FieldGroupSchema = Type.Object({
5855
5933
  id: Type.String({
5856
5934
  minLength: 1,
@@ -5858,7 +5936,7 @@ const FieldGroupSchema = Type.Object({
5858
5936
  }),
5859
5937
  label: Label,
5860
5938
  fieldIds: Type.Array(Type.String())
5861
- }, closed$2$1);
5939
+ }, closed$3$1);
5862
5940
  const ModelDisplayInputSchema = Type.Object({
5863
5941
  /** Field whose value labels an entry in lists, pickers and relation summaries. */
5864
5942
  titleFieldId: Type.Optional(Type.String()),
@@ -5867,7 +5945,7 @@ const ModelDisplayInputSchema = Type.Object({
5867
5945
  defaultSort: Type.Optional(Type.Object({
5868
5946
  fieldId: Type.String(),
5869
5947
  direction: Type.Enum(SORT_DIRECTIONS)
5870
- }, closed$2$1)),
5948
+ }, closed$3$1)),
5871
5949
  /** Editor layout: named sections of the entry form. */
5872
5950
  groups: Type.Optional(Type.Array(FieldGroupSchema)),
5873
5951
  /** Fields rendered as blocks in the entry document's canvas, in order. Default: see `effectiveLayout`. */
@@ -5876,11 +5954,11 @@ const ModelDisplayInputSchema = Type.Object({
5876
5954
  coverFieldId: Type.Optional(Type.String()),
5877
5955
  /** Properties shown as chips in the document's strip. Default: the first non-empty ones. */
5878
5956
  stripFieldIds: Type.Optional(Type.Array(Type.String()))
5879
- }, closed$2$1);
5957
+ }, closed$3$1);
5880
5958
  const ComponentDisplayInputSchema = Type.Object({
5881
5959
  titleFieldId: Type.Optional(Type.String()),
5882
5960
  groups: Type.Optional(Type.Array(FieldGroupSchema))
5883
- }, closed$2$1);
5961
+ }, closed$3$1);
5884
5962
  const definitionBase = {
5885
5963
  id: Type.Optional(Type.String()),
5886
5964
  apiKey: Type.String(),
@@ -5900,7 +5978,7 @@ const ModelDefinitionInputSchema = Type.Object({
5900
5978
  /** Entries have separate draft and published states. Default true. */
5901
5979
  draftAndPublish: Type.Optional(Type.Boolean()),
5902
5980
  display: Type.Optional(ModelDisplayInputSchema)
5903
- }, closed$2$1);
5981
+ }, closed$3$1);
5904
5982
  const ComponentDefinitionInputSchema = Type.Object({
5905
5983
  ...definitionBase,
5906
5984
  kind: Type.Literal("component"),
@@ -5913,7 +5991,7 @@ const ComponentDefinitionInputSchema = Type.Object({
5913
5991
  maxLength: 64
5914
5992
  })),
5915
5993
  display: Type.Optional(ComponentDisplayInputSchema)
5916
- }, closed$2$1);
5994
+ }, closed$3$1);
5917
5995
  const DefinitionInputSchema = Type.Union([ModelDefinitionInputSchema, ComponentDefinitionInputSchema]);
5918
5996
  const isComponentDefinition = (definition) => definition.kind === "component";
5919
5997
  const isModelDefinition = (definition) => definition.kind !== "component";
@@ -5936,7 +6014,7 @@ const LocaleInputSchema = Type.Object({
5936
6014
  * enum values...). Each schema is closed (`additionalProperties: false`) so a typo in a hand-edited schema
5937
6015
  * file is reported instead of ignored. Defaults are applied by `normalizeDefinition`.
5938
6016
  */
5939
- const closed$1$1 = { additionalProperties: false };
6017
+ const closed$2$1 = { additionalProperties: false };
5940
6018
  const NonNegativeInt = Type.Integer({ minimum: 0 });
5941
6019
  const pattern = (regex) => Type.String({ pattern: regex.source });
5942
6020
  const lengthRules = {
@@ -5964,7 +6042,7 @@ const EnumValueSchema = Type.Object({
5964
6042
  minLength: 1,
5965
6043
  maxLength: 200
5966
6044
  })
5967
- }, closed$1$1);
6045
+ }, closed$2$1);
5968
6046
  const SETTINGS_SCHEMAS = {
5969
6047
  string: Type.Object({
5970
6048
  ...lengthRules,
@@ -5972,20 +6050,20 @@ const SETTINGS_SCHEMAS = {
5972
6050
  minLength: 1,
5973
6051
  maxLength: 500
5974
6052
  }))
5975
- }, closed$1$1),
5976
- text: Type.Object(lengthRules, closed$1$1),
6053
+ }, closed$2$1),
6054
+ text: Type.Object(lengthRules, closed$2$1),
5977
6055
  richtext: Type.Object({
5978
6056
  formatVersion: Type.Optional(Type.Integer({ minimum: 1 })),
5979
6057
  maxLength: Type.Optional(Type.Integer({ minimum: 1 }))
5980
- }, closed$1$1),
6058
+ }, closed$2$1),
5981
6059
  number: Type.Object({
5982
6060
  min: Type.Optional(Type.Number()),
5983
6061
  max: Type.Optional(Type.Number())
5984
- }, closed$1$1),
6062
+ }, closed$2$1),
5985
6063
  integer: Type.Object({
5986
6064
  min: Type.Optional(Type.Integer()),
5987
6065
  max: Type.Optional(Type.Integer())
5988
- }, closed$1$1),
6066
+ }, closed$2$1),
5989
6067
  decimal: Type.Object({
5990
6068
  precision: Type.Optional(Type.Integer({
5991
6069
  minimum: 1,
@@ -5997,68 +6075,70 @@ const SETTINGS_SCHEMAS = {
5997
6075
  })),
5998
6076
  min: Type.Optional(pattern(DECIMAL_PATTERN)),
5999
6077
  max: Type.Optional(pattern(DECIMAL_PATTERN))
6000
- }, closed$1$1),
6078
+ }, closed$2$1),
6001
6079
  biginteger: Type.Object({
6002
6080
  min: Type.Optional(pattern(BIGINTEGER_PATTERN)),
6003
6081
  max: Type.Optional(pattern(BIGINTEGER_PATTERN))
6004
- }, closed$1$1),
6005
- boolean: Type.Object({}, closed$1$1),
6082
+ }, closed$2$1),
6083
+ boolean: Type.Object({}, closed$2$1),
6006
6084
  date: Type.Object({
6007
6085
  min: Type.Optional(pattern(DATE_PATTERN)),
6008
6086
  max: Type.Optional(pattern(DATE_PATTERN))
6009
- }, closed$1$1),
6087
+ }, closed$2$1),
6010
6088
  datetime: Type.Object({
6011
6089
  min: Type.Optional(pattern(DATETIME_PATTERN)),
6012
6090
  max: Type.Optional(pattern(DATETIME_PATTERN))
6013
- }, closed$1$1),
6091
+ }, closed$2$1),
6014
6092
  time: Type.Object({
6015
6093
  min: Type.Optional(pattern(TIME_PATTERN)),
6016
6094
  max: Type.Optional(pattern(TIME_PATTERN))
6017
- }, closed$1$1),
6095
+ }, closed$2$1),
6018
6096
  enum: Type.Object({
6019
6097
  values: Type.Array(EnumValueSchema, {
6020
6098
  minItems: 1,
6021
6099
  maxItems: 500
6022
6100
  }),
6023
6101
  multiple: Type.Optional(Type.Boolean())
6024
- }, closed$1$1),
6025
- json: Type.Object({}, closed$1$1),
6102
+ }, closed$2$1),
6103
+ json: Type.Object({}, closed$2$1),
6026
6104
  slug: Type.Object({
6027
6105
  ...lengthRules,
6028
6106
  sourceFieldId: Type.Optional(Type.String())
6029
- }, closed$1$1),
6030
- email: Type.Object({}, closed$1$1),
6107
+ }, closed$2$1),
6108
+ email: Type.Object({}, closed$2$1),
6031
6109
  url: Type.Object({ protocols: Type.Optional(Type.Array(Type.Enum([
6032
6110
  "http",
6033
6111
  "https",
6034
6112
  "mailto",
6035
6113
  "tel"
6036
- ]), { minItems: 1 })) }, closed$1$1),
6037
- uid: Type.Object(lengthRules, closed$1$1),
6114
+ ]), { minItems: 1 })) }, closed$2$1),
6115
+ uid: Type.Object(lengthRules, closed$2$1),
6038
6116
  media: Type.Object({
6039
6117
  multiple: Type.Optional(Type.Boolean()),
6040
6118
  allowedKinds: Type.Optional(Type.Array(Type.Enum(MEDIA_KINDS), { minItems: 1 })),
6041
6119
  ...countRules
6042
- }, closed$1$1),
6120
+ }, closed$2$1),
6043
6121
  relation: Type.Object({
6044
6122
  target: Type.String(),
6045
6123
  cardinality: Type.Enum(RELATION_CARDINALITIES),
6046
6124
  ...countRules
6047
- }, closed$1$1),
6125
+ }, closed$2$1),
6048
6126
  component: Type.Object({
6049
6127
  component: Type.String(),
6050
6128
  repeatable: Type.Optional(Type.Boolean()),
6051
6129
  ...countRules
6052
- }, closed$1$1),
6130
+ }, closed$2$1),
6053
6131
  dynamiczone: Type.Object({
6054
6132
  components: Type.Array(Type.String(), {
6055
6133
  minItems: 1,
6056
6134
  maxItems: 100
6057
6135
  }),
6058
6136
  ...countRules
6059
- }, closed$1$1)
6137
+ }, closed$2$1)
6060
6138
  };
6061
- const closed$12 = { additionalProperties: false };
6139
+ /** Whether a field holds the built-in SEO component (by stable ID, whatever its API ID or editor). */
6140
+ const isSeoField = (field) => field.type === "component" && field.settings.component === "f3905a00-d2a3-4c62-899a-c894bfd6cb9b" && !field.settings.repeatable;
6141
+ const closed$1$1 = { additionalProperties: false };
6062
6142
  const Placeholder = Type.Optional(Type.String({ maxLength: 200 }));
6063
6143
  const isMultipleEnum = (field) => field.type === "enum" && field.settings.multiple;
6064
6144
  const entries = [
@@ -6070,7 +6150,7 @@ const entries = [
6070
6150
  "url",
6071
6151
  "uid"
6072
6152
  ],
6073
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed$12)
6153
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1$1)
6074
6154
  },
6075
6155
  {
6076
6156
  id: "textarea",
@@ -6081,7 +6161,7 @@ const entries = [
6081
6161
  minimum: 2,
6082
6162
  maximum: 40
6083
6163
  }))
6084
- }, closed$12)
6164
+ }, closed$1$1)
6085
6165
  },
6086
6166
  {
6087
6167
  id: "color",
@@ -6089,12 +6169,12 @@ const entries = [
6089
6169
  optionsSchema: Type.Object({
6090
6170
  format: Type.Optional(Type.Enum(["hex", "rgb"])),
6091
6171
  presets: Type.Optional(Type.Array(Type.String({ maxLength: 32 }), { maxItems: 50 }))
6092
- }, closed$12)
6172
+ }, closed$1$1)
6093
6173
  },
6094
6174
  {
6095
6175
  id: "richText",
6096
6176
  dataTypes: ["richtext"],
6097
- optionsSchema: Type.Object({ toolbar: Type.Optional(Type.Enum(["full", "minimal"])) }, closed$12)
6177
+ optionsSchema: Type.Object({ toolbar: Type.Optional(Type.Enum(["full", "minimal"])) }, closed$1$1)
6098
6178
  },
6099
6179
  {
6100
6180
  id: "numberInput",
@@ -6107,17 +6187,17 @@ const entries = [
6107
6187
  optionsSchema: Type.Object({
6108
6188
  placeholder: Placeholder,
6109
6189
  step: Type.Optional(Type.Number({ exclusiveMinimum: 0 }))
6110
- }, closed$12)
6190
+ }, closed$1$1)
6111
6191
  },
6112
6192
  {
6113
6193
  id: "toggle",
6114
6194
  dataTypes: ["boolean"],
6115
- optionsSchema: Type.Object({}, closed$12)
6195
+ optionsSchema: Type.Object({}, closed$1$1)
6116
6196
  },
6117
6197
  {
6118
6198
  id: "checkbox",
6119
6199
  dataTypes: ["boolean"],
6120
- optionsSchema: Type.Object({}, closed$12)
6200
+ optionsSchema: Type.Object({}, closed$1$1)
6121
6201
  },
6122
6202
  {
6123
6203
  id: "segmented",
@@ -6125,35 +6205,35 @@ const entries = [
6125
6205
  optionsSchema: Type.Object({
6126
6206
  trueLabel: Type.Optional(Type.String({ maxLength: 50 })),
6127
6207
  falseLabel: Type.Optional(Type.String({ maxLength: 50 }))
6128
- }, closed$12),
6208
+ }, closed$1$1),
6129
6209
  supports: (field) => !isMultipleEnum(field)
6130
6210
  },
6131
6211
  {
6132
6212
  id: "select",
6133
6213
  dataTypes: ["enum"],
6134
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed$12)
6214
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1$1)
6135
6215
  },
6136
6216
  {
6137
6217
  id: "radio",
6138
6218
  dataTypes: ["enum"],
6139
- optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$12),
6219
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$1$1),
6140
6220
  supports: (field) => !isMultipleEnum(field)
6141
6221
  },
6142
6222
  {
6143
6223
  id: "checkboxGroup",
6144
6224
  dataTypes: ["enum"],
6145
- optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$12),
6225
+ optionsSchema: Type.Object({ layout: Type.Optional(Type.Enum(["vertical", "horizontal"])) }, closed$1$1),
6146
6226
  supports: isMultipleEnum
6147
6227
  },
6148
6228
  {
6149
6229
  id: "datePicker",
6150
6230
  dataTypes: ["date"],
6151
- optionsSchema: Type.Object({}, closed$12)
6231
+ optionsSchema: Type.Object({}, closed$1$1)
6152
6232
  },
6153
6233
  {
6154
6234
  id: "dateTimePicker",
6155
6235
  dataTypes: ["datetime"],
6156
- optionsSchema: Type.Object({ display: Type.Optional(Type.Enum(["local", "utc"])) }, closed$12)
6236
+ optionsSchema: Type.Object({ display: Type.Optional(Type.Enum(["local", "utc"])) }, closed$1$1)
6157
6237
  },
6158
6238
  {
6159
6239
  id: "timePicker",
@@ -6161,12 +6241,12 @@ const entries = [
6161
6241
  optionsSchema: Type.Object({ stepMinutes: Type.Optional(Type.Integer({
6162
6242
  minimum: 1,
6163
6243
  maximum: 60
6164
- })) }, closed$12)
6244
+ })) }, closed$1$1)
6165
6245
  },
6166
6246
  {
6167
6247
  id: "slugInput",
6168
6248
  dataTypes: ["slug"],
6169
- optionsSchema: Type.Object({ placeholder: Placeholder }, closed$12)
6249
+ optionsSchema: Type.Object({ placeholder: Placeholder }, closed$1$1)
6170
6250
  },
6171
6251
  {
6172
6252
  id: "jsonEditor",
@@ -6174,27 +6254,33 @@ const entries = [
6174
6254
  optionsSchema: Type.Object({ rows: Type.Optional(Type.Integer({
6175
6255
  minimum: 3,
6176
6256
  maximum: 80
6177
- })) }, closed$12)
6257
+ })) }, closed$1$1)
6178
6258
  },
6179
6259
  {
6180
6260
  id: "mediaPicker",
6181
6261
  dataTypes: ["media"],
6182
- optionsSchema: Type.Object({}, closed$12)
6262
+ optionsSchema: Type.Object({}, closed$1$1)
6183
6263
  },
6184
6264
  {
6185
6265
  id: "relationPicker",
6186
6266
  dataTypes: ["relation"],
6187
- optionsSchema: Type.Object({ allowInlineCreate: Type.Optional(Type.Boolean()) }, closed$12)
6267
+ optionsSchema: Type.Object({ allowInlineCreate: Type.Optional(Type.Boolean()) }, closed$1$1)
6188
6268
  },
6189
6269
  {
6190
6270
  id: "componentEditor",
6191
6271
  dataTypes: ["component"],
6192
- optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$12)
6272
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$1$1)
6273
+ },
6274
+ {
6275
+ id: SEO_EDITOR_ID,
6276
+ dataTypes: ["component"],
6277
+ optionsSchema: Type.Object({}, closed$1$1),
6278
+ supports: isSeoField
6193
6279
  },
6194
6280
  {
6195
6281
  id: "dynamicZoneEditor",
6196
6282
  dataTypes: ["dynamiczone"],
6197
- optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$12)
6283
+ optionsSchema: Type.Object({ collapsed: Type.Optional(Type.Boolean()) }, closed$1$1)
6198
6284
  }
6199
6285
  ];
6200
6286
  const EDITOR_CATALOGUE = new Map(entries.map((entry) => [entry.id, entry]));
@@ -6595,7 +6681,10 @@ const RESERVED_TYPE_NAMES = [
6595
6681
  "SnapshotChangeKind",
6596
6682
  "SnapshotChangeLocale",
6597
6683
  "SnapshotChangePage",
6598
- "SnapshotInfo"
6684
+ "SnapshotInfo",
6685
+ "SiteInfo",
6686
+ "SiteSeoDefaults",
6687
+ "SiteSeoLocale"
6599
6688
  ];
6600
6689
  /**
6601
6690
  * Model and component API keys the admin uses as static URL segments (`/content/new`,
@@ -6629,7 +6718,8 @@ const RESERVED_FIELD_API_KEYS = [
6629
6718
  const RESERVED_QUERY_NAMES = [
6630
6719
  "_schemaVersion",
6631
6720
  "_changes",
6632
- "_snapshot"
6721
+ "_snapshot",
6722
+ "_site"
6633
6723
  ];
6634
6724
  /** Every name a definition contributes to the GraphQL schema. */
6635
6725
  const generatedNames = (definition) => {
@@ -8007,7 +8097,9 @@ const BUILT_IN_THEME_KEYS = [
8007
8097
  "shapio",
8008
8098
  "classic",
8009
8099
  "murdered-out",
8010
- "snowed"
8100
+ "forest",
8101
+ "snowed",
8102
+ "butter"
8011
8103
  ];
8012
8104
  /**
8013
8105
  * A token value an extension may use: a hex colour or a colour function whose arguments are numbers, units
@@ -8117,6 +8209,85 @@ const checkThemeContrast = (tokens, variant) => {
8117
8209
  };
8118
8210
  return [...THEME_TEXT_PAIRS.map((pair) => measure(pair, 4.5)), ...uiPairs.map((pair) => measure(pair, 3))];
8119
8211
  };
8212
+ /**
8213
+ * The built-in SEO component as first created. All fields are public and none has a length limit: the 70 and
8214
+ * 160 character marks are admin-only counters (`SEO_TITLE_SOFT_MAX`), never validation.
8215
+ */
8216
+ const seoComponentDefinition = () => normalizeDefinition({
8217
+ id: SEO_COMPONENT_ID,
8218
+ kind: "component",
8219
+ apiKey: "seo",
8220
+ label: "SEO",
8221
+ description: "Search and social metadata: title, description, image, canonical URL and indexing.",
8222
+ category: "Built-in",
8223
+ icon: "search",
8224
+ fields: [
8225
+ {
8226
+ id: SEO_FIELD_IDS.title,
8227
+ apiKey: "title",
8228
+ label: "Title",
8229
+ type: "string"
8230
+ },
8231
+ {
8232
+ id: SEO_FIELD_IDS.description,
8233
+ apiKey: "description",
8234
+ label: "Description",
8235
+ type: "text"
8236
+ },
8237
+ {
8238
+ id: SEO_FIELD_IDS.image,
8239
+ apiKey: "image",
8240
+ label: "Social image",
8241
+ type: "media",
8242
+ settings: {
8243
+ multiple: false,
8244
+ allowedKinds: ["image"]
8245
+ }
8246
+ },
8247
+ {
8248
+ id: SEO_FIELD_IDS.canonical,
8249
+ apiKey: "canonical",
8250
+ label: "Canonical URL",
8251
+ type: "url",
8252
+ settings: { protocols: ["https", "http"] }
8253
+ },
8254
+ {
8255
+ id: SEO_FIELD_IDS.noindex,
8256
+ apiKey: "noindex",
8257
+ label: "Hide from search engines",
8258
+ type: "boolean"
8259
+ }
8260
+ ],
8261
+ display: { titleFieldId: SEO_FIELD_IDS.title }
8262
+ });
8263
+ const closed$12 = { additionalProperties: false };
8264
+ /** Media asset IDs are UUIDs. */
8265
+ const ASSET_ID_PATTERN = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
8266
+ /** One locale's default texts. Every key is optional: a missing one falls back along the locale chain. */
8267
+ const SeoLocaleDefaultsSchema = Type.Object({
8268
+ siteName: Type.Optional(Type.String({
8269
+ minLength: 1,
8270
+ maxLength: 200
8271
+ })),
8272
+ titleTemplate: Type.Optional(Type.String({
8273
+ minLength: 2,
8274
+ maxLength: 200,
8275
+ pattern: SEO_TITLE_TEMPLATE_PATTERN.source
8276
+ })),
8277
+ description: Type.Optional(Type.String({
8278
+ minLength: 1,
8279
+ maxLength: 1e3
8280
+ }))
8281
+ }, closed$12);
8282
+ /**
8283
+ * A site's SEO defaults (`sites.seo_defaults`): texts per locale code, the default social image (a public
8284
+ * image asset of the same site) and the Twitter handle for the whole site.
8285
+ */
8286
+ const SeoDefaultsSchema = Type.Object({
8287
+ locales: Type.Record(Type.String({ pattern: LOCALE_CODE_PATTERN.source }), SeoLocaleDefaultsSchema, { additionalProperties: false }),
8288
+ imageId: Type.Union([Type.String({ pattern: ASSET_ID_PATTERN.source }), Type.Null()]),
8289
+ twitterHandle: Type.Union([Type.String({ pattern: SEO_TWITTER_HANDLE_PATTERN.source }), Type.Null()])
8290
+ }, closed$12);
8120
8291
  //#endregion
8121
8292
  //#region src/content/model.ts
8122
8293
  const modelNotFound = (key) => new AppError(404, "MODEL_NOT_FOUND", `No content model "${key}"`, { modelKey: key });
@@ -10214,7 +10385,7 @@ const getPermissionsVersion = async (trx = db) => {
10214
10385
  return (await trx.selectFrom("system_versions").select("permissions_version").executeTakeFirstOrThrow()).permissions_version;
10215
10386
  };
10216
10387
  /** Call in the same transaction as every permission change, so caches reload exactly when it commits. */
10217
- const bumpPermissionsVersion = (trx = db) => trx.updateTable("system_versions").set((eb) => ({
10388
+ const bumpPermissionsVersion$2 = (trx = db) => trx.updateTable("system_versions").set((eb) => ({
10218
10389
  permissions_version: eb("permissions_version", "+", eb.lit(1)),
10219
10390
  updated_at: currentTimestamp()
10220
10391
  })).execute();
@@ -10264,7 +10435,8 @@ const SITE_ACTIONS = [
10264
10435
  "deployments.manage",
10265
10436
  "deployments.trigger",
10266
10437
  "changes.manage",
10267
- "changes.ship"
10438
+ "changes.ship",
10439
+ "site.settings"
10268
10440
  ];
10269
10441
  const GLOBAL_ACTIONS = [...NETWORK_ACTIONS, ...SITE_ACTIONS];
10270
10442
  const EMPTY_MASK$1 = {
@@ -10360,7 +10532,7 @@ const ensureSystemRoles = async (database) => {
10360
10532
  })), trx);
10361
10533
  changed ||= added.length > 0;
10362
10534
  }
10363
- if (changed) await bumpPermissionsVersion(trx);
10535
+ if (changed) await bumpPermissionsVersion$2(trx);
10364
10536
  });
10365
10537
  };
10366
10538
  //#endregion
@@ -10642,6 +10814,26 @@ const countContents = async (siteId, trx = db) => {
10642
10814
  };
10643
10815
  };
10644
10816
  const deleteById$3 = (id, trx = db) => trx.deleteFrom("sites").where("id", "=", id).executeTakeFirst();
10817
+ /** A site's SEO defaults (null until first saved) and the version an update must name. */
10818
+ const findSeoDefaults = (id, trx = db) => trx.selectFrom("sites").select([
10819
+ "id",
10820
+ "key",
10821
+ "name",
10822
+ "version",
10823
+ "seo_defaults"
10824
+ ]).where("id", "=", id).executeTakeFirst();
10825
+ /** Optimistic update of the SEO defaults: applies only at `expectedVersion`; bumps the version. */
10826
+ const updateSeoDefaultsIfVersion = (id, expectedVersion, seoDefaults, trx = db) => trx.updateTable("sites").set((eb) => ({
10827
+ seo_defaults: JSON.stringify(seoDefaults),
10828
+ version: eb("version", "+", 1),
10829
+ updated_at: /* @__PURE__ */ new Date()
10830
+ })).where("id", "=", id).where("version", "=", expectedVersion).returning([
10831
+ "id",
10832
+ "key",
10833
+ "name",
10834
+ "version",
10835
+ "seo_defaults"
10836
+ ]).executeTakeFirst();
10645
10837
  //#endregion
10646
10838
  //#region src/services/roleAssignments.ts
10647
10839
  const assignmentKey = (assignment) => `${assignment.roleId}:${assignment.siteId ?? "*"}`;
@@ -11949,8 +12141,9 @@ const groupByRole = (grants) => {
11949
12141
  };
11950
12142
  /**
11951
12143
  * Every role's grants, held in memory and reloaded when `system_versions.permissions_version` moves.
11952
- * The version is read on every lookup: that durable check is what makes a permission change visible to
11953
- * every instance at once (NOTIFY would only be an optimisation). Lookups on the pool share one reload; a
12144
+ * The version is checked on every lookup: read here, or read once by the request (`versions`, see
12145
+ * `KnownVersions`). That durable check is what makes a permission change visible to every instance from the
12146
+ * next request (NOTIFY would only be an optimisation). Lookups on the pool share one reload; a
11954
12147
  * lookup inside a transaction reads (and, when stale, reloads) through that transaction alone, never
11955
12148
  * through a reload running on another request's transaction.
11956
12149
  */
@@ -11969,21 +12162,21 @@ const createPermissionCache = (database) => {
11969
12162
  const sharedReload = (version) => reloading ??= reload(version, database).finally(() => {
11970
12163
  reloading = void 0;
11971
12164
  });
11972
- const current = async (executor) => {
11973
- const version = await getPermissionsVersion(executor ?? database);
12165
+ const current = async (executor, versions) => {
12166
+ const version = versions?.permissionsVersion ?? await getPermissionsVersion(executor ?? database);
11974
12167
  if (snapshot && snapshot.version === version) return snapshot;
11975
12168
  const loaded = await (executor ? reload(version, executor) : sharedReload(version));
11976
12169
  if (!snapshot || loaded.version >= snapshot.version) snapshot = loaded;
11977
12170
  return loaded;
11978
12171
  };
11979
12172
  return {
11980
- getGrants: async (roleIds, executor) => {
12173
+ getGrants: async (roleIds, executor, versions) => {
11981
12174
  if (roleIds.length === 0) return [];
11982
- const { byRole } = await current(executor);
12175
+ const { byRole } = await current(executor, versions);
11983
12176
  return roleIds.flatMap((roleId) => byRole.get(roleId) ?? []);
11984
12177
  },
11985
- getSiteAppRoleIds: async (siteId, audience, executor) => {
11986
- const { bindings } = await current(executor);
12178
+ getSiteAppRoleIds: async (siteId, audience, executor, versions) => {
12179
+ const { bindings } = await current(executor, versions);
11987
12180
  return bindings.get(bindingKey(siteId, audience)) ?? [];
11988
12181
  }
11989
12182
  };
@@ -12074,7 +12267,7 @@ const APP_ACTIONS = new Set(APP_CONTENT_ACTIONS);
12074
12267
  * outside a site holds no role). Both are delivery audiences: they see `public: false` fields only where a
12075
12268
  * grant names them.
12076
12269
  */
12077
- const rolesOf = async (principal, grants, executor) => {
12270
+ const rolesOf = async (principal, grants, executor, versions) => {
12078
12271
  switch (principal.kind) {
12079
12272
  case "system": return "all";
12080
12273
  case "admin": return {
@@ -12095,13 +12288,13 @@ const rolesOf = async (principal, grants, executor) => {
12095
12288
  actions: READ_ONLY$1
12096
12289
  };
12097
12290
  case "appUser": return {
12098
- roleIds: [...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor), ...principal.roleIds],
12291
+ roleIds: [...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor, versions), ...principal.roleIds],
12099
12292
  networkRoleIds: [],
12100
12293
  audience: "delivery",
12101
12294
  actions: APP_ACTIONS
12102
12295
  };
12103
12296
  case "anonymous": return {
12104
- roleIds: principal.siteId === null ? [] : await grants.getSiteAppRoleIds(principal.siteId, "public", executor),
12297
+ roleIds: principal.siteId === null ? [] : await grants.getSiteAppRoleIds(principal.siteId, "public", executor, versions),
12105
12298
  networkRoleIds: [],
12106
12299
  audience: "delivery",
12107
12300
  actions: APP_ACTIONS
@@ -12121,58 +12314,64 @@ const actsOnSite = (principal, siteId) => {
12121
12314
  };
12122
12315
  /**
12123
12316
  * The one permission evaluator (ADR 0005): principal → roles → grants → Policy, for REST and GraphQL.
12124
- * Deny by default at every step.
12125
- */
12126
- const createPermissionEvaluator = ({ grants, fields }) => ({
12127
- evaluate: async (principal, request, executor) => {
12128
- const roles = await rolesOf(principal, grants, executor);
12129
- if (roles === "all") return ALLOW_ALL_POLICY;
12130
- if (roles.actions !== "all" && !roles.actions.has(request.action)) return DENIED_POLICY;
12131
- let roleIds = roles.roleIds;
12132
- if (NETWORK_CONTENT_ACTIONS.has(request.action)) {
12133
- const modelSite = await fields.getModelSite(request.modelId, executor);
12134
- roleIds = modelSite !== null && actsOnSite(principal, modelSite) ? roles.roleIds : roles.networkRoleIds;
12135
- }
12136
- const held = await grants.getGrants(roleIds, executor);
12137
- if (held.length === 0) return DENIED_POLICY;
12138
- const modelFields = roles.audience === "delivery" ? await fields.getModelFields(request.modelId, executor) : void 0;
12139
- return buildPolicy(held, request, roles.audience, modelFields);
12140
- },
12141
- canPerform: async (principal, action, executor) => {
12142
- const roles = await rolesOf(principal, grants, executor);
12143
- if (roles === "all") return true;
12144
- if (roles.audience === "delivery") return false;
12145
- const roleIds = NETWORK_ACTION_SET.has(action) ? roles.networkRoleIds : roles.roleIds;
12146
- return allowsGlobalAction(await grants.getGrants(roleIds, executor), action);
12147
- },
12148
- canPerformOnSite: async (principal, action, siteId, executor) => {
12149
- const roles = await rolesOf(principal, grants, executor);
12150
- if (roles === "all") return true;
12151
- if (roles.audience === "delivery") return false;
12152
- const counted = !NETWORK_ACTION_SET.has(action) || SITE_GRANTABLE_ACTIONS.has(action) && actsOnSite(principal, siteId) ? roles.roleIds : roles.networkRoleIds;
12153
- return allowsGlobalAction(await grants.getGrants(counted, executor), action);
12154
- }
12155
- });
12317
+ * Deny by default at every step. `atVersions` gives the same evaluator for one request, whose caches are
12318
+ * checked against the versions the request read once instead of read again on every call.
12319
+ */
12320
+ const createPermissionEvaluator = ({ grants, fields }) => {
12321
+ const at = (versions) => ({
12322
+ evaluate: async (principal, request, executor) => {
12323
+ const roles = await rolesOf(principal, grants, executor, versions);
12324
+ if (roles === "all") return ALLOW_ALL_POLICY;
12325
+ if (roles.actions !== "all" && !roles.actions.has(request.action)) return DENIED_POLICY;
12326
+ let roleIds = roles.roleIds;
12327
+ if (NETWORK_CONTENT_ACTIONS.has(request.action)) {
12328
+ const modelSite = await fields.getModelSite(request.modelId, executor, versions);
12329
+ roleIds = modelSite !== null && actsOnSite(principal, modelSite) ? roles.roleIds : roles.networkRoleIds;
12330
+ }
12331
+ const held = await grants.getGrants(roleIds, executor, versions);
12332
+ if (held.length === 0) return DENIED_POLICY;
12333
+ const modelFields = roles.audience === "delivery" ? await fields.getModelFields(request.modelId, executor, versions) : void 0;
12334
+ return buildPolicy(held, request, roles.audience, modelFields);
12335
+ },
12336
+ canPerform: async (principal, action, executor) => {
12337
+ const roles = await rolesOf(principal, grants, executor, versions);
12338
+ if (roles === "all") return true;
12339
+ if (roles.audience === "delivery") return false;
12340
+ const roleIds = NETWORK_ACTION_SET.has(action) ? roles.networkRoleIds : roles.roleIds;
12341
+ return allowsGlobalAction(await grants.getGrants(roleIds, executor, versions), action);
12342
+ },
12343
+ canPerformOnSite: async (principal, action, siteId, executor) => {
12344
+ const roles = await rolesOf(principal, grants, executor, versions);
12345
+ if (roles === "all") return true;
12346
+ if (roles.audience === "delivery") return false;
12347
+ const counted = !NETWORK_ACTION_SET.has(action) || SITE_GRANTABLE_ACTIONS.has(action) && actsOnSite(principal, siteId) ? roles.roleIds : roles.networkRoleIds;
12348
+ return allowsGlobalAction(await grants.getGrants(counted, executor, versions), action);
12349
+ },
12350
+ atVersions: at
12351
+ });
12352
+ return at(void 0);
12353
+ };
12156
12354
  //#endregion
12157
12355
  //#region src/schema/fieldVisibility.ts
12158
12356
  /**
12159
12357
  * The permission evaluator's view of field visibility (`public` flags), served from the active schema.
12160
- * Uses the durable version check on every call, so a field made non-public is hidden on every instance at
12161
- * once, whether or not it got the notification. Deprecated fields are reported as non-public: they are
12162
- * hidden from the API unless a role names them explicitly. Components are not delivery models.
12358
+ * Uses the durable version check on every call (or the version the request read once, `versions`), so a
12359
+ * field made non-public is hidden on every instance from the next request, whether or not it got the
12360
+ * notification. Deprecated fields are reported as non-public: they are hidden from the API unless a role
12361
+ * names them explicitly. Components are not delivery models.
12163
12362
  */
12164
12363
  const createSchemaFieldVisibility = (registry) => {
12165
- const activeModel = async (modelId, executor) => {
12166
- const active = (await registry.getSnapshot(executor)).byId.get(modelId);
12364
+ const activeModel = async (modelId, executor, versions) => {
12365
+ const active = (await registry.getSnapshot(executor, versions)).byId.get(modelId);
12167
12366
  return active && active.definition.kind !== "component" ? active.definition : void 0;
12168
12367
  };
12169
12368
  return {
12170
- getModelFields: async (modelId, executor) => (await activeModel(modelId, executor))?.fields.map((field) => ({
12369
+ getModelFields: async (modelId, executor, versions) => (await activeModel(modelId, executor, versions))?.fields.map((field) => ({
12171
12370
  id: field.id,
12172
12371
  public: field.public && !field.deprecated
12173
12372
  })),
12174
- hasModel: async (modelId, executor) => await activeModel(modelId, executor) !== void 0,
12175
- getModelSite: async (modelId, executor) => (await registry.getSnapshot(executor)).scopeOf(modelId) ?? null
12373
+ hasModel: async (modelId, executor, versions) => await activeModel(modelId, executor, versions) !== void 0,
12374
+ getModelSite: async (modelId, executor, versions) => (await registry.getSnapshot(executor, versions)).scopeOf(modelId) ?? null
12176
12375
  };
12177
12376
  };
12178
12377
  //#endregion
@@ -12214,8 +12413,8 @@ const createSchemaRegistry = ({ db, log }) => {
12214
12413
  }
12215
12414
  };
12216
12415
  return {
12217
- getSnapshot: async (executor) => {
12218
- const version = await getSchemaVersion(executor ?? db);
12416
+ getSnapshot: async (executor, versions) => {
12417
+ const version = versions?.schemaVersion ?? await getSchemaVersion(executor ?? db);
12219
12418
  if (cached && cached.version >= version) return cached;
12220
12419
  return executor ? readSnapshot(executor) : loadAtLeast(version);
12221
12420
  },
@@ -12645,6 +12844,25 @@ const selectFields = (model, mask, selected) => {
12645
12844
  const allowed = model.definition.fields.filter((field) => maskAllows(mask, field));
12646
12845
  return selected ? allowed.filter((field) => selected.some((choice) => choice.id === field.id)) : allowed;
12647
12846
  };
12847
+ /**
12848
+ * Renders a delivered rich-text value's HTML on demand (GraphQL's `RichText.html` resolver). A symbol key, so
12849
+ * `JSON.stringify` never writes it: REST responses carry `html` only when the request asked for it.
12850
+ */
12851
+ const RICH_TEXT_HTML = Symbol("shapio.richTextHtml");
12852
+ const projectRichText = (document, mode, options) => {
12853
+ const render = () => renderRichTextHtml(document, (id) => options.mediaAssets?.get(id)?.url);
12854
+ const { doc, ...envelope } = document;
12855
+ const shaped = mode === "json" ? { ...document } : mode === "html" ? {
12856
+ ...envelope,
12857
+ html: render()
12858
+ } : {
12859
+ ...envelope,
12860
+ doc,
12861
+ html: render()
12862
+ };
12863
+ shaped[RICH_TEXT_HTML] = render;
12864
+ return shaped;
12865
+ };
12648
12866
  const isRecord$8 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12649
12867
  const isListField = (field) => field.type === "relation" && field.settings.cardinality === "many" || field.type === "media" && field.settings.multiple || field.type === "component" && field.settings.repeatable || field.type === "dynamiczone";
12650
12868
  const projectRelation = (field, value, options, topLevel) => {
@@ -12694,10 +12912,7 @@ const projectValue = (field, value, options, topLevel) => {
12694
12912
  ...projected
12695
12913
  }] : [];
12696
12914
  });
12697
- case "richtext": return options.richTextHtml ? {
12698
- ...value,
12699
- html: renderRichTextHtml(value, (id) => options.mediaAssets?.get(id)?.url)
12700
- } : value;
12915
+ case "richtext": return options.richText ? projectRichText(value, options.richText, options) : value;
12701
12916
  case "media": {
12702
12917
  if (!options.mediaAssets) return value;
12703
12918
  const assets = (Array.isArray(value) ? value : [value]).flatMap((id) => {
@@ -12774,7 +12989,6 @@ const toEntryData = (model, data) => data === void 0 ? void 0 : projectData(data
12774
12989
  fields: model.definition.fields.filter((field) => !field.deprecated),
12775
12990
  visibleTargets: null,
12776
12991
  populated: /* @__PURE__ */ new Map(),
12777
- richTextHtml: false,
12778
12992
  mediaAssets: null
12779
12993
  });
12780
12994
  const reject = (message, details) => {
@@ -12973,11 +13187,11 @@ const unknownKeyMessage = (path) => {
12973
13187
  if (segments[0] === "themes" && segments.length === 4) return `unknown theme token "${key}"; the tokens are listed in documentation/extensions.md`;
12974
13188
  return `unknown key "${key}"`;
12975
13189
  };
12976
- const keysOf = (value) => isRecord$6(value) ? Object.keys(value) : [];
13190
+ const keysOf$1 = (value) => isRecord$6(value) ? Object.keys(value) : [];
12977
13191
  /** Rules TypeBox cannot express (key patterns, uniqueness, reserved names); tolerant of a malformed config. */
12978
13192
  const findNamingProblems = (config) => {
12979
13193
  const problems = [];
12980
- for (const key of keysOf(config.hooks)) if (!HOOK_MODEL_KEY_PATTERN.test(key)) problems.push({
13194
+ for (const key of keysOf$1(config.hooks)) if (!HOOK_MODEL_KEY_PATTERN.test(key)) problems.push({
12981
13195
  path: `/hooks/${key}`,
12982
13196
  message: "must be a model API ID, a model UUID or \"*\""
12983
13197
  });
@@ -12994,7 +13208,7 @@ const findNamingProblems = (config) => {
12994
13208
  });
12995
13209
  prefixes.add(route.prefix);
12996
13210
  });
12997
- for (const name of keysOf(config.services)) if (!SERVICE_NAME_PATTERN.test(name)) problems.push({
13211
+ for (const name of keysOf$1(config.services)) if (!SERVICE_NAME_PATTERN.test(name)) problems.push({
12998
13212
  path: `/services/${name}`,
12999
13213
  message: "must be a JavaScript identifier"
13000
13214
  });
@@ -13002,7 +13216,7 @@ const findNamingProblems = (config) => {
13002
13216
  path: `/services/${name}`,
13003
13217
  message: `"${name}" is one of Shapio's own services`
13004
13218
  });
13005
- for (const name of keysOf(config.jobs)) if (!JOB_NAME_PATTERN.test(name)) problems.push({
13219
+ for (const name of keysOf$1(config.jobs)) if (!JOB_NAME_PATTERN.test(name)) problems.push({
13006
13220
  path: `/jobs/${name}`,
13007
13221
  message: "must start with a letter and use letters, digits, \".\", \"_\" or \"-\""
13008
13222
  });
@@ -13323,6 +13537,20 @@ const SYSTEM_ATTRIBUTES = [
13323
13537
  "createdAt",
13324
13538
  "updatedAt"
13325
13539
  ];
13540
+ /**
13541
+ * Delivery shapes of a rich-text value (ADR 0003): `json` the stored document `{ format, version, doc }`,
13542
+ * `html` `{ format, version, html }` (sanitized HTML rendered from the document), `both` the document plus
13543
+ * `html`.
13544
+ */
13545
+ const RICH_TEXT_MODES = [
13546
+ "json",
13547
+ "html",
13548
+ "both"
13549
+ ];
13550
+ /** What a delivery read returns for rich text when the request does not say (`?richText=`). */
13551
+ const DEFAULT_RICH_TEXT_MODE = "json";
13552
+ /** `?seo=`: SEO fields as stored, or merged with the site's SEO defaults (plan seo-fields). */
13553
+ const SEO_MODES = ["raw", "resolved"];
13326
13554
  const ENTRY_LIST_STATUSES = [
13327
13555
  "draft",
13328
13556
  "published",
@@ -13599,7 +13827,7 @@ const compileAuthorCondition = (adminUserId, dialect = contentDialect()) => sql`
13599
13827
  const compileSearch = (fieldId, text, dialect = contentDialect()) => containsInsensitiveExpression(fieldId, text, dialect);
13600
13828
  const SNAPSHOT_CTE = "content_snapshot";
13601
13829
  /** The FROM item for a source, aliased `alias`, and the CTE it needs (snapshots only). */
13602
- const sourceOf$1 = (source, modelId, siteId, dialect) => {
13830
+ const sourceOf$2 = (source, modelId, siteId, dialect) => {
13603
13831
  if (source.kind === "heads") return {
13604
13832
  cte: null,
13605
13833
  table: sql.table("entry_heads")
@@ -13648,20 +13876,46 @@ const entryScope = (plan, dialect) => plan.orderedByEntry === true && plan.condi
13648
13876
  * alone. A snapshot keeps the join: the publication log still lists entries deleted since.
13649
13877
  */
13650
13878
  const countFrom = (plan, table, where, joined) => plan.source.kind === "heads" && plan.conditions.length === 0 ? sql`from ${table} h where ${where}` : joined;
13651
- /** The SELECT for a plan: rows, plus the matching COUNT for pagination. */
13652
- const compileHeadQuery = (plan, dialect = contentDialect()) => {
13653
- const { cte, table } = sourceOf$1(plan.source, plan.modelId, plan.siteId, dialect);
13879
+ const HEAD_COLUMNS$1 = sql`h.entry_id, h.locale, h.data, h.version, h.revision_id, h.updated_at,
13880
+ h.autosaved_at, e.created_at, e.updated_at as entry_updated_at, e.created_by_admin_id, e.owner_app_user_id`;
13881
+ /** The parts every statement over a plan's heads shares. */
13882
+ const headQueryParts = (plan, dialect) => {
13883
+ const { cte, table } = sourceOf$2(plan.source, plan.modelId, plan.siteId, dialect);
13654
13884
  const where = whereOf(plan, table, dialect);
13655
13885
  const from = sql`from ${table} h join entries e on e.id = h.entry_id and e.deleted_at is null where ${where}`;
13656
- const prefix = cte ?? sql``;
13657
13886
  const orderBy = plan.orderBy.length > 0 ? sql` order by ${sql.join([...plan.orderBy])}` : sql``;
13658
13887
  const limit = plan.limit !== void 0 ? sql` limit ${plan.limit}` : sql``;
13659
13888
  const offset = plan.offset ? sql` offset ${plan.offset}` : sql``;
13660
13889
  return {
13661
- rows: sql`${prefix}select h.entry_id, h.locale, h.data, h.version, h.revision_id, h.updated_at,
13662
- h.autosaved_at, e.created_at, e.updated_at as entry_updated_at, e.created_by_admin_id, e.owner_app_user_id
13663
- ${from}${entryScope(plan, dialect)}${orderBy}${limit}${offset}`,
13664
- count: sql`${prefix}select count(*) as total ${countFrom(plan, table, where, from)}`
13890
+ prefix: cte ?? sql``,
13891
+ rows: sql`${from}${entryScope(plan, dialect)}${orderBy}${limit}${offset}`,
13892
+ countFrom: countFrom(plan, table, where, from)
13893
+ };
13894
+ };
13895
+ /** The SELECT for a plan: rows, plus the matching COUNT for pagination. */
13896
+ const compileHeadQuery = (plan, dialect = contentDialect()) => {
13897
+ const { prefix, rows, countFrom: counted } = headQueryParts(plan, dialect);
13898
+ return {
13899
+ rows: sql`${prefix}select ${HEAD_COLUMNS$1}
13900
+ ${rows}`,
13901
+ count: sql`${prefix}select count(*) as total ${counted}`
13902
+ };
13903
+ };
13904
+ /**
13905
+ * One delivery read as a single statement (plan delivery-perf): the page's heads with, on every row, the
13906
+ * matching total and the site's current publication sequence (`publication_state.last_seq`), so all three
13907
+ * come from one snapshot of the database on every dialect without a transaction. The uncorrelated
13908
+ * subqueries run once per statement. An empty page carries no row, so `meta` reads the total and the
13909
+ * sequence alone, for callers that read it in the same transaction as `rows`.
13910
+ */
13911
+ const compileHeadPage = (plan, options, dialect = contentDialect()) => {
13912
+ const { prefix, rows, countFrom: counted } = headQueryParts(plan, dialect);
13913
+ const total = options.total ? sql`(select count(*) ${counted})` : sql`null`;
13914
+ const seq = sql`(select ps.last_seq from publication_state ps where ps.site_id = ${dialect.uuid(plan.siteId)})`;
13915
+ return {
13916
+ rows: sql`${prefix}select ${HEAD_COLUMNS$1}, ${total} as page_total, ${seq} as page_seq
13917
+ ${rows}`,
13918
+ meta: sql`${prefix}select ${total} as page_total, ${seq} as page_seq`
13665
13919
  };
13666
13920
  };
13667
13921
  //#endregion
@@ -13753,6 +14007,8 @@ const TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
13753
14007
  "populate",
13754
14008
  "locale",
13755
14009
  "snapshot",
14010
+ "richText",
14011
+ "seo",
13756
14012
  "q",
13757
14013
  "status",
13758
14014
  "author"
@@ -13911,6 +14167,27 @@ const parseAdminFilters = (tree, context) => {
13911
14167
  ...author !== void 0 ? { author } : {}
13912
14168
  };
13913
14169
  };
14170
+ const RICH_TEXT_MODE_SET = new Set(RICH_TEXT_MODES);
14171
+ /** `?richText=json|html|both` on delivery and preview reads; the default there, nothing on admin reads. */
14172
+ const parseRichText = (tree, context) => {
14173
+ const value = single(tree.richText, "richText");
14174
+ if (!context.allowRichText) {
14175
+ if (value !== void 0) throw queryInvalid("richText is only available on the delivery and preview APIs");
14176
+ return {};
14177
+ }
14178
+ if (value !== void 0 && !RICH_TEXT_MODE_SET.has(value)) throw queryInvalid(`"richText" must be one of ${RICH_TEXT_MODES.join(", ")}`);
14179
+ return { richText: value ?? "json" };
14180
+ };
14181
+ const SEO_MODE_SET = new Set(SEO_MODES);
14182
+ /** `?seo=raw|resolved` on delivery and preview reads; `resolved` needs a readable SEO field on the model. */
14183
+ const parseSeo = (tree, context) => {
14184
+ const value = single(tree.seo, "seo");
14185
+ if (value === void 0) return {};
14186
+ if (!context.allowSeo) throw queryInvalid("seo is only available on the delivery and preview APIs");
14187
+ if (!SEO_MODE_SET.has(value)) throw queryInvalid(`"seo" must be one of ${SEO_MODES.join(", ")}`);
14188
+ if (value === "resolved" && !context.model.fields.some((field) => !field.deprecated && isSeoField(field) && context.isReadable(field))) throw queryInvalid(`"${context.model.apiKey}" has no SEO field to resolve`);
14189
+ return { seo: value };
14190
+ };
13914
14191
  const parseContentQuery = (tree, context) => {
13915
14192
  for (const key of Object.keys(tree)) if (!TOP_LEVEL_KEYS.has(key)) throw queryInvalid(`Unknown query parameter "${key}"`);
13916
14193
  const filters = tree.filters;
@@ -13932,6 +14209,8 @@ const parseContentQuery = (tree, context) => {
13932
14209
  populate: parsePopulate(context, tree.populate),
13933
14210
  locale,
13934
14211
  snapshot,
14212
+ ...parseRichText(tree, context),
14213
+ ...parseSeo(tree, context),
13935
14214
  ...parseAdminFilters(tree, context)
13936
14215
  };
13937
14216
  };
@@ -14060,6 +14339,29 @@ const outdatedSharedLocales = (model, heads) => model.localized ? heads.filter((
14060
14339
  */
14061
14340
  const runHeadQuery = async (query, executor = db) => (await query.execute(executor)).rows;
14062
14341
  const runCountQuery = async (query, executor = db) => Number((await query.execute(executor)).rows[0]?.total ?? 0);
14342
+ const toPageMeta = (row) => ({
14343
+ total: row.page_total === null ? null : Number(row.page_total),
14344
+ seq: row.page_seq === null ? null : Number(row.page_seq)
14345
+ });
14346
+ /**
14347
+ * Runs a head page compiled by `compileHeadPage`: the rows, and the total and sequence carried on them (null
14348
+ * when the page is empty; then `runHeadPageMeta` in the same transaction reads them).
14349
+ */
14350
+ const runHeadPage = async (query, executor = db) => {
14351
+ const result = (await query.execute(executor)).rows;
14352
+ const [first] = result;
14353
+ return {
14354
+ rows: result.map(({ page_total: _total, page_seq: _seq, ...row }) => row),
14355
+ meta: first ? toPageMeta(first) : null
14356
+ };
14357
+ };
14358
+ const runHeadPageMeta = async (query, executor = db) => {
14359
+ const [row] = (await query.execute(executor)).rows;
14360
+ return row ? toPageMeta(row) : {
14361
+ total: null,
14362
+ seq: null
14363
+ };
14364
+ };
14063
14365
  /** Runs `fn` in one REPEATABLE READ, read-only transaction: a response reads one consistent moment. */
14064
14366
  const withConsistentRead = (fn, database = db) => database.transaction().setIsolationLevel("repeatable read").setAccessMode("read only").execute(fn);
14065
14367
  //#endregion
@@ -14105,6 +14407,51 @@ const populateRelations = async (env, rows, fields, tree) => {
14105
14407
  return populated;
14106
14408
  };
14107
14409
  //#endregion
14410
+ //#region src/content/seo.ts
14411
+ const EXPECTED_TYPES = {
14412
+ title: /* @__PURE__ */ new Set(["string", "text"]),
14413
+ description: /* @__PURE__ */ new Set(["text", "string"]),
14414
+ image: /* @__PURE__ */ new Set(["media"]),
14415
+ canonical: /* @__PURE__ */ new Set(["url", "string"]),
14416
+ noindex: /* @__PURE__ */ new Set(["boolean"])
14417
+ };
14418
+ const keysOf = (model) => {
14419
+ const component = model.components.get(SEO_COMPONENT_ID)?.definition;
14420
+ const keys = {};
14421
+ for (const [key, id] of Object.entries(SEO_FIELD_IDS)) {
14422
+ const field = component?.fields.find((candidate) => candidate.id === id && !candidate.deprecated);
14423
+ const single = field?.type !== "media" || !field.settings.multiple;
14424
+ if (field && single && EXPECTED_TYPES[key].has(field.type)) keys[key] = field.apiKey;
14425
+ }
14426
+ return keys;
14427
+ };
14428
+ /** The entry's own title, only when the caller may read the title field (never a hidden value). */
14429
+ const fallbackTitleOf = (model, policy, row) => {
14430
+ const field = effectiveTitleField(model.definition);
14431
+ if (!field || !maskAllows(policy.readMask, field)) return null;
14432
+ const value = row.data[field.id];
14433
+ return typeof value === "string" ? value : null;
14434
+ };
14435
+ /** The locale whose defaults apply: the head's for localized models, the requested one otherwise. */
14436
+ const defaultsLocaleOf = (env, model, row) => model.definition.localized ? row.locale : env.locale ?? env.snapshot.defaultLocale;
14437
+ /** Whether the selected fields include an SEO field (only those are resolved). */
14438
+ const seoFieldsOf = (fields) => fields.filter(isSeoField);
14439
+ /** Replaces each SEO field's projected value with its resolved value. */
14440
+ const resolveSeoFields = (env, options, model, policy, fields, row, data) => {
14441
+ const seoFields = seoFieldsOf(fields);
14442
+ if (seoFields.length === 0) return data;
14443
+ const keys = keysOf(model);
14444
+ const context = {
14445
+ defaults: seoDefaultsForLocale(options.defaults, fallbackChain(env.snapshot, defaultsLocaleOf(env, model, row))),
14446
+ image: options.image,
14447
+ fallbackTitle: fallbackTitleOf(model, policy, row),
14448
+ keys
14449
+ };
14450
+ const output = { ...data };
14451
+ for (const field of seoFields) output[field.apiKey] = resolveSeo(data[field.apiKey], context);
14452
+ return output;
14453
+ };
14454
+ //#endregion
14108
14455
  //#region src/content/read.ts
14109
14456
  /** Evaluated through the read's executor: a read inside a transaction never asks the pool for a second connection. */
14110
14457
  const readPolicy = (env, modelId) => env.permissions.evaluate(env.actor, {
@@ -14131,33 +14478,85 @@ const fetchHeadsByIds = async (env, model, policy, ids, locales) => {
14131
14478
  /**
14132
14479
  * Relation targets the caller may see in these rows: for delivery, only targets that are published (at the
14133
14480
  * same snapshot) and readable under their model's policy, so a published entry never leaks the ID of a
14134
- * draft (brief §10). Admin reads see every ID (null).
14481
+ * draft (brief §10). Admin reads see every ID (null). The heads read are returned for populate.
14135
14482
  */
14136
- const visibleRelationTargets = async (env, model, rows, fields) => {
14137
- if (env.audience === "admin") return null;
14483
+ const relationTargets = async (env, model, rows, fields) => {
14484
+ const fetched = /* @__PURE__ */ new Map();
14485
+ if (env.audience === "admin") return {
14486
+ visible: null,
14487
+ fetched
14488
+ };
14138
14489
  const visible = /* @__PURE__ */ new Set();
14139
14490
  const targets = targetsByModel(model, rows.map((row) => row.data), fields);
14140
14491
  for (const [targetModelId, ids] of targets) {
14141
14492
  const target = resolveModelById(env.snapshot, targetModelId);
14142
14493
  if (!target) continue;
14143
14494
  const policy = await readPolicy(env, targetModelId);
14144
- (await fetchHeadsByIds(env, target, policy, [...ids])).forEach((row) => visible.add(row.entry_id));
14495
+ const found = await fetchHeadsByIds(env, target, policy, [...ids]);
14496
+ found.forEach((row) => visible.add(row.entry_id));
14497
+ fetched.set(targetModelId, {
14498
+ ids,
14499
+ rows: found
14500
+ });
14145
14501
  }
14146
- return visible;
14502
+ return {
14503
+ visible,
14504
+ fetched
14505
+ };
14506
+ };
14507
+ /**
14508
+ * `fetchHeadsByIds` for populate, answered from the heads relation visibility already read when it asked for
14509
+ * every one of these IDs under the same policy and locale scope (populate's default), else from the database.
14510
+ */
14511
+ const fetchReusing = (fetched) => async (env, model, policy, ids) => {
14512
+ const known = fetched.get(model.definition.id);
14513
+ if (known && ids.every((id) => known.ids.has(id))) {
14514
+ const wanted = new Set(ids);
14515
+ return known.rows.filter((row) => wanted.has(row.entry_id));
14516
+ }
14517
+ return fetchHeadsByIds(env, model, policy, ids);
14518
+ };
14519
+ /**
14520
+ * Whether projecting these rows reads anything more (relation targets, populated entries, asset views). When
14521
+ * not, a delivery read is complete in the one statement that fetched the rows (plan delivery-perf).
14522
+ */
14523
+ const needsFollowUpReads = (env, model, rows, query, policy) => {
14524
+ if (query.populate.size > 0) return true;
14525
+ const fields = selectFields(model, policy.readMask, query.fields);
14526
+ const documents = rows.map((row) => row.data);
14527
+ return env.audience === "delivery" && targetsByModel(model, documents, fields).size > 0 || env.media !== void 0 && (mediaIdsOf(model, documents, fields).size > 0 || seoImageIdOf(env, fields) !== void 0);
14147
14528
  };
14529
+ /** The site's default social image a `?seo=resolved` read needs (when the response carries an SEO field). */
14530
+ const seoImageIdOf = (env, fields) => env.seo?.imageId && seoFieldsOf(fields).length > 0 ? env.seo.imageId : void 0;
14531
+ /** A default SEO image is shown only while it is a public image (never through a signed URL). */
14532
+ const isPublicImage = (view) => view.visibility === "public" && view.mimeType.startsWith("image/");
14148
14533
  /**
14149
14534
  * Asset views for the media a response shows (media fields and rich-text images). Private assets get signed,
14150
14535
  * expiring URLs (package G); the read mask already decided whether the caller may see the field at all.
14151
14536
  * Delivery views carry only what a site needs, never storage keys or internal attribution.
14152
14537
  */
14153
14538
  const loadMediaViews = async (env, model, rows, fields) => {
14154
- if (!env.media) return null;
14539
+ if (!env.media) return {
14540
+ byId: null,
14541
+ seoImage: null
14542
+ };
14155
14543
  const ids = mediaIdsOf(model, rows.map((row) => row.data), fields);
14156
- if (ids.size === 0) return /* @__PURE__ */ new Map();
14544
+ const seoImageId = seoImageIdOf(env, fields);
14545
+ if (seoImageId !== void 0) ids.add(seoImageId);
14546
+ if (ids.size === 0) return {
14547
+ byId: /* @__PURE__ */ new Map(),
14548
+ seoImage: null
14549
+ };
14157
14550
  const assets = await findLiveManyOnSite(env.siteId, [...ids], env.executor);
14158
14551
  const views = await toAssetViews(env.media, assets, env.executor);
14159
- return new Map(views.map((view) => [view.id, env.audience === "delivery" ? toDeliveryAsset(view) : view]));
14552
+ const shaped = (view) => env.audience === "delivery" ? toDeliveryAsset(view) : view;
14553
+ const seoImage = views.find((view) => view.id === seoImageId && isPublicImage(view));
14554
+ return {
14555
+ byId: new Map(views.map((view) => [view.id, shaped(view)])),
14556
+ seoImage: seoImage ? shaped(seoImage) : null
14557
+ };
14160
14558
  };
14559
+ /** An asset as delivery shows it: what a site needs, never storage keys or internal attribution. */
14161
14560
  const toDeliveryAsset = (view) => ({
14162
14561
  id: view.id,
14163
14562
  filename: view.filename,
@@ -14189,27 +14588,33 @@ const systemAttributes = (env, row) => ({
14189
14588
  });
14190
14589
  const projectRows = async (env, model, policy, rows, query) => {
14191
14590
  const fields = selectFields(model, policy.readMask, query.fields);
14192
- const visibleTargets = await visibleRelationTargets(env, model, rows, fields);
14591
+ const { visible: visibleTargets, fetched } = await relationTargets(env, model, rows, fields);
14193
14592
  const populateEnv = {
14194
14593
  ...env,
14195
14594
  project: projectRows,
14196
- fetch: fetchHeadsByIds,
14595
+ fetch: fetchReusing(fetched),
14197
14596
  policyFor: readPolicy,
14198
14597
  system: systemAttributes
14199
14598
  };
14200
14599
  const populated = await populateRelations(populateEnv, rows, fields, query.populate);
14201
- const mediaAssets = await loadMediaViews(env, model, rows, fields);
14202
- return rows.map((row) => ({
14203
- row,
14204
- data: projectData(row.data, {
14600
+ const media = await loadMediaViews(env, model, rows, fields);
14601
+ return rows.map((row) => {
14602
+ const data = projectData(row.data, {
14205
14603
  model,
14206
14604
  fields,
14207
14605
  visibleTargets,
14208
14606
  populated,
14209
- richTextHtml: env.audience === "delivery",
14210
- mediaAssets
14211
- })
14212
- }));
14607
+ ...env.audience === "delivery" ? { richText: env.richText ?? "json" } : {},
14608
+ mediaAssets: media.byId
14609
+ });
14610
+ return {
14611
+ row,
14612
+ data: env.seo ? resolveSeoFields(env, {
14613
+ defaults: env.seo,
14614
+ image: media.seoImage
14615
+ }, model, policy, fields, row, data) : data
14616
+ };
14617
+ });
14213
14618
  };
14214
14619
  //#endregion
14215
14620
  //#region src/services/contentAccess.ts
@@ -14419,6 +14824,8 @@ const parseQueryFor = (context, model, policy, rawQuery, options) => parseConten
14419
14824
  isReadable: (field) => maskAllows(policy.readMask, field),
14420
14825
  allowSnapshot: options.allowSnapshot,
14421
14826
  allowAdminFilters: options.allowAdminFilters ?? false,
14827
+ allowRichText: options.allowRichText ?? false,
14828
+ allowSeo: options.allowSeo ?? false,
14422
14829
  resolveModel: (modelId) => resolveModelById(context.snapshot, modelId)?.definition
14423
14830
  });
14424
14831
  /** The admin content list: drafts, filtered/sorted/paginated by the same compiler as delivery. */
@@ -15439,19 +15846,19 @@ const toSiteRef = (row) => ({
15439
15846
  id: row.id,
15440
15847
  key: row.key
15441
15848
  });
15442
- const siteNotFound = () => new AppError(404, "SITE_NOT_FOUND", "Site not found");
15849
+ const siteNotFound$1 = () => new AppError(404, "SITE_NOT_FOUND", "Site not found");
15443
15850
  /** A site's summary by ID (`me` for a site the admin holds no role on: they see its name, nothing else). */
15444
15851
  /** How many sites the instance has, whatever roles the caller holds (one: per-site schema UI is moot). */
15445
15852
  const countSites = () => count();
15446
15853
  const getSiteSummary = async (siteId) => {
15447
15854
  const site = await findById$12(siteId);
15448
- if (!site) throw siteNotFound();
15855
+ if (!site) throw siteNotFound$1();
15449
15856
  return toSiteSummary(site);
15450
15857
  };
15451
15858
  /** The reference of a site by ID (jobs and deferred work carry the ID of the site they belong to). */
15452
15859
  const getSiteRef = async (siteId, executor = db) => {
15453
15860
  const site = await findById$12(siteId, executor);
15454
- if (!site) throw siteNotFound();
15861
+ if (!site) throw siteNotFound$1();
15455
15862
  return toSiteRef(site);
15456
15863
  };
15457
15864
  /**
@@ -15465,7 +15872,7 @@ const listAccessibleSites = async (principal) => {
15465
15872
  const listSites$1 = async (principal) => (await listAccessibleSites(principal)).map(toSiteView);
15466
15873
  const getSite$1 = async (principal, id) => {
15467
15874
  const site = (await listAccessibleSites(principal)).find((row) => row.id === id);
15468
- if (!site) throw siteNotFound();
15875
+ if (!site) throw siteNotFound$1();
15469
15876
  return toSiteView(site);
15470
15877
  };
15471
15878
  /** Every site, primary first (`shapio sites list`, which runs on the server host without a principal). */
@@ -15501,7 +15908,7 @@ const createSite$1 = async (context, input, database = db) => {
15501
15908
  /** Optimistic: fails with VERSION_CONFLICT when the site changed since `expectedVersion` was read. */
15502
15909
  const renameSite = async (context, id, input) => db.transaction().execute(async (trx) => {
15503
15910
  const current = await findById$12(id, trx);
15504
- if (!current) throw siteNotFound();
15911
+ if (!current) throw siteNotFound$1();
15505
15912
  const updated = await renameIfVersion(id, input.expectedVersion, input.name.trim(), trx);
15506
15913
  if (!updated) throw new AppError(409, "VERSION_CONFLICT", "The site changed since you loaded it", {
15507
15914
  expectedVersion: input.expectedVersion,
@@ -15529,7 +15936,7 @@ const renameSite = async (context, id, input) => db.transaction().execute(async
15529
15936
  const deleteSite$1 = async (context, id) => {
15530
15937
  await db.transaction().execute(async (trx) => {
15531
15938
  const site = await lockById$4(id, trx);
15532
- if (!site) throw siteNotFound();
15939
+ if (!site) throw siteNotFound$1();
15533
15940
  if (site.is_primary) throw new AppError(409, "SITE_IS_PRIMARY", "The primary site cannot be deleted");
15534
15941
  await lockSchema(trx);
15535
15942
  const contents = {
@@ -15558,24 +15965,171 @@ const deleteSite$1 = async (context, id) => {
15558
15965
  });
15559
15966
  });
15560
15967
  };
15968
+ /** The site a lookup names, read on its own (a request whose state was read without its site). */
15969
+ const findSite = async (lookup) => {
15970
+ switch (lookup.by) {
15971
+ case "id": return findById$12(lookup.id).then((row) => row && toSiteRef(row));
15972
+ case "key": return findByKey(lookup.key).then((row) => row && toSiteRef(row));
15973
+ case "primary": return toSiteRef(await findPrimary());
15974
+ }
15975
+ };
15561
15976
  /**
15562
15977
  * Which site a request is about (sites plan §H): the credential's site, else the site the request names,
15563
15978
  * else the primary site. A request that names a different site than its credential is refused
15564
15979
  * (403 `SITE_MISMATCH`), never redirected; an unknown key is 404 `SITE_NOT_FOUND`.
15980
+ * `read`: where the site is read; a request passes its once-per-request state read
15981
+ * (`repositories/requestState.ts`), which reads the site with the versions in one statement.
15565
15982
  */
15566
- const resolveSite = async ({ credentialSiteId, requestedKey }) => {
15983
+ const resolveSite = async ({ credentialSiteId, requestedKey }, read = findSite) => {
15567
15984
  if (credentialSiteId !== void 0) {
15568
- const site = await findById$12(credentialSiteId);
15569
- if (!site) throw siteNotFound();
15985
+ const site = await read({
15986
+ by: "id",
15987
+ id: credentialSiteId
15988
+ });
15989
+ if (!site) throw siteNotFound$1();
15570
15990
  if (requestedKey !== void 0 && requestedKey !== site.key) throw new AppError(403, "SITE_MISMATCH", `This credential belongs to site "${site.key}", not "${requestedKey}"`);
15571
- return toSiteRef(site);
15991
+ return site;
15572
15992
  }
15573
15993
  if (requestedKey !== void 0) {
15574
- const site = await findByKey(requestedKey);
15994
+ const site = await read({
15995
+ by: "key",
15996
+ key: requestedKey
15997
+ });
15575
15998
  if (!site) throw new AppError(404, "SITE_NOT_FOUND", `No site has the key "${requestedKey}"`);
15576
- return toSiteRef(site);
15999
+ return site;
15577
16000
  }
15578
- return toSiteRef(await findPrimary());
16001
+ const primary = await read({ by: "primary" });
16002
+ if (!primary) throw new Error("No primary site");
16003
+ return primary;
16004
+ };
16005
+ //#endregion
16006
+ //#region src/permissions/memo.ts
16007
+ /**
16008
+ * One request's evaluator, memoized per (principal, action, model): a request touches the same models many
16009
+ * times (REST: the read, relation visibility, populate, usage; GraphQL: lists, relation batches,
16010
+ * localizations), and evaluates each policy once (ADR 0005). Calls for another principal bypass the memo.
16011
+ * A policy still being evaluated is shared only on the executor that evaluates it: one evaluated inside a
16012
+ * read transaction is never awaited from another transaction. Once settled, every executor reuses it.
16013
+ */
16014
+ const memoizePermissions = (base, principal) => {
16015
+ const settled = /* @__PURE__ */ new Map();
16016
+ const pooled = /* @__PURE__ */ new Map();
16017
+ const byTransaction = /* @__PURE__ */ new WeakMap();
16018
+ const pendingFor = (executor) => {
16019
+ if (!executor) return pooled;
16020
+ let pending = byTransaction.get(executor);
16021
+ if (!pending) {
16022
+ pending = /* @__PURE__ */ new Map();
16023
+ byTransaction.set(executor, pending);
16024
+ }
16025
+ return pending;
16026
+ };
16027
+ return {
16028
+ evaluate: (caller, request, executor) => {
16029
+ if (caller !== principal) return base.evaluate(caller, request, executor);
16030
+ const key = `${request.action}:${request.modelId}`;
16031
+ const done = settled.get(key);
16032
+ if (done) return Promise.resolve(done);
16033
+ const pending = pendingFor(executor);
16034
+ let policy = pending.get(key);
16035
+ if (!policy) {
16036
+ policy = base.evaluate(caller, request, executor);
16037
+ pending.set(key, policy);
16038
+ policy.then((value) => settled.set(key, value), () => void 0);
16039
+ }
16040
+ return policy;
16041
+ },
16042
+ canPerform: (caller, action, executor) => base.canPerform(caller, action, executor),
16043
+ canPerformOnSite: (caller, action, siteId, executor) => base.canPerformOnSite(caller, action, siteId, executor)
16044
+ };
16045
+ };
16046
+ //#endregion
16047
+ //#region src/repositories/requestState.ts
16048
+ /**
16049
+ * Everything a request checks before its work, in one statement: the durable schema and permissions versions
16050
+ * (`system_versions`, one row) and, when asked, the request's site. Read once per request on the pool, before
16051
+ * any transaction; caches compare against these versions for the rest of the request.
16052
+ */
16053
+ const read = async (lookup, trx = db) => {
16054
+ if (!lookup) {
16055
+ const row = await trx.selectFrom("system_versions").select(["schema_version", "permissions_version"]).executeTakeFirstOrThrow();
16056
+ return {
16057
+ schemaVersion: row.schema_version,
16058
+ permissionsVersion: row.permissions_version,
16059
+ site: void 0
16060
+ };
16061
+ }
16062
+ const row = await trx.selectFrom("system_versions").leftJoin("sites", (join) => {
16063
+ switch (lookup.by) {
16064
+ case "id": return join.on("sites.id", "=", lookup.id);
16065
+ case "key": return join.on("sites.key", "=", lookup.key);
16066
+ case "primary": return join.on("sites.is_primary", "=", true);
16067
+ }
16068
+ }).select([
16069
+ "system_versions.schema_version",
16070
+ "system_versions.permissions_version",
16071
+ "sites.id as site_id",
16072
+ "sites.key as site_key"
16073
+ ]).executeTakeFirstOrThrow();
16074
+ return {
16075
+ schemaVersion: row.schema_version,
16076
+ permissionsVersion: row.permissions_version,
16077
+ site: row.site_id !== null && row.site_key !== null ? {
16078
+ id: row.site_id,
16079
+ key: row.site_key
16080
+ } : void 0
16081
+ };
16082
+ };
16083
+ //#endregion
16084
+ //#region src/services/requestState.ts
16085
+ /** One statement on the pool: the schema and permissions versions, and the request's site when asked. */
16086
+ const readRequestState = async (lookup) => {
16087
+ const row = await read(lookup);
16088
+ return {
16089
+ versions: {
16090
+ schemaVersion: row.schemaVersion,
16091
+ permissionsVersion: row.permissionsVersion
16092
+ },
16093
+ site: row.site
16094
+ };
16095
+ };
16096
+ //#endregion
16097
+ //#region src/plugins/requestState.ts
16098
+ /**
16099
+ * The durable schema and permissions versions this request checks its caches against, read once on the pool
16100
+ * before any transaction (ADR 0002, ADR 0005): a change committed after the read applies from the next
16101
+ * request. The first caller reads; site resolution (an `onRequest` hook, before any other caller on a site
16102
+ * route) passes its site lookup so the site is read in the same statement.
16103
+ */
16104
+ const getRequestState = (request, lookup) => {
16105
+ request.requestState ??= readRequestState(lookup);
16106
+ return request.requestState;
16107
+ };
16108
+ /** Reads the request's site with its state, or on its own when the state was already read without it. */
16109
+ const readRequestSite = async (request, lookup) => request.requestState ? findSite(lookup) : (await getRequestState(request, lookup)).site;
16110
+ /**
16111
+ * The server's evaluator at the request's versions. Inside a transaction before the state was read (no route
16112
+ * does that today), the unbound evaluator checks the versions itself through the transaction, so no pooled
16113
+ * connection is asked for while the transaction holds one.
16114
+ */
16115
+ const boundPermissions = async (request, executor) => {
16116
+ const base = request.server.permissions;
16117
+ if (executor && !request.requestState) return base;
16118
+ const { versions } = await getRequestState(request);
16119
+ return base.atVersions?.(versions) ?? base;
16120
+ };
16121
+ /**
16122
+ * The request's evaluator: checked against the request's versions, and memoized per (principal, action,
16123
+ * model) for the request (`permissions/memo.ts`), for REST as for GraphQL. Create it after site resolution,
16124
+ * which settles the principal.
16125
+ */
16126
+ const getRequestPermissions = (request) => {
16127
+ request.requestPermissions ??= memoizePermissions({
16128
+ evaluate: async (principal, policyRequest, executor) => (await boundPermissions(request, executor)).evaluate(principal, policyRequest, executor),
16129
+ canPerform: async (principal, action, executor) => (await boundPermissions(request, executor)).canPerform(principal, action, executor),
16130
+ canPerformOnSite: async (principal, action, siteId, executor) => (await boundPermissions(request, executor)).canPerformOnSite(principal, action, siteId, executor)
16131
+ }, request.principal);
16132
+ return request.requestPermissions;
15579
16133
  };
15580
16134
  //#endregion
15581
16135
  //#region src/plugins/siteResolution.ts
@@ -15654,7 +16208,7 @@ const siteResolutionPlugin = fp(async (app, { apiPrefix }) => {
15654
16208
  const site = await resolveSite({
15655
16209
  credentialSiteId: await credentialSiteOf(request),
15656
16210
  requestedKey
15657
- });
16211
+ }, (lookup) => readRequestSite(request, lookup));
15658
16212
  request.site = site;
15659
16213
  request.principal = principalForSite(request.principal, site.id);
15660
16214
  if (request.principal.kind === "admin" && request.principal.roleIds.length === 0 && request.routeOptions.config?.siteAccess !== "unassigned") throw new AppError(403, "SITE_FORBIDDEN", "You have no role on this site");
@@ -15821,7 +16375,8 @@ const revokeApiToken = async (context, permissions, id) => {
15821
16375
  const resolveApiToken = async (value, now = /* @__PURE__ */ new Date()) => {
15822
16376
  const row = await findLiveByHash$1(hashToken(value));
15823
16377
  if (!row || row.expires_at !== null && row.expires_at.getTime() <= now.getTime()) return;
15824
- await touchLastUsed$1(row.id, now, /* @__PURE__ */ new Date(now.getTime() - LAST_SEEN_WRITE_INTERVAL_MS));
16378
+ const staleBefore = /* @__PURE__ */ new Date(now.getTime() - LAST_SEEN_WRITE_INTERVAL_MS);
16379
+ if (row.last_used_at === null || row.last_used_at.getTime() < staleBefore.getTime()) await touchLastUsed$1(row.id, now, staleBefore);
15825
16380
  return {
15826
16381
  kind: "token",
15827
16382
  tokenId: row.id,
@@ -15906,7 +16461,7 @@ const adminSessionPlugin = fp(async (app, { urls, secureCookies }) => {
15906
16461
  app.decorate("requireGlobalPermission", (action) => {
15907
16462
  return async function requireGlobalPermission(request, reply) {
15908
16463
  await requireAdmin.call(this, request, reply);
15909
- if (!await app.permissions.canPerform(request.principal, action)) throw new AppError(403, "FORBIDDEN", `Your role does not allow ${action}`);
16464
+ if (!await getRequestPermissions(request).canPerform(request.principal, action)) throw new AppError(403, "FORBIDDEN", `Your role does not allow ${action}`);
15910
16465
  };
15911
16466
  });
15912
16467
  }, {
@@ -17059,10 +17614,12 @@ const toErrorResponse = (error) => {
17059
17614
  };
17060
17615
  /** The single error handler: consistent `{ error: { code, message, details? } }` bodies and logging. */
17061
17616
  const errorHandlerPlugin = fp(async (app) => {
17617
+ app.decorateRequest("errorCode", void 0);
17062
17618
  app.setErrorHandler((error, request, reply) => {
17063
17619
  const { statusCode, body } = toErrorResponse(error);
17620
+ request.errorCode = body.error.code;
17064
17621
  if (statusCode >= 500) request.log.error({ err: error }, "request failed");
17065
- else request.log.info({
17622
+ else request.log.debug({
17066
17623
  statusCode,
17067
17624
  code: body.error.code
17068
17625
  }, "request rejected");
@@ -17073,6 +17630,7 @@ const errorHandlerPlugin = fp(async (app) => {
17073
17630
  code: "NOT_FOUND",
17074
17631
  message: `Route ${request.method} ${request.url} not found`
17075
17632
  } };
17633
+ request.errorCode = body.error.code;
17076
17634
  return reply.code(404).send(body);
17077
17635
  });
17078
17636
  }, { name: "shapio-error-handler" });
@@ -17157,6 +17715,14 @@ const gracefulClosePlugin = fp(async (app) => {
17157
17715
  });
17158
17716
  });
17159
17717
  //#endregion
17718
+ //#region src/constants/graphql.ts
17719
+ /** A site's GraphQL schema is dropped from memory when no request has used it for this long. */
17720
+ const GRAPHQL_SITE_SCHEMA_IDLE_MS = 6e5;
17721
+ /** `/api/graphql/playground?query=`: the longest operation the page opens with (it travels in the URL). */
17722
+ const PLAYGROUND_QUERY_MAX_LENGTH = 8192;
17723
+ /** `/api/graphql/playground?theme=`: GraphiQL's two themes. */
17724
+ const PLAYGROUND_THEMES = ["light", "dark"];
17725
+ //#endregion
17160
17726
  //#region src/helpers/vary.ts
17161
17727
  /**
17162
17728
  * Adds header names to the reply's `Vary` without dropping the ones already there (`Origin`, set by
@@ -17803,12 +18369,15 @@ const startSchemaChangeListener = ({ connectionString, registry, log }) => creat
17803
18369
  //#region src/plugins/schemaSnapshot.ts
17804
18370
  /**
17805
18371
  * The schema registry and per-request snapshot pinning (ADR 0002). The pin is lazy: requests that never
17806
- * touch the schema (health, static admin) pay nothing; the first call in a request does the version check.
18372
+ * touch the schema (health, static admin) pay nothing; the first call in a request checks the cache against
18373
+ * the request's schema version (`getRequestState`, read once per request).
17807
18374
  */
17808
18375
  const schemaSnapshotPlugin = fp(async (app, { registry, listen, contentPorts }) => {
17809
18376
  app.decorate("schemaRegistry", registry);
17810
18377
  app.decorate("schemaContent", contentPorts);
17811
18378
  app.decorateRequest("schemaSnapshotPin", null);
18379
+ app.decorateRequest("requestState", null);
18380
+ app.decorateRequest("requestPermissions", null);
17812
18381
  let listener;
17813
18382
  if (listen) app.addHook("onReady", async () => {
17814
18383
  listener = startSchemaChangeListener({
@@ -17827,7 +18396,7 @@ const schemaSnapshotPlugin = fp(async (app, { registry, listen, contentPorts })
17827
18396
  });
17828
18397
  /** The full schema (every site's definitions and the shared ones) this request is pinned to. */
17829
18398
  const getRequestNetworkSchema = (request) => {
17830
- request.schemaSnapshotPin ??= request.server.schemaRegistry.getSnapshot();
18399
+ request.schemaSnapshotPin ??= getRequestState(request).then(({ versions }) => request.server.schemaRegistry.getSnapshot(void 0, versions));
17831
18400
  return request.schemaSnapshotPin;
17832
18401
  };
17833
18402
  /**
@@ -17996,42 +18565,137 @@ const executeOperation = async (schema, prepared, input, context) => execute({
17996
18565
  contextValue: context
17997
18566
  });
17998
18567
  //#endregion
17999
- //#region src/usage/restFieldReads.ts
18000
- const restFieldReads = async ({ snapshot, model, policy, query, policyFor }) => {
18001
- const fields = selectFields(model, policy.readMask, query.fields);
18002
- const selection = query.fields === null ? "implicit" : "explicit";
18003
- const reads = fields.map((field) => ({
18004
- path: field.id,
18005
- selection
18006
- }));
18007
- for (const field of fields) {
18008
- if (field.type !== "relation" || !query.populate.has(field.id)) continue;
18009
- const target = resolveModelById(snapshot, field.settings.target);
18010
- if (!target) continue;
18011
- const targetPolicy = await policyFor(target.definition.id);
18012
- if (!targetPolicy.allowed) continue;
18013
- for (const targetField of selectFields(target, targetPolicy.readMask, null)) reads.push({
18014
- path: relationFieldPath(field.id, targetField.id),
18015
- selection: "implicit"
18016
- });
18568
+ //#region src/services/siteSeo.ts
18569
+ /**
18570
+ * A site's SEO defaults (plan seo-fields): default texts per locale, a default social image and the Twitter
18571
+ * handle, stored on `sites.seo_defaults`. Edited by `site.settings`; delivered on `GET /api/site` and merged
18572
+ * into SEO fields by `?seo=resolved` reads.
18573
+ */
18574
+ const SITE_UPDATED_EVENT = "site.updated";
18575
+ const siteNotFound = () => new AppError(404, "SITE_NOT_FOUND", "Site not found");
18576
+ /** Stored defaults, or the empty ones when none were saved (or the stored value no longer fits the schema). */
18577
+ const readDefaults = (stored) => stored !== null && Value.Check(SeoDefaultsSchema, stored) ? stored : EMPTY_SEO_DEFAULTS;
18578
+ /** The defaults a resolved delivery read merges in. */
18579
+ const loadSeoDefaults = async (siteId, executor) => readDefaults((await findSeoDefaults(siteId, executor))?.seo_defaults ?? null);
18580
+ /** The caller reads something on this site (the same rule as its content: deny by default). */
18581
+ const assertReadsSite = async (context) => {
18582
+ for (const { definition } of context.snapshot.definitions) {
18583
+ if (!isModelDefinition(definition)) continue;
18584
+ if ((await context.permissions.evaluate(context.actor, {
18585
+ action: "read",
18586
+ modelId: definition.id
18587
+ })).allowed) return;
18017
18588
  }
18018
- return reads;
18589
+ throw context.actor.kind === "anonymous" ? new AppError(401, "UNAUTHENTICATED", "This site needs a delivery token") : new AppError(403, "FORBIDDEN", "You may not read content on this site");
18019
18590
  };
18020
- //#endregion
18021
- //#region src/services/contentDelivery.ts
18022
- const readableModel = async (context, modelKey) => {
18023
- const model = resolveModel(context.snapshot, modelKey);
18024
- const policy = await context.permissions.evaluate(context.actor, {
18025
- action: "read",
18026
- modelId: model.definition.id
18591
+ /**
18592
+ * Reading the defaults needs read access to some content of the site (editors see the template in the entry's
18593
+ * search-result preview); changing them needs `site.settings` (the route checks it).
18594
+ */
18595
+ const getSiteSeo$1 = async (context) => {
18596
+ await assertReadsSite(context);
18597
+ const row = await findSeoDefaults(context.site.id, context.db);
18598
+ if (!row) throw siteNotFound();
18599
+ return {
18600
+ version: row.version,
18601
+ seo: readDefaults(row.seo_defaults)
18602
+ };
18603
+ };
18604
+ const invalid$1 = (code, message, details) => new AppError(400, code, message, details);
18605
+ const assertKnownLocales = (snapshot, seo) => {
18606
+ const known = new Set(snapshot.locales.map((locale) => locale.code));
18607
+ const unknown = Object.keys(seo.locales).filter((code) => !known.has(code));
18608
+ if (unknown.length > 0) throw invalid$1("UNKNOWN_LOCALE", `Unknown locale "${unknown[0]}"`, { locales: unknown });
18609
+ };
18610
+ /** The default image must be a live, public image of this site: delivery shows it to anyone. */
18611
+ const assertUsableImage = async (context, imageId) => {
18612
+ if (imageId === null) return;
18613
+ const [asset] = await findLiveManyOnSite(context.site.id, [imageId], context.db);
18614
+ if (!asset) throw invalid$1("SEO_IMAGE_INVALID", "The default image is not an asset of this site", { imageId });
18615
+ if (!isPublicImage({
18616
+ visibility: asset.visibility,
18617
+ mimeType: asset.mime_type
18618
+ })) throw invalid$1("SEO_IMAGE_INVALID", "The default image must be a public image", { imageId });
18619
+ };
18620
+ /** Without empty locale entries, so a cleared locale leaves nothing behind. */
18621
+ const compact = (seo) => ({
18622
+ ...seo,
18623
+ locales: Object.fromEntries(Object.entries(seo.locales).filter(([, texts]) => Object.keys(texts).length > 0))
18624
+ });
18625
+ /** Optimistic: fails with VERSION_CONFLICT when the site changed since `expectedVersion` was read. */
18626
+ const updateSiteSeo$1 = async (context, input) => {
18627
+ const seo = compact(input.seo);
18628
+ assertKnownLocales(context.snapshot, seo);
18629
+ await assertUsableImage(context, seo.imageId);
18630
+ return context.db.transaction().execute(async (trx) => {
18631
+ const current = await findSeoDefaults(context.site.id, trx);
18632
+ if (!current) throw siteNotFound();
18633
+ const updated = await updateSeoDefaultsIfVersion(context.site.id, input.expectedVersion, seo, trx);
18634
+ if (!updated) throw new AppError(409, "VERSION_CONFLICT", "The site changed since you loaded it", {
18635
+ expectedVersion: input.expectedVersion,
18636
+ actualVersion: current.version
18637
+ });
18638
+ const metadata = {
18639
+ locales: Object.keys(seo.locales),
18640
+ imageId: seo.imageId,
18641
+ twitterHandle: seo.twitterHandle
18642
+ };
18643
+ await recordAudit(trx, {
18644
+ ...context,
18645
+ action: "site.seo_update",
18646
+ target: {
18647
+ type: "site",
18648
+ id: context.site.id
18649
+ },
18650
+ metadata
18651
+ });
18652
+ await writeOutboxEvent(trx, {
18653
+ type: SITE_UPDATED_EVENT,
18654
+ aggregateType: "site",
18655
+ aggregateId: context.site.id,
18656
+ payload: {
18657
+ siteKey: context.site.key,
18658
+ changed: ["seo"]
18659
+ },
18660
+ siteId: context.site.id
18661
+ });
18662
+ return {
18663
+ version: updated.version,
18664
+ seo: readDefaults(updated.seo_defaults)
18665
+ };
18027
18666
  });
18028
- if (!policy.allowed) throw context.actor.kind === "anonymous" ? new AppError(401, "UNAUTHENTICATED", "This content needs a delivery token") : new AppError(403, "FORBIDDEN", `Your token may not read "${modelKey}"`);
18667
+ };
18668
+ const deliveryImage = async (context, imageId) => {
18669
+ if (imageId === null || !context.media) return null;
18670
+ const assets = await findLiveManyOnSite(context.site.id, [imageId], context.db);
18671
+ const [view] = await toAssetViews(context.media, assets, context.db);
18672
+ return view && isPublicImage(view) ? toDeliveryAsset(view) : null;
18673
+ };
18674
+ /** `GET /api/site`: the site's key, name and SEO defaults, its default image as a delivered asset. */
18675
+ const getDeliverySite$1 = async (context) => {
18676
+ await assertReadsSite(context);
18677
+ const row = await findSeoDefaults(context.site.id, context.db);
18678
+ if (!row) throw siteNotFound();
18679
+ const { imageId, ...seo } = readDefaults(row.seo_defaults);
18029
18680
  return {
18030
- model,
18031
- policy
18681
+ key: row.key,
18682
+ name: row.name,
18683
+ seo: {
18684
+ ...seo,
18685
+ image: await deliveryImage(context, imageId)
18686
+ }
18032
18687
  };
18033
18688
  };
18034
- const deliveryEnvironment = (context, executor, source, locale) => ({
18689
+ //#endregion
18690
+ //#region src/services/contentDeliveryReads.ts
18691
+ /**
18692
+ * Delivery-shaped reads (delivery, preview, GraphQL root fields) at one consistent moment (plan
18693
+ * delivery-perf). The heads, the page total and the site's publication sequence come from one statement, so
18694
+ * a read that needs nothing else (no relation, populate or asset follow-up) runs without a transaction.
18695
+ * Anything that needs a follow-up statement runs in one REPEATABLE READ, read-only transaction, so every
18696
+ * statement of the response sees the moment `meta.snapshot` names.
18697
+ */
18698
+ const deliveryEnvironment = (context, executor, source, locale, richText, seo) => ({
18035
18699
  executor,
18036
18700
  snapshot: context.snapshot,
18037
18701
  permissions: context.permissions,
@@ -18040,91 +18704,101 @@ const deliveryEnvironment = (context, executor, source, locale) => ({
18040
18704
  audience: "delivery",
18041
18705
  source,
18042
18706
  locale,
18043
- ...context.media ? { media: context.media } : {}
18707
+ ...context.media ? { media: context.media } : {},
18708
+ ...richText ? { richText } : {},
18709
+ ...seo ? { seo } : {}
18044
18710
  });
18045
- /** Runs a delivery read in one consistent transaction at the requested (or current) publication sequence. */
18046
- const atSnapshot = async (context, query, options, read) => withConsistentRead(async (trx) => {
18047
- const current = await currentSeq(context.site.id, trx);
18048
- if (query.snapshot !== void 0 && options.drafts) throw queryInvalid("snapshot reads published content; it cannot be combined with drafts");
18049
- if (query.snapshot !== void 0 && query.snapshot > current) throw snapshotInvalid(query.snapshot, current);
18050
- const source = options.drafts ? {
18051
- kind: "heads",
18052
- state: "draft"
18053
- } : query.snapshot !== void 0 ? {
18054
- kind: "snapshot",
18055
- seq: query.snapshot
18056
- } : {
18057
- kind: "heads",
18058
- state: "published"
18059
- };
18060
- return read(deliveryEnvironment(context, trx, source, query.locale), query.snapshot ?? current);
18061
- }, context.db);
18711
+ const environmentOf = (read, executor) => deliveryEnvironment(read.context, executor, read.source, read.query.locale, read.query.richText, read.seo);
18062
18712
  /**
18063
- * Counts a successful REST read for field usage (plan developer-face §5): once per request, after the
18064
- * projection, by principal. Admin users and draft reads are never counted.
18713
+ * Selected top-level relation or media fields, populate, or a default SEO image to show: the read will need a
18714
+ * follow-up statement.
18065
18715
  */
18066
- const recordUsage = async (context, options, model, policy, query) => {
18067
- const principalKey = usagePrincipalKey(context.actor);
18068
- if (!options.usage || options.drafts || principalKey === null) return;
18069
- const reads = await restFieldReads({
18070
- snapshot: context.snapshot,
18071
- model,
18072
- policy,
18073
- query,
18074
- policyFor: (modelId) => context.permissions.evaluate(context.actor, {
18075
- action: "read",
18076
- modelId
18077
- })
18078
- });
18079
- options.usage.recordRequest(context.site.id, principalKey, query.snapshot ?? null);
18080
- options.usage.recordFieldReads(context.site.id, principalKey, model.definition.id, reads);
18716
+ const followsUp = (read) => read.query.populate.size > 0 || selectFields(read.model, read.policy.readMask, read.query.fields).some((field) => field.type === "relation" || field.type === "media" || Boolean(read.seo?.imageId) && isSeoField(field));
18717
+ /**
18718
+ * Loads the site's SEO defaults when the query asks for resolved SEO. Read before the heads, outside their
18719
+ * consistent moment: defaults are not part of a snapshot (`?snapshot=N&seo=resolved` uses today's).
18720
+ */
18721
+ const withSeoDefaults = async (read) => read.query.seo === "resolved" ? {
18722
+ ...read,
18723
+ seo: await loadSeoDefaults(read.context.site.id, read.context.db)
18724
+ } : read;
18725
+ const seqOf = (seq) => {
18726
+ if (seq === null) throw new Error("The site has no publication state");
18727
+ return seq;
18728
+ };
18729
+ /**
18730
+ * Runs the plan's statement on the pool when the read cannot need a follow-up, and keeps that result when
18731
+ * the rows indeed need none; otherwise reads again inside one REPEATABLE READ transaction. `finish` projects
18732
+ * the rows (through the same executor), so its own statements see the same moment.
18733
+ */
18734
+ const readConsistently = async (read, plan, options, finish) => {
18735
+ const compiled = compileHeadPage(plan, options);
18736
+ const { db } = read.context;
18737
+ if (!followsUp(read)) {
18738
+ const env = environmentOf(read, db);
18739
+ const { rows, meta } = await runHeadPage(compiled.rows, db);
18740
+ if (meta && !needsFollowUpReads(env, read.model, rows, read.query, read.policy)) return finish({
18741
+ env,
18742
+ rows,
18743
+ total: meta.total,
18744
+ seq: seqOf(meta.seq)
18745
+ });
18746
+ }
18747
+ return withConsistentRead(async (trx) => {
18748
+ const env = environmentOf(read, trx);
18749
+ const page = await runHeadPage(compiled.rows, trx);
18750
+ const meta = page.meta ?? await runHeadPageMeta(compiled.meta, trx);
18751
+ return finish({
18752
+ env,
18753
+ rows: page.rows,
18754
+ total: meta.total,
18755
+ seq: seqOf(meta.seq)
18756
+ });
18757
+ }, db);
18081
18758
  };
18082
- const toEntries = (env, projected) => projected.map(({ row, data }) => ({
18759
+ const localesOf = (read) => readScopeFor(read.context.snapshot, read.model.definition, read.query.locale, { fallback: read.fallback });
18760
+ const toEntries = async (read, env, rows) => (await projectRows(env, read.model, read.policy, rows, read.query)).map(({ row, data }) => ({
18083
18761
  ...systemAttributes(env, row),
18084
18762
  ...data
18085
18763
  }));
18086
- const ITEM_PARAMETERS$1 = /* @__PURE__ */ new Set([
18087
- "fields",
18088
- "populate",
18089
- "locale",
18090
- "snapshot"
18091
- ]);
18092
- /** Reading one entry takes only these query parameters; anything else is refused, not ignored. */
18093
- const assertItemParameters = (rawQuery, allowed) => {
18094
- const unsupported = [...new URLSearchParams(rawQuery).keys()].find((key) => !allowed.has(key.split("[")[0] ?? key));
18095
- if (unsupported !== void 0) throw queryInvalid(`"${unsupported}" is not supported when reading one entry`);
18096
- };
18097
- const localesOf = (read) => readScopeFor(read.context.snapshot, read.model.definition, read.query.locale, { fallback: read.fallback });
18098
- /** One page of entries in the delivery shape, with the total and the page size used. */
18099
- const readEntryPage = async (read) => {
18100
- const { context, env, model, policy, query } = read;
18764
+ /**
18765
+ * One page of entries in the delivery shape, with the total (`total: false` skips counting: a singleton) and
18766
+ * the publication sequence it was read at. `check` runs on the sequence before anything is projected.
18767
+ */
18768
+ const readEntryPage = async (unloaded, options = { total: true }) => {
18769
+ const read = await withSeoDefaults(unloaded);
18770
+ const { context, model, policy, query } = read;
18101
18771
  const { limit, offset } = toLimitOffset(query.page, query.pageSize);
18102
18772
  const sort = query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, (field) => maskAllows(policy.readMask, field));
18103
- const compiled = compileHeadQuery({
18773
+ const plan = {
18104
18774
  siteId: context.site.id,
18105
18775
  modelId: model.definition.id,
18106
- source: env.source,
18776
+ source: read.source,
18107
18777
  locales: localesOf(read),
18108
18778
  conditions: [...queryConditions(context, query, policy), ...read.conditions ?? []],
18109
18779
  orderBy: compileOrderBy(sort),
18110
18780
  orderedByEntry: leadsWithEntryColumn(sort),
18111
18781
  limit,
18112
18782
  offset
18113
- });
18114
- const rows = await runHeadQuery(compiled.rows, env.executor);
18115
- return {
18116
- entries: toEntries(env, await projectRows(env, model, policy, rows, query)),
18117
- limit,
18118
- count: () => runCountQuery(compiled.count, env.executor)
18119
18783
  };
18784
+ return readConsistently(read, plan, { total: options.total }, async ({ env, rows, total, seq }) => {
18785
+ options.check?.(seq);
18786
+ return {
18787
+ entries: await toEntries(read, env, rows),
18788
+ limit,
18789
+ total: total ?? 0,
18790
+ seq
18791
+ };
18792
+ });
18120
18793
  };
18121
- /** One entry by ID in the delivery shape, or undefined when it is not there for this read. */
18122
- const readOneEntry = async (read, id) => {
18123
- const { context, env, model, policy, query } = read;
18124
- const byId = compileHeadQuery({
18794
+ /** One entry by ID in the delivery shape (undefined when it is not there for this read), and the sequence. */
18795
+ const readOneEntry = async (unloaded, id, options = {}) => {
18796
+ const read = await withSeoDefaults(unloaded);
18797
+ const { context, model, policy, query } = read;
18798
+ const plan = {
18125
18799
  siteId: context.site.id,
18126
18800
  modelId: model.definition.id,
18127
- source: env.source,
18801
+ source: read.source,
18128
18802
  locales: localesOf(read),
18129
18803
  conditions: [
18130
18804
  ...queryConditions(context, {
@@ -18137,77 +18811,19 @@ const readOneEntry = async (read, id) => {
18137
18811
  ],
18138
18812
  orderBy: [],
18139
18813
  limit: 1
18140
- });
18141
- const rows = await runHeadQuery(byId.rows, env.executor);
18142
- const [entry] = toEntries(env, await projectRows(env, model, policy, rows, query));
18143
- return entry;
18144
- };
18145
- /** `GET /api/content/:modelKey`: a page of a collection, or a singleton's one entry. */
18146
- const listDelivery$1 = async (context, modelKey, rawQuery, options = {}) => {
18147
- const { model, policy } = await readableModel(context, modelKey);
18148
- const query = parseQueryFor(context, model, policy, rawQuery, { allowSnapshot: true });
18149
- const locale = query.locale ?? context.snapshot.defaultLocale;
18150
- const result = await atSnapshot(context, query, options, async (env, seq) => {
18151
- const { entries, limit, count } = await readEntryPage({
18152
- context,
18153
- env,
18154
- model,
18155
- policy,
18156
- query,
18157
- fallback: options.fallback ?? true
18158
- });
18159
- if (model.definition.kind === "singleton") {
18160
- const [entry] = entries;
18161
- if (!entry) throw entryNotFound(modelKey);
18162
- return {
18163
- data: entry,
18164
- meta: {
18165
- locale,
18166
- snapshot: seq
18167
- }
18168
- };
18169
- }
18170
- return {
18171
- data: entries,
18172
- meta: {
18173
- locale,
18174
- snapshot: seq,
18175
- pagination: paginationMeta(query.page, limit, await count())
18176
- }
18177
- };
18178
- });
18179
- await recordUsage(context, options, model, policy, query);
18180
- return result;
18181
- };
18182
- /** `GET /api/content/:modelKey/:id`: one published entry (404 for drafts, unknown or unreadable entries). */
18183
- const getDelivery$1 = async (context, modelKey, id, rawQuery, options = {}) => {
18184
- assertItemParameters(rawQuery, ITEM_PARAMETERS$1);
18185
- const { model, policy } = await readableModel(context, modelKey);
18186
- const query = parseQueryFor(context, model, policy, rawQuery, { allowSnapshot: true });
18187
- const result = await atSnapshot(context, query, options, async (env, seq) => {
18188
- const entry = await readOneEntry({
18189
- context,
18190
- env,
18191
- model,
18192
- policy,
18193
- query,
18194
- fallback: options.fallback ?? true
18195
- }, id);
18196
- if (!entry) throw entryNotFound(id);
18814
+ };
18815
+ return readConsistently(read, plan, { total: false }, async ({ env, rows, seq }) => {
18816
+ options.check?.(seq);
18817
+ const [entry] = await toEntries(read, env, rows);
18197
18818
  return {
18198
- data: entry,
18199
- meta: {
18200
- locale: query.locale ?? context.snapshot.defaultLocale,
18201
- snapshot: seq
18202
- }
18819
+ entry,
18820
+ seq
18203
18821
  };
18204
18822
  });
18205
- await recordUsage(context, options, model, policy, query);
18206
- return result;
18207
18823
  };
18208
18824
  //#endregion
18209
18825
  //#region src/services/contentBatchReads.ts
18210
- const sourceOf = (options) => {
18826
+ const sourceOf$1 = (options) => {
18211
18827
  if (options.drafts) return {
18212
18828
  kind: "heads",
18213
18829
  state: "draft"
@@ -18225,7 +18841,7 @@ const loadDeliveryEntries = async (context, modelId, ids, options) => {
18225
18841
  const found = /* @__PURE__ */ new Map();
18226
18842
  const model = resolveModelById(context.snapshot, modelId);
18227
18843
  if (!model || ids.length === 0) return found;
18228
- const env = deliveryEnvironment(context, context.db, sourceOf(options), options.locale);
18844
+ const env = deliveryEnvironment(context, context.db, sourceOf$1(options), options.locale);
18229
18845
  const policy = await readPolicy(env, modelId);
18230
18846
  const scope = readScopeFor(context.snapshot, model.definition, options.locale, { fallback: options.fallback });
18231
18847
  const rows = await fetchHeadsByIds(env, model, policy, ids, scope);
@@ -18247,7 +18863,7 @@ const loadDeliveryLocalizations = async (context, modelId, entryIds, options) =>
18247
18863
  const found = new Map(entryIds.map((id) => [id, []]));
18248
18864
  const model = resolveModelById(context.snapshot, modelId);
18249
18865
  if (!model || !model.definition.localized || entryIds.length === 0) return found;
18250
- const source = sourceOf({
18866
+ const source = sourceOf$1({
18251
18867
  ...options,
18252
18868
  locale: void 0,
18253
18869
  fallback: false
@@ -18307,48 +18923,17 @@ const createLoaders = (siteId, content) => {
18307
18923
  };
18308
18924
  };
18309
18925
  //#endregion
18310
- //#region src/schema/codegen/graphql/permissions.ts
18926
+ //#region src/controllers/contentContext.ts
18311
18927
  /**
18312
- * The request's evaluator, memoized per (model, action): a GraphQL request touches the same models many
18313
- * times (lists, relation batches, localizations), and evaluates each policy once (ADR 0005). Calls for
18314
- * another principal (never made by content services today) bypass the memo. The memo is kept per executor:
18315
- * a policy evaluated inside one root field's read transaction is never awaited from another's.
18928
+ * The service context for a content request: the pinned snapshot, the principal and dependencies.
18929
+ * `permissions` is the request's evaluator: checked against the versions the request read once, and
18930
+ * memoized per (principal, action, model), so the read, relation visibility, populate and usage recording
18931
+ * evaluate each model's policy once.
18316
18932
  */
18317
- const memoizePermissions = (base, principal) => {
18318
- const pooled = /* @__PURE__ */ new Map();
18319
- const byTransaction = /* @__PURE__ */ new WeakMap();
18320
- const memoFor = (executor) => {
18321
- if (!executor) return pooled;
18322
- let memo = byTransaction.get(executor);
18323
- if (!memo) {
18324
- memo = /* @__PURE__ */ new Map();
18325
- byTransaction.set(executor, memo);
18326
- }
18327
- return memo;
18328
- };
18329
- return {
18330
- evaluate: (caller, request, executor) => {
18331
- if (caller !== principal) return base.evaluate(caller, request, executor);
18332
- const memo = memoFor(executor);
18333
- const key = `${request.action}:${request.modelId}`;
18334
- let policy = memo.get(key);
18335
- if (!policy) {
18336
- policy = base.evaluate(caller, request, executor);
18337
- memo.set(key, policy);
18338
- }
18339
- return policy;
18340
- },
18341
- canPerform: (caller, action, executor) => base.canPerform(caller, action, executor),
18342
- canPerformOnSite: (caller, action, siteId, executor) => base.canPerformOnSite(caller, action, siteId, executor)
18343
- };
18344
- };
18345
- //#endregion
18346
- //#region src/controllers/contentContext.ts
18347
- /** The service context for a content request: the pinned snapshot, the principal and dependencies. */
18348
18933
  const contentContextFor = async (request) => ({
18349
18934
  db: request.server.db,
18350
18935
  snapshot: await getRequestSchema(request),
18351
- permissions: request.server.permissions,
18936
+ permissions: getRequestPermissions(request),
18352
18937
  actor: request.principal,
18353
18938
  site: getRequestSite(request),
18354
18939
  hooks: request.server.contentHooks,
@@ -18383,7 +18968,7 @@ const isAdminPrincipal = (principal) => principal.kind === "admin" || principal.
18383
18968
  /** The context every resolver of one `/api/graphql` request receives (built after site resolution). */
18384
18969
  const createGraphqlRequestContext = (request) => {
18385
18970
  const site = getRequestSite(request);
18386
- const permissions = memoizePermissions(request.server.permissions, request.principal);
18971
+ const permissions = getRequestPermissions(request);
18387
18972
  let base;
18388
18973
  const content = async (snapshot) => {
18389
18974
  base ??= contentContextFor(request);
@@ -18469,9 +19054,16 @@ const BOOTSTRAP = `(() => {
18469
19054
  const root = document.getElementById('graphiql');
18470
19055
  const endpoint = root.dataset.endpoint;
18471
19056
  const csrfUrl = root.dataset.csrf;
19057
+ const props = { shouldPersistHeaders: false };
19058
+ if (root.dataset.query) {
19059
+ props.query = root.dataset.query;
19060
+ }
19061
+ if (root.dataset.theme) {
19062
+ props.forcedTheme = root.dataset.theme;
19063
+ }
18472
19064
  const start = (csrfToken) => {
18473
19065
  const fetcher = GraphiQL.createFetcher({ url: endpoint, headers: { 'x-csrf-token': csrfToken } });
18474
- ReactDOM.createRoot(root).render(React.createElement(GraphiQL, { fetcher, shouldPersistHeaders: false }));
19066
+ ReactDOM.createRoot(root).render(React.createElement(GraphiQL, { ...props, fetcher }));
18475
19067
  };
18476
19068
  fetch(csrfUrl, { credentials: 'same-origin' })
18477
19069
  .then((response) => {
@@ -18504,9 +19096,11 @@ const loadPlaygroundAssets = () => {
18504
19096
  });
18505
19097
  return assets;
18506
19098
  };
19099
+ const dataAttribute = (name, value) => value === void 0 || value === "" ? "" : ` data-${name}="${escapeHtml$1(value)}"`;
18507
19100
  /** The playground page; `assetsPath` and the URLs are absolute paths (BASE_PATH included). */
18508
- const renderPlaygroundPage = (urls) => {
19101
+ const renderPlaygroundPage = (urls, options = {}) => {
18509
19102
  const asset = (file) => escapeHtml$1(`${urls.assets}/${file}`);
19103
+ const extra = dataAttribute("query", options.query) + dataAttribute("theme", options.theme);
18510
19104
  return `<!doctype html>
18511
19105
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
18512
19106
  <title>Shapio GraphQL</title>
@@ -18514,7 +19108,7 @@ const renderPlaygroundPage = (urls) => {
18514
19108
  <link rel="stylesheet" href="${asset("playground.css")}">
18515
19109
  </head>
18516
19110
  <body>
18517
- <div id="graphiql" data-endpoint="${escapeHtml$1(urls.endpoint)}" data-csrf="${escapeHtml$1(urls.csrf)}">Loading…</div>
19111
+ <div id="graphiql" data-endpoint="${escapeHtml$1(urls.endpoint)}" data-csrf="${escapeHtml$1(urls.csrf)}"${extra}>Loading…</div>
18518
19112
  <script src="${asset("react.production.min.js")}"><\/script>
18519
19113
  <script src="${asset("react-dom.production.min.js")}"><\/script>
18520
19114
  <script src="${asset("graphiql.min.js")}"><\/script>
@@ -18524,15 +19118,21 @@ const renderPlaygroundPage = (urls) => {
18524
19118
  //#endregion
18525
19119
  //#region src/controllers/graphqlPlayground.ts
18526
19120
  let assets;
18527
- /** The page is a network route; `?site=` only chooses the site its GraphQL requests name (`?site=` on the endpoint). */
19121
+ /**
19122
+ * The page is a network route; `?site=` only chooses the site its GraphQL requests name (`?site=` on the
19123
+ * endpoint). `?query=` opens the editor on that operation, `?theme=light|dark` fixes GraphiQL's theme.
19124
+ */
18528
19125
  const getPlaygroundPage = async (request, reply) => {
18529
19126
  const { urls } = request.server;
18530
19127
  const endpoint = urls.withBasePath("/api/graphql");
18531
- const { site } = request.query;
19128
+ const { site, query, theme } = request.query;
18532
19129
  return reply.type("text/html; charset=utf-8").send(renderPlaygroundPage({
18533
19130
  assets: urls.withBasePath("/api/graphql/playground"),
18534
19131
  endpoint: site === void 0 ? endpoint : `${endpoint}?${SITE_QUERY_PARAMETER}=${encodeURIComponent(site)}`,
18535
19132
  csrf: urls.withBasePath("/api/admin/auth/csrf")
19133
+ }, {
19134
+ query,
19135
+ theme
18536
19136
  }));
18537
19137
  };
18538
19138
  const getPlaygroundAsset = async (request, reply) => {
@@ -18590,7 +19190,11 @@ const graphqlPlaygroundRoutes = async (app) => {
18590
19190
  app.get("/playground", {
18591
19191
  ...admin,
18592
19192
  schema: {
18593
- querystring: Type.Object({ [SITE_QUERY_PARAMETER]: Type.Optional(Type.String({ pattern: SITE_KEY_PATTERN })) }),
19193
+ querystring: Type.Object({
19194
+ [SITE_QUERY_PARAMETER]: Type.Optional(Type.String({ pattern: SITE_KEY_PATTERN })),
19195
+ query: Type.Optional(Type.String({ maxLength: PLAYGROUND_QUERY_MAX_LENGTH })),
19196
+ theme: Type.Optional(Type.Enum(PLAYGROUND_THEMES))
19197
+ }),
18594
19198
  response: { 200: Type.String() }
18595
19199
  }
18596
19200
  }, getPlaygroundPage);
@@ -18606,10 +19210,6 @@ const graphqlPlaygroundRoutes = async (app) => {
18606
19210
  }, getPlaygroundAsset);
18607
19211
  };
18608
19212
  //#endregion
18609
- //#region src/constants/graphql.ts
18610
- /** A site's GraphQL schema is dropped from memory when no request has used it for this long. */
18611
- const GRAPHQL_SITE_SCHEMA_IDLE_MS = 6e5;
18612
- //#endregion
18613
19213
  //#region src/schema/codegen/graphql/documentCache.ts
18614
19214
  const createDocumentCache = (maxEntries = 500) => {
18615
19215
  const bySchema = /* @__PURE__ */ new WeakMap();
@@ -18661,7 +19261,7 @@ const memoType = (cache, key, create) => {
18661
19261
  * The types every Shapio GraphQL schema has. All of their names are in `RESERVED_TYPE_NAMES`
18662
19262
  * (`@shapio/schema` naming.ts), so no model can collide with them.
18663
19263
  */
18664
- const nonNull$1 = (type) => new GraphQLNonNull(type);
19264
+ const nonNull$2 = (type) => new GraphQLNonNull(type);
18665
19265
  const createJsonScalar = () => new GraphQLScalarType({
18666
19266
  name: "JSON",
18667
19267
  description: "Any JSON value (JSON fields and rich-text documents).",
@@ -18716,7 +19316,7 @@ const filterType = (name, value, operators) => new GraphQLInputObjectType({
18716
19316
  fields: () => {
18717
19317
  const fields = {};
18718
19318
  for (const operator of operators) {
18719
- const type = operator === "$null" || operator === "$notNull" ? GraphQLBoolean : operator === "$in" || operator === "$nin" ? new GraphQLList(nonNull$1(value)) : value;
19319
+ const type = operator === "$null" || operator === "$notNull" ? GraphQLBoolean : operator === "$in" || operator === "$nin" ? new GraphQLList(nonNull$2(value)) : value;
18720
19320
  fields[operatorFieldName(operator)] = {
18721
19321
  type,
18722
19322
  description: OPERATOR_DESCRIPTIONS[operator]
@@ -18740,15 +19340,15 @@ const createFilterTypes = (int53) => ({
18740
19340
  const createPageInfo = () => new GraphQLObjectType({
18741
19341
  name: "PageInfo",
18742
19342
  fields: {
18743
- page: { type: nonNull$1(GraphQLInt) },
18744
- pageSize: { type: nonNull$1(GraphQLInt) },
18745
- pageCount: { type: nonNull$1(GraphQLInt) },
19343
+ page: { type: nonNull$2(GraphQLInt) },
19344
+ pageSize: { type: nonNull$2(GraphQLInt) },
19345
+ pageCount: { type: nonNull$2(GraphQLInt) },
18746
19346
  hasNextPage: {
18747
- type: nonNull$1(GraphQLBoolean),
19347
+ type: nonNull$2(GraphQLBoolean),
18748
19348
  resolve: (info) => info.page < info.pageCount
18749
19349
  },
18750
19350
  hasPreviousPage: {
18751
- type: nonNull$1(GraphQLBoolean),
19351
+ type: nonNull$2(GraphQLBoolean),
18752
19352
  resolve: (info) => info.page > 1
18753
19353
  }
18754
19354
  }
@@ -18758,13 +19358,13 @@ const createRichText = (json) => new GraphQLObjectType({
18758
19358
  description: "A rich-text document: the versioned JSON document and its sanitized HTML rendering.",
18759
19359
  fields: {
18760
19360
  json: {
18761
- type: nonNull$1(json),
19361
+ type: nonNull$2(json),
18762
19362
  description: "`{ format, version, doc }` (ProseMirror JSON)",
18763
- resolve: ({ html: _html, ...document }) => document
19363
+ resolve: ({ html: _html, [RICH_TEXT_HTML]: _render, ...document }) => document
18764
19364
  },
18765
19365
  html: {
18766
- type: nonNull$1(GraphQLString),
18767
- resolve: (value) => value.html ?? ""
19366
+ type: nonNull$2(GraphQLString),
19367
+ resolve: (value) => value[RICH_TEXT_HTML]?.() ?? (typeof value.html === "string" ? value.html : "")
18768
19368
  }
18769
19369
  }
18770
19370
  });
@@ -18772,36 +19372,36 @@ const createFocalPoint = () => new GraphQLObjectType({
18772
19372
  name: "FocalPoint",
18773
19373
  description: "Where the subject of an image is, as fractions of its width and height (0 to 1).",
18774
19374
  fields: {
18775
- x: { type: nonNull$1(GraphQLFloat) },
18776
- y: { type: nonNull$1(GraphQLFloat) }
19375
+ x: { type: nonNull$2(GraphQLFloat) },
19376
+ y: { type: nonNull$2(GraphQLFloat) }
18777
19377
  }
18778
19378
  });
18779
19379
  const createMediaVariant = () => new GraphQLObjectType({
18780
19380
  name: "MediaVariant",
18781
19381
  description: "A ready image variant (thumbnail, responsive width, converted format).",
18782
19382
  fields: {
18783
- name: { type: nonNull$1(GraphQLString) },
19383
+ name: { type: nonNull$2(GraphQLString) },
18784
19384
  width: { type: GraphQLInt },
18785
19385
  height: { type: GraphQLInt },
18786
19386
  format: { type: GraphQLString },
18787
- mimeType: { type: nonNull$1(GraphQLString) },
18788
- url: { type: nonNull$1(GraphQLString) }
19387
+ mimeType: { type: nonNull$2(GraphQLString) },
19388
+ url: { type: nonNull$2(GraphQLString) }
18789
19389
  }
18790
19390
  });
18791
19391
  const createMedia = (focalPoint, variant) => new GraphQLObjectType({
18792
19392
  name: "Media",
18793
19393
  description: "A media asset. Private assets carry signed URLs that expire at `urlExpiresAt`.",
18794
19394
  fields: {
18795
- id: { type: nonNull$1(GraphQLID) },
18796
- filename: { type: nonNull$1(GraphQLString) },
18797
- mimeType: { type: nonNull$1(GraphQLString) },
18798
- sizeBytes: { type: nonNull$1(GraphQLFloat) },
19395
+ id: { type: nonNull$2(GraphQLID) },
19396
+ filename: { type: nonNull$2(GraphQLString) },
19397
+ mimeType: { type: nonNull$2(GraphQLString) },
19398
+ sizeBytes: { type: nonNull$2(GraphQLFloat) },
18799
19399
  width: { type: GraphQLInt },
18800
19400
  height: { type: GraphQLInt },
18801
- alt: { type: nonNull$1(GraphQLString) },
18802
- caption: { type: nonNull$1(GraphQLString) },
19401
+ alt: { type: nonNull$2(GraphQLString) },
19402
+ caption: { type: nonNull$2(GraphQLString) },
18803
19403
  focalPoint: { type: focalPoint },
18804
- url: { type: nonNull$1(GraphQLString) },
19404
+ url: { type: nonNull$2(GraphQLString) },
18805
19405
  urlExpiresAt: { type: GraphQLString },
18806
19406
  variants: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(variant))) }
18807
19407
  }
@@ -18810,15 +19410,15 @@ const createMedia = (focalPoint, variant) => new GraphQLObjectType({
18810
19410
  const createEntry$2 = () => new GraphQLObjectType({
18811
19411
  name: "Entry",
18812
19412
  fields: {
18813
- id: { type: nonNull$1(GraphQLID) },
18814
- locale: { type: nonNull$1(GraphQLString) },
18815
- version: { type: nonNull$1(GraphQLInt) },
19413
+ id: { type: nonNull$2(GraphQLID) },
19414
+ locale: { type: nonNull$2(GraphQLString) },
19415
+ version: { type: nonNull$2(GraphQLInt) },
18816
19416
  status: {
18817
- type: nonNull$1(GraphQLString),
19417
+ type: nonNull$2(GraphQLString),
18818
19418
  description: "draft, published or modified"
18819
19419
  },
18820
- createdAt: { type: nonNull$1(GraphQLString) },
18821
- updatedAt: { type: nonNull$1(GraphQLString) },
19420
+ createdAt: { type: nonNull$2(GraphQLString) },
19421
+ updatedAt: { type: nonNull$2(GraphQLString) },
18822
19422
  publishedAt: { type: GraphQLString }
18823
19423
  }
18824
19424
  });
@@ -19488,6 +20088,12 @@ const updateDeliveryEntry$1 = async (context, modelKey, id, input) => toResult(a
19488
20088
  }));
19489
20089
  const deleteDeliveryEntry$1 = (context, modelKey, id) => deleteEntry$1(context, modelKey, id);
19490
20090
  //#endregion
20091
+ //#region src/schema/codegen/graphql/descriptions.ts
20092
+ const describeDefinition = ({ label, description }) => {
20093
+ const help = description?.trim();
20094
+ return help ? `${label}\n\n${help}` : label;
20095
+ };
20096
+ //#endregion
19491
20097
  //#region src/schema/codegen/graphql/context.ts
19492
20098
  /** An entry as a GraphQL source object. Drafts carry no publication time. */
19493
20099
  const entryNode = (entry, scope) => ({
@@ -19627,7 +20233,7 @@ const definitionFields = (build, owner, masked) => {
19627
20233
  const resolve = config.resolve;
19628
20234
  fields[field.apiKey] = {
19629
20235
  ...config,
19630
- ...field.description ? { description: field.description } : {},
20236
+ description: describeDefinition(field),
19631
20237
  resolve: (node, args, context, info) => {
19632
20238
  if (masked && !Object.hasOwn(node.data, field.apiKey)) {
19633
20239
  if (field.required) throw maskedFieldError(field);
@@ -19641,7 +20247,7 @@ const definitionFields = (build, owner, masked) => {
19641
20247
  };
19642
20248
  const componentType = (build, component) => memoType(build.objects, component.id, () => new GraphQLObjectType({
19643
20249
  name: componentTypeName(component),
19644
- description: component.description ?? component.label,
20250
+ description: describeDefinition(component),
19645
20251
  fields: () => definitionFields(build, component, false)
19646
20252
  }));
19647
20253
  const SYSTEM_STRING = (name) => ({
@@ -19651,7 +20257,7 @@ const SYSTEM_STRING = (name) => ({
19651
20257
  const entryType = (build, model) => memoType(build.objects, model.id, () => {
19652
20258
  const type = new GraphQLObjectType({
19653
20259
  name: modelTypeName(model),
19654
- description: model.description ?? model.label,
20260
+ description: describeDefinition(model),
19655
20261
  fields: () => ({
19656
20262
  id: {
19657
20263
  type: new GraphQLNonNull(GraphQLID),
@@ -19728,7 +20334,7 @@ const inputFields = (build, owner) => {
19728
20334
  const type = fieldInputType(build, owner, field);
19729
20335
  if (type) fields[field.apiKey] = {
19730
20336
  type,
19731
- ...field.description ? { description: field.description } : {}
20337
+ description: describeDefinition(field)
19732
20338
  };
19733
20339
  }
19734
20340
  return fields;
@@ -19788,6 +20394,172 @@ const toWriteData = (build, owner, input) => {
19788
20394
  return output;
19789
20395
  };
19790
20396
  //#endregion
20397
+ //#region src/usage/restFieldReads.ts
20398
+ const restFieldReads = async ({ snapshot, model, policy, query, policyFor }) => {
20399
+ const fields = selectFields(model, policy.readMask, query.fields);
20400
+ const selection = query.fields === null ? "implicit" : "explicit";
20401
+ const reads = fields.map((field) => ({
20402
+ path: field.id,
20403
+ selection
20404
+ }));
20405
+ for (const field of fields) {
20406
+ if (field.type !== "relation" || !query.populate.has(field.id)) continue;
20407
+ const target = resolveModelById(snapshot, field.settings.target);
20408
+ if (!target) continue;
20409
+ const targetPolicy = await policyFor(target.definition.id);
20410
+ if (!targetPolicy.allowed) continue;
20411
+ for (const targetField of selectFields(target, targetPolicy.readMask, null)) reads.push({
20412
+ path: relationFieldPath(field.id, targetField.id),
20413
+ selection: "implicit"
20414
+ });
20415
+ }
20416
+ return reads;
20417
+ };
20418
+ //#endregion
20419
+ //#region src/services/contentDelivery.ts
20420
+ const readableModel = async (context, modelKey) => {
20421
+ const model = resolveModel(context.snapshot, modelKey);
20422
+ const policy = await context.permissions.evaluate(context.actor, {
20423
+ action: "read",
20424
+ modelId: model.definition.id
20425
+ });
20426
+ if (!policy.allowed) throw context.actor.kind === "anonymous" ? new AppError(401, "UNAUTHENTICATED", "This content needs a delivery token") : new AppError(403, "FORBIDDEN", `Your token may not read "${modelKey}"`);
20427
+ return {
20428
+ model,
20429
+ policy
20430
+ };
20431
+ };
20432
+ /** The heads a read serves: drafts (GraphQL previews), a pinned snapshot, or the published heads. */
20433
+ const sourceOf = (query, options) => {
20434
+ if (query.snapshot !== void 0 && options.drafts) throw queryInvalid("snapshot reads published content; it cannot be combined with drafts");
20435
+ return options.drafts ? {
20436
+ kind: "heads",
20437
+ state: "draft"
20438
+ } : query.snapshot !== void 0 ? {
20439
+ kind: "snapshot",
20440
+ seq: query.snapshot
20441
+ } : {
20442
+ kind: "heads",
20443
+ state: "published"
20444
+ };
20445
+ };
20446
+ /** A pinned snapshot must already exist: checked against the sequence the read itself saw. */
20447
+ const snapshotCheck = (query) => (current) => {
20448
+ if (query.snapshot !== void 0 && query.snapshot > current) throw snapshotInvalid(query.snapshot, current);
20449
+ };
20450
+ /**
20451
+ * Counts a successful REST read for field usage (plan developer-face §5): once per request, after the
20452
+ * projection, by principal. Admin users and draft reads are never counted.
20453
+ */
20454
+ const recordUsage = async (context, options, model, policy, query) => {
20455
+ const principalKey = usagePrincipalKey(context.actor);
20456
+ if (!options.usage || options.drafts || principalKey === null) return;
20457
+ const reads = await restFieldReads({
20458
+ snapshot: context.snapshot,
20459
+ model,
20460
+ policy,
20461
+ query,
20462
+ policyFor: (modelId) => context.permissions.evaluate(context.actor, {
20463
+ action: "read",
20464
+ modelId
20465
+ })
20466
+ });
20467
+ options.usage.recordRequest(context.site.id, principalKey, query.snapshot ?? null);
20468
+ options.usage.recordFieldReads(context.site.id, principalKey, model.definition.id, reads);
20469
+ };
20470
+ /** Records usage after the response when the caller can defer it (REST), else before returning. */
20471
+ const afterRead = async (options, task) => {
20472
+ if (options.defer) {
20473
+ options.defer(task);
20474
+ return;
20475
+ }
20476
+ await task();
20477
+ };
20478
+ const ITEM_PARAMETERS$1 = /* @__PURE__ */ new Set([
20479
+ "fields",
20480
+ "populate",
20481
+ "locale",
20482
+ "snapshot",
20483
+ "richText",
20484
+ "seo"
20485
+ ]);
20486
+ /** Reading one entry takes only these query parameters; anything else is refused, not ignored. */
20487
+ const assertItemParameters = (rawQuery, allowed) => {
20488
+ const unsupported = [...new URLSearchParams(rawQuery).keys()].find((key) => !allowed.has(key.split("[")[0] ?? key));
20489
+ if (unsupported !== void 0) throw queryInvalid(`"${unsupported}" is not supported when reading one entry`);
20490
+ };
20491
+ /** `GET /api/content/:modelKey`: a page of a collection, or a singleton's one entry. */
20492
+ const listDelivery$1 = async (context, modelKey, rawQuery, options = {}) => {
20493
+ const { model, policy } = await readableModel(context, modelKey);
20494
+ const query = parseQueryFor(context, model, policy, rawQuery, {
20495
+ allowSnapshot: true,
20496
+ allowRichText: true,
20497
+ allowSeo: true
20498
+ });
20499
+ const locale = query.locale ?? context.snapshot.defaultLocale;
20500
+ const singleton = model.definition.kind === "singleton";
20501
+ const { entries, limit, total, seq } = await readEntryPage({
20502
+ context,
20503
+ source: sourceOf(query, options),
20504
+ model,
20505
+ policy,
20506
+ query,
20507
+ fallback: options.fallback ?? true
20508
+ }, {
20509
+ total: !singleton,
20510
+ check: snapshotCheck(query)
20511
+ });
20512
+ const snapshot = query.snapshot ?? seq;
20513
+ let result;
20514
+ if (singleton) {
20515
+ const [entry] = entries;
20516
+ if (!entry) throw entryNotFound(modelKey);
20517
+ result = {
20518
+ data: entry,
20519
+ meta: {
20520
+ locale,
20521
+ snapshot
20522
+ }
20523
+ };
20524
+ } else result = {
20525
+ data: entries,
20526
+ meta: {
20527
+ locale,
20528
+ snapshot,
20529
+ pagination: paginationMeta(query.page, limit, total)
20530
+ }
20531
+ };
20532
+ await afterRead(options, () => recordUsage(context, options, model, policy, query));
20533
+ return result;
20534
+ };
20535
+ /** `GET /api/content/:modelKey/:id`: one published entry (404 for drafts, unknown or unreadable entries). */
20536
+ const getDelivery$1 = async (context, modelKey, id, rawQuery, options = {}) => {
20537
+ assertItemParameters(rawQuery, ITEM_PARAMETERS$1);
20538
+ const { model, policy } = await readableModel(context, modelKey);
20539
+ const query = parseQueryFor(context, model, policy, rawQuery, {
20540
+ allowSnapshot: true,
20541
+ allowRichText: true,
20542
+ allowSeo: true
20543
+ });
20544
+ const { entry, seq } = await readOneEntry({
20545
+ context,
20546
+ source: sourceOf(query, options),
20547
+ model,
20548
+ policy,
20549
+ query,
20550
+ fallback: options.fallback ?? true
20551
+ }, id, { check: snapshotCheck(query) });
20552
+ if (!entry) throw entryNotFound(id);
20553
+ await afterRead(options, () => recordUsage(context, options, model, policy, query));
20554
+ return {
20555
+ data: entry,
20556
+ meta: {
20557
+ locale: query.locale ?? context.snapshot.defaultLocale,
20558
+ snapshot: query.snapshot ?? seq
20559
+ }
20560
+ };
20561
+ };
20562
+ //#endregion
19791
20563
  //#region src/schema/codegen/graphql/queryArgs.ts
19792
20564
  /**
19793
20565
  * `XFilter` and `XSort` inputs, and their translation to the REST querystring the content compiler parses
@@ -19834,7 +20606,7 @@ const createFilterInput = (model, fixed) => {
19834
20606
  for (const [name, filter] of SYSTEM_FILTERS) fields[name] = { type: fixed.filters[filter] };
19835
20607
  for (const field of filterableFields(model)) fields[field.apiKey] = {
19836
20608
  type: fixed.filters[FILTER_TYPE_BY_DATA_TYPE[field.type]],
19837
- ...field.description ? { description: field.description } : {}
20609
+ description: describeDefinition(field)
19838
20610
  };
19839
20611
  fields.and = {
19840
20612
  type: new GraphQLList(new GraphQLNonNull(type)),
@@ -20133,6 +20905,65 @@ const mutationFields = (build, model) => {
20133
20905
  return fields;
20134
20906
  };
20135
20907
  //#endregion
20908
+ //#region src/schema/codegen/graphql/siteFields.ts
20909
+ /**
20910
+ * `_site` (plan seo-fields): the request's site and its SEO defaults, through the same service as REST
20911
+ * `GET /api/site`. Underscored like `_snapshot`, so a model called `site` keeps its own root fields; the name
20912
+ * and the `SiteInfo`/`SiteSeo*` types are reserved in `@shapio/schema` naming.ts. SEO fields on entries stay
20913
+ * raw in GraphQL: merge them with `resolveSeo()` from `@shapio/client`.
20914
+ */
20915
+ const SITE_QUERY = "_site";
20916
+ const nonNull$1 = (type) => new GraphQLNonNull(type);
20917
+ const createTypes$1 = (media) => {
20918
+ const locale = new GraphQLObjectType({
20919
+ name: "SiteSeoLocale",
20920
+ description: "One locale's default SEO texts; a missing one falls back along the locale chain",
20921
+ fields: {
20922
+ locale: { type: nonNull$1(GraphQLString) },
20923
+ siteName: { type: GraphQLString },
20924
+ titleTemplate: {
20925
+ type: GraphQLString,
20926
+ description: "Page titles go through it: `%s` is replaced by the title, e.g. `%s · Acme`"
20927
+ },
20928
+ description: {
20929
+ type: GraphQLString,
20930
+ description: "Used where an entry has no SEO description"
20931
+ }
20932
+ }
20933
+ });
20934
+ const seo = new GraphQLObjectType({
20935
+ name: "SiteSeoDefaults",
20936
+ fields: {
20937
+ locales: {
20938
+ type: nonNull$1(new GraphQLList(nonNull$1(locale))),
20939
+ resolve: (source) => Object.entries(source.locales).map(([code, texts]) => ({
20940
+ locale: code,
20941
+ ...texts
20942
+ }))
20943
+ },
20944
+ twitterHandle: { type: GraphQLString },
20945
+ image: {
20946
+ type: media,
20947
+ description: "The default social image (a public image), or null"
20948
+ }
20949
+ }
20950
+ });
20951
+ return new GraphQLObjectType({
20952
+ name: "SiteInfo",
20953
+ description: "The site this request reads, and its SEO defaults",
20954
+ fields: {
20955
+ key: { type: nonNull$1(GraphQLString) },
20956
+ name: { type: nonNull$1(GraphQLString) },
20957
+ seo: { type: nonNull$1(seo) }
20958
+ }
20959
+ });
20960
+ };
20961
+ const siteQueryFields = (snapshot, fixed) => ({ [SITE_QUERY]: {
20962
+ type: nonNull$1(createTypes$1(fixed.media)),
20963
+ description: "The request's site: its key, name and SEO defaults",
20964
+ resolve: async (_root, _args, context) => getDeliverySite$1(await context.content(snapshot))
20965
+ } });
20966
+ //#endregion
20136
20967
  //#region src/db/snapshotDiffQuery.ts
20137
20968
  const POSTGRES = {
20138
20969
  uuid: (value) => sql`${value}::uuid`,
@@ -20533,7 +21364,7 @@ const buildGraphqlSchema = (snapshot) => {
20533
21364
  description: "The schema version this API reflects; it changes whenever a model changes",
20534
21365
  resolve: () => snapshot.version
20535
21366
  };
20536
- Object.assign(query, snapshotQueryFields(snapshot));
21367
+ Object.assign(query, snapshotQueryFields(snapshot), siteQueryFields(snapshot, build.fixed));
20537
21368
  const schema = new GraphQLSchema({
20538
21369
  query: new GraphQLObjectType({
20539
21370
  name: "Query",
@@ -20817,6 +21648,56 @@ const publishingPlugin = fp(async (app, { runtime }) => {
20817
21648
  app.decorate("publishing", runtime);
20818
21649
  }, { name: "shapio-publishing" });
20819
21650
  //#endregion
21651
+ //#region src/plugins/requestLog.ts
21652
+ /** How the caller authenticated, as logged: the principal's kind, never its ids. */
21653
+ const PRINCIPAL_LABELS = {
21654
+ anonymous: "anonymous",
21655
+ admin: "admin",
21656
+ token: "token",
21657
+ appUser: "app",
21658
+ system: "system"
21659
+ };
21660
+ /** The concrete path without its query string, which may carry credentials (signed media URLs, OAuth). */
21661
+ const pathOf = (url) => {
21662
+ const queryStart = url.indexOf("?");
21663
+ return queryStart === -1 ? url : url.slice(0, queryStart);
21664
+ };
21665
+ /** Milliseconds with one decimal. */
21666
+ const roundMs = (ms) => Math.round(ms * 10) / 10;
21667
+ /**
21668
+ * The request's one response line. `request.principal` is undefined when the request failed before
21669
+ * authentication ran; `request.site` only on site routes; `request.errorCode` only when the central error
21670
+ * handler (plugins/errorHandler.ts) answered.
21671
+ */
21672
+ const responseLineOf = (request, reply) => {
21673
+ const principal = request.principal;
21674
+ return {
21675
+ method: request.method,
21676
+ ...request.routeOptions.url ? { route: request.routeOptions.url } : {},
21677
+ path: pathOf(request.url),
21678
+ status: reply.statusCode,
21679
+ ms: roundMs(reply.elapsedTime),
21680
+ ...request.site ? { site: request.site.key } : {},
21681
+ ...principal ? { principal: PRINCIPAL_LABELS[principal.kind] } : {},
21682
+ ...request.errorCode ? { code: request.errorCode } : {}
21683
+ };
21684
+ };
21685
+ /**
21686
+ * Replaces Fastify's request logging (`disableRequestLogging`): one info line per response with the fields
21687
+ * that matter, and the request-start line (full redacted URL, host, remote address) at debug. The logger's
21688
+ * `reqId` binding is `request.id`, the id audit entries and extension contexts carry.
21689
+ */
21690
+ const requestLogPlugin = fp(async (app) => {
21691
+ app.addHook("onRequest", async (request) => {
21692
+ request.log.debug({ req: request }, "incoming request");
21693
+ });
21694
+ app.addHook("onResponse", async (request, reply) => {
21695
+ const line = responseLineOf(request, reply);
21696
+ if (request.routeOptions.config?.requestLog === "debug") request.log.debug(line, "request");
21697
+ else request.log.info(line, "request");
21698
+ });
21699
+ }, { name: "shapio-request-log" });
21700
+ //#endregion
20820
21701
  //#region src/plugins/security.ts
20821
21702
  const CORS_METHODS = [
20822
21703
  "GET",
@@ -21362,11 +22243,30 @@ const NOOP_USAGE_TRACKER = {
21362
22243
  };
21363
22244
  //#endregion
21364
22245
  //#region src/plugins/usage.ts
22246
+ /** Starts queued recordings after the response and lets `flush` and shutdown wait for them. */
22247
+ const createAfterResponseQueue = () => {
22248
+ const pending = /* @__PURE__ */ new Set();
22249
+ const run = (log, task) => {
22250
+ const started = new Promise((resolve) => setImmediate(resolve)).then(task).catch((error) => {
22251
+ log.error({ err: error }, "recording field usage failed");
22252
+ }).finally(() => pending.delete(started));
22253
+ pending.add(started);
22254
+ };
22255
+ const settled = async () => {
22256
+ await Promise.all([...pending]);
22257
+ };
22258
+ return {
22259
+ run,
22260
+ settled
22261
+ };
22262
+ };
21365
22263
  /**
21366
22264
  * Field usage from delivery traffic (plan developer-face §5): one in-memory aggregator per instance,
21367
22265
  * flushed on a timer, when it fills up, and when the app closes (so a clean shutdown loses nothing).
21368
22266
  */
21369
22267
  const usagePlugin = fp(async (app, { config, db }) => {
22268
+ const queue = createAfterResponseQueue();
22269
+ app.decorate("recordUsageAfterResponse", queue.run);
21370
22270
  if (!config.enabled) {
21371
22271
  app.decorate("usage", NOOP_USAGE_TRACKER);
21372
22272
  return;
@@ -21377,8 +22277,15 @@ const usagePlugin = fp(async (app, { config, db }) => {
21377
22277
  flushIntervalMs: config.flushIntervalMs,
21378
22278
  log
21379
22279
  });
21380
- app.decorate("usage", aggregator);
22280
+ app.decorate("usage", {
22281
+ ...aggregator,
22282
+ flush: async () => {
22283
+ await queue.settled();
22284
+ await aggregator.flush();
22285
+ }
22286
+ });
21381
22287
  app.addHook("onClose", async () => {
22288
+ await queue.settled();
21382
22289
  await aggregator.close();
21383
22290
  });
21384
22291
  }, { name: "shapio-usage" });
@@ -21616,7 +22523,7 @@ const createRole$1 = async (context, input, schema) => {
21616
22523
  kind: input.kind
21617
22524
  }, trx);
21618
22525
  await insertPermissions$1(toRows$1(role.id, input.permissions), trx);
21619
- await bumpPermissionsVersion(trx);
22526
+ await bumpPermissionsVersion$2(trx);
21620
22527
  await recordAudit(trx, {
21621
22528
  ...context,
21622
22529
  action: "role.create",
@@ -21661,7 +22568,7 @@ const updateRole$1 = async (context, id, input, schema) => db.transaction().exec
21661
22568
  if (input.permissions) {
21662
22569
  await deletePermissionsForRole$1(id, trx);
21663
22570
  await insertPermissions$1(toRows$1(id, input.permissions), trx);
21664
- await bumpPermissionsVersion(trx);
22571
+ await bumpPermissionsVersion$2(trx);
21665
22572
  }
21666
22573
  await recordAudit(trx, {
21667
22574
  ...context,
@@ -21684,7 +22591,7 @@ const deleteRole$1 = async (context, id) => {
21684
22591
  if (holders.users > 0 || holders.tokens > 0) throw new AppError(409, "ROLE_IN_USE", "Remove this role from every admin and API token first", holders);
21685
22592
  await deleteRevokedForRole(id, trx);
21686
22593
  await deleteById$5(id, trx);
21687
- await bumpPermissionsVersion(trx);
22594
+ await bumpPermissionsVersion$2(trx);
21688
22595
  await recordAudit(trx, {
21689
22596
  ...context,
21690
22597
  action: "role.delete",
@@ -21758,7 +22665,7 @@ const createAppRole$1 = async (context, input, schema) => {
21758
22665
  description: input.description.trim()
21759
22666
  }, trx);
21760
22667
  await insertPermissions(toRows(role.id, input.permissions), trx);
21761
- await bumpPermissionsVersion(trx);
22668
+ await bumpPermissionsVersion$2(trx);
21762
22669
  await recordAudit(trx, {
21763
22670
  ...context,
21764
22671
  action: "app_role.create",
@@ -21800,7 +22707,7 @@ const updateAppRole$1 = async (context, id, input, schema) => db.transaction().e
21800
22707
  if (input.permissions) {
21801
22708
  await deletePermissionsForRole(id, trx);
21802
22709
  await insertPermissions(toRows(id, input.permissions), trx);
21803
- await bumpPermissionsVersion(trx);
22710
+ await bumpPermissionsVersion$2(trx);
21804
22711
  }
21805
22712
  await recordAudit(trx, {
21806
22713
  ...context,
@@ -21825,7 +22732,7 @@ const deleteAppRole$1 = async (context, id) => {
21825
22732
  const holders = await countHolders(id, trx);
21826
22733
  if (holders > 0) throw new AppError(409, "ROLE_IN_USE", "Remove this role from every app user first", { users: holders });
21827
22734
  await deleteById$2(id, trx);
21828
- await bumpPermissionsVersion(trx);
22735
+ await bumpPermissionsVersion$2(trx);
21829
22736
  await recordAudit(trx, {
21830
22737
  ...context,
21831
22738
  action: "app_role.delete",
@@ -22317,7 +23224,7 @@ const updateAppUser$1 = async (context, id, input) => db.transaction().execute(a
22317
23224
  }
22318
23225
  });
22319
23226
  }
22320
- await bumpPermissionsVersion(trx);
23227
+ await bumpPermissionsVersion$2(trx);
22321
23228
  const view = await findSummaryById(id, trx);
22322
23229
  if (!view) throw notFound$4();
22323
23230
  return toAdminView(view);
@@ -22339,7 +23246,7 @@ const deleteAppUserInTransaction = async (context, user, trx) => {
22339
23246
  await endAllSignIns(user.id, now, "deleted", trx);
22340
23247
  await consumeAllForUser$1("email_confirmations", user.id, now, trx);
22341
23248
  await consumeAllForUser$1("app_password_resets", user.id, now, trx);
22342
- await bumpPermissionsVersion(trx);
23249
+ await bumpPermissionsVersion$2(trx);
22343
23250
  await recordAudit(trx, {
22344
23251
  ...context,
22345
23252
  action: "app_user.delete",
@@ -23647,7 +24554,6 @@ const toInputFormat = (model, fields, data) => {
23647
24554
  fields: present,
23648
24555
  visibleTargets: null,
23649
24556
  populated: /* @__PURE__ */ new Map(),
23650
- richTextHtml: false,
23651
24557
  mediaAssets: null
23652
24558
  });
23653
24559
  };
@@ -25926,9 +26832,99 @@ const createDefinitionRoutes = (category) => async (app) => {
25926
26832
  }, handlers.listChanges);
25927
26833
  };
25928
26834
  //#endregion
26835
+ //#region src/services/builtinComponents.ts
26836
+ const existing = (context, active) => ({
26837
+ status: "existing",
26838
+ definitionId: active.definition.id,
26839
+ version: active.version,
26840
+ schemaVersion: context.snapshot.version
26841
+ });
26842
+ /** Another definition already holds the API ID in some view the shared component would join. */
26843
+ const conflictOf = async (context) => {
26844
+ const taken = context.snapshot.network.definitions.find((entry) => entry.definition.id !== "f3905a00-d2a3-4c62-899a-c894bfd6cb9b" && entry.definition.apiKey.toLowerCase() === "seo".toLowerCase());
26845
+ if (!taken) return;
26846
+ const siteKey = taken.siteId === null ? null : (await loadSiteKeys(context.db)).get(taken.siteId) ?? null;
26847
+ const where = siteKey === null ? "shared with all sites" : `on site "${siteKey}"`;
26848
+ return new AppError(409, "SEO_COMPONENT_CONFLICT", `The API ID "${taken.definition.apiKey}" is already used by the ${taken.definition.kind} "${taken.definition.label}" (${where}). Rename or delete it, then add SEO fields again.`, {
26849
+ definitionId: taken.definition.id,
26850
+ apiKey: taken.definition.apiKey,
26851
+ kind: taken.definition.kind,
26852
+ label: taken.definition.label,
26853
+ scope: taken.siteId === null ? "network" : "site",
26854
+ siteKey
26855
+ });
26856
+ };
26857
+ /** The SEO component belongs to another site only: it cannot be shared from here. */
26858
+ const elsewhere = () => new AppError(409, "SEO_COMPONENT_CONFLICT", "The SEO component is kept on another site. A network admin can share it with all sites.", {
26859
+ definitionId: SEO_COMPONENT_ID,
26860
+ scope: "site"
26861
+ });
26862
+ /**
26863
+ * Makes sure the built-in SEO component is in the request's view: returns it when it is (shared, or, for an
26864
+ * instance seeded before it was shared, the site's own), else creates it shared (needs `schema.create` on
26865
+ * every site). Idempotent: a concurrent create that won is returned as existing on the next call.
26866
+ */
26867
+ const ensureSeoComponent$1 = async (context) => {
26868
+ const inView = context.snapshot.byId.get(SEO_COMPONENT_ID);
26869
+ if (inView) return existing(context, inView);
26870
+ await assertCanCreate(context, null);
26871
+ if (context.snapshot.network.byId.has("f3905a00-d2a3-4c62-899a-c894bfd6cb9b")) throw elsewhere();
26872
+ const conflict = await conflictOf(context);
26873
+ if (conflict) throw conflict;
26874
+ const outcome = await applyChange(context, {
26875
+ category: "component",
26876
+ definition: seoComponentDefinition(),
26877
+ expectedVersion: null,
26878
+ scope: "network"
26879
+ });
26880
+ if (outcome.status !== "activated") throw new Error(`Creating the SEO component ended ${outcome.status}`);
26881
+ return {
26882
+ status: "created",
26883
+ definitionId: outcome.definitionId,
26884
+ version: outcome.version ?? 1,
26885
+ schemaVersion: outcome.schemaVersion
26886
+ };
26887
+ };
26888
+ //#endregion
26889
+ //#region src/controllers/builtinComponents.ts
26890
+ const ensureSeoComponent = async (request, reply) => {
26891
+ const outcome = await ensureSeoComponent$1(await schemaContextFor(request));
26892
+ return reply.code(outcome.status === "created" ? 201 : 200).send(outcome);
26893
+ };
26894
+ //#endregion
26895
+ //#region src/routes/admin/components/builtinSchemas.ts
26896
+ const EnsureOutcomeSchema = Type.Object({
26897
+ /** `created` when this call created it (a new schema version), `existing` when it was already there. */
26898
+ status: Type.Enum(["existing", "created"]),
26899
+ definitionId: Type.String(),
26900
+ version: Type.Integer(),
26901
+ schemaVersion: Type.Integer()
26902
+ });
26903
+ const ensureSeoComponentSchema = { response: {
26904
+ 200: EnsureOutcomeSchema,
26905
+ 201: EnsureOutcomeSchema,
26906
+ 403: ErrorResponseSchema,
26907
+ 409: ErrorResponseSchema,
26908
+ 422: ErrorResponseSchema
26909
+ } };
26910
+ //#endregion
26911
+ //#region src/routes/admin/components/builtin.ts
26912
+ /** /api/admin/components/builtin: Shapio's built-in components, created on first use (plan seo-fields). */
26913
+ const builtinComponentsRoutes = async (app) => {
26914
+ declareSiteScope(app, "site");
26915
+ app.post("/seo/ensure", {
26916
+ schema: ensureSeoComponentSchema,
26917
+ preHandler: app.requireAdmin,
26918
+ config: { audit: { action: "schema.activate" } }
26919
+ }, ensureSeoComponent);
26920
+ };
26921
+ //#endregion
25929
26922
  //#region src/routes/admin/components/index.ts
25930
- /** /api/admin/components: reusable components (no entries of their own). */
25931
- const componentsRoutes = createDefinitionRoutes("component");
26923
+ /** /api/admin/components: reusable components (no entries of their own), and the built-in ones. */
26924
+ const componentsRoutes = async (app) => {
26925
+ await app.register(createDefinitionRoutes("component"));
26926
+ await app.register(builtinComponentsRoutes, { prefix: "/builtin" });
26927
+ };
25932
26928
  //#endregion
25933
26929
  //#region src/controllers/adminContent.ts
25934
26930
  const revisionSummary = (row) => ({
@@ -27947,6 +28943,60 @@ const adminSetupRoutes = async (app) => {
27947
28943
  }, completeSetup);
27948
28944
  };
27949
28945
  //#endregion
28946
+ //#region src/controllers/siteSeo.ts
28947
+ const contextFor$2 = async (request) => ({
28948
+ ...toSiteActorContext(request),
28949
+ db: request.server.db,
28950
+ snapshot: await getRequestSchema(request),
28951
+ permissions: getRequestPermissions(request)
28952
+ });
28953
+ const getSiteSeo = async (request) => getSiteSeo$1(await contextFor$2(request));
28954
+ const updateSiteSeo = async (request) => updateSiteSeo$1(await contextFor$2(request), request.body);
28955
+ //#endregion
28956
+ //#region src/routes/admin/site/schemas.ts
28957
+ /** The request's site's SEO defaults and the site version an update must name. */
28958
+ const SiteSeoSchema = Type.Object({
28959
+ version: Type.Integer(),
28960
+ seo: SeoDefaultsSchema
28961
+ });
28962
+ const getSiteSeoSchema = { response: {
28963
+ 200: SiteSeoSchema,
28964
+ 403: ErrorResponseSchema,
28965
+ 404: ErrorResponseSchema
28966
+ } };
28967
+ const updateSiteSeoSchema = {
28968
+ body: Type.Object({
28969
+ expectedVersion: Type.Integer({ minimum: 1 }),
28970
+ seo: SeoDefaultsSchema
28971
+ }, { additionalProperties: false }),
28972
+ response: {
28973
+ 200: SiteSeoSchema,
28974
+ 400: ErrorResponseSchema,
28975
+ 404: ErrorResponseSchema,
28976
+ 409: ErrorResponseSchema
28977
+ }
28978
+ };
28979
+ //#endregion
28980
+ //#region src/routes/admin/site/index.ts
28981
+ /**
28982
+ * /api/admin/site: the request's site's own settings (plan seo-fields), named by the `Shapio-Site` header.
28983
+ * Reading them needs read access to some content of the site (checked by the service); changing them needs
28984
+ * `site.settings` on that site (a site admin needs no network role).
28985
+ */
28986
+ const adminSiteRoutes = async (app) => {
28987
+ declareSiteScope(app, "site");
28988
+ const settings = { preHandler: app.requireGlobalPermission("site.settings") };
28989
+ app.get("/seo", {
28990
+ schema: getSiteSeoSchema,
28991
+ preHandler: app.requireAdmin
28992
+ }, getSiteSeo);
28993
+ app.put("/seo", {
28994
+ schema: updateSiteSeoSchema,
28995
+ config: { audit: { action: "site.seo_update" } },
28996
+ ...settings
28997
+ }, updateSiteSeo);
28998
+ };
28999
+ //#endregion
27950
29000
  //#region src/services/siteAppRoles.ts
27951
29001
  const toView$3 = (siteId, rows) => {
27952
29002
  const of = (audience) => rows.filter((row) => row.audience === audience).map((row) => row.role_id);
@@ -27976,7 +29026,7 @@ const setSiteAppRoles$1 = async (context, siteId, input) => db.transaction().exe
27976
29026
  const before = toView$3(siteId, await listForSite$1(siteId, trx));
27977
29027
  await replaceForAudience(siteId, "public", next.public, trx);
27978
29028
  await replaceForAudience(siteId, "authenticated", next.authenticated, trx);
27979
- await bumpPermissionsVersion(trx);
29029
+ await bumpPermissionsVersion$2(trx);
27980
29030
  await recordAudit(trx, {
27981
29031
  ...context,
27982
29032
  action: "site.app_roles_update",
@@ -28090,10 +29140,10 @@ const adminSitesRoutes = async (app) => {
28090
29140
  };
28091
29141
  //#endregion
28092
29142
  //#region src/controllers/apiTokens.ts
28093
- const listTokens = async (request) => listApiTokens(toSiteActorContext(request), request.server.permissions);
29143
+ const listTokens = async (request) => listApiTokens(toSiteActorContext(request), getRequestPermissions(request));
28094
29144
  const createToken = async (request, reply) => {
28095
29145
  const { name, roleId, expiresAt, network } = request.body;
28096
- const created = await createApiToken(toSiteActorContext(request), request.server.permissions, {
29146
+ const created = await createApiToken(toSiteActorContext(request), getRequestPermissions(request), {
28097
29147
  name,
28098
29148
  roleId,
28099
29149
  expiresAt: expiresAt ? new Date(expiresAt) : null,
@@ -28102,7 +29152,7 @@ const createToken = async (request, reply) => {
28102
29152
  return reply.code(201).send(created);
28103
29153
  };
28104
29154
  const revokeToken = async (request, reply) => {
28105
- await revokeApiToken(toSiteActorContext(request), request.server.permissions, request.params.id);
29155
+ await revokeApiToken(toSiteActorContext(request), getRequestPermissions(request), request.params.id);
28106
29156
  return reply.code(204).send();
28107
29157
  };
28108
29158
  //#endregion
@@ -28682,6 +29732,7 @@ const adminIdentityRoutes = async (app) => {
28682
29732
  await app.register(adminEditingRoutes);
28683
29733
  await app.register(adminUsageRoutes, { prefix: "/usage" });
28684
29734
  await app.register(adminSitesRoutes, { prefix: "/sites" });
29735
+ await app.register(adminSiteRoutes, { prefix: "/site" });
28685
29736
  };
28686
29737
  //#endregion
28687
29738
  //#region src/services/locales.ts
@@ -29362,7 +30413,7 @@ const mediaContextFor = (request) => {
29362
30413
  storage: server.mediaStorage,
29363
30414
  urls: server.urls,
29364
30415
  signingSecret: server.signingSecret,
29365
- permissions: server.permissions,
30416
+ permissions: getRequestPermissions(request),
29366
30417
  limits: {
29367
30418
  maxUploadBytes: server.config.storage.maxUploadBytes,
29368
30419
  allowedTypes: server.config.storage.allowedTypes
@@ -31317,7 +32368,8 @@ const requestJson = async (runtime, input) => {
31317
32368
  status: response.status,
31318
32369
  ok: isSuccessStatus(response.status),
31319
32370
  json,
31320
- text: response.body
32371
+ text: response.body,
32372
+ location: response.headers.location
31321
32373
  };
31322
32374
  };
31323
32375
  /** A short, safe description of a failed provider response (status plus the provider's own message). */
@@ -31398,6 +32450,28 @@ const findDeploymentSince$1 = async (context, since) => {
31398
32450
  return (deployment.deployment_trigger?.type === void 0 || deployment.deployment_trigger.type === "deploy_hook") && createdSince(toEpochMs(deployment.created_on), since);
31399
32451
  });
31400
32452
  };
32453
+ const isFinished = (deployment) => {
32454
+ const { name, status } = deployment.latest_stage ?? {};
32455
+ return status === "failure" || status === "canceled" || status === "skipped" || name === "deploy" && status === "success";
32456
+ };
32457
+ /** The newest unfinished deployment, whatever started it (the build a 304 from the deploy hook refers to). */
32458
+ const findPendingDeployment = async (context) => {
32459
+ return (await apiRequest$2(context, `${projectPath(context)}/deployments`)).find((deployment) => !isFinished(deployment));
32460
+ };
32461
+ /** A 304 without Location: Cloudflare already has a deployment queued for the hook's branch. */
32462
+ const isAlreadyQueued = (response) => response.status === 304 && !response.location;
32463
+ const ALREADY_QUEUED_MESSAGE = "Cloudflare: a deployment was already queued for this branch (deploy hook HTTP 304), so no new one was started";
32464
+ const followQueuedDeployment = async (context) => {
32465
+ const pending = await findPendingDeployment(context);
32466
+ if (!pending) return {
32467
+ status: "triggered",
32468
+ message: ALREADY_QUEUED_MESSAGE
32469
+ };
32470
+ return {
32471
+ ...reportOf(context, pending),
32472
+ message: `${ALREADY_QUEUED_MESSAGE}; following it`
32473
+ };
32474
+ };
31401
32475
  const deploymentOf$1 = (context) => {
31402
32476
  const id = context.run.provider_ref;
31403
32477
  return id ? apiRequest$2(context, `${projectPath(context)}/deployments/${encodeURIComponent(id)}`) : findDeploymentSince$1(context, context.run.triggered_at ?? context.run.created_at);
@@ -31436,6 +32510,7 @@ const cloudflarePagesProvider = {
31436
32510
  policy: context.policy,
31437
32511
  signal: context.signal
31438
32512
  });
32513
+ if (isAlreadyQueued(response)) return followQueuedDeployment(context);
31439
32514
  const envelope = envelopeOf(response);
31440
32515
  if (!response.ok || envelope.success === false) throw new Error(`Cloudflare deploy hook ${describeFailure(response, errorMessageOf$2(response))}`);
31441
32516
  const id = envelope.result?.id;
@@ -35255,7 +36330,8 @@ const WEBHOOK_EVENT_CATALOGUE = [
35255
36330
  "locale.removed"
35256
36331
  ]),
35257
36332
  ...group("change_set", Object.values(CHANGE_SET_EVENTS)),
35258
- ...group("deployment", Object.values(DEPLOYMENT_EVENTS))
36333
+ ...group("deployment", Object.values(DEPLOYMENT_EVENTS)),
36334
+ ...group("site", ["site.updated"])
35259
36335
  ];
35260
36336
  /** Sent by "Send test"; never produced by the outbox. */
35261
36337
  const WEBHOOK_TEST_EVENT = "webhook.test";
@@ -35523,7 +36599,7 @@ const redeliver$1 = async (runtime, context, webhookId, deliveryId) => {
35523
36599
  //#region src/controllers/webhooks.ts
35524
36600
  const contextFor = (request) => ({
35525
36601
  ...toSiteActorContext(request),
35526
- permissions: request.server.permissions
36602
+ permissions: getRequestPermissions(request)
35527
36603
  });
35528
36604
  const listWebhooks$1 = async (request) => listWebhooks$2(request.server.publishing, getRequestSite(request));
35529
36605
  const listEventTypes = async () => ({ items: [...listEventTypes$1()] });
@@ -37825,7 +38901,7 @@ const openStoredBundle = (storage, stored) => storage.get(stored.driver).getStre
37825
38901
  const removeStoredBundle = (storage, stored) => storage.get(stored.driver).delete(stored.key);
37826
38902
  //#endregion
37827
38903
  //#region src/constants/version.ts
37828
- const SHAPIO_VERSION = "0.3.1";
38904
+ const SHAPIO_VERSION = "0.4.0";
37829
38905
  //#endregion
37830
38906
  //#region src/repositories/transferExport.ts
37831
38907
  /**
@@ -38448,7 +39524,7 @@ const importUserBatch = async (db, siteId, users) => {
38448
39524
  ...account
38449
39525
  }, trx);
38450
39526
  }
38451
- await bumpPermissionsVersion(trx);
39527
+ await bumpPermissionsVersion$2(trx);
38452
39528
  });
38453
39529
  result.added = added.length;
38454
39530
  return result;
@@ -38885,7 +39961,7 @@ const writeMediaFile = async (context, assetId, input, upload) => {
38885
39961
  const transferContextFor = (request) => ({
38886
39962
  ...toSiteActorContext(request),
38887
39963
  db: request.server.db,
38888
- permissions: request.server.permissions,
39964
+ permissions: getRequestPermissions(request),
38889
39965
  storage: request.server.mediaStorage,
38890
39966
  schemaContext: async () => ({
38891
39967
  db: request.server.db,
@@ -39991,15 +41067,20 @@ const sendCacheable = (request, reply, payload) => {
39991
41067
  if (typeof match === "string" && match.split(",").some((candidate) => candidate.trim() === etag)) return reply.code(304).send();
39992
41068
  return reply.type("application/json; charset=utf-8").send(body);
39993
41069
  };
41070
+ /** Field usage is counted after the response is sent, off its path (plan delivery-perf). */
41071
+ const usageOptions = (request) => ({
41072
+ usage: request.server.usage,
41073
+ defer: (task) => request.server.recordUsageAfterResponse(request.log, task)
41074
+ });
39994
41075
  const listDelivery = async (request, reply) => {
39995
41076
  const context = await contentContextFor(request);
39996
41077
  const apiKey = apiKeyOfRoute(context.snapshot, request.params.modelKey);
39997
- return sendCacheable(request, reply, await listDelivery$1(context, apiKey, rawQueryOf(request), { usage: request.server.usage }));
41078
+ return sendCacheable(request, reply, await listDelivery$1(context, apiKey, rawQueryOf(request), usageOptions(request)));
39998
41079
  };
39999
41080
  const getDelivery = async (request, reply) => {
40000
41081
  const context = await contentContextFor(request);
40001
41082
  const apiKey = apiKeyOfRoute(context.snapshot, request.params.modelKey);
40002
- return sendCacheable(request, reply, await getDelivery$1(context, apiKey, request.params.id, rawQueryOf(request), { usage: request.server.usage }));
41083
+ return sendCacheable(request, reply, await getDelivery$1(context, apiKey, request.params.id, rawQueryOf(request), usageOptions(request)));
40003
41084
  };
40004
41085
  //#endregion
40005
41086
  //#region src/controllers/deliveryWrites.ts
@@ -40180,6 +41261,50 @@ ${graphql ? renderGraphqlSection(graphql) : ""}
40180
41261
  </main></body></html>`;
40181
41262
  };
40182
41263
  //#endregion
41264
+ //#region src/routes/site/schemas.ts
41265
+ /** The default social image as delivery shows any asset (the media field shape). */
41266
+ const DeliveredImageSchema = Type.Record(Type.String(), Type.Unknown());
41267
+ const getDeliverySiteSchema = { response: {
41268
+ 200: Type.Object({
41269
+ key: Type.String(),
41270
+ name: Type.String(),
41271
+ seo: Type.Object({
41272
+ locales: Type.Record(Type.String(), SeoLocaleDefaultsSchema),
41273
+ twitterHandle: Type.Union([Type.String({ pattern: SEO_TWITTER_HANDLE_PATTERN.source }), Type.Null()]),
41274
+ image: Type.Union([DeliveredImageSchema, Type.Null()])
41275
+ })
41276
+ }),
41277
+ 304: { type: "null" },
41278
+ 401: ErrorResponseSchema,
41279
+ 403: ErrorResponseSchema
41280
+ } };
41281
+ //#endregion
41282
+ //#region src/schema/codegen/openapiSite.ts
41283
+ /**
41284
+ * `GET /api/site` in the OpenAPI document (plan seo-fields): the site's key, name and SEO defaults. Its schema
41285
+ * is the route's own TypeBox schema, never a second copy.
41286
+ */
41287
+ const SITE_TAG = {
41288
+ name: "Site",
41289
+ description: "The request's site and its SEO defaults"
41290
+ };
41291
+ const errorRef$1 = { $ref: "#/components/schemas/Error" };
41292
+ const sitePaths = () => ({ "/api/site": { get: {
41293
+ tags: [SITE_TAG.name],
41294
+ summary: "The site's key, name and SEO defaults (texts per locale, default image, Twitter handle)",
41295
+ operationId: "getSite",
41296
+ responses: {
41297
+ "200": {
41298
+ description: "OK",
41299
+ content: { "application/json": { schema: getDeliverySiteSchema.response[200] } }
41300
+ },
41301
+ "401": {
41302
+ description: "Anonymous, and the public role may read nothing on this site",
41303
+ content: { "application/json": { schema: errorRef$1 } }
41304
+ }
41305
+ }
41306
+ } } });
41307
+ //#endregion
40183
41308
  //#region src/routes/snapshots/schemas.ts
40184
41309
  const SeqSchema = Type.Integer({ minimum: 0 });
40185
41310
  const NullableInteger = Type.Union([Type.Integer(), Type.Null()]);
@@ -40448,13 +41573,20 @@ const COMMON_SCHEMAS = {
40448
41573
  "doc"
40449
41574
  ]
40450
41575
  },
40451
- RichTextOutput: { allOf: [ref("RichText"), {
41576
+ RichTextOutput: {
40452
41577
  type: "object",
40453
- properties: { html: {
40454
- type: "string",
40455
- description: "Sanitized HTML rendered from the JSON"
40456
- } }
40457
- }] },
41578
+ description: "Rich text as delivered, per the `richText` query parameter: `doc` with `json` (the default), `html` with `html`, both with `both`.",
41579
+ properties: {
41580
+ format: { const: "shapio-richtext" },
41581
+ version: { const: 1 },
41582
+ doc: { type: "object" },
41583
+ html: {
41584
+ type: "string",
41585
+ description: "Sanitized HTML rendered from the JSON"
41586
+ }
41587
+ },
41588
+ required: ["format", "version"]
41589
+ },
40458
41590
  MediaAsset: {
40459
41591
  type: "object",
40460
41592
  properties: {
@@ -40564,9 +41696,20 @@ const listParameters = (model, delivery) => [
40564
41696
  schema: { type: "string" },
40565
41697
  description: "Search the title field"
40566
41698
  },
40567
- ...itemParameters(delivery)
41699
+ ...itemParameters(model, delivery)
40568
41700
  ];
40569
- const itemParameters = (delivery) => [
41701
+ /** `?seo=` is offered where it works: delivery reads of a model with an SEO field. */
41702
+ const seoParameters = (model) => model.fields.some((field) => !field.deprecated && isSeoField(field)) ? [{
41703
+ name: "seo",
41704
+ in: "query",
41705
+ schema: {
41706
+ type: "string",
41707
+ enum: [...SEO_MODES],
41708
+ default: "raw"
41709
+ },
41710
+ description: "SEO fields as stored (`raw`) or merged with the site's SEO defaults (`resolved`): the title through the site's title template (the entry's own title when empty), description and image from the defaults, `noindex` a boolean. A pinned `snapshot` uses today's defaults."
41711
+ }] : [];
41712
+ const itemParameters = (model, delivery) => [
40570
41713
  {
40571
41714
  name: "fields",
40572
41715
  in: "query",
@@ -40585,15 +41728,28 @@ const itemParameters = (delivery) => [
40585
41728
  schema: { type: "string" },
40586
41729
  description: "Locale to serve; falls back along the locale chain"
40587
41730
  },
40588
- ...delivery ? [{
40589
- name: "snapshot",
40590
- in: "query",
40591
- schema: {
40592
- type: "integer",
40593
- minimum: 1
41731
+ ...delivery ? [
41732
+ {
41733
+ name: "snapshot",
41734
+ in: "query",
41735
+ schema: {
41736
+ type: "integer",
41737
+ minimum: 1
41738
+ },
41739
+ description: "Read content as of this publication sequence number (`meta.snapshot`)"
41740
+ },
41741
+ {
41742
+ name: "richText",
41743
+ in: "query",
41744
+ schema: {
41745
+ type: "string",
41746
+ enum: [...RICH_TEXT_MODES],
41747
+ default: DEFAULT_RICH_TEXT_MODE
41748
+ },
41749
+ description: "Rich-text shape: the JSON document, sanitized HTML rendered from it, or both"
40594
41750
  },
40595
- description: "Read content as of this publication sequence number (`meta.snapshot`)"
40596
- }] : []
41751
+ ...seoParameters(model)
41752
+ ] : []
40597
41753
  ];
40598
41754
  const idParameter = {
40599
41755
  name: "id",
@@ -40647,7 +41803,7 @@ const modelPaths = (model) => {
40647
41803
  tags: [tag],
40648
41804
  summary: single ? `Read the published ${model.label}` : `List published ${model.label} entries`,
40649
41805
  operationId: `list${name}`,
40650
- parameters: single ? itemParameters(true) : listParameters(model, true),
41806
+ parameters: single ? itemParameters(model, true) : listParameters(model, true),
40651
41807
  responses: {
40652
41808
  "200": json({
40653
41809
  type: "object",
@@ -40670,7 +41826,7 @@ const modelPaths = (model) => {
40670
41826
  tags: [tag],
40671
41827
  summary: `Read one published ${model.label} entry`,
40672
41828
  operationId: `get${name}`,
40673
- parameters: [idParameter, ...itemParameters(true)],
41829
+ parameters: [idParameter, ...itemParameters(model, true)],
40674
41830
  responses: {
40675
41831
  "200": json({
40676
41832
  type: "object",
@@ -40834,11 +41990,15 @@ const generateOpenApi = (snapshot, options) => {
40834
41990
  },
40835
41991
  servers: [{ url: options.serverUrl }],
40836
41992
  security: [{ bearerAuth: [] }, { cookieAuth: [] }],
40837
- tags: [...models.map((model) => ({
40838
- name: model.label,
40839
- description: model.description ?? `${model.kind} \`${model.apiKey}\``
40840
- })), SNAPSHOTS_TAG],
40841
- paths: Object.assign({}, ...models.map(modelPaths), snapshotPaths()),
41993
+ tags: [
41994
+ ...models.map((model) => ({
41995
+ name: model.label,
41996
+ description: model.description ?? `${model.kind} \`${model.apiKey}\``
41997
+ })),
41998
+ SNAPSHOTS_TAG,
41999
+ SITE_TAG
42000
+ ],
42001
+ paths: Object.assign({}, ...models.map(modelPaths), snapshotPaths(), sitePaths()),
40842
42002
  components: {
40843
42003
  schemas,
40844
42004
  securitySchemes: {
@@ -40872,8 +42032,11 @@ export type RichTextNode = {
40872
42032
 
40873
42033
  export type RichText = { format: 'shapio-richtext'; version: 1; doc: RichTextNode };
40874
42034
 
40875
- /** Delivery adds sanitized HTML rendered from the JSON. */
40876
- export type RichTextOutput = RichText & { html: string };
42035
+ /**
42036
+ * Rich text as delivery returns it, per the request's \`richText\`: \`doc\` with \`json\` (the default), sanitized
42037
+ * \`html\` rendered from the document with \`html\`, both with \`both\`.
42038
+ */
42039
+ export type RichTextOutput = { format: 'shapio-richtext'; version: 1; doc?: RichTextNode; html?: string };
40877
42040
 
40878
42041
  export type MediaAsset = {
40879
42042
  id: string;
@@ -41032,14 +42195,14 @@ const docsRoutes = async (app) => {
41032
42195
  //#endregion
41033
42196
  //#region src/db/migrations/20261001120000_create_platform_tables.ts
41034
42197
  var _20261001120000_create_platform_tables_exports = /* @__PURE__ */ __exportAll({
41035
- down: () => down$27,
41036
- up: () => up$27
42198
+ down: () => down$30,
42199
+ up: () => up$30
41037
42200
  });
41038
42201
  /**
41039
42202
  * Foundation tables shared by every package: durable versions, the job queue, the transactional outbox
41040
42203
  * and the audit log. Statuses are text with CHECK constraints so new states need only a constraint change.
41041
42204
  */
41042
- const up$27 = async (db) => {
42205
+ const up$30 = async (db) => {
41043
42206
  await db.schema.createTable("system_versions").addColumn("id", "boolean", (col) => col.primaryKey().defaultTo(true).check(sql`id`)).addColumn("schema_version", "integer", (col) => col.notNull().defaultTo(0)).addColumn("permissions_version", "integer", (col) => col.notNull().defaultTo(0)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41044
42207
  await sql`insert into system_versions default values`.execute(db);
41045
42208
  await db.schema.createTable("jobs").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("type", "text", (col) => col.notNull()).addColumn("payload", "jsonb", (col) => col.notNull().defaultTo(sql`'{}'::jsonb`)).addColumn("status", "text", (col) => col.notNull().defaultTo("pending").check(sql`status in ('pending', 'running', 'succeeded', 'dead')`)).addColumn("priority", "integer", (col) => col.notNull().defaultTo(0)).addColumn("run_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("attempts", "integer", (col) => col.notNull().defaultTo(0)).addColumn("max_attempts", "integer", (col) => col.notNull().defaultTo(10).check(sql`max_attempts > 0`)).addColumn("idempotency_key", "text").addColumn("locked_by", "text").addColumn("locked_until", "timestamptz").addColumn("checkpoint", "jsonb").addColumn("result", "jsonb").addColumn("last_error", "text").addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("finished_at", "timestamptz").execute();
@@ -41061,7 +42224,7 @@ const up$27 = async (db) => {
41061
42224
  "occurred_at desc"
41062
42225
  ]).execute();
41063
42226
  };
41064
- const down$27 = async (db) => {
42227
+ const down$30 = async (db) => {
41065
42228
  await db.schema.dropTable("audit_events").execute();
41066
42229
  await db.schema.dropTable("outbox_events").execute();
41067
42230
  await db.schema.dropTable("jobs").execute();
@@ -41070,8 +42233,8 @@ const down$27 = async (db) => {
41070
42233
  //#endregion
41071
42234
  //#region src/db/migrations/20261001130000_create_admin_identity.ts
41072
42235
  var _20261001130000_create_admin_identity_exports = /* @__PURE__ */ __exportAll({
41073
- down: () => down$26,
41074
- up: () => up$26
42236
+ down: () => down$29,
42237
+ up: () => up$29
41075
42238
  });
41076
42239
  /**
41077
42240
  * Admin identity (package B, ADR 0005): admin users, roles and their permission grants, server-side
@@ -41080,7 +42243,7 @@ var _20261001130000_create_admin_identity_exports = /* @__PURE__ */ __exportAll(
41080
42243
  */
41081
42244
  const withTimestamps$3 = (table) => table.addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`));
41082
42245
  const uuidPrimaryKey$3 = (table) => table.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`));
41083
- const up$26 = async (db) => {
42246
+ const up$29 = async (db) => {
41084
42247
  await withTimestamps$3(uuidPrimaryKey$3(db.schema.createTable("admin_users")).addColumn("email", "text", (col) => col.notNull().unique()).addColumn("name", "text", (col) => col.notNull().defaultTo("")).addColumn("password_hash", "text", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull().defaultTo("active").check(sql`status in ('active', 'disabled')`)).addColumn("last_login_at", "timestamptz").addColumn("password_changed_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`))).execute();
41085
42248
  await withTimestamps$3(uuidPrimaryKey$3(db.schema.createTable("admin_roles")).addColumn("key", "text", (col) => col.notNull().unique().check(sql`key ~ '^[a-z][a-z0-9-]{0,62}$'`)).addColumn("name", "text", (col) => col.notNull()).addColumn("description", "text", (col) => col.notNull().defaultTo("")).addColumn("kind", "text", (col) => col.notNull().defaultTo("admin").check(sql`kind in ('admin', 'delivery')`)).addColumn("is_system", "boolean", (col) => col.notNull().defaultTo(false)).addColumn("version", "integer", (col) => col.notNull().defaultTo(1))).execute();
41086
42249
  await withTimestamps$3(uuidPrimaryKey$3(db.schema.createTable("admin_role_permissions")).addColumn("role_id", "uuid", (col) => col.notNull().references("admin_roles.id").onDelete("cascade")).addColumn("action", "text", (col) => col.notNull()).addColumn("model_id", "text").addColumn("condition", "text", (col) => col.check(sql`condition in ('ownedByPrincipal')`)).addColumn("field_ids", sql`text[]`).addUniqueConstraint("admin_role_permissions_grant_uq", [
@@ -41100,7 +42263,7 @@ const up$26 = async (db) => {
41100
42263
  await withTimestamps$3(uuidPrimaryKey$3(db.schema.createTable("api_tokens")).addColumn("name", "text", (col) => col.notNull()).addColumn("token_hash", "text", (col) => col.notNull().unique()).addColumn("token_prefix", "text", (col) => col.notNull()).addColumn("role_id", "uuid", (col) => col.notNull().references("admin_roles.id").onDelete("restrict")).addColumn("created_by", "uuid", (col) => col.references("admin_users.id").onDelete("set null")).addColumn("expires_at", "timestamptz").addColumn("last_used_at", "timestamptz").addColumn("revoked_at", "timestamptz")).execute();
41101
42264
  await db.schema.createIndex("api_tokens_role_idx").on("api_tokens").column("role_id").execute();
41102
42265
  };
41103
- const down$26 = async (db) => {
42266
+ const down$29 = async (db) => {
41104
42267
  await db.schema.dropTable("api_tokens").execute();
41105
42268
  await db.schema.dropTable("setup_tokens").execute();
41106
42269
  await db.schema.dropTable("password_resets").execute();
@@ -41114,31 +42277,31 @@ const down$26 = async (db) => {
41114
42277
  //#endregion
41115
42278
  //#region src/db/migrations/20261001130500_create_system_settings.ts
41116
42279
  var _20261001130500_create_system_settings_exports = /* @__PURE__ */ __exportAll({
41117
- down: () => down$25,
41118
- up: () => up$25
42280
+ down: () => down$28,
42281
+ up: () => up$28
41119
42282
  });
41120
42283
  /**
41121
42284
  * Instance-wide key/value settings that must survive restarts and be shared by every instance, e.g. the
41122
42285
  * generated signing secret. General purpose: later packages add keys, not tables.
41123
42286
  */
41124
- const up$25 = async (db) => {
42287
+ const up$28 = async (db) => {
41125
42288
  await db.schema.createTable("system_settings").addColumn("key", "text", (col) => col.primaryKey()).addColumn("value", "text", (col) => col.notNull()).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41126
42289
  };
41127
- const down$25 = async (db) => {
42290
+ const down$28 = async (db) => {
41128
42291
  await db.schema.dropTable("system_settings").execute();
41129
42292
  };
41130
42293
  //#endregion
41131
42294
  //#region src/db/migrations/20261001140000_create_schema_tables.ts
41132
42295
  var _20261001140000_create_schema_tables_exports = /* @__PURE__ */ __exportAll({
41133
- down: () => down$24,
41134
- up: () => up$24
42296
+ down: () => down$27,
42297
+ up: () => up$27
41135
42298
  });
41136
42299
  /**
41137
42300
  * Schema registry (package D, ADR 0002): models and components (one table, `kind` discriminator), immutable
41138
42301
  * schema revisions, the per-model active pointer, schema change jobs, locales and schema settings.
41139
42302
  * Model definitions are data: creating or changing a model never runs DDL (CONTRIBUTING.md rule 1).
41140
42303
  */
41141
- const up$24 = async (db) => {
42304
+ const up$27 = async (db) => {
41142
42305
  await db.schema.createTable("models").addColumn("id", "uuid", (col) => col.primaryKey()).addColumn("kind", "text", (col) => col.notNull().check(sql`kind in ('collection', 'singleton', 'component')`)).addColumn("api_key", "text", (col) => col.notNull()).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("deleted_at", "timestamptz").execute();
41143
42306
  await sql`create unique index models_api_key_uq on models (lower(api_key)) where deleted_at is null`.execute(db);
41144
42307
  await db.schema.createTable("schema_revisions").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("model_id", "uuid", (col) => col.notNull().references("models.id").onDelete("restrict")).addColumn("version", "integer", (col) => col.notNull().check(sql`version > 0`)).addColumn("definition", "jsonb", (col) => col.notNull()).addColumn("hash", "text", (col) => col.notNull()).addColumn("parent_revision_id", "uuid", (col) => col.references("schema_revisions.id")).addColumn("created_by_type", "text", (col) => col.notNull()).addColumn("created_by_id", "text").addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addUniqueConstraint("schema_revisions_model_version_uq", ["model_id", "version"]).execute();
@@ -41166,7 +42329,7 @@ const up$24 = async (db) => {
41166
42329
  await db.schema.createTable("schema_settings").addColumn("id", "boolean", (col) => col.primaryKey().defaultTo(true).check(sql`id`)).addColumn("read_only", "boolean", (col) => col.notNull().defaultTo(false)).addColumn("read_only_reason", "text").addColumn("updated_by_type", "text").addColumn("updated_by_id", "text").addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41167
42330
  await sql`insert into schema_settings default values`.execute(db);
41168
42331
  };
41169
- const down$24 = async (db) => {
42332
+ const down$27 = async (db) => {
41170
42333
  await db.schema.dropTable("schema_settings").execute();
41171
42334
  await db.schema.dropTable("locales").execute();
41172
42335
  await db.schema.dropTable("schema_change_jobs").execute();
@@ -41178,8 +42341,8 @@ const down$24 = async (db) => {
41178
42341
  //#endregion
41179
42342
  //#region src/db/migrations/20261001150000_create_content_tables.ts
41180
42343
  var _20261001150000_create_content_tables_exports = /* @__PURE__ */ __exportAll({
41181
- down: () => down$23,
41182
- up: () => up$23
42344
+ down: () => down$26,
42345
+ up: () => up$26
41183
42346
  });
41184
42347
  /**
41185
42348
  * Content storage (package E, ADR 0001): entries, immutable content revisions, mutable heads (one per
@@ -41187,7 +42350,7 @@ var _20261001150000_create_content_tables_exports = /* @__PURE__ */ __exportAll(
41187
42350
  * and the publication log behind `?snapshot=N`. User content models never get tables of their own
41188
42351
  * (CONTRIBUTING.md rule 1): their values are JSONB keyed by stable field IDs.
41189
42352
  */
41190
- const up$23 = async (db) => {
42353
+ const up$26 = async (db) => {
41191
42354
  await db.schema.createTable("entries").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("model_id", "uuid", (col) => col.notNull().references("models.id").onDelete("restrict")).addColumn("owner_app_user_id", "uuid").addColumn("created_by_admin_id", "uuid", (col) => col.references("admin_users.id").onDelete("set null")).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("updated_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("deleted_at", "timestamptz").execute();
41192
42355
  await sql`create index entries_model_idx on entries (model_id, created_at) where deleted_at is null`.execute(db);
41193
42356
  await sql`create index entries_owner_idx on entries (owner_app_user_id) where owner_app_user_id is not null`.execute(db);
@@ -41251,7 +42414,7 @@ const up$23 = async (db) => {
41251
42414
  await sql`create unique index publication_log_open_uq on publication_log (entry_id, locale) where to_seq is null`.execute(db);
41252
42415
  await db.schema.createIndex("publication_log_model_seq_idx").on("publication_log").columns(["model_id", "from_seq"]).execute();
41253
42416
  };
41254
- const down$23 = async (db) => {
42417
+ const down$26 = async (db) => {
41255
42418
  await db.schema.dropTable("publication_log").execute();
41256
42419
  await db.schema.dropTable("publication_state").execute();
41257
42420
  await db.schema.dropTable("unique_values").execute();
@@ -41264,8 +42427,8 @@ const down$23 = async (db) => {
41264
42427
  //#endregion
41265
42428
  //#region src/db/migrations/20261001160000_create_media_tables.ts
41266
42429
  var _20261001160000_create_media_tables_exports = /* @__PURE__ */ __exportAll({
41267
- down: () => down$22,
41268
- up: () => up$22
42430
+ down: () => down$25,
42431
+ up: () => up$25
41269
42432
  });
41270
42433
  /**
41271
42434
  * Media library (package G): folders, assets, image variants, references from content and upload grants.
@@ -41276,7 +42439,7 @@ const withTimestamps$2 = (table) => table.addColumn("created_at", "timestamptz",
41276
42439
  const uuidPrimaryKey$2 = (table) => table.addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`));
41277
42440
  const STORAGE_DRIVER_CHECK = sql`storage_driver in ('local', 's3')`;
41278
42441
  const VISIBILITY_CHECK = sql`visibility in ('public', 'private')`;
41279
- const up$22 = async (db) => {
42442
+ const up$25 = async (db) => {
41280
42443
  await withTimestamps$2(uuidPrimaryKey$2(db.schema.createTable("media_folders")).addColumn("parent_id", "uuid", (col) => col.references("media_folders.id").onDelete("restrict")).addColumn("name", "text", (col) => col.notNull().check(sql`length(name) between 1 and 255`)).addColumn("created_by", "uuid", (col) => col.references("admin_users.id").onDelete("set null")).addColumn("version", "integer", (col) => col.notNull().defaultTo(1))).execute();
41281
42444
  await sql`create unique index media_folders_sibling_name_uq on media_folders
41282
42445
  (coalesce(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), lower(name))`.execute(db);
@@ -41305,7 +42468,7 @@ const up$22 = async (db) => {
41305
42468
  ]).execute();
41306
42469
  await db.schema.createTable("media_upload_grants").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("asset_id", "uuid", (col) => col.notNull()).addColumn("kind", "text", (col) => col.notNull().check(sql`kind in ('create', 'replace')`)).addColumn("storage_driver", "text", (col) => col.notNull().check(STORAGE_DRIVER_CHECK)).addColumn("storage_key", "text", (col) => col.notNull().unique()).addColumn("original_filename", "text", (col) => col.notNull()).addColumn("declared_mime_type", "text", (col) => col.notNull()).addColumn("max_size_bytes", "bigint", (col) => col.notNull()).addColumn("folder_id", "uuid").addColumn("visibility", "text", (col) => col.notNull().check(VISIBILITY_CHECK)).addColumn("created_by", "uuid", (col) => col.references("admin_users.id").onDelete("set null")).addColumn("created_by_principal", "text", (col) => col.notNull()).addColumn("status", "text", (col) => col.notNull().defaultTo("pending").check(sql`status in ('pending', 'consumed', 'rejected', 'expired')`)).addColumn("expires_at", "timestamptz", (col) => col.notNull()).addColumn("consumed_at", "timestamptz").addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41307
42470
  };
41308
- const down$22 = async (db) => {
42471
+ const down$25 = async (db) => {
41309
42472
  await db.schema.dropTable("media_upload_grants").execute();
41310
42473
  await db.schema.dropTable("media_references").execute();
41311
42474
  await db.schema.dropTable("media_variants").execute();
@@ -41315,8 +42478,8 @@ const down$22 = async (db) => {
41315
42478
  //#endregion
41316
42479
  //#region src/db/migrations/20261001170000_create_publishing_tables.ts
41317
42480
  var _20261001170000_create_publishing_tables_exports = /* @__PURE__ */ __exportAll({
41318
- down: () => down$21,
41319
- up: () => up$21
42481
+ down: () => down$24,
42482
+ up: () => up$24
41320
42483
  });
41321
42484
  /**
41322
42485
  * Publishing (package H): scheduled publications, releases, webhooks and their delivery log, deployment
@@ -41355,13 +42518,13 @@ const createPreviewTables = async (db) => {
41355
42518
  await uuidPrimaryKey$1(db.schema.createTable("preview_tokens")).addColumn("token_hash", "text", (col) => col.notNull().unique()).addColumn("token_prefix", "text", (col) => col.notNull()).addColumn("model_id", "uuid", (col) => col.notNull()).addColumn("entry_id", "uuid", (col) => col.references("entries.id").onDelete("cascade")).addColumn("locale", "text").addColumn("connection_id", "uuid", (col) => col.references("deployment_connections.id").onDelete("set null")).addColumn("delivery_role_id", "uuid", (col) => col.references("admin_roles.id").onDelete("set null")).addColumn("created_by", "uuid", (col) => col.notNull().references("admin_users.id").onDelete("cascade")).addColumn("expires_at", "timestamptz", (col) => col.notNull()).addColumn("revoked_at", "timestamptz").addColumn("last_used_at", "timestamptz").addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41356
42519
  await db.schema.createIndex("preview_tokens_entry_idx").on("preview_tokens").columns(["entry_id", "created_at desc"]).execute();
41357
42520
  };
41358
- const up$21 = async (db) => {
42521
+ const up$24 = async (db) => {
41359
42522
  await createSchedulingTables(db);
41360
42523
  await createWebhookTables(db);
41361
42524
  await createDeploymentTables(db);
41362
42525
  await createPreviewTables(db);
41363
42526
  };
41364
- const down$21 = async (db) => {
42527
+ const down$24 = async (db) => {
41365
42528
  await db.schema.dropTable("preview_tokens").execute();
41366
42529
  await db.schema.dropTable("deployment_runs").execute();
41367
42530
  await db.schema.dropTable("deployment_connections").execute();
@@ -41374,8 +42537,8 @@ const down$21 = async (db) => {
41374
42537
  //#endregion
41375
42538
  //#region src/db/migrations/20261001180000_create_app_users.ts
41376
42539
  var _20261001180000_create_app_users_exports = /* @__PURE__ */ __exportAll({
41377
- down: () => down$20,
41378
- up: () => up$20
42540
+ down: () => down$23,
42541
+ up: () => up$23
41379
42542
  });
41380
42543
  /**
41381
42544
  * App users (package I, ADR 0005): the end users of sites and apps built on Shapio, with their own roles
@@ -41426,7 +42589,7 @@ const createCredentials = async (db) => {
41426
42589
  }
41427
42590
  await db.schema.createTable("app_login_codes").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("app_user_id", "uuid", (col) => col.notNull().references("app_users.id").onDelete("cascade")).addColumn("code_hash", "text", (col) => col.notNull().unique()).addColumn("expires_at", "timestamptz", (col) => col.notNull()).addColumn("used_at", "timestamptz").addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).execute();
41428
42591
  };
41429
- const up$20 = async (db) => {
42592
+ const up$23 = async (db) => {
41430
42593
  await createUsers(db);
41431
42594
  await createRoles(db);
41432
42595
  await createCredentials(db);
@@ -41436,7 +42599,7 @@ const up$20 = async (db) => {
41436
42599
  foreign key (owner_app_user_id) references app_users (id) on delete set null
41437
42600
  `.execute(db);
41438
42601
  };
41439
- const down$20 = async (db) => {
42602
+ const down$23 = async (db) => {
41440
42603
  await sql`alter table entries drop constraint if exists entries_owner_app_user_fk`.execute(db);
41441
42604
  await db.schema.dropTable("app_login_codes").execute();
41442
42605
  await db.schema.dropTable("app_password_resets").execute();
@@ -41451,8 +42614,8 @@ const down$20 = async (db) => {
41451
42614
  //#endregion
41452
42615
  //#region src/db/migrations/20261002120000_create_extension_hook_runs.ts
41453
42616
  var _20261002120000_create_extension_hook_runs_exports = /* @__PURE__ */ __exportAll({
41454
- down: () => down$19,
41455
- up: () => up$19
42617
+ down: () => down$22,
42618
+ up: () => up$22
41456
42619
  });
41457
42620
  /**
41458
42621
  * Extension points (package K, ADR 0009): the ledger of post-commit lifecycle hooks (`afterCreate`,
@@ -41460,17 +42623,17 @@ var _20261002120000_create_extension_hook_runs_exports = /* @__PURE__ */ __expor
41460
42623
  * as the hook's own database work, so a hook re-run after a worker crash finds its row and is skipped:
41461
42624
  * exactly once for everything the hook does through its transaction.
41462
42625
  */
41463
- const up$19 = async (db) => {
42626
+ const up$22 = async (db) => {
41464
42627
  await db.schema.createTable("extension_hook_runs").addColumn("event_id", "uuid", (col) => col.notNull()).addColumn("hook", "text", (col) => col.notNull()).addColumn("job_id", "uuid").addColumn("completed_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addPrimaryKeyConstraint("extension_hook_runs_pkey", ["event_id", "hook"]).execute();
41465
42628
  };
41466
- const down$19 = async (db) => {
42629
+ const down$22 = async (db) => {
41467
42630
  await db.schema.dropTable("extension_hook_runs").execute();
41468
42631
  };
41469
42632
  //#endregion
41470
42633
  //#region src/db/migrations/20261002130000_app_auth_token_version_and_login_code_pkce.ts
41471
42634
  var _20261002130000_app_auth_token_version_and_login_code_pkce_exports = /* @__PURE__ */ __exportAll({
41472
- down: () => down$18,
41473
- up: () => up$18
42635
+ down: () => down$21,
42636
+ up: () => up$21
41474
42637
  });
41475
42638
  /**
41476
42639
  * App users (ADR 0005):
@@ -41481,20 +42644,20 @@ var _20261002130000_app_auth_token_version_and_login_code_pkce_exports = /* @__P
41481
42644
  * sign-in; the one-time login code is only exchanged with the matching verifier. Codes live a minute, so
41482
42645
  * any pending ones (issued without a challenge) are dropped rather than given a placeholder.
41483
42646
  */
41484
- const up$18 = async (db) => {
42647
+ const up$21 = async (db) => {
41485
42648
  await db.schema.alterTable("app_users").addColumn("token_version", "integer", (col) => col.notNull().defaultTo(0)).execute();
41486
42649
  await sql`delete from app_login_codes`.execute(db);
41487
42650
  await db.schema.alterTable("app_login_codes").addColumn("code_challenge", "text", (col) => col.notNull()).execute();
41488
42651
  };
41489
- const down$18 = async (db) => {
42652
+ const down$21 = async (db) => {
41490
42653
  await db.schema.alterTable("app_login_codes").dropColumn("code_challenge").execute();
41491
42654
  await db.schema.alterTable("app_users").dropColumn("token_version").execute();
41492
42655
  };
41493
42656
  //#endregion
41494
42657
  //#region src/db/migrations/20261002180000_add_publication_log_diff_indexes.ts
41495
42658
  var _20261002180000_add_publication_log_diff_indexes_exports = /* @__PURE__ */ __exportAll({
41496
- down: () => down$17,
41497
- up: () => up$17
42659
+ down: () => down$20,
42660
+ up: () => up$20
41498
42661
  });
41499
42662
  /**
41500
42663
  * The snapshot diff (repositories/snapshotDiff.ts) finds the (entry, locale) pairs whose live period opened
@@ -41503,7 +42666,7 @@ var _20261002180000_add_publication_log_diff_indexes_exports = /* @__PURE__ */ _
41503
42666
  * - `(entry_id, locale, from_seq)`: "the row live at seq N" is the latest row starting at or before N.
41504
42667
  * Periods that opened in the range use the existing `(model_id, from_seq)` index.
41505
42668
  */
41506
- const up$17 = async (db) => {
42669
+ const up$20 = async (db) => {
41507
42670
  await sql`create index publication_log_to_seq_idx on publication_log (to_seq) where to_seq is not null`.execute(db);
41508
42671
  await db.schema.createIndex("publication_log_entry_locale_from_idx").on("publication_log").columns([
41509
42672
  "entry_id",
@@ -41511,15 +42674,15 @@ const up$17 = async (db) => {
41511
42674
  "from_seq"
41512
42675
  ]).execute();
41513
42676
  };
41514
- const down$17 = async (db) => {
42677
+ const down$20 = async (db) => {
41515
42678
  await db.schema.dropIndex("publication_log_entry_locale_from_idx").execute();
41516
42679
  await db.schema.dropIndex("publication_log_to_seq_idx").execute();
41517
42680
  };
41518
42681
  //#endregion
41519
42682
  //#region src/db/migrations/20261002180100_create_usage_tables.ts
41520
42683
  var _20261002180100_create_usage_tables_exports = /* @__PURE__ */ __exportAll({
41521
- down: () => down$16,
41522
- up: () => up$16
42684
+ down: () => down$19,
42685
+ up: () => up$19
41523
42686
  });
41524
42687
  /**
41525
42688
  * Field usage from delivery traffic (plan developer-face §5): daily counters written by the in-memory
@@ -41527,7 +42690,7 @@ var _20261002180100_create_usage_tables_exports = /* @__PURE__ */ __exportAll({
41527
42690
  * payloads. `principal_key` is `token:<id>`, `app_users` or `anonymous`. Pruned by the retention job
41528
42691
  * after USAGE_RETENTION_DAYS.
41529
42692
  */
41530
- const up$16 = async (db) => {
42693
+ const up$19 = async (db) => {
41531
42694
  await db.schema.createTable("field_reads").addColumn("day", "date", (col) => col.notNull()).addColumn("model_id", "uuid", (col) => col.notNull()).addColumn("field_path", "text", (col) => col.notNull()).addColumn("principal_key", "text", (col) => col.notNull()).addColumn("selection", "text", (col) => col.notNull().check(sql`selection in ('explicit', 'implicit')`)).addColumn("reads", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_read_at", "timestamptz", (col) => col.notNull()).addPrimaryKeyConstraint("field_reads_pkey", [
41532
42695
  "day",
41533
42696
  "model_id",
@@ -41538,15 +42701,15 @@ const up$16 = async (db) => {
41538
42701
  await db.schema.createIndex("field_reads_model_day_idx").on("field_reads").columns(["model_id", "day"]).execute();
41539
42702
  await db.schema.createTable("token_reads").addColumn("day", "date", (col) => col.notNull()).addColumn("principal_key", "text", (col) => col.notNull()).addColumn("requests", "bigint", (col) => col.notNull().defaultTo(0)).addColumn("last_snapshot", "bigint").addColumn("last_read_at", "timestamptz", (col) => col.notNull()).addPrimaryKeyConstraint("token_reads_pkey", ["day", "principal_key"]).execute();
41540
42703
  };
41541
- const down$16 = async (db) => {
42704
+ const down$19 = async (db) => {
41542
42705
  await db.schema.dropTable("token_reads").execute();
41543
42706
  await db.schema.dropTable("field_reads").execute();
41544
42707
  };
41545
42708
  //#endregion
41546
42709
  //#region src/db/migrations/20261002180200_create_change_sets.ts
41547
42710
  var _20261002180200_create_change_sets_exports = /* @__PURE__ */ __exportAll({
41548
- down: () => down$15,
41549
- up: () => up$15
42711
+ down: () => down$18,
42712
+ up: () => up$18
41550
42713
  });
41551
42714
  /**
41552
42715
  * Change sets (developer-face plan §5, ADR 0010): schema drafts and entry publications that ship as ONE
@@ -41725,7 +42888,7 @@ const migrateReleases = async (db) => {
41725
42888
  await sql`drop table release_items`.execute(db);
41726
42889
  await sql`drop table releases`.execute(db);
41727
42890
  };
41728
- const up$15 = async (db) => {
42891
+ const up$18 = async (db) => {
41729
42892
  await createTables(db);
41730
42893
  await linkExecutionRecords(db);
41731
42894
  await allowConversionRevisions(db);
@@ -41797,7 +42960,7 @@ const restoreReleases = async (db) => {
41797
42960
  await sql`update webhooks set events = array_remove(array_remove(events, 'change_set.shipping'),
41798
42961
  'change_set.discarded')`.execute(db);
41799
42962
  };
41800
- const down$15 = async (db) => {
42963
+ const down$18 = async (db) => {
41801
42964
  await restoreReleases(db);
41802
42965
  await sql`alter table content_revisions drop constraint content_revisions_reason_check`.execute(db);
41803
42966
  await sql`alter table content_revisions add constraint content_revisions_reason_check
@@ -41818,8 +42981,8 @@ const down$15 = async (db) => {
41818
42981
  //#endregion
41819
42982
  //#region src/db/migrations/20261002190000_create_editor_presence_and_content_health.ts
41820
42983
  var _20261002190000_create_editor_presence_and_content_health_exports = /* @__PURE__ */ __exportAll({
41821
- down: () => down$14,
41822
- up: () => up$14
42984
+ down: () => down$17,
42985
+ up: () => up$17
41823
42986
  });
41824
42987
  /**
41825
42988
  * Editor experience (plan editor-experience §9):
@@ -41830,7 +42993,7 @@ var _20261002190000_create_editor_presence_and_content_health_exports = /* @__PU
41830
42993
  * - `content_health_findings`: open and recently resolved findings of the health rules per entry and
41831
42994
  * locale (`*` for the entry as a whole). An evaluation upserts what it found and resolves the rest.
41832
42995
  */
41833
- const up$14 = async (db) => {
42996
+ const up$17 = async (db) => {
41834
42997
  await sql`
41835
42998
  create unlogged table editor_presence (
41836
42999
  entry_id uuid not null,
@@ -41867,15 +43030,15 @@ const up$14 = async (db) => {
41867
43030
  await sql`create index content_health_findings_resolved_idx on content_health_findings (resolved_at)
41868
43031
  where resolved_at is not null`.execute(db);
41869
43032
  };
41870
- const down$14 = async (db) => {
43033
+ const down$17 = async (db) => {
41871
43034
  await sql`drop table content_health_findings`.execute(db);
41872
43035
  await sql`drop table editor_presence`.execute(db);
41873
43036
  };
41874
43037
  //#endregion
41875
43038
  //#region src/db/migrations/20261003120000_add_changes_ship_permission.ts
41876
43039
  var _20261003120000_add_changes_ship_permission_exports = /* @__PURE__ */ __exportAll({
41877
- down: () => down$13,
41878
- up: () => up$13
43040
+ down: () => down$16,
43041
+ up: () => up$16
41879
43042
  });
41880
43043
  /**
41881
43044
  * `changes.ship` (agentic plan §I): shipping a change set (and scheduling a ship) becomes its own global
@@ -41883,7 +43046,7 @@ var _20261003120000_add_changes_ship_permission_exports = /* @__PURE__ */ __expo
41883
43046
  * live. Every role that holds `changes.manage` today keeps what it could do: it gets `changes.ship` too.
41884
43047
  * The permissions version moves only when grants changed, so running instances reload exactly then.
41885
43048
  */
41886
- const up$13 = async (db) => {
43049
+ const up$16 = async (db) => {
41887
43050
  await sql`
41888
43051
  with granted as (
41889
43052
  insert into admin_role_permissions (role_id, action, model_id, condition, field_ids)
@@ -41897,7 +43060,7 @@ const up$13 = async (db) => {
41897
43060
  where exists (select 1 from granted)
41898
43061
  `.execute(db);
41899
43062
  };
41900
- const down$13 = async (db) => {
43063
+ const down$16 = async (db) => {
41901
43064
  await sql`
41902
43065
  with revoked as (
41903
43066
  delete from admin_role_permissions where action = 'changes.ship' returning 1
@@ -41909,8 +43072,8 @@ const down$13 = async (db) => {
41909
43072
  //#endregion
41910
43073
  //#region src/db/migrations/20261003130000_create_sites.ts
41911
43074
  var _20261003130000_create_sites_exports = /* @__PURE__ */ __exportAll({
41912
- down: () => down$12,
41913
- up: () => up$12
43075
+ down: () => down$15,
43076
+ up: () => up$15
41914
43077
  });
41915
43078
  /**
41916
43079
  * Sites (plan agentic-ecosystem §H, ADR 0011): one instance hosts many sites with one schema and one login.
@@ -42098,7 +43261,7 @@ const siteRoleAssignments = async (db) => {
42098
43261
  `.execute(db);
42099
43262
  await sql`alter table admin_invitations drop column role_ids`.execute(db);
42100
43263
  };
42101
- const up$12 = async (db) => {
43264
+ const up$15 = async (db) => {
42102
43265
  await createSites(db);
42103
43266
  await addSiteColumns(db);
42104
43267
  await tieCopiesToEntries(db);
@@ -42111,7 +43274,7 @@ const assertSingleSite = async (db) => {
42111
43274
  const { rows } = await sql`select count(*)::text as count from sites`.execute(db);
42112
43275
  if (Number(rows[0]?.count ?? 0) > 1) throw new Error("Cannot roll back sites while more than one site exists: merging them would merge their snapshot ledgers and uniqueness and widen role assignments. Delete the other sites first.");
42113
43276
  };
42114
- const down$12 = async (db) => {
43277
+ const down$15 = async (db) => {
42115
43278
  await assertSingleSite(db);
42116
43279
  await sql`alter table admin_invitations add column role_ids uuid[] not null default '{}'::uuid[]`.execute(db);
42117
43280
  await sql`
@@ -42182,8 +43345,8 @@ const down$12 = async (db) => {
42182
43345
  //#endregion
42183
43346
  //#region src/db/migrations/20261003140200_enqueue_field_index_layout.ts
42184
43347
  var _20261003140200_enqueue_field_index_layout_exports = /* @__PURE__ */ __exportAll({
42185
- down: () => down$11,
42186
- up: () => up$11
43348
+ down: () => down$14,
43349
+ up: () => up$14
42187
43350
  });
42188
43351
  /**
42189
43352
  * Field index layout v2 (sites plan §H): per-field expression indexes on `entry_heads` lead with `site_id`,
@@ -42195,7 +43358,7 @@ var _20261003140200_enqueue_field_index_layout_exports = /* @__PURE__ */ __expor
42195
43358
  */
42196
43359
  const JOB_TYPE = "schema.fieldIndexLayout";
42197
43360
  const IDEMPOTENCY_KEY = "schema.fieldIndexLayout:v2";
42198
- const up$11 = async (db) => {
43361
+ const up$14 = async (db) => {
42199
43362
  await sql`
42200
43363
  insert into jobs (type, payload, max_attempts, idempotency_key)
42201
43364
  select ${JOB_TYPE}, '{}'::jsonb, 5, ${IDEMPOTENCY_KEY}
@@ -42208,14 +43371,14 @@ const up$11 = async (db) => {
42208
43371
  `.execute(db);
42209
43372
  };
42210
43373
  /** The layout job is idempotent; rolling back only forgets it if it has not run yet. */
42211
- const down$11 = async (db) => {
43374
+ const down$14 = async (db) => {
42212
43375
  await sql`delete from jobs where idempotency_key = ${IDEMPOTENCY_KEY} and status = 'pending'`.execute(db);
42213
43376
  };
42214
43377
  //#endregion
42215
43378
  //#region src/db/migrations/20261003140500_change_set_items_site_and_preview_entry.ts
42216
43379
  var _20261003140500_change_set_items_site_and_preview_entry_exports = /* @__PURE__ */ __exportAll({
42217
- down: () => down$10,
42218
- up: () => up$10
43380
+ down: () => down$13,
43381
+ up: () => up$13
42219
43382
  });
42220
43383
  /**
42221
43384
  * Sites, publishing (plan agentic-ecosystem §H, package G5).
@@ -42226,7 +43389,7 @@ var _20261003140500_change_set_items_site_and_preview_entry_exports = /* @__PURE
42226
43389
  * - `preview_tokens.entry_id` becomes required: a preview token previews one entry of one site. Model-wide
42227
43390
  * tokens (short-lived by design) are deleted; whoever needs one opens the entry's preview again.
42228
43391
  */
42229
- const up$10 = async (db) => {
43392
+ const up$13 = async (db) => {
42230
43393
  await sql`alter table change_sets add constraint change_sets_id_site_uq unique (id, site_id)`.execute(db);
42231
43394
  await sql`alter table change_set_items add column site_id uuid`.execute(db);
42232
43395
  await sql`
@@ -42242,7 +43405,7 @@ const up$10 = async (db) => {
42242
43405
  await sql`delete from preview_tokens where entry_id is null`.execute(db);
42243
43406
  await sql`alter table preview_tokens alter column entry_id set not null`.execute(db);
42244
43407
  };
42245
- const down$10 = async (db) => {
43408
+ const down$13 = async (db) => {
42246
43409
  await sql`alter table preview_tokens alter column entry_id drop not null`.execute(db);
42247
43410
  await sql`alter table change_set_items drop constraint change_set_items_entry_site_fk`.execute(db);
42248
43411
  await sql`alter table change_set_items drop constraint change_set_items_set_site_fk`.execute(db);
@@ -42252,8 +43415,8 @@ const down$10 = async (db) => {
42252
43415
  //#endregion
42253
43416
  //#region src/db/migrations/20261003150000_widen_deployment_providers.ts
42254
43417
  var _20261003150000_widen_deployment_providers_exports = /* @__PURE__ */ __exportAll({
42255
- down: () => down$9,
42256
- up: () => up$9
43418
+ down: () => down$12,
43419
+ up: () => up$12
42257
43420
  });
42258
43421
  /**
42259
43422
  * Deploy adapters (plan agentic-ecosystem §B2): deployment connections may use the Vercel and Netlify
@@ -42268,11 +43431,11 @@ const replaceCheck = async (db, check) => {
42268
43431
  check (${check}) not valid`.execute(db);
42269
43432
  await sql`alter table deployment_connections validate constraint deployment_connections_provider_check`.execute(db);
42270
43433
  };
42271
- const up$9 = async (db) => {
43434
+ const up$12 = async (db) => {
42272
43435
  await replaceCheck(db, WIDENED);
42273
43436
  };
42274
43437
  /** Refuses while Vercel or Netlify connections exist: a down migration never deletes user data. */
42275
- const down$9 = async (db) => {
43438
+ const down$12 = async (db) => {
42276
43439
  const { rows } = await sql`
42277
43440
  select count(*)::text as count from deployment_connections where provider in ('vercel', 'netlify')
42278
43441
  `.execute(db);
@@ -42283,8 +43446,8 @@ const down$9 = async (db) => {
42283
43446
  //#endregion
42284
43447
  //#region src/db/migrations/20261003160000_create_assist_runs.ts
42285
43448
  var _20261003160000_create_assist_runs_exports = /* @__PURE__ */ __exportAll({
42286
- down: () => down$8,
42287
- up: () => up$8
43449
+ down: () => down$11,
43450
+ up: () => up$11
42288
43451
  });
42289
43452
  /**
42290
43453
  * Editor assists (plan agentic-ecosystem §A0, §I): one `assist_runs` row per assist request (who, which
@@ -42300,14 +43463,14 @@ const replaceSourceCheck = async (db, check) => {
42300
43463
  await sql`alter table change_sets add constraint change_sets_source_check check (${check}) not valid`.execute(db);
42301
43464
  await sql`alter table change_sets validate constraint change_sets_source_check`.execute(db);
42302
43465
  };
42303
- const up$8 = async (db) => {
43466
+ const up$11 = async (db) => {
42304
43467
  await db.schema.createTable("assist_runs").addColumn("id", "uuid", (col) => col.primaryKey().defaultTo(sql`gen_random_uuid()`)).addColumn("site_id", "uuid", (col) => col.notNull().references("sites.id").onDelete("cascade")).addColumn("actor_type", "text", (col) => col.notNull()).addColumn("actor_id", "text", (col) => col.notNull()).addColumn("action", "text", (col) => col.notNull().check(ACTIONS)).addColumn("rule", "text").addColumn("provider", "text", (col) => col.notNull()).addColumn("model", "text", (col) => col.notNull()).addColumn("input_tokens", "integer", (col) => col.notNull().defaultTo(0)).addColumn("output_tokens", "integer", (col) => col.notNull().defaultTo(0)).addColumn("duration_ms", "integer", (col) => col.notNull().defaultTo(0)).addColumn("status", "text", (col) => col.notNull().check(sql`status in ('queued', 'running', 'succeeded', 'failed')`)).addColumn("error_code", "text").addColumn("job_id", "uuid", (col) => col.references("jobs.id").onDelete("set null")).addColumn("created_at", "timestamptz", (col) => col.notNull().defaultTo(sql`now()`)).addColumn("finished_at", "timestamptz").execute();
42305
43468
  await db.schema.createIndex("assist_runs_site_created_idx").on("assist_runs").columns(["site_id", "created_at"]).execute();
42306
43469
  await db.schema.createIndex("assist_runs_created_idx").on("assist_runs").column("created_at").execute();
42307
43470
  await replaceSourceCheck(db, SOURCES_WIDENED);
42308
43471
  };
42309
43472
  /** Refuses while change sets opened by assist exist: a down migration never deletes user data. */
42310
- const down$8 = async (db) => {
43473
+ const down$11 = async (db) => {
42311
43474
  const { rows } = await sql`
42312
43475
  select count(*)::text as count from change_sets where source = 'assist'
42313
43476
  `.execute(db);
@@ -42319,8 +43482,8 @@ const down$8 = async (db) => {
42319
43482
  //#endregion
42320
43483
  //#region src/db/migrations/20261003170000_audit_events_seq.ts
42321
43484
  var _20261003170000_audit_events_seq_exports$2 = /* @__PURE__ */ __exportAll({
42322
- down: () => down$7,
42323
- up: () => up$7
43485
+ down: () => down$10,
43486
+ up: () => up$10
42324
43487
  });
42325
43488
  /**
42326
43489
  * `audit_events.seq`: a monotonic tiebreaker after `occurred_at`, so events written in the same instant (one
@@ -42329,17 +43492,17 @@ var _20261003170000_audit_events_seq_exports$2 = /* @__PURE__ */ __exportAll({
42329
43492
  * are numbered in the order the rewrite reads them. No index: the audit queries filter and sort by
42330
43493
  * `occurred_at` first, and `seq` only breaks ties.
42331
43494
  */
42332
- const up$7 = async (db) => {
43495
+ const up$10 = async (db) => {
42333
43496
  await db.schema.alterTable("audit_events").addColumn("seq", "bigint", (col) => col.notNull().generatedAlwaysAsIdentity()).execute();
42334
43497
  };
42335
- const down$7 = async (db) => {
43498
+ const down$10 = async (db) => {
42336
43499
  await db.schema.alterTable("audit_events").dropColumn("seq").execute();
42337
43500
  };
42338
43501
  //#endregion
42339
43502
  //#region src/db/migrations/20261004120000_models_site_scope.ts
42340
43503
  var _20261004120000_models_site_scope_exports$2 = /* @__PURE__ */ __exportAll({
42341
- down: () => down$6,
42342
- up: () => up$6
43504
+ down: () => down$9,
43505
+ up: () => up$9
42343
43506
  });
42344
43507
  /**
42345
43508
  * Per-site schema (plan site-schema): a definition belongs to one site (`models.site_id`) or is shared by every
@@ -42352,7 +43515,7 @@ var _20261004120000_models_site_scope_exports$2 = /* @__PURE__ */ __exportAll({
42352
43515
  * over `lower(api_key)` cannot hold. Share or delete them first; then `down` is lossless.
42353
43516
  */
42354
43517
  const NIL_UUID$1 = "00000000-0000-0000-0000-000000000000";
42355
- const up$6 = async (db) => {
43518
+ const up$9 = async (db) => {
42356
43519
  await sql`alter table models add column site_id uuid references sites (id)`.execute(db);
42357
43520
  await sql`create index models_site_idx on models (site_id)`.execute(db);
42358
43521
  await sql`drop index models_api_key_uq`.execute(db);
@@ -42362,7 +43525,7 @@ const up$6 = async (db) => {
42362
43525
  `.execute(db);
42363
43526
  await sql`alter table schema_drafts add column shared boolean not null default false`.execute(db);
42364
43527
  };
42365
- const down$6 = async (db) => {
43528
+ const down$9 = async (db) => {
42366
43529
  const { rows } = await sql`select count(*)::text as count from models where site_id is not null`.execute(db);
42367
43530
  if (Number(rows[0]?.count ?? 0) > 0) throw new Error("Cannot roll back per-site schema while definitions belong to a site: two sites may share an API ID. Share or delete the site definitions first.");
42368
43531
  await sql`alter table schema_drafts drop column shared`.execute(db);
@@ -42372,10 +43535,50 @@ const down$6 = async (db) => {
42372
43535
  await sql`alter table models drop column site_id`.execute(db);
42373
43536
  };
42374
43537
  //#endregion
43538
+ //#region src/db/migrations/20261004130000_site_seo_settings.ts
43539
+ var _20261004130000_site_seo_settings_exports$2 = /* @__PURE__ */ __exportAll({
43540
+ down: () => down$8,
43541
+ up: () => up$8
43542
+ });
43543
+ /**
43544
+ * SEO defaults per site (plan seo-fields): `sites.seo_defaults` holds the site's default texts per locale, its
43545
+ * default social image and Twitter handle (`SeoDefaults` in @shapio/schema). Nullable, no default: a
43546
+ * catalog-only change, no table rewrite.
43547
+ *
43548
+ * `site.settings` is the site action that edits them. Every role that holds `publishing.manage` (everything
43549
+ * about what a site publishes) gets it; the built-in owner and admin roles get it from `ensureSystemRoles`.
43550
+ * The permissions version moves only when grants changed, so running instances reload exactly then.
43551
+ */
43552
+ const up$8 = async (db) => {
43553
+ await sql`alter table sites add column seo_defaults jsonb`.execute(db);
43554
+ await sql`
43555
+ with granted as (
43556
+ insert into admin_role_permissions (role_id, action, model_id, condition, field_ids)
43557
+ select role_id, 'site.settings', null, null, null
43558
+ from admin_role_permissions
43559
+ where action = 'publishing.manage' and model_id is null
43560
+ on conflict on constraint admin_role_permissions_grant_uq do nothing
43561
+ returning 1
43562
+ )
43563
+ update system_versions set permissions_version = permissions_version + 1, updated_at = now()
43564
+ where exists (select 1 from granted)
43565
+ `.execute(db);
43566
+ };
43567
+ const down$8 = async (db) => {
43568
+ await sql`
43569
+ with revoked as (
43570
+ delete from admin_role_permissions where action = 'site.settings' returning 1
43571
+ )
43572
+ update system_versions set permissions_version = permissions_version + 1, updated_at = now()
43573
+ where exists (select 1 from revoked)
43574
+ `.execute(db);
43575
+ await sql`alter table sites drop column seo_defaults`.execute(db);
43576
+ };
43577
+ //#endregion
42375
43578
  //#region src/db/migrations/mysql/0001_baseline.ts
42376
43579
  var _0001_baseline_exports$1 = /* @__PURE__ */ __exportAll({
42377
- down: () => down$5,
42378
- up: () => up$5
43580
+ down: () => down$7,
43581
+ up: () => up$7
42379
43582
  });
42380
43583
  /**
42381
43584
  * MySQL baseline: Shapio's whole system schema as of PostgreSQL migration
@@ -43558,13 +44761,13 @@ const withoutForeignKeyChecks = async (db, statements) => db.connection().execut
43558
44761
  await sql`set foreign_key_checks = 1`.execute(connection);
43559
44762
  }
43560
44763
  });
43561
- const up$5 = async (db) => withoutForeignKeyChecks(db, STATEMENTS$1);
43562
- const down$5 = async (db) => withoutForeignKeyChecks(db, [...TABLES$1].reverse().map((table) => `drop table if exists \`${table}\``));
44764
+ const up$7 = async (db) => withoutForeignKeyChecks(db, STATEMENTS$1);
44765
+ const down$7 = async (db) => withoutForeignKeyChecks(db, [...TABLES$1].reverse().map((table) => `drop table if exists \`${table}\``));
43563
44766
  //#endregion
43564
44767
  //#region src/db/migrations/mysql/20261003170000_audit_events_seq.ts
43565
44768
  var _20261003170000_audit_events_seq_exports$1 = /* @__PURE__ */ __exportAll({
43566
- down: () => down$4,
43567
- up: () => up$4
44769
+ down: () => down$6,
44770
+ up: () => up$6
43568
44771
  });
43569
44772
  /**
43570
44773
  * MySQL twin of `../20261003170000_audit_events_seq.ts`: `seq` is `AUTO_INCREMENT`, which InnoDB only allows
@@ -43572,26 +44775,26 @@ var _20261003170000_audit_events_seq_exports$1 = /* @__PURE__ */ __exportAll({
43572
44775
  * tiebreaker). Existing rows are numbered in primary-key order. `db/mysql/tables.ts` lists `seq` as the
43573
44776
  * table's identity, so an insert's emulated RETURNING finds its row by `LAST_INSERT_ID()`.
43574
44777
  */
43575
- const up$4 = async (db) => {
44778
+ const up$6 = async (db) => {
43576
44779
  await sql`alter table \`audit_events\`
43577
44780
  add column \`seq\` bigint not null auto_increment,
43578
44781
  add unique key \`audit_events_seq_key\` (\`seq\`)`.execute(db);
43579
44782
  };
43580
- const down$4 = async (db) => {
44783
+ const down$6 = async (db) => {
43581
44784
  await sql`alter table \`audit_events\` drop column \`seq\``.execute(db);
43582
44785
  };
43583
44786
  //#endregion
43584
44787
  //#region src/db/migrations/mysql/20261004120000_models_site_scope.ts
43585
44788
  var _20261004120000_models_site_scope_exports$1 = /* @__PURE__ */ __exportAll({
43586
- down: () => down$3,
43587
- up: () => up$3
44789
+ down: () => down$5,
44790
+ up: () => up$5
43588
44791
  });
43589
44792
  /**
43590
44793
  * MySQL twin of `../20261004120000_models_site_scope.ts`. The per-scope API ID key uses functional key parts,
43591
44794
  * like the baseline: `coalesce(site_id, '')` for the scope (MySQL has no `NULLS NOT DISTINCT`) and the
43592
44795
  * case-folded API ID of live rows only (a deleted row's part is NULL, and NULLs never collide).
43593
44796
  */
43594
- const up$3 = async (db) => {
44797
+ const up$5 = async (db) => {
43595
44798
  await sql`alter table \`models\` add column \`site_id\` char(36) character set ascii collate ascii_general_ci`.execute(db);
43596
44799
  await sql`create index \`models_site_idx\` on \`models\` (\`site_id\`)`.execute(db);
43597
44800
  await sql`alter table \`models\` add constraint \`models_site_id_fkey\` foreign key (\`site_id\`) references \`sites\` (\`id\`)`.execute(db);
@@ -43600,7 +44803,7 @@ const up$3 = async (db) => {
43600
44803
  ((coalesce(\`site_id\`, '')), (case when (\`deleted_at\` IS NULL) then lower(\`api_key\`) end))`.execute(db);
43601
44804
  await sql`alter table \`schema_drafts\` add column \`shared\` tinyint(1) not null default 0`.execute(db);
43602
44805
  };
43603
- const down$3 = async (db) => {
44806
+ const down$5 = async (db) => {
43604
44807
  const { rows } = await sql`select count(*) as count from \`models\` where \`site_id\` is not null`.execute(db);
43605
44808
  if (Number(rows[0]?.count ?? 0) > 0) throw new Error("Cannot roll back per-site schema while definitions belong to a site: two sites may share an API ID. Share or delete the site definitions first.");
43606
44809
  await sql`alter table \`schema_drafts\` drop column \`shared\``.execute(db);
@@ -43612,17 +44815,45 @@ const down$3 = async (db) => {
43612
44815
  await sql`alter table \`models\` drop column \`site_id\``.execute(db);
43613
44816
  };
43614
44817
  //#endregion
44818
+ //#region src/db/migrations/mysql/20261004130000_site_seo_settings.ts
44819
+ var _20261004130000_site_seo_settings_exports$1 = /* @__PURE__ */ __exportAll({
44820
+ down: () => down$4,
44821
+ up: () => up$4
44822
+ });
44823
+ /**
44824
+ * MySQL twin of `../20261004130000_site_seo_settings.ts`. `insert ignore` stands in for `on conflict do
44825
+ * nothing` (the grant's unique key); the permissions version moves only when a grant was added or removed.
44826
+ */
44827
+ const bumpPermissionsVersion$1 = (db) => sql`update \`system_versions\` set \`permissions_version\` = \`permissions_version\` + 1,
44828
+ \`updated_at\` = current_timestamp(6)`.execute(db);
44829
+ const up$4 = async (db) => {
44830
+ await sql`alter table \`sites\` add column \`seo_defaults\` json`.execute(db);
44831
+ const granted = await sql`
44832
+ insert ignore into \`admin_role_permissions\` (\`id\`, \`role_id\`, \`action\`, \`model_id\`, \`condition\`, \`field_ids\`)
44833
+ select uuid(), \`role_id\`, 'site.settings', null, null, null
44834
+ from \`admin_role_permissions\`
44835
+ where \`action\` = 'publishing.manage' and \`model_id\` is null
44836
+ `.execute(db);
44837
+ if (Number(granted.numAffectedRows ?? 0) > 0) await bumpPermissionsVersion$1(db);
44838
+ };
44839
+ const down$4 = async (db) => {
44840
+ const revoked = await sql`delete from \`admin_role_permissions\` where \`action\` = 'site.settings'`.execute(db);
44841
+ if (Number(revoked.numAffectedRows ?? 0) > 0) await bumpPermissionsVersion$1(db);
44842
+ await sql`alter table \`sites\` drop column \`seo_defaults\``.execute(db);
44843
+ };
44844
+ //#endregion
43615
44845
  //#region src/db/migrations/mysql/index.ts
43616
44846
  const MYSQL_MIGRATIONS = {
43617
44847
  "0001_baseline": _0001_baseline_exports$1,
43618
44848
  "20261003170000_audit_events_seq": _20261003170000_audit_events_seq_exports$1,
43619
- "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports$1
44849
+ "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports$1,
44850
+ "20261004130000_site_seo_settings": _20261004130000_site_seo_settings_exports$1
43620
44851
  };
43621
44852
  //#endregion
43622
44853
  //#region src/db/migrations/sqlite/0001_baseline.ts
43623
44854
  var _0001_baseline_exports = /* @__PURE__ */ __exportAll({
43624
- down: () => down$2,
43625
- up: () => up$2
44855
+ down: () => down$3,
44856
+ up: () => up$3
43626
44857
  });
43627
44858
  /**
43628
44859
  * SQLite baseline: Shapio's whole system schema as of PostgreSQL migration
@@ -44767,18 +45998,18 @@ const TABLES = [
44767
45998
  "assist_runs",
44768
45999
  "sequences"
44769
46000
  ];
44770
- const up$2 = async (db) => {
46001
+ const up$3 = async (db) => {
44771
46002
  for (const statement of STATEMENTS) await sql.raw(statement).execute(db);
44772
46003
  };
44773
- const down$2 = async (db) => {
46004
+ const down$3 = async (db) => {
44774
46005
  await sql`pragma defer_foreign_keys = on`.execute(db);
44775
46006
  for (const table of [...TABLES].reverse()) await sql.raw(`drop table if exists "${table}"`).execute(db);
44776
46007
  };
44777
46008
  //#endregion
44778
46009
  //#region src/db/migrations/sqlite/20261003170000_audit_events_seq.ts
44779
46010
  var _20261003170000_audit_events_seq_exports = /* @__PURE__ */ __exportAll({
44780
- down: () => down$1,
44781
- up: () => up$1
46011
+ down: () => down$2,
46012
+ up: () => up$2
44782
46013
  });
44783
46014
  /**
44784
46015
  * SQLite twin of `../20261003170000_audit_events_seq.ts`. SQLite cannot add a column whose default calls a
@@ -44805,7 +46036,7 @@ const INDEXES = [
44805
46036
  `create index "audit_events_occurred_at_idx" on "audit_events" (occurred_at DESC)`,
44806
46037
  `create index "audit_events_target_idx" on "audit_events" (target_type, target_id, occurred_at DESC)`
44807
46038
  ];
44808
- const up$1 = async (db) => {
46039
+ const up$2 = async (db) => {
44809
46040
  await sql`insert into "sequences" (name, value) values ('audit_events_seq', 0)`.execute(db);
44810
46041
  await sql`alter table "audit_events" rename to "audit_events_before_seq"`.execute(db);
44811
46042
  await sql`create table "audit_events" (
@@ -44833,15 +46064,15 @@ const up$1 = async (db) => {
44833
46064
  await sql`drop table "audit_events_before_seq"`.execute(db);
44834
46065
  for (const statement of INDEXES) await sql.raw(statement).execute(db);
44835
46066
  };
44836
- const down$1 = async (db) => {
46067
+ const down$2 = async (db) => {
44837
46068
  await sql`alter table "audit_events" drop column "seq"`.execute(db);
44838
46069
  await sql`delete from "sequences" where name = 'audit_events_seq'`.execute(db);
44839
46070
  };
44840
46071
  //#endregion
44841
46072
  //#region src/db/migrations/sqlite/20261004120000_models_site_scope.ts
44842
46073
  var _20261004120000_models_site_scope_exports = /* @__PURE__ */ __exportAll({
44843
- down: () => down,
44844
- up: () => up
46074
+ down: () => down$1,
46075
+ up: () => up$1
44845
46076
  });
44846
46077
  /**
44847
46078
  * SQLite twin of `../20261004120000_models_site_scope.ts`. `down` rebuilds `models` without `site_id`
@@ -44851,7 +46082,7 @@ var _20261004120000_models_site_scope_exports = /* @__PURE__ */ __exportAll({
44851
46082
  * deletes nothing they reference.
44852
46083
  */
44853
46084
  const NIL_UUID = "00000000-0000-0000-0000-000000000000";
44854
- const up = async (db) => {
46085
+ const up$1 = async (db) => {
44855
46086
  await sql`alter table "models" add column "site_id" text_uuid references sites (id)`.execute(db);
44856
46087
  await sql`create index "models_site_idx" on "models" (site_id)`.execute(db);
44857
46088
  await sql`drop index "models_api_key_uq"`.execute(db);
@@ -44861,7 +46092,7 @@ const up = async (db) => {
44861
46092
  `.execute(db);
44862
46093
  await sql`alter table "schema_drafts" add column "shared" integer_boolean not null default 0`.execute(db);
44863
46094
  };
44864
- const down = async (db) => {
46095
+ const down$1 = async (db) => {
44865
46096
  const { rows } = await sql`select count(*) as count from "models" where site_id is not null`.execute(db);
44866
46097
  if (Number(rows[0]?.count ?? 0) > 0) throw new Error("Cannot roll back per-site schema while definitions belong to a site: two sites may share an API ID. Share or delete the site definitions first.");
44867
46098
  await sql`alter table "schema_drafts" drop column "shared"`.execute(db);
@@ -44884,11 +46115,38 @@ const down = async (db) => {
44884
46115
  await sql`create unique index "models_api_key_uq" on "models" (lower(api_key)) where (deleted_at IS NULL)`.execute(db);
44885
46116
  };
44886
46117
  //#endregion
46118
+ //#region src/db/migrations/sqlite/20261004130000_site_seo_settings.ts
46119
+ var _20261004130000_site_seo_settings_exports = /* @__PURE__ */ __exportAll({
46120
+ down: () => down,
46121
+ up: () => up
46122
+ });
46123
+ /**
46124
+ * SQLite twin of `../20261004130000_site_seo_settings.ts`. `insert or ignore` stands in for `on conflict do
46125
+ * nothing` (the grant's unique index); the permissions version moves only when a grant was added or removed.
46126
+ */
46127
+ const bumpPermissionsVersion = (db) => sql`update "system_versions" set permissions_version = permissions_version + 1, updated_at = shapio_now()`.execute(db);
46128
+ const up = async (db) => {
46129
+ await sql`alter table "sites" add column "seo_defaults" text_jsonb`.execute(db);
46130
+ const granted = await sql`
46131
+ insert or ignore into "admin_role_permissions" (role_id, action, model_id, condition, field_ids)
46132
+ select role_id, 'site.settings', null, null, null
46133
+ from "admin_role_permissions"
46134
+ where action = 'publishing.manage' and model_id is null
46135
+ `.execute(db);
46136
+ if (Number(granted.numAffectedRows ?? 0) > 0) await bumpPermissionsVersion(db);
46137
+ };
46138
+ const down = async (db) => {
46139
+ const revoked = await sql`delete from "admin_role_permissions" where action = 'site.settings'`.execute(db);
46140
+ if (Number(revoked.numAffectedRows ?? 0) > 0) await bumpPermissionsVersion(db);
46141
+ await sql`alter table "sites" drop column "seo_defaults"`.execute(db);
46142
+ };
46143
+ //#endregion
44887
46144
  //#region src/db/migrations/sqlite/index.ts
44888
46145
  const SQLITE_MIGRATIONS = {
44889
46146
  "0001_baseline": _0001_baseline_exports,
44890
46147
  "20261003170000_audit_events_seq": _20261003170000_audit_events_seq_exports,
44891
- "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports
46148
+ "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports,
46149
+ "20261004130000_site_seo_settings": _20261004130000_site_seo_settings_exports
44892
46150
  };
44893
46151
  //#endregion
44894
46152
  //#region src/db/migrations/index.ts
@@ -44919,7 +46177,8 @@ const MIGRATIONS = {
44919
46177
  "20261003150000_widen_deployment_providers": _20261003150000_widen_deployment_providers_exports,
44920
46178
  "20261003160000_create_assist_runs": _20261003160000_create_assist_runs_exports,
44921
46179
  "20261003170000_audit_events_seq": _20261003170000_audit_events_seq_exports$2,
44922
- "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports$2
46180
+ "20261004120000_models_site_scope": _20261004120000_models_site_scope_exports$2,
46181
+ "20261004130000_site_seo_settings": _20261004130000_site_seo_settings_exports$2
44923
46182
  };
44924
46183
  const PROVIDERS = {
44925
46184
  postgres: { getMigrations: () => Promise.resolve({ ...MIGRATIONS }) },
@@ -45067,13 +46326,17 @@ const getVersionSchema = { response: { 200: Type.Object({
45067
46326
  const healthRoutes = async (app) => {
45068
46327
  declareSiteScope(app, "network");
45069
46328
  const config = { rateLimit: false };
46329
+ const probeConfig = {
46330
+ ...config,
46331
+ requestLog: "debug"
46332
+ };
45070
46333
  app.get("/health", {
45071
46334
  schema: getHealthSchema,
45072
- config
46335
+ config: probeConfig
45073
46336
  }, getHealth);
45074
46337
  app.get("/ready", {
45075
46338
  schema: getReadySchema,
45076
- config
46339
+ config: probeConfig
45077
46340
  }, getReady);
45078
46341
  app.get("/version", {
45079
46342
  schema: getVersionSchema,
@@ -45388,7 +46651,9 @@ const DRAFT = {
45388
46651
  const ITEM_PARAMETERS = /* @__PURE__ */ new Set([
45389
46652
  "fields",
45390
46653
  "populate",
45391
- "locale"
46654
+ "locale",
46655
+ "richText",
46656
+ "seo"
45392
46657
  ]);
45393
46658
  const unauthenticated = () => new AppError(401, "INVALID_PREVIEW_TOKEN", "The preview token is invalid, expired or revoked");
45394
46659
  const allows = (mask, fieldId) => mask.mode === "all" || mask.fieldIds.includes(fieldId);
@@ -45461,7 +46726,11 @@ const authorize = async (runtime, base, fields, request) => {
45461
46726
  };
45462
46727
  /** The token's locale wins; a different `locale` in the query is refused rather than silently ignored. */
45463
46728
  const scopedQuery = (scope, rawQuery) => {
45464
- const query = parseQueryFor(scope.context, scope.model, scope.policy, rawQuery, { allowSnapshot: false });
46729
+ const query = parseQueryFor(scope.context, scope.model, scope.policy, rawQuery, {
46730
+ allowSnapshot: false,
46731
+ allowRichText: true,
46732
+ allowSeo: true
46733
+ });
45465
46734
  if (scope.token.locale && query.locale && query.locale !== scope.token.locale) throw new AppError(403, "PREVIEW_SCOPE", `This preview token is for locale "${scope.token.locale}"`);
45466
46735
  return scope.token.locale ? {
45467
46736
  ...query,
@@ -45470,9 +46739,9 @@ const scopedQuery = (scope, rawQuery) => {
45470
46739
  };
45471
46740
  /** A preview token reads its one entry only. */
45472
46741
  const entryCondition = (token) => [entryIdIs(token.entry_id)];
45473
- const readOf = (scope, executor, query) => ({
46742
+ const readOf = (scope, query) => ({
45474
46743
  context: scope.context,
45475
- env: deliveryEnvironment(scope.context, executor, DRAFT, query.locale),
46744
+ source: DRAFT,
45476
46745
  model: scope.model,
45477
46746
  policy: scope.policy,
45478
46747
  query,
@@ -45487,30 +46756,26 @@ const metaOf = (scope, query) => ({
45487
46756
  const listPreview$1 = async (runtime, base, fields, request) => {
45488
46757
  const scope = await authorize(runtime, base, fields, request);
45489
46758
  const query = scopedQuery(scope, request.rawQuery);
45490
- return withConsistentRead(async (trx) => {
45491
- const { entries, limit, count } = await readEntryPage(readOf(scope, trx, query));
45492
- return {
45493
- data: entries,
45494
- meta: {
45495
- ...metaOf(scope, query),
45496
- pagination: paginationMeta(query.page, limit, await count())
45497
- }
45498
- };
45499
- }, scope.context.db);
46759
+ const { entries, limit, total } = await readEntryPage(readOf(scope, query));
46760
+ return {
46761
+ data: entries,
46762
+ meta: {
46763
+ ...metaOf(scope, query),
46764
+ pagination: paginationMeta(query.page, limit, total)
46765
+ }
46766
+ };
45500
46767
  };
45501
46768
  const getPreview$1 = async (runtime, base, fields, request) => {
45502
46769
  assertItemParameters(request.rawQuery, ITEM_PARAMETERS);
45503
46770
  const scope = await authorize(runtime, base, fields, request);
45504
46771
  if (scope.token.entry_id !== request.id) throw new AppError(403, "PREVIEW_SCOPE", "This preview token is for another entry");
45505
46772
  const query = scopedQuery(scope, request.rawQuery);
45506
- return withConsistentRead(async (trx) => {
45507
- const entry = await readOneEntry(readOf(scope, trx, query), request.id);
45508
- if (!entry) throw entryNotFound(request.id);
45509
- return {
45510
- data: entry,
45511
- meta: metaOf(scope, query)
45512
- };
45513
- }, scope.context.db);
46773
+ const { entry } = await readOneEntry(readOf(scope, query), request.id);
46774
+ if (!entry) throw entryNotFound(request.id);
46775
+ return {
46776
+ data: entry,
46777
+ meta: metaOf(scope, query)
46778
+ };
45514
46779
  };
45515
46780
  //#endregion
45516
46781
  //#region src/controllers/previewContent.ts
@@ -45571,6 +46836,20 @@ const previewRoutes = async (app) => {
45571
46836
  }, getPreview);
45572
46837
  };
45573
46838
  //#endregion
46839
+ //#region src/controllers/site.ts
46840
+ const getDeliverySite = async (request, reply) => sendCacheable(request, reply, await getDeliverySite$1(await contentContextFor(request)));
46841
+ //#endregion
46842
+ //#region src/routes/site/index.ts
46843
+ /**
46844
+ * /api/site (plan seo-fields): the delivery view of the request's site (token, `Shapio-Site` or `?site=`):
46845
+ * its key, name and SEO defaults, for pages without an entry and for GraphQL users merging SEO themselves.
46846
+ * Readable by any caller that reads content on the site; cacheable like delivery reads.
46847
+ */
46848
+ const siteRoutes = async (app) => {
46849
+ declareSiteScope(app, "site");
46850
+ app.get("/", { schema: getDeliverySiteSchema }, getDeliverySite);
46851
+ };
46852
+ //#endregion
45574
46853
  //#region src/controllers/snapshots.ts
45575
46854
  const listSnapshotChanges = async (request) => listSnapshotChanges$1(await contentContextFor(request), request.query);
45576
46855
  const getCurrentSnapshot = async (request) => currentSnapshot(await contentContextFor(request));
@@ -45672,6 +46951,7 @@ const buildApp = async (config, deps) => {
45672
46951
  } } : {},
45673
46952
  trustProxy: toFastifyTrustProxy(config.server.trustProxy),
45674
46953
  genReqId: () => randomUUID(),
46954
+ logController: new LogController({ disableRequestLogging: true }),
45675
46955
  ajv: { customOptions: { removeAdditional: false } }
45676
46956
  };
45677
46957
  const app = Fastify(options).withTypeProvider();
@@ -45681,6 +46961,7 @@ const buildApp = async (config, deps) => {
45681
46961
  });
45682
46962
  const schemaLookup = deps.fieldVisibility ?? createSchemaFieldVisibility(schemaRegistry);
45683
46963
  await app.register(auditDeclarationPlugin);
46964
+ await app.register(requestLogPlugin);
45684
46965
  await app.register(gracefulClosePlugin);
45685
46966
  await app.register(errorHandlerPlugin);
45686
46967
  await app.register(servicesPlugin, {
@@ -45756,6 +47037,7 @@ const buildApp = async (config, deps) => {
45756
47037
  await app.register(themeCataloguePlugin, { catalogue: buildThemeCatalogue(extensions.loaded.config.themes ?? []) });
45757
47038
  await app.register(adminExtensionsRoutes, { prefix: urls.withBasePath("/api/admin/extensions") });
45758
47039
  await app.register(deliveryRoutes, { prefix: urls.withBasePath("/api/content") });
47040
+ await app.register(siteRoutes, { prefix: urls.withBasePath("/api/site") });
45759
47041
  await app.register(snapshotsRoutes, { prefix: urls.withBasePath("/api/snapshots") });
45760
47042
  await app.register(docsRoutes, { prefix: urls.withBasePath("/api/docs") });
45761
47043
  if (config.graphql.enabled) {
@@ -47995,4 +49277,4 @@ const startDedicatedWorker = async (config) => {
47995
49277
  //#endregion
47996
49278
  export { toTypeName as $, update$8 as A, checkThemeContrast as B, BEFORE_EVENTS as C, listForAssets as D, buildEditorManifest as E, RESERVED_FIELD_API_KEYS as F, mergePulledLock as G, hashDefinition as H, RESERVED_TYPE_NAMES as I, schemaFilePath as J, parseDefinition as K, SCHEMA_KIND_DIRECTORIES as L, describeError as M, LOCK_FILE_PATH as N, listLiveByDriver as O, RESERVED_DEFINITION_API_KEYS as P, suggestPlural as Q, SITES_DIRECTORY as R, AFTER_EVENTS as S, EXTENSION_ROUTE_ROOT as T, lockCoversSite as U, foldApiKey as V, lockEntriesForSite as W, serializeDefinition as X, scopeDirectory as Y, serializeLockFile as Z, createLogger as _, loadConfig as a, createStableId as at, SITE_KEY_PATTERN as b, getPendingMigrations as c, sqliteDriverOf as ct, createMediaStorage as d, upgradeLockFile as et, createSite$1 as f, generateToken as g, SYSTEM_CLI_ACTOR as h, assertDatabaseReachable as i, SAFE_LINK_PATTERN as it, STORAGE_DRIVERS$1 as j, lockById$5 as k, migrateToLatest as l, createAdminDirect as m, startServer as n, withSiteCovered as nt, loadEnvFileIfPresent as o, validateRichText as ot, listAllSites as p, parseLockFile as q, copyObjectVerified as r, RICHTEXT_FORMAT as rt, buildApp as s, createDb as st, startDedicatedWorker as t, validateSchema as tt, SHAPIO_VERSION as u, createUrlBuilder as v, EXTENSION_JOB_PREFIX as w, loadProjectConfig as x, loopbackUrl as y, THEME_VARIANTS as z };
47997
49279
 
47998
- //# sourceMappingURL=worker-BAmQPoTJ.js.map
49280
+ //# sourceMappingURL=worker-i1rY2TIZ.js.map