@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
@@ -70,9 +70,86 @@ export function collectBlockSfcs(layerRoots: string[], subdir = 'app/blocks', li
70
70
  return [...byName.values()].sort()
71
71
  }
72
72
 
73
+ /**
74
+ * Each layer's OWN `server/db/manifest.ts` (ADR-0012 ownership manifest), one per layer that has one.
75
+ * Unlike `collectDefinitions`, this does NOT dedupe by basename: every layer's manifest is a distinct
76
+ * module contribution (no shadow/override semantics — `media`'s manifest and `public`'s manifest both
77
+ * happen to be named `manifest.ts`, and both must survive). Layer order is whatever `layerRoots` is.
78
+ */
79
+ export function collectManifestFiles(layerRoots: string[], list?: Lister): string[] {
80
+ const files: string[] = []
81
+ for (const root of layerRoots) {
82
+ for (const file of listDefinitionFiles(join(root, 'server/db'), list)) {
83
+ if (basename(file) === 'manifest.ts') files.push(file)
84
+ }
85
+ }
86
+ return files
87
+ }
88
+
73
89
  export function renderRegistry(absPaths: string[]): string {
74
90
  if (!absPaths.length) return 'export default []'
75
91
  const imports = absPaths.map((p, i) => `import _${i} from ${JSON.stringify(p)}`)
76
92
  const arr = absPaths.map((_, i) => `_${i}`).join(', ')
77
93
  return `${imports.join('\n')}\nexport default [${arr}]`
78
94
  }
95
+
96
+ /**
97
+ * A registry combining package-provided items (each package's `kestrelDiscovery.<property>`, read via a
98
+ * bare specifier — no filesystem/`node_modules` guessing) with consumer/layer-scanned files, with a
99
+ * SAME-NAME layer item overriding a package's (via `mergeKestrelDiscovered`, evaluated at virtual-load
100
+ * time — package names aren't knowable at codegen time without loading the package, which this
101
+ * deliberately avoids doing at build time). `nameOfExpr` is a JS arrow-function source string the
102
+ * generated module evaluates per item (e.g. `'(x) => x.name'` for collections,
103
+ * `'(x) => __kestrelTableName(x)'` for schema-tables, paired with `extraImports` supplying
104
+ * `__kestrelTableName`).
105
+ */
106
+ export function renderPackageMergedRegistry(opts: {
107
+ packages: string[]
108
+ property: 'collections' | 'schemaTables'
109
+ consumerFiles: string[]
110
+ nameOfExpr: string
111
+ extraImports?: string
112
+ preamble?: string
113
+ }): string {
114
+ const pkgImports = opts.packages.map((spec, i) => `import { kestrelDiscovery as __pkg${i} } from ${JSON.stringify(spec)}`)
115
+ const pkgSpread = opts.packages.map((_, i) => `...(__pkg${i}.${opts.property} ?? [])`).join(', ')
116
+ const consumerImports = opts.consumerFiles.map((p, i) => `import _c${i} from ${JSON.stringify(p)}`)
117
+ const consumerArr = opts.consumerFiles.map((_, i) => `_c${i}`).join(', ')
118
+ return [
119
+ opts.preamble ?? '',
120
+ opts.extraImports ?? '',
121
+ `import { mergeKestrelDiscovered } from '@michaelthielemann/kestrel-core'`,
122
+ ...pkgImports,
123
+ ...consumerImports,
124
+ `export default mergeKestrelDiscovered([${pkgSpread}], [${consumerArr}], ${opts.nameOfExpr})`,
125
+ ].filter(Boolean).join('\n')
126
+ }
127
+
128
+ /**
129
+ * A registry concatenating package-provided items (`kestrelDiscovery.<property>`) with consumer/layer-
130
+ * scanned files — NO dedup (mirrors `collectManifestFiles`'s own "every contribution is distinct, no
131
+ * shadow semantics" contract; unlike collections/schema-tables, two manifests sharing a name is not a
132
+ * schema conflict, so there is nothing to override).
133
+ */
134
+ export function renderPackageConcatRegistry(opts: {
135
+ packages: string[]
136
+ property: 'manifest'
137
+ consumerFiles: string[]
138
+ preamble?: string
139
+ }): string {
140
+ const pkgImports = opts.packages.map((spec, i) => `import { kestrelDiscovery as __pkg${i} } from ${JSON.stringify(spec)}`)
141
+ // `opts.packages` is expected to be pre-filtered to packages that actually contribute this property (the
142
+ // caller's per-category list), so every `.manifest` read here SHOULD be defined — but the spread stays
143
+ // conditional (`...(x ? [x] : [])`, not a bare `x`) as a defense-in-depth guard: a package mis-listed in
144
+ // the wrong `PACKAGE_*` category contributes nothing instead of polluting the array with `undefined`.
145
+ const pkgItems = opts.packages.map((_, i) => `...(__pkg${i}.${opts.property} ? [__pkg${i}.${opts.property}] : [])`)
146
+ const consumerImports = opts.consumerFiles.map((p, i) => `import _c${i} from ${JSON.stringify(p)}`)
147
+ const consumerArr = opts.consumerFiles.map((_, i) => `_c${i}`)
148
+ const all = [...pkgItems, ...consumerArr].join(', ')
149
+ return [
150
+ opts.preamble ?? '',
151
+ ...pkgImports,
152
+ ...consumerImports,
153
+ `export default [${all}]`,
154
+ ].filter(Boolean).join('\n')
155
+ }
@@ -10,6 +10,11 @@ declare module '#kestrel/schema-tables' {
10
10
  const tables: unknown[]
11
11
  export default tables
12
12
  }
13
+ declare module '#kestrel/module-manifests' {
14
+ import type { OwnershipManifest } from '@michaelthielemann/kestrel-contracts'
15
+ const manifests: OwnershipManifest[]
16
+ export default manifests
17
+ }
13
18
  declare module '#build/kestrel-layouts.mjs' {
14
19
  export const kestrelLayouts: string[]
15
20
  }
@@ -5,6 +5,30 @@ import { KESTREL_COMPONENT_PREFIX, KESTREL_OVERRIDE_PRIORITY } from './shared'
5
5
  /** Where a consumer places a component that replaces a shipped one, relative to their `srcDir`. */
6
6
  export const KESTREL_OVERRIDE_DIR = 'Kestrel/components'
7
7
 
8
+ /**
9
+ * RULING: the app-side layers (`admin`, `ui`, and the `app/` halves of `media`/`public`) stay LAYERS —
10
+ * they are not bridged into packages, and there is no `addComponentsDir`/`addImportsDir`
11
+ * module doing so. `admin` is the layer with the most at stake here: it registers by far the most
12
+ * components under `kestrelComponents()` (`layers/admin/nuxt.config.ts`, alongside `media`/`public`/`ui`),
13
+ * so it is the layer this override mechanism protects most. This module's own override mechanism (below)
14
+ * is WHY: it depends on Kestrel's own components being real, Nuxt-scannable `.vue` files in a real layer,
15
+ * resolved at a higher `addComponentsDir` priority than a consumer's override — a package's compiled
16
+ * `dist` output cannot participate in that priority system the same way. Bridging would cost the admin its
17
+ * per-component override story for no boot-order/discovery benefit these app-side files need (none of the
18
+ * eager-module-load or field-type-registration hazards the *server*-side package cut existed to fix apply
19
+ * here). Revisit only with strong evidence the cost is worth paying — see `docs/internals/layers-and-packages.md` §
20
+ * "The component/composable bridge".
21
+ *
22
+ * Composables are a separate question this mechanism does NOT cover: none of `admin`/`ui`'s ~30
23
+ * `use*`-named composables carry a `useKestrel*` prefix, and none need to — every one is internal editor
24
+ * state/logic (`useEditForm`, `useBlockTree`, `useCollectionOps`, …). Kestrel's own layers reuse them
25
+ * freely across the merged auto-import namespace (`media` uses `ui`'s `useT`/`useToast`/`useDragReorder`);
26
+ * what stays true is that no extension, package, or third-party consumer imports one, and none is ever
27
+ * documented as something a consumer reaches for — a consumer builds their own UI against collections/
28
+ * pipelines/field types, not by importing Kestrel's editor internals. There is consequently no override
29
+ * mechanism for composables (unlike components) and none is needed.
30
+ */
31
+
8
32
  export default defineNuxtModule({
9
33
  meta: { name: 'kestrel-component-namespace' },
10
34
  setup(_options, nuxt) {
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { defineNuxtModule } from '@nuxt/kit'
3
- import { resolveKestrel, type KestrelConfig } from '../../server/utils/kestrel-config'
3
+ import { resolveKestrel, type KestrelConfig } from '@michaelthielemann/kestrel-core'
4
4
  import { diagnoseAppShell } from './app-shell'
5
5
 
6
6
  /**
@@ -78,6 +78,11 @@ export default defineNuxtModule<KestrelConfig>({
78
78
  srv.aiDisclosure = c.aiDisclosure
79
79
  // Answer-engine toggles; `llmsFull` is server-only (it gates a Nitro route and what the publisher writes).
80
80
  srv.seo = c.seo
81
+ // Read by delivery-live's catch-all/read pipeline (see `KestrelConfig.delivery`'s own TSDoc) to
82
+ // decide whether to answer at all — everything else about it stays 'static'-shaped either way.
83
+ srv.delivery = c.delivery
84
+ // Extra path prefixes the live catch-all must fall through on (see `KestrelConfig.deliveryExempt`).
85
+ srv.deliveryExempt = c.deliveryExempt
81
86
  // Static-publish target for the runtime publisher (server-only). S3 creds are env-only; prefer the
82
87
  // output-specific creds, fall back to the shared media creds so a single S3 account "just works".
83
88
  srv.output = {
@@ -0,0 +1,69 @@
1
+ import { defineNuxtModule } from '@nuxt/kit'
2
+ import { assertEffectiveOrderMatches, resolvePluginOrder, validatePluginOrder } from './plugin-order'
3
+
4
+ /**
5
+ * Owns the execution order of the plugins Kestrel's own 6 layers ship (core/fields/media/auth/
6
+ * collections/public — see `plugin-order.ts`'s `PLUGIN_ORDER`), replacing Nitro's layer-then-filename
7
+ * sort with an explicit, declared list. A consumer's own plugins (and any extension layer's) are
8
+ * untouched — they keep using Nitro's default scan-and-append, landing wherever their own layer position
9
+ * in `extends` naturally puts them; this module only orders Kestrel's own.
10
+ *
11
+ * Mechanism: the `nitro:config` hook's config object (which becomes `nitro.options` once Nitro's own
12
+ * instance is built) gets `.plugins` pushed with `PLUGIN_ORDER`'s resolved absolute paths, in order — this
13
+ * hook runs BEFORE Nitro's own `scanPlugins` step. That step only appends a scanned file when it is not
14
+ * ALREADY in `nitro.options.plugins`
15
+ * (`nitropack/dist/core/index.mjs`: `if (!nitro.options.plugins.includes(plugin)) plugins.push(plugin)`),
16
+ * so every one of our explicitly-pushed paths is skipped by the scan (already present, same absolute path)
17
+ * and keeps our declared position — nothing needs renaming or moving on disk. `nitro:config` alone only
18
+ * proves what WE push, though — a separate `nitro:init` hook (which fires after every `nitro:config` hook
19
+ * and Nitro's own scan-and-append have both already run, against the fully-built Nitro instance) asserts
20
+ * the REAL, final `nitro.options.plugins` still starts with our declared order, contiguous and duplicate-
21
+ * free — see `assertEffectiveOrderMatches`'s own TSDoc for why this observer, not the push alone, is what
22
+ * actually proves "declared order ≡ effective order".
23
+ *
24
+ * The ADR-0029 eager-barrel-load guard for a future `00.*` plugin: NOT a mechanism in this module. It
25
+ * doesn't need to be — `@michaelthielemann/kestrel-media`/`@michaelthielemann/kestrel-collections`/`@michaelthielemann/kestrel-publishing`'s own barrels now
26
+ * self-guard (a used-binding `@michaelthielemann/kestrel-fields` import, first), so importing ANY of them is safe from ANY
27
+ * plugin position, including a hypothetical new `00.*` file. The computed rail proving this
28
+ * (`test/architecture/kestrel-discovery.test.ts`'s "every package whose OWN module graph reaches
29
+ * buildCollection()...") already re-checks every package on every test run, so a FUTURE package that
30
+ * starts calling `buildCollection()` without the guard fails there — independent of whatever plugin order
31
+ * this module declares. Plugin order literally cannot reintroduce this hazard class any more, which is the
32
+ * honest, verifiable version of "prevents a future 00.* plugin from eagerly loading a heavy barrel
33
+ * pre-seed": the guard lives at the package boundary, not the boot-order boundary.
34
+ */
35
+ /** The minimal shape this module needs from `nuxt` — narrowed so a test can pass a plain mock object
36
+ * instead of a real Nuxt instance (mirrors testing `defineNuxtModule`'s callback logic directly, not
37
+ * through Nuxt's own module-loading machinery). */
38
+ export interface MinimalNuxt {
39
+ options: { _layers: readonly { cwd: string }[] }
40
+ hook: {
41
+ (name: 'nitro:config', fn: (nitro: { plugins?: (string | undefined)[] }) => void): void
42
+ (name: 'nitro:init', fn: (nitro: { options: { plugins: (string | undefined)[] } }) => void): void
43
+ }
44
+ }
45
+
46
+ /** The module's actual logic, exported separately from `defineNuxtModule`'s wrapper so a test can call it
47
+ * against a plain mock `nuxt`/`nitro` pair and assert `nitro.plugins` ends up EXACTLY
48
+ * `resolvePluginOrder(roots)`, in order — the "declared order ≡ effective order" proof, at unit speed,
49
+ * independent of the real-boot proof (a real `nuxt build`, inspected directly). */
50
+ export function setupPluginOrder(nuxt: MinimalNuxt): void {
51
+ const roots = nuxt.options._layers.map((layer) => layer.cwd)
52
+ validatePluginOrder(roots) // throws — fails the build loudly on any drift, before Nitro ever scans
53
+
54
+ nuxt.hook('nitro:config', (nitro) => {
55
+ nitro.plugins ||= []
56
+ nitro.plugins.push(...resolvePluginOrder(roots))
57
+ })
58
+
59
+ nuxt.hook('nitro:init', (nitro) => {
60
+ assertEffectiveOrderMatches(nitro.options.plugins, resolvePluginOrder(roots))
61
+ })
62
+ }
63
+
64
+ export default defineNuxtModule({
65
+ meta: { name: 'kestrel-plugin-order' },
66
+ setup(_options, nuxt) {
67
+ setupPluginOrder(nuxt)
68
+ },
69
+ })
@@ -0,0 +1,273 @@
1
+ import { existsSync, readdirSync } from 'node:fs'
2
+ import { join, sep } from 'node:path'
3
+
4
+ const PLUGIN_EXTENSIONS = ['js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx']
5
+
6
+ /** One of Kestrel's own `server/plugins/**` files, in the position it must execute at. */
7
+ export interface PluginEntry {
8
+ /** The layer directory name (`layers/<layer>`). */
9
+ layer: string
10
+ /** Path relative to the layer root. */
11
+ file: string
12
+ /** Keys (`${layer}/${file}`) of OTHER entries that MUST appear at a lower index than this one — empty
13
+ * when nothing's correctness depends on this entry's position. Machine-checked by `validatePluginOrder`
14
+ * against `PLUGIN_ORDER`'s actual array positions, not merely asserted. */
15
+ after: string[]
16
+ /** One line: the real dependency this entry's `after` encodes (when non-empty), or the evidence it's
17
+ * safe to reorder (when empty) — always named, never assumed. */
18
+ evidence: string
19
+ }
20
+
21
+ /** `${layer}/${file}` — the identity `after` references and `validatePluginOrder` indexes by. */
22
+ const key = (p: Pick<PluginEntry, 'layer' | 'file'>): string => `${p.layer}/${p.file}`
23
+
24
+ /**
25
+ * The declared execution order for every plugin Kestrel itself ships — DATA, read and pushed by
26
+ * `layers/core/modules/plugin-order/index.ts`, replacing Nitro's own layer-then-filename sort for these
27
+ * files. `validatePluginOrder` (below) makes TWO kinds of drift a loud build failure instead of a silent
28
+ * Nitro auto-append or an unenforced comment: (1) a plugin file on disk that isn't declared here (or a
29
+ * declared entry with no file behind it), and (2) an `after` dependency whose real position in this array
30
+ * doesn't actually satisfy it — a reshuffle that moves a dependent entry ahead of what it depends on fails
31
+ * the SAME way a missing/phantom file does, not silently.
32
+ *
33
+ * Audited against a real boot in both contexts (in-repo `nuxt build` and the playground's
34
+ * `extends: ['..', ...]` composition) via a throwaway probe module reading the resolved
35
+ * `nitro.options.plugins`. Confirmed identical for Kestrel's own 6 layers in both contexts:
36
+ * core → fields → media → auth → collections → public,
37
+ * filename-sort within each layer — which is exactly what this list encodes explicitly.
38
+ */
39
+ export const PLUGIN_ORDER: PluginEntry[] = [
40
+ {
41
+ layer: 'core', file: 'server/plugins/00.config.ts', after: [],
42
+ evidence: 'Nothing depends on core/00.config.ts to come after anything else; everything downstream depends on IT (see the after: entries below).',
43
+ },
44
+ {
45
+ layer: 'core', file: 'server/plugins/00.migrate.ts', after: ['core/server/plugins/00.config.ts'],
46
+ evidence: 'Calls useDb(), which reads the config provider 00.config.ts seeds.',
47
+ },
48
+ {
49
+ layer: 'core', file: 'server/plugins/01.register-introspection-pipeline.ts', after: [],
50
+ evidence: 'registerPipeline into a Map; read lazily on first request, never at another plugin\'s init.',
51
+ },
52
+ {
53
+ layer: 'core', file: 'server/plugins/01.register-openapi-pipeline.ts', after: [],
54
+ evidence: 'registerPipeline into a Map; read lazily on first request, never at another plugin\'s init.',
55
+ },
56
+ {
57
+ layer: 'core', file: 'server/plugins/01.register-tooling-pipelines.ts', after: [],
58
+ evidence: 'registerPipeline into a Map; read lazily on first request, never at another plugin\'s init.',
59
+ },
60
+ {
61
+ layer: 'core', file: 'server/plugins/02.schema-sync.ts', after: ['core/server/plugins/00.migrate.ts'],
62
+ evidence: 'Dev-only additive auto-migration; its own header comment: "Runs after 00.migrate, so built-in collections are already migrated and this is a no-op on them".',
63
+ },
64
+ {
65
+ layer: 'core', file: 'server/plugins/04.outbox-worker.ts', after: [],
66
+ evidence: 'Starts its own setInterval poll loop against useDb(); no other plugin\'s behavior depends on when this one starts polling (the poll itself is unconditional, no readiness handshake with another plugin).',
67
+ },
68
+ {
69
+ layer: 'core', file: 'server/plugins/05.reindex-refs.ts', after: [],
70
+ evidence: 'registerReindexRefs() registers a handler on the outbox event bus; dispatched later by the outbox worker, never read at another plugin\'s init.',
71
+ },
72
+ {
73
+ layer: 'fields', file: 'server/plugins/01.register-blocks-pipeline.ts', after: [],
74
+ evidence: 'registerPipeline into a Map; read lazily on first request.',
75
+ },
76
+ {
77
+ layer: 'fields', file: 'server/plugins/01.register-field-populate.ts', after: [],
78
+ evidence: 'Registers the single global row populator; field populators (media/link/richtext/relation) are looked up at READ time, so this plugin\'s order vs. the layer plugins that register them does not matter (stated in the plugin\'s own header comment).',
79
+ },
80
+ {
81
+ layer: 'media', file: 'server/plugins/01.register-media-pipelines.ts', after: [],
82
+ evidence: 'registerPipeline into a Map; read lazily on first request.',
83
+ },
84
+ {
85
+ layer: 'media', file: 'server/plugins/02.register-media.ts', after: [],
86
+ evidence: 'registerFieldPopulator; looked up at read time, not at another plugin\'s init.',
87
+ },
88
+ {
89
+ layer: 'media', file: 'server/plugins/04.variant-capture.ts', after: [],
90
+ evidence: 'Hooks the render request context (isRendererContext) and stashes discovered variants during SSR; reconciled per-render, no dependency on another plugin having run first.',
91
+ },
92
+ {
93
+ layer: 'media', file: 'server/plugins/05.media-cleanup.ts', after: [],
94
+ evidence: 'registerMediaCleanup() registers a handler on the outbox event bus; dispatched later.',
95
+ },
96
+ {
97
+ layer: 'auth', file: 'server/plugins/01.register-auth-pipelines.ts', after: [],
98
+ evidence: 'registerPipeline into a Map; read lazily on first request.',
99
+ },
100
+ {
101
+ layer: 'collections', file: 'server/plugins/01.register.ts', after: [],
102
+ evidence: 'Reads the #kestrel/collections and #kestrel/blocks VIRTUALS directly (never allCollections()); writes the runtime registry, but no plugin in this list reads that registry at its own init — only at request time, well after every plugin has finished (documented invariant, docs/internals/architecture.md § Server plugins).',
103
+ },
104
+ {
105
+ layer: 'collections', file: 'server/plugins/02.register-relation-populate.ts', after: [],
106
+ evidence: 'registerFieldPopulator; looked up at read time.',
107
+ },
108
+ {
109
+ layer: 'public', file: 'server/plugins/00.ensure-snapshot-triggers.ts',
110
+ after: ['core/server/plugins/00.migrate.ts', 'core/server/plugins/02.schema-sync.ts'],
111
+ evidence: 'THE one real cross-layer dependency: needs the snapshot store\'s table to already exist, created by core\'s 00.migrate (in-repo) or 02.schema-sync (consumer). Self-tolerant even so — a boot before either has run logs a warning and returns rather than crashing (ensureSnapshotTriggers\'s own TSDoc); still placed after both so the warning path is never hit in practice.',
112
+ },
113
+ {
114
+ layer: 'public', file: 'server/plugins/01.register-public-pipelines.ts', after: [],
115
+ evidence: 'registerPipeline into a Map; read lazily on first request.',
116
+ },
117
+ {
118
+ layer: 'public', file: 'server/plugins/02.register-links.ts', after: [],
119
+ evidence: 'registerFieldPopulator; looked up at read time.',
120
+ },
121
+ {
122
+ layer: 'public', file: 'server/plugins/03.redirects.ts', after: [],
123
+ evidence: 'registerAfterStep registers a DEFERRED, when-scoped pipeline hook; invoked on a future write, not at another plugin\'s init.',
124
+ },
125
+ {
126
+ layer: 'public', file: 'server/plugins/05.plan-publish.ts', after: [],
127
+ evidence: 'Registers planPublish on the outbox bus; dispatched later by the outbox worker, after commit — never at plugin init.',
128
+ },
129
+ {
130
+ layer: 'public', file: 'server/plugins/zz.publish.ts', after: [],
131
+ evidence: 'Registers a deferred CRITICAL after-step and starts the boot publish; the boot publish\'s own registry read sits after an `await` inside publishFull, so it resumes only once the synchronous plugin loop (including collections\' 01.register) has already finished — order-free by construction (documented invariant, docs/internals/architecture.md § Server plugins).',
132
+ },
133
+ ]
134
+
135
+ /** POSIX-normalize a path for comparison — `path.join`'s OUTPUT is OS-native (backslash on Windows), but
136
+ * `PluginEntry.file` is authored with literal forward slashes (`'server/plugins/00.config.ts'`) and Nuxt's
137
+ * own `_layers[].cwd` values are not guaranteed to be OS-native either (bundlers commonly normalize to
138
+ * POSIX internally regardless of platform). Comparing through one canonical POSIX form — rather than two
139
+ * parallel branches guessing which separator a given string happens to use — removes the asymmetry a
140
+ * Windows build would otherwise hit. */
141
+ const toPosix = (p: string): string => p.split(sep).join('/')
142
+
143
+ /** Find layer `name`'s root among Nuxt's resolved `_layers` cwds. Nitro plugin paths must be absolute
144
+ * filesystem paths — there is no bare-specifier equivalent for a Nitro plugin the way there is for a
145
+ * package export — so this lookup (mirroring the pattern auto-discovery used for the pre-package fields
146
+ * seed) is still needed. */
147
+ function layerRoot(roots: string[], name: string): string {
148
+ const root = roots.find((r) => toPosix(r).endsWith(`/layers/${name}`))
149
+ if (!root) throw new Error(`[kestrel] plugin-order: could not locate the "${name}" layer root`)
150
+ return root
151
+ }
152
+
153
+ /** `PLUGIN_ORDER` (or an injected `list`, for testing drift scenarios without editing the real constant),
154
+ * resolved to absolute file paths in declared order — what gets pushed into `nitro.options.plugins`. */
155
+ export function resolvePluginOrder(roots: string[], list: PluginEntry[] = PLUGIN_ORDER): string[] {
156
+ return list.map((p) => join(layerRoot(roots, p.layer), p.file))
157
+ }
158
+
159
+ /** Nitro's OWN plugin file extension set (`nitropack`'s `GLOB_SCAN_PATTERN` for `plugins/`) — matched
160
+ * exactly, not approximated, so this scan can never be fooled by a file Nitro would pick up that this
161
+ * didn't look for. */
162
+ const PLUGIN_FILE_RE = new RegExp(`\\.(${PLUGIN_EXTENSIONS.join('|')})$`)
163
+ const isPluginFile = (name: string) => PLUGIN_FILE_RE.test(name) && !name.endsWith('.test.ts') && !/\.test\.(js|mjs|cjs|mts|cts|tsx|jsx)$/.test(name)
164
+
165
+ /** Every file under `dir` Nitro's own scan would find — RECURSIVELY (Nitro's glob is `plugins/**\/*.{ext}`,
166
+ * not a flat listing), matching Nitro's exact extension set. Mirrors Nitro's own convention for real, not
167
+ * approximately. */
168
+ function walkPluginFiles(dir: string, out: string[] = []): string[] {
169
+ if (!existsSync(dir)) return out
170
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
171
+ const full = join(dir, entry.name)
172
+ if (entry.isDirectory()) walkPluginFiles(full, out)
173
+ else if (isPluginFile(entry.name)) out.push(full)
174
+ }
175
+ return out
176
+ }
177
+
178
+ /** Every plugin file Nitro would ACTUALLY scan across the layers `list` declares — derived from `list`
179
+ * itself (not a separately hardcoded layer set) so an injected test `list` governs exactly the layers it
180
+ * claims to, never silently falling back to a hardcoded layer set regardless of what was actually passed
181
+ * in — the shape a test injecting a fully isolated fake `list`/`roots` pair depends on. */
182
+ function scanActualPlugins(roots: string[], list: PluginEntry[]): string[] {
183
+ const layers = [...new Set(list.map((p) => p.layer))]
184
+ const found: string[] = []
185
+ for (const layer of layers) found.push(...walkPluginFiles(join(layerRoot(roots, layer), 'server/plugins')))
186
+ return found.sort()
187
+ }
188
+
189
+ /** `PLUGIN_ORDER` entries reachable from `list`, by `key`. */
190
+ function assertOrderConstraintsSatisfied(list: PluginEntry[]): void {
191
+ const indexOf = new Map(list.map((p, i) => [key(p), i]))
192
+ const violations: string[] = []
193
+ for (const p of list) {
194
+ for (const dep of p.after) {
195
+ const depIndex = indexOf.get(dep)
196
+ const ownIndex = indexOf.get(key(p))!
197
+ if (depIndex === undefined) {
198
+ violations.push(`${key(p)} declares after: "${dep}", but no such entry exists in PLUGIN_ORDER`)
199
+ } else if (depIndex >= ownIndex) {
200
+ violations.push(`${key(p)} must come after ${dep} (${p.evidence}), but is declared at index ${ownIndex} vs. ${dep}'s index ${depIndex}`)
201
+ }
202
+ }
203
+ }
204
+ if (violations.length) {
205
+ throw new Error(['[kestrel] plugin-order: PLUGIN_ORDER violates a declared after: dependency.', ...violations.map((v) => ` ${v}`)].join('\n'))
206
+ }
207
+ }
208
+
209
+ /**
210
+ * LOUD failure for BOTH kinds of drift: (1) a plugin file present on disk but missing from `PLUGIN_ORDER`
211
+ * (which Nitro's own scan-and-append would otherwise silently tack onto the END, in undeclared position —
212
+ * see nitropack's `scanPlugins`/`options.plugins.push` for exactly that fallback behavior) or a declared
213
+ * entry whose file no longer exists; (2) a declared `after` dependency the array's ACTUAL positions don't
214
+ * satisfy — a reshuffle, not just a deletion, fails just as loudly. Throws — the ONLY way a Nuxt module's
215
+ * `setup()` can fail the build loudly (a Nitro hook can't itself abort configuration once already running).
216
+ * Called from `setup()`, before the `nitro:config` hook — a build-time failure, not a Nitro-time one.
217
+ */
218
+ export function validatePluginOrder(roots: string[], list: PluginEntry[] = PLUGIN_ORDER): void {
219
+ assertOrderConstraintsSatisfied(list)
220
+
221
+ const declared = new Set(resolvePluginOrder(roots, list))
222
+ const actual = new Set(scanActualPlugins(roots, list))
223
+ const missing = [...actual].filter((f) => !declared.has(f)) // on disk, not declared
224
+ const phantom = [...declared].filter((f) => !actual.has(f)) // declared, not on disk
225
+ if (missing.length === 0 && phantom.length === 0) return
226
+ const lines: string[] = ['[kestrel] plugin-order: PLUGIN_ORDER has drifted from the real server/plugins/ files.']
227
+ if (missing.length) lines.push(` On disk but NOT declared (would silently land last, undeclared, via Nitro's own scan-and-append): ${missing.join(', ')}`)
228
+ if (phantom.length) lines.push(` Declared but the file does not exist: ${phantom.join(', ')}`)
229
+ lines.push(' Fix PLUGIN_ORDER in layers/core/modules/plugin-order/plugin-order.ts.')
230
+ throw new Error(lines.join('\n'))
231
+ }
232
+
233
+ /**
234
+ * The permanent, in-process observer of the REAL merge: `validatePluginOrder`/`resolvePluginOrder` only
235
+ * prove what THIS module pushes — they cannot see what Nitro's own scan-and-append, another module's
236
+ * `nitro:config` hook, or Nitro's own internal/dev-tooling plugins do to the array afterward. Called from
237
+ * `index.ts`'s `nitro:init` hook (which receives the fully-built Nitro instance, after every `nitro:config`
238
+ * hook AND Nitro's own scan have both already run) against the REAL `nitro.options.plugins`, this asserts
239
+ * our declared order survived that whole pipeline intact: every `expected` path appears, in order, as ONE
240
+ * CONTIGUOUS RUN somewhere in the real array — NOT necessarily a prefix. A real `nuxt dev` boot pushes
241
+ * `@nuxt/devtools`'/`@nuxt/nitro-server`'s own internal plugins (an inline devtools runtime, dev-server
242
+ * request logging) ahead of everything via their own earlier-registered `nitro:config` hooks — legitimate
243
+ * Nuxt/Nitro tooling this module neither owns nor should reject, so "prefix" is the wrong bar; "our own
244
+ * declared plugins never get split apart or interleaved with anyone else's" is the real, checkable
245
+ * invariant, and is what this asserts. Also asserts the real array has no duplicate entries anywhere
246
+ * (Nitro's own `!plugins.includes(x)` scan guard is SUPPOSED to prevent one, but this checks the outcome
247
+ * directly rather than trusting that guard blind). Throws — a boot-time, not merely a config-time, failure.
248
+ */
249
+ export function assertEffectiveOrderMatches(actualPlugins: (string | undefined)[], expected: string[]): void {
250
+ const cleaned = actualPlugins.filter((p): p is string => typeof p === 'string')
251
+ const seen = new Set<string>()
252
+ const duplicates = new Set<string>()
253
+ for (const p of cleaned) {
254
+ if (seen.has(p)) duplicates.add(p)
255
+ seen.add(p)
256
+ }
257
+ if (duplicates.size) {
258
+ throw new Error(`[kestrel] plugin-order: nitro.options.plugins contains duplicate entries: ${[...duplicates].join(', ')}`)
259
+ }
260
+ if (expected.length === 0) return
261
+ let start = -1
262
+ for (let i = 0; i + expected.length <= cleaned.length; i++) {
263
+ if (expected.every((e, j) => cleaned[i + j] === e)) { start = i; break }
264
+ }
265
+ if (start === -1) {
266
+ throw new Error(
267
+ '[kestrel] plugin-order: the REAL nitro.options.plugins does not contain PLUGIN_ORDER as one unbroken, '
268
+ + "in-order run — something split our declared block apart or interleaved into it.\n"
269
+ + ` Expected (contiguous, any position): ${expected.join(', ')}\n`
270
+ + ` Actual full list: ${cleaned.join(', ')}`,
271
+ )
272
+ }
273
+ }
@@ -5,5 +5,6 @@ export default defineNuxtConfig({
5
5
  fileURLToPath(new URL('./modules/component-namespace/index.ts', import.meta.url)),
6
6
  fileURLToPath(new URL('./modules/auto-discovery/index.ts', import.meta.url)),
7
7
  fileURLToPath(new URL('./modules/kestrel/index.ts', import.meta.url)),
8
+ fileURLToPath(new URL('./modules/plugin-order/index.ts', import.meta.url)),
8
9
  ],
9
10
  })
@@ -0,0 +1,98 @@
1
+ import { getRequestHeader } from 'h3'
2
+ import {
3
+ MAX_JSON_BODY, assertBodyLimit, isRoutablePipeline, toHttpError, tryResolveDefaultPipeline,
4
+ parseFilter, parsePipelineRoute, getCollectionOr404, useDb, readIfUnmodifiedSince,
5
+ } from '@michaelthielemann/kestrel-core'
6
+ import type { PipelineContext, PipelineTrace } from '@michaelthielemann/kestrel-core'
7
+ import { runPipelineForEventAuto, resolveEventPrincipal } from '@michaelthielemann/kestrel-access'
8
+ // The one handler behind the whole pipeline URL scheme:
9
+ //
10
+ // GET /api/<collection>/<readPipeline>[/<id>]
11
+ // POST /api/<collection>/<writePipeline>[/<id>]
12
+ // POST /api/<pipeline> (collection-less: login, publish, createPreview)
13
+ //
14
+ // It is the ONLY route file under server/api: every endpoint is a pipeline, and this is where each one is
15
+ // resolved, verb-checked and run.
16
+
17
+ /** Reads take their whole input from the query string, in the shape the read steps expect. One builder for
18
+ * every read pipeline — the generic list/detail reads and the editor's tooling reads pick the keys they
19
+ * need out of the same object. */
20
+ function readInput(query: Record<string, unknown>): Record<string, unknown> {
21
+ return {
22
+ ...query,
23
+ locale: query.locale as string | undefined,
24
+ sort: query.sort as string | undefined,
25
+ page: query.page ? Number(query.page) : undefined,
26
+ perPage: query.perPage ? Number(query.perPage) : undefined,
27
+ filter: parseFilter(query),
28
+ depth: query.depth ? Number(query.depth) : 0,
29
+ }
30
+ }
31
+
32
+ function assertMethod(method: string, read: boolean, pipeline: string): void {
33
+ const allowed = read ? method === 'GET' || method === 'HEAD' : method === 'POST'
34
+ if (!allowed) {
35
+ throw createError({ statusCode: 405, statusMessage: `${pipeline} is a ${read ? 'read' : 'write'} pipeline — use ${read ? 'GET' : 'POST'}` })
36
+ }
37
+ }
38
+
39
+ /** Embed the trace as a `$`-prefixed sidecar, matching the `$media`/`$translations` convention on rows. A
40
+ * batch/array result (createMany, duplicate) or a bare value (a not-found singleton's `null`) has no
41
+ * object to spread into, so it is wrapped under `data` instead. */
42
+ function embedTrace(result: unknown, trace: PipelineTrace): unknown {
43
+ if (result === null || typeof result !== 'object' || Array.isArray(result)) return { data: result, $pipeline: trace }
44
+ return { ...(result as Record<string, unknown>), $pipeline: trace }
45
+ }
46
+
47
+ function logPipelineRun(trace: PipelineTrace): void {
48
+ const steps = trace.steps.map((s) => `${s.name}${s.status === 'ok' ? '' : `:${s.status}`}=${s.ms}ms`).join(' ')
49
+ const pipeline = trace.collection ? `${trace.collection}/${trace.pipeline}` : trace.pipeline
50
+ console.log(`[kestrel] pipeline ${pipeline} ${steps} total=${trace.ms}ms`)
51
+ }
52
+
53
+ export default defineEventHandler(async (event) => {
54
+ const route = parsePipelineRoute(event.path)
55
+ const collection = route.collection === null ? null : getCollectionOr404(route.collection)
56
+ const resolved = tryResolveDefaultPipeline(route.collection, route.pipeline)
57
+ if (!isRoutablePipeline(resolved)) {
58
+ throw createError({ statusCode: 404, statusMessage: `Unknown pipeline: ${route.pipeline}` })
59
+ }
60
+ assertMethod(getMethod(event), resolved.read, route.pipeline)
61
+
62
+ const query = getQuery(event) as Record<string, unknown>
63
+ // The body is buffered HERE, before the pipeline's gates run — so it must be bounded, or an anonymous /
64
+ // cross-site / IP-blocked caller could stream an unbounded body into memory before being refused.
65
+ if (!resolved.read && !resolved.rawBody) assertBodyLimit(getRequestHeader(event, 'content-length'), MAX_JSON_BODY)
66
+ let ctx: PipelineContext | undefined
67
+ let result: unknown
68
+ try {
69
+ result = await runPipelineForEventAuto(event, {
70
+ op: route.pipeline,
71
+ collection,
72
+ db: collection ? useDb() : null,
73
+ id: route.id,
74
+ locale: query.locale as string | undefined,
75
+ // A `rawBody` pipeline owns its own body read (multipart, ciphertext, a size-capped stream), so the
76
+ // router must not consume the stream first.
77
+ input: resolved.read ? readInput(query) : resolved.rawBody ? undefined : await readBody(event),
78
+ // Optimistic concurrency: the editor sends the baseline it loaded, and a stale save is refused with
79
+ // 409. Only `updateOne` has a step that reads it.
80
+ work: resolved.read ? {} : { expectedUpdatedAt: readIfUnmodifiedSince(event) },
81
+ onContext: (c) => { ctx = c },
82
+ })
83
+ } catch (error) {
84
+ // The ONE call site that turns a step-raised KestrelError into an HTTP response (see
85
+ // utils/kestrel-error-map.ts); everything else (the runner, the drivers) passes it through untouched.
86
+ // Gate denials (401/403) never reach here as a KestrelError — they're already createError-shaped by
87
+ // the time they leave the runner (see runner.ts's denied/refused) and pass through unchanged.
88
+ throw toHttpError(error)
89
+ } finally {
90
+ if (import.meta.dev && ctx) logPipelineRun(ctx.trace.toJSON())
91
+ }
92
+ if (route.pipeline === 'createOne' || route.pipeline === 'createMany') setResponseStatus(event, 201)
93
+
94
+ if (ctx && query.debug === 'pipeline' && resolveEventPrincipal(event).role === 'admin') {
95
+ return embedTrace(result, ctx.trace.toJSON())
96
+ }
97
+ return result
98
+ })
@@ -0,0 +1,12 @@
1
+ import { setResolvedKestrelConfig } from '@michaelthielemann/kestrel-core'
2
+ import { resolveServerKestrelConfig } from '../utils/server-config'
3
+
4
+ // Runs before every other core plugin (00.migrate.ts included — it calls useDb(), which now reads the
5
+ // provider) and before media/auth/collections/public's own plugins (docs/internals/architecture.md § Server
6
+ // plugins: core → media → auth → collections → public). Resolves the config ONCE with the app's own
7
+ // runtimeConfig-preferred, file/env-fallback precedence and pushes it into the config provider — the seam
8
+ // package code (locale.ts, revision-retention.ts, db.ts today; the future package-side movers of the
9
+ // same files) reads from instead of touching `useRuntimeConfig()` directly.
10
+ export default defineNitroPlugin(() => {
11
+ setResolvedKestrelConfig(resolveServerKestrelConfig())
12
+ })
@@ -1,6 +1,7 @@
1
1
  import { resolve } from 'node:path'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
4
+ import { useDb } from '@michaelthielemann/kestrel-core'
4
5
 
5
6
  export default defineNitroPlugin(() => {
6
7
  // The committed drizzle-kit migrations only exist in Kestrel's own repo. When Kestrel is consumed as
@@ -0,0 +1,5 @@
1
+ import { buildIntrospectionPipelines, registerPipeline } from '@michaelthielemann/kestrel-core'
2
+ // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
3
+ export default defineNitroPlugin(() => {
4
+ for (const def of buildIntrospectionPipelines()) registerPipeline(def)
5
+ })
@@ -0,0 +1,5 @@
1
+ import { buildOpenApiPipelines, registerPipeline } from '@michaelthielemann/kestrel-core'
2
+ // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
3
+ export default defineNitroPlugin(() => {
4
+ for (const def of buildOpenApiPipelines()) registerPipeline(def)
5
+ })
@@ -0,0 +1,5 @@
1
+ import { buildToolingPipelines, registerPipeline } from '@michaelthielemann/kestrel-core'
2
+ // Registration only — nothing resolves a pipeline here; the registry is read on the first request.
3
+ export default defineNitroPlugin(() => {
4
+ for (const def of buildToolingPipelines()) registerPipeline(def)
5
+ })