@michaelthielemann/kestrel 3.0.0 → 4.0.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 (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,88 +0,0 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
- import { currentSessionEpoch } from './session-epoch'
3
-
4
- export function deriveSigningKey(rawSecret: string, passwordHash?: string, epoch = 0): string {
5
- // The `epoch` is a server-side revocation counter: bumping it (on logout / force-logout) changes the key
6
- // so every previously-issued token fails verification immediately — the stateless model's "hard logout".
7
- return createHmac('sha256', rawSecret).update('kestrel-session-v1').update(passwordHash ?? '').update(`e${epoch}`).digest('base64url')
8
- }
9
-
10
- export function signSession(secret: string, expMs: number): string {
11
- const payload = Buffer.from(JSON.stringify({ exp: expMs })).toString('base64url')
12
- const sig = createHmac('sha256', secret).update(payload).digest('base64url')
13
- return `${payload}.${sig}`
14
- }
15
-
16
- export function verifySession(secret: string, value: string | undefined, nowMs: number): { valid: boolean; exp?: number } {
17
- if (!value || typeof value !== 'string') return { valid: false }
18
- const dot = value.indexOf('.')
19
- if (dot <= 0 || dot === value.length - 1) return { valid: false }
20
- const payload = value.slice(0, dot)
21
- const sig = value.slice(dot + 1)
22
- const expected = createHmac('sha256', secret).update(payload).digest('base64url')
23
- const sigBuf = Buffer.from(sig)
24
- const expBuf = Buffer.from(expected)
25
- if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return { valid: false }
26
- let exp: unknown
27
- try {
28
- exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp
29
- } catch {
30
- return { valid: false }
31
- }
32
- if (typeof exp !== 'number' || exp <= nowMs) return { valid: false }
33
- return { valid: true, exp }
34
- }
35
-
36
- /**
37
- * Sliding-expiry decision: refresh the session cookie once its remaining lifetime drops below HALF the
38
- * idle window. Activity within the window keeps the session alive (each refresh resets exp to now+maxAge);
39
- * inactivity longer than the window lets it expire → the admin must log in again. The halfway threshold
40
- * bounds Set-Cookie churn to at most once per half-window instead of on every request.
41
- */
42
- export function shouldRefreshSession(exp: number, nowMs: number, maxAgeMs: number): boolean {
43
- return exp - nowMs < maxAgeMs / 2
44
- }
45
-
46
- let devSecret: string | undefined
47
-
48
- export interface SessionSettings {
49
- secret: string
50
- maxAge: number
51
- secureCookies: boolean
52
- cookieName: string
53
- }
54
-
55
- export function sessionSettings({ prerender = import.meta.prerender === true } = {}): SessionSettings {
56
- // Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
57
- // that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
58
- // not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
59
- // sets NODE_ENV=test; KESTREL_DEV=1 is an explicit escape hatch for other dev contexts.
60
- const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
61
- const isProd = !explicitDev
62
- const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
63
- // `nuxt generate` prerenders every page through `/api/route`, which passes the access guard and lands
64
- // here with NODE_ENV=production. A prerender request never issues a cookie, so enforcing the flag there
65
- // only means a dev `.env` silently drops pages from the static output.
66
- if (isProd && !secureCookies && !prerender) {
67
- throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
68
- }
69
- const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
70
- const maxAge = Number.isFinite(rawMaxAge) && rawMaxAge > 0 ? rawMaxAge : 604800
71
- let secret = process.env.KESTREL_SESSION_SECRET
72
- if (!secret) {
73
- if (isProd) throw new Error('KESTREL_SESSION_SECRET is required in production')
74
- if (!devSecret) {
75
- devSecret = randomBytes(32).toString('base64url')
76
- console.warn('[kestrel auth] KESTREL_SESSION_SECRET not set — using a random per-process dev secret; sessions reset on restart')
77
- }
78
- secret = devSecret
79
- } else if (isProd && Buffer.byteLength(secret) < 32) {
80
- throw new Error('KESTREL_SESSION_SECRET must be at least 32 bytes in production')
81
- }
82
- return {
83
- secret: deriveSigningKey(secret, process.env.KESTREL_ADMIN_PASSWORD_HASH, currentSessionEpoch()),
84
- maxAge,
85
- secureCookies,
86
- cookieName: secureCookies ? '__Host-kestrel_session' : 'kestrel_session',
87
- }
88
- }
@@ -1,19 +0,0 @@
1
- import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
- import { defineCollection } from '../../../core/server/utils/defineCollection'
3
-
4
- const built = buildCollection(defineCollection({
5
- name: 'pages',
6
- mode: 'multi',
7
- translatable: true,
8
- pageLike: true,
9
- seo: true,
10
- blocks: { enabled: true },
11
- status: true,
12
- builtin: true,
13
- label: { singular: { en: 'Page', de: 'Seite' }, plural: { en: 'Pages', de: 'Seiten' }, new: { en: 'New Page', de: 'Neue Seite' } },
14
- icon: 'file-text',
15
- fields: { title: { type: 'text', required: true } },
16
- }))
17
-
18
- export const pages = built.table
19
- export default built
@@ -1,79 +0,0 @@
1
- import { fieldIs } from '../../../core/server/utils/defineCollection'
2
- import type { FieldPopulator } from '../../../core/server/utils/populate'
3
- import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
4
- import { memoResolver } from '../../../core/server/utils/resolve-scope'
5
-
6
- /** Fetches a related record FULLY populated (its own media / links / nested relations resolved), or null
7
- * when the id is stale / deleted / draft. Injected so the populator stays pure + unit-testable; the plugin
8
- * wires the real `getOne`. `depth` is the ALREADY-decremented depth for the related read (the cycle guard). */
9
- export type ResolveRecord = (
10
- collection: string,
11
- id: number,
12
- depth: number,
13
- locale: string,
14
- publicOnly: boolean,
15
- ) => Record<string, unknown> | null
16
-
17
- /**
18
- * Run a related-record fetch, mapping `getOne`'s 404 (a stale / deleted / draft target — the intended
19
- * "skip this reference" signal) to `null`, but letting ANY OTHER error propagate. Swallowing every error
20
- * here would drop a relation on a real fault (a DB error, a downstream populator throwing on malformed
21
- * stored data) while the page still renders + records `success` — violating fail-loud. Pure + testable.
22
- */
23
- export function skipMissing(fetch: () => Record<string, unknown>): Record<string, unknown> | null {
24
- try {
25
- return fetch()
26
- } catch (e) {
27
- if ((e as { statusCode?: number })?.statusCode === 404) return null
28
- throw e
29
- }
30
- }
31
-
32
- /**
33
- * The `relation` field populator: expands a relation id (single, `${name}Id` in columns / bare in props)
34
- * or id array (many, always bare) into the fully-populated related record(s) under a `$<name>` sibling,
35
- * leaving the raw id column intact — so write round-trips and the admin relation-picker keep working.
36
- * Stale / deleted / draft ids are skipped (single → null, many → filtered out) so one bad reference never
37
- * fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
38
- * relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
39
- * field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
40
- *
41
- * Under `ctx.publicOnly` a relation into a collection `isPublicCollection` rejects is left unexpanded
42
- * (raw id only, NO `$<name>` sibling at all — a relation field targets exactly one collection, so a
43
- * `many` relation is all-or-nothing): expansion must not reach a record the caller could not have
44
- * requested directly. The check runs BEFORE `resolve`, so a withheld target never enters the memo.
45
- */
46
- export function buildRelationFieldPopulator(
47
- resolveRecord: ResolveRecord,
48
- isPublicCollection: (collection: string) => boolean,
49
- ): FieldPopulator {
50
- // The same target (collection+id+depth+locale+scope) is resolved once — build-wide during a generate run
51
- // (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
52
- // distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
53
- // memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
54
- // wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
55
- // page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
56
- // `publicOnly` is part of the key because the same record populates DIFFERENTLY under it (its own
57
- // non-public relations are withheld) — sharing one entry would serve one scope's record to the other.
58
- const key = (collection: string, id: number, depth: number, locale: string, publicOnly: boolean) => `rel:${collection}:${id}:${depth}:${locale}:${publicOnly}`
59
- const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
60
- return (bag, key, field, ctx, keyMode) => {
61
- if (!fieldIs(field, 'relation')) return
62
- const collection = field.relation.collection
63
- const publicOnly = ctx.publicOnly === true
64
- if (publicOnly && !isPublicCollection(collection)) return
65
- const depth = ctx.depth - 1
66
- if (field.relation.many) {
67
- const ids = bag[key]
68
- if (Array.isArray(ids)) {
69
- bag['$' + key] = ids
70
- .filter((n): n is number => typeof n === 'number')
71
- .map((id) => resolve(collection, id, depth, ctx.locale, publicOnly))
72
- .filter((r): r is Record<string, unknown> => r != null)
73
- }
74
- } else {
75
- const id = bag[keyMode === 'columns' ? `${key}Id` : key]
76
- if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale, publicOnly)
77
- }
78
- }
79
- }
@@ -1,9 +0,0 @@
1
- // The batch operations the bulk endpoint accepts — the single source of truth shared by the server
2
- // handler (`bulk.post.ts`) and any client that builds a bulk request. Pure, so the allow-list is
3
- // unit-testable without a Nitro context.
4
- export const BULK_ACTIONS = ['delete', 'publish', 'unpublish', 'duplicate'] as const
5
- export type BulkAction = typeof BULK_ACTIONS[number]
6
-
7
- export function isBulkAction(value: unknown): value is BulkAction {
8
- return typeof value === 'string' && (BULK_ACTIONS as readonly string[]).includes(value)
9
- }
@@ -1,98 +0,0 @@
1
- import type { SerializedField } from '../../server/utils/serialize-collection' // type-only, erased client-side
2
-
3
- /**
4
- * The single shared source of truth for list filtering: which normalized "kind" a field/meta column maps
5
- * to, and which operators that kind allows. Both the server (validation + predicate building) and the admin
6
- * UI (operator select + typed value control) import this — so the allowed-operators-per-field-type table is
7
- * defined exactly once. Kept in `core/app/utils` (the server-and-client shared home) and free of any DB or
8
- * column-naming detail: each side maps its own column keys.
9
- */
10
-
11
- export type FilterOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'notContains'
12
-
13
- /**
14
- * A field's storage/semantics bucket for filtering. `text`/`richtext` do substring LIKE; `stringSet`/`idSet`
15
- * are JSON-array membership (multi choice / many relation+media); `ref` is a single FK id (eq/ne only);
16
- * `enum`/`boolean` are equality-only; `number`/`datetime` support the full comparison set.
17
- */
18
- export type FilterKind = 'number' | 'text' | 'datetime' | 'boolean' | 'enum' | 'ref' | 'richtext' | 'stringSet' | 'idSet'
19
-
20
- const CMP = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'] as const
21
-
22
- export const OPS_BY_KIND: Record<FilterKind, readonly FilterOp[]> = {
23
- number: CMP,
24
- datetime: CMP,
25
- text: ['eq', 'ne', 'contains'],
26
- richtext: ['contains'], // `contains` matches the stored HTML SOURCE (searching 'p' can hit a '<p>' tag) —
27
- // a stripped shadow column / FTS index is a later enhancement (documented caveat).
28
- boolean: ['eq', 'ne'],
29
- enum: ['eq', 'ne'],
30
- ref: ['eq', 'ne'], // a single relation/media FK id — lt/gt on an id is meaningless.
31
- stringSet: ['contains', 'notContains'],
32
- idSet: ['contains', 'notContains'],
33
- }
34
-
35
- /** The operator a column's filter row defaults to in the admin UI. `datetime` defaults to `gte` ("on or
36
- * after") — `eq` on a millisecond timestamp practically never matches, so it made the filter useless. The
37
- * admin always emits the operator explicitly, so this does NOT change the wire parser's separate
38
- * absent-operator default (a bare `filter[field]` still parses as `eq`, URLs round-trip unchanged). */
39
- export const DEFAULT_OP: Record<FilterKind, FilterOp> = {
40
- number: 'eq', datetime: 'gte', text: 'eq', boolean: 'eq', enum: 'eq', ref: 'eq',
41
- richtext: 'contains', stringSet: 'contains', idSet: 'contains',
42
- }
43
-
44
- const ALL_OPS = new Set<string>(['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'contains', 'notContains'])
45
-
46
- /** Whether a raw wire token is a known operator at all (parse-time guard → a clean 400 for garbage). */
47
- export const isFilterOp = (s: string): s is FilterOp => ALL_OPS.has(s)
48
-
49
- /** Whether `op` is allowed for a column of this kind (list-time guard → a clean 400, never a 500). */
50
- export const opAllowed = (kind: FilterKind, op: FilterOp): boolean => OPS_BY_KIND[kind].includes(op)
51
-
52
- /**
53
- * The wire key for a filter clause: `filter[field]` or `filter[field][op]`. `[^\]]+` keeps the field/op
54
- * tokens bracket-free; the optional second group is the operator (absent → `eq`, so a bare
55
- * `filter[status]=x` stays backward compatible). Defined ONCE here (the shared client+server home) so the
56
- * admin URL parser (`parseListQuery`) and the server wire parser (`parseFilter`) use exactly one regex.
57
- */
58
- export const FILTER_RE = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/
59
-
60
- /**
61
- * The FilterKind for a field, or `null` when it is not filterable (link / json / repeater / range-datetime).
62
- * Unknown consumer types fall back to the conservative `text` (equality + substring on their scalar column).
63
- */
64
- export function fieldFilterKind(f: SerializedField): FilterKind | null {
65
- switch (f.type) {
66
- case 'text':
67
- case 'slug':
68
- return 'text'
69
- case 'richtext':
70
- return 'richtext'
71
- case 'number':
72
- return 'number'
73
- case 'boolean':
74
- return 'boolean'
75
- case 'datetime':
76
- return f.options?.range ? null : 'datetime'
77
- case 'choice':
78
- return f.options?.multiple ? 'stringSet' : 'enum'
79
- case 'relation':
80
- case 'media':
81
- return f.single ? 'ref' : 'idSet'
82
- case 'link':
83
- case 'json':
84
- case 'repeater':
85
- return null
86
- default:
87
- return 'text'
88
- }
89
- }
90
-
91
- /** The FilterKind of each filterable meta/system column (keyed by its wire key). */
92
- export const META_FILTER_KIND: Record<string, FilterKind> = {
93
- id: 'number',
94
- path: 'text',
95
- status: 'enum',
96
- createdAt: 'datetime',
97
- updatedAt: 'datetime',
98
- }
@@ -1,27 +0,0 @@
1
- // The single source of truth for collection-list page sizes and, derived from them, the bulk-operation id
2
- // cap. Lives in `core/app/utils` (the established server+client shared home — same as `filter-ops.ts`) so
3
- // the admin list UI, the `list()` query engine, and the `bulk` endpoint all agree on ONE set of limits
4
- // instead of each hardcoding its own.
5
-
6
- /** The page sizes offered in the list's per-page selector. */
7
- export const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500] as const
8
-
9
- /** The page size a list falls back to when none (or a garbage value) is supplied. */
10
- export const DEFAULT_PER_PAGE = 25
11
-
12
- /** The largest page a single list request may return — also the ceiling `clampPerPage` clamps to. */
13
- export const MAX_PER_PAGE = 500
14
-
15
- /** Coerce an untrusted page size to a sane integer: a non-finite input (undefined / NaN / Infinity / a
16
- * non-numeric string) falls back to `DEFAULT_PER_PAGE`; anything else is floored and clamped to
17
- * `[1, MAX_PER_PAGE]`. Guards against `LIMIT NaN` (which SQLite binds as "no limit") — an unauthenticated
18
- * cap-bypass / DoS lever. */
19
- export function clampPerPage(n: unknown): number {
20
- const v = Number(n)
21
- if (!Number.isFinite(v)) return DEFAULT_PER_PAGE
22
- return Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(v)))
23
- }
24
-
25
- /** A bulk op can never carry more ids than one page can show (you select what you can see), so the id cap
26
- * is DERIVED from the max page size, not guessed. */
27
- export const MAX_BULK_IDS = MAX_PER_PAGE
@@ -1,12 +0,0 @@
1
- /**
2
- * The public route path for a page row: other locales are prefixed with `/<locale>`; the primary locale
3
- * is unprefixed unless `prefixPrimary` is set (then it is prefixed too, root `/` → `/<primary>`). Single
4
- * source of truth for the URL scheme shared by the prerender routes, the sitemap, `populate-links`, the
5
- * `[...slug].vue` resolution, the slug-uniqueness check, and the admin SEO preview. Pure, so it is
6
- * unit-testable and safe to import from both server and app code.
7
- */
8
- export function localePath(path: string, locale: string, primaryLocale: string, prefixPrimary = false): string {
9
- const p = path.startsWith('/') ? path : `/${path}`
10
- if (locale === primaryLocale && !prefixPrimary) return p
11
- return p === '/' ? `/${locale}` : `/${locale}${p}`
12
- }
@@ -1,16 +0,0 @@
1
- /**
2
- * Turn arbitrary text (a record title) into a url-safe slug: lowercase, fold diacritics to ASCII
3
- * (`Über` → `uber`, `Café` → `cafe`) via NFKD + combining-mark removal, then collapse every run of
4
- * non-`[a-z0-9]` into a single hyphen and trim the ends. Letters with no ASCII fold (ß, CJK, …) act as
5
- * separators — Kestrel does NO language-specific transliteration, so the result is predictable. Returns
6
- * `''` for blank / symbol-only input (the caller decides: fall back to the manual slug, or reject). Pure.
7
- */
8
- export function slugify(input: string): string {
9
- if (typeof input !== 'string') return ''
10
- return input
11
- .normalize('NFKD')
12
- .replace(/\p{M}/gu, '') // strip combining diacritical marks left by NFKD (é → e, ü → u)
13
- .toLowerCase()
14
- .replace(/[^a-z0-9]+/g, '-')
15
- .replace(/^-+|-+$/g, '')
16
- }
@@ -1,8 +0,0 @@
1
- // Per-record stale-reference map for the editor: every reference this record holds that now points at a
2
- // deleted or unpublished target, with its field/block location + reason. Derived on read (auto-clears when
3
- // the link is fixed or the target restored). Fetched non-blocking alongside the editor load, like the
4
- // translations map.
5
- export default defineEventHandler((event) => {
6
- const collection = requireCollection(event)
7
- return recordDeadRefs(useDb(), collection, requireId(event))
8
- })
@@ -1,4 +0,0 @@
1
- export default defineEventHandler((event) => {
2
- const collection = requireCollection(event)
3
- return resolveTranslations(useDb(), collection, requireId(event))
4
- })
@@ -1,5 +0,0 @@
1
- export default defineEventHandler((event) => {
2
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
3
- const collection = requireCollection(event)
4
- return remove(useDb(), collection, requireId(event))
5
- })
@@ -1,11 +0,0 @@
1
- export default defineEventHandler((event) => {
2
- const collection = requireCollection(event)
3
- const query = getQuery(event)
4
- const depth = Number(query.depth ?? 0)
5
- const locale = query.locale as string | undefined
6
- const publishedOnly = publishedOnlyForScope(event.context.readScope)
7
- // See index.get.ts: the public-set restriction follows the ROLE, so the renderer keeps full population;
8
- // a missing principal fails closed onto it.
9
- const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
10
- return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly, publicOnly)
11
- })
@@ -1,8 +0,0 @@
1
- export default defineEventHandler(async (event) => {
2
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
3
- const collection = requireCollection(event)
4
- const id = requireId(event)
5
- const body = await readBody(event)
6
- // Optimistic concurrency: the editor sends the baseline it loaded; a stale save is refused with 409.
7
- return update(useDb(), collection, id, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
8
- })
@@ -1,37 +0,0 @@
1
- import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
2
- import { BULK_ACTIONS, isBulkAction, type BulkAction } from '../../../app/utils/bulk-actions'
3
-
4
- // The ONE command endpoint for collection batch operations — the single wire surface behind both the
5
- // per-row Quick Actions and the selection Bulk bar (a row action IS a bulk action with one id). POST is
6
- // classified 'write' on the `{collection}` resource by the access guard (resourceForPath('/api/{c}/bulk')
7
- // === '{c}'), so CSRF + per-collection policy apply exactly as for a single-record write — no bypass.
8
- // A literal `bulk.post.ts` segment beats the dynamic `[id]` route (no `[id].post.ts` exists), so no clash.
9
- //
10
- // body: { action: 'delete' | 'publish' | 'unpublish' | 'duplicate', ids: number[] }
11
- // 200: { action, count, ids } (for 'duplicate', `ids` are the CREATED ids)
12
- //
13
- // delete + publish/unpublish are all-or-nothing (an unknown id 404s before any write); duplicate is a
14
- // sequential row action. All batching lives in crud.ts — this handler is a thin parse-and-dispatch.
15
-
16
- function parseBulkBody(body: unknown): { action: BulkAction; ids: number[] } {
17
- const action = (body as { action?: unknown })?.action
18
- if (!isBulkAction(action)) {
19
- throw createError({ statusCode: 400, statusMessage: `Invalid bulk action: ${String(action)} (expected one of ${BULK_ACTIONS.join(', ')})` })
20
- }
21
- const ids = parseIdList((body as { ids?: unknown })?.ids, MAX_BULK_IDS)
22
- return { action, ids }
23
- }
24
-
25
- export default defineEventHandler(async (event) => {
26
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
27
- const collection = requireCollection(event)
28
- const { action, ids } = parseBulkBody(await readBody(event))
29
- const db = useDb()
30
-
31
- if (action === 'delete') return { action, ...removeMany(db, collection, ids) }
32
- if (action === 'duplicate') {
33
- const created = await duplicateMany(db, collection, ids)
34
- return { action, count: created.length, ids: created.map((r) => r.id as number) }
35
- }
36
- return { action, ...setStatusMany(db, collection, ids, action === 'publish' ? 'published' : 'draft') }
37
- })
@@ -1,24 +0,0 @@
1
- export default defineEventHandler((event) => {
2
- const collection = requireCollection(event)
3
- const query = getQuery(event)
4
- const db = useDb()
5
- const publishedOnly = publishedOnlyForScope(event.context.readScope)
6
- // Keyed on the ROLE, not the read scope: the renderer reads published-only too, but it produces the
7
- // static site and must still see every relation the output embeds. Only a visitor the guard scopes to
8
- // the public collection set is barred from reaching further through a populated relation. Fail-CLOSED
9
- // on a missing principal, like `publishedOnlyForScope` above — an absent one is a guard regression.
10
- const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
11
-
12
- if (collection.def.mode === 'single') {
13
- return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0, publicOnly)
14
- }
15
-
16
- return list(db, collection, {
17
- locale: query.locale as string | undefined,
18
- sort: query.sort as string | undefined,
19
- page: query.page ? Number(query.page) : undefined,
20
- perPage: query.perPage ? Number(query.perPage) : undefined,
21
- filter: parseFilter(query as Record<string, unknown>),
22
- depth: query.depth ? Number(query.depth) : 0,
23
- }, publishedOnly, publicOnly)
24
- })
@@ -1,8 +0,0 @@
1
- export default defineEventHandler(async (event) => {
2
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
3
- const collection = requireCollection(event)
4
- const body = await readBody(event)
5
- const row = create(useDb(), collection, body)
6
- setResponseStatus(event, 201)
7
- return row
8
- })
@@ -1,76 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest'
2
- import { createError } from 'h3'
3
- import Database from 'better-sqlite3'
4
- import { drizzle } from 'drizzle-orm/better-sqlite3'
5
- import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
- import { defineCollection } from '../../utils/defineCollection'
7
- import { putSingleton } from '../../utils/crud'
8
- import { requireCollection } from '../../utils/http'
9
- import { clearRegistry, registerCollection } from '../../utils/registry'
10
- import { clearWriteListeners } from '../../utils/write-events'
11
- import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
12
- import { desiredSchema } from '../../schema/desired'
13
- import { diffSchema } from '../../schema/diff'
14
- import { renderSqlite } from '../../schema/render-sqlite'
15
-
16
- const settings = buildCollection(defineCollection({
17
- name: 'settings', mode: 'single',
18
- fields: { title: { type: 'text', required: true } },
19
- }))
20
-
21
- interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
22
-
23
- let db: ReturnType<typeof drizzle>
24
-
25
- // Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
26
- // than a stub (same rationale as options.get.test.ts).
27
- Object.assign(globalThis, {
28
- defineEventHandler: (handler: unknown) => handler,
29
- createError,
30
- getQuery: (event: FakeEvent) => event.query,
31
- readBody: (event: FakeEvent) => Promise.resolve(event.body),
32
- useDb: () => db,
33
- requireAdmin: () => {},
34
- requireCollection,
35
- readIfUnmodifiedSince: () => undefined,
36
- putSingleton,
37
- runWriteEffects,
38
- })
39
-
40
- const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
41
- const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
42
-
43
- beforeEach(() => {
44
- const sqlite = new Database(':memory:')
45
- for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
46
- db = drizzle(sqlite)
47
- clearRegistry()
48
- registerCollection(settings)
49
- clearWriteListeners()
50
- clearWriteEffects()
51
- })
52
-
53
- describe('PUT /api/[collection] — singleton save', () => {
54
- it('writes the row and returns it', async () => {
55
- await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
56
- })
57
-
58
- it('runs post-write effects with the saved row', async () => {
59
- const seen: unknown[] = []
60
- registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
61
- await put('settings', { title: 'Hello' })
62
- expect(seen).toEqual([['settings', 'Hello']])
63
- })
64
-
65
- it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
66
- registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
67
- await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
68
- })
69
-
70
- it('does not run effects when validation rejects the body', async () => {
71
- let ran = false
72
- registerWriteEffect(() => { ran = true })
73
- await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
74
- expect(ran).toBe(false)
75
- })
76
- })
@@ -1,24 +0,0 @@
1
- export default defineEventHandler(async (event) => {
2
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
3
- const collection = requireCollection(event)
4
- const query = getQuery(event)
5
- const body = await readBody(event)
6
- const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
7
- // Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
8
- // (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
9
- // write-event bus swallows throws by design. See write-effects.ts.
10
- try {
11
- await runWriteEffects(collection.def, saved)
12
- } catch (error) {
13
- // The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
14
- // hand the new baseline back with the error, or the retry these failures ask for would 409 on a
15
- // precondition that is stale by construction.
16
- const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
17
- throw createError({
18
- statusCode: err.statusCode ?? 500,
19
- statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
20
- data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
21
- })
22
- }
23
- return saved
24
- })
@@ -1,60 +0,0 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
- import { createError } from 'h3'
3
- import Database from 'better-sqlite3'
4
- import { drizzle } from 'drizzle-orm/better-sqlite3'
5
- import { buildCollection } from '../../../../fields/server/utils/buildCollection'
6
- import { defineCollection } from '../../utils/defineCollection'
7
- import { create } from '../../utils/crud'
8
- import { requireCollection, parseIdList } from '../../utils/http'
9
- import { clearRegistry, registerCollection } from '../../utils/registry'
10
- import { pickerOptions } from '../../utils/picker'
11
- import { desiredSchema } from '../../schema/desired'
12
- import { diffSchema } from '../../schema/diff'
13
- import { renderSqlite } from '../../schema/render-sqlite'
14
-
15
- const posts = buildCollection(defineCollection({
16
- name: 'posts', mode: 'multi', translatable: false,
17
- fields: { title: { type: 'text', required: true } },
18
- }))
19
-
20
- interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string>; readScope?: string } }
21
-
22
- let db: ReturnType<typeof drizzle>
23
-
24
- // Same rationale as translations.get.test.ts: bind the handler's auto-imported helpers to the REAL
25
- // implementations so this proves the actual wiring, not a stub the server does not have.
26
- Object.assign(globalThis, {
27
- defineEventHandler: (handler: unknown) => handler,
28
- createError,
29
- getQuery: (event: FakeEvent) => event.query,
30
- useDb: () => db,
31
- requireCollection,
32
- parseIdList,
33
- pickerOptions,
34
- publishedOnlyForScope: () => false,
35
- })
36
-
37
- const handler = (await import('./options.get')).default as unknown as (event: FakeEvent) => ReturnType<typeof pickerOptions>
38
- const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
39
-
40
- beforeEach(() => {
41
- const sqlite = new Database(':memory:')
42
- for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table]), {}))) sqlite.exec(stmt)
43
- db = drizzle(sqlite)
44
- clearRegistry()
45
- registerCollection(posts)
46
- })
47
- afterEach(() => clearRegistry())
48
-
49
- describe('GET /api/{collection}/options?ids=', () => {
50
- it('resolves more than 100 ids in one request instead of truncating', () => {
51
- const ids = Array.from({ length: 150 }, (_, i) => (create(db, posts, { title: `T${i}` }) as { id: number }).id)
52
- const r = get('posts', { ids: ids.join(',') })
53
- expect(r.data.length).toBe(150)
54
- })
55
-
56
- it('400s (never silently truncates) an ids list over the shared bulk cap', () => {
57
- const ids = Array.from({ length: 501 }, (_, i) => i + 1)
58
- expect(() => get('posts', { ids: ids.join(',') })).toThrowError(expect.objectContaining({ statusCode: 400 }))
59
- })
60
- })