@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
@@ -8,6 +8,6 @@ defineBlock({ label: { en: 'Prose' }, icon: 'file-text' })
8
8
 
9
9
  <template>
10
10
  <!-- richtext is sanitized server-side on write -->
11
- <!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in layers/fields/server/field-registry/index.ts -->
11
+ <!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in packages/kestrel-fields/src/server/field-registry/index.ts -->
12
12
  <div class="block-prose" v-html="body" />
13
13
  </template>
@@ -1,39 +0,0 @@
1
- export interface CsrfHeaders {
2
- secFetchSite?: string | null
3
- origin?: string | null
4
- referer?: string | null
5
- host?: string | null
6
- }
7
-
8
- function hostOf(url: string | null | undefined): string | null {
9
- if (!url) return null
10
- try {
11
- return new URL(url).host
12
- } catch {
13
- return null
14
- }
15
- }
16
-
17
- export function isCrossSiteWrite(h: CsrfHeaders): boolean {
18
- if (h.secFetchSite) {
19
- return !(h.secFetchSite === 'same-origin' || h.secFetchSite === 'none')
20
- }
21
- // hostOf() lowercases per the URL spec; the raw Host header does not, so normalize both
22
- // sides — host comparison is case-insensitive (RFC 3986).
23
- const host = (h.host ?? '').toLowerCase()
24
- // The literal opaque origin `null` (a browser signal from a sandboxed iframe, a data: URL, or some
25
- // redirect chains) is unparseable by new URL, so it must NOT fall through to the all-absent "allow"
26
- // path — per spec it is cross-origin. Treat it as cross-site.
27
- if (h.origin === 'null') return true
28
- const originHost = hostOf(h.origin)
29
- if (originHost !== null) return originHost !== host
30
- const refererHost = hostOf(h.referer)
31
- if (refererHost !== null) return refererHost !== host
32
- // A write carrying NONE of Sec-Fetch-Site / Origin / Referer is treated as same-site (allowed). A
33
- // browser ALWAYS attaches Origin and/or Sec-Fetch-Site to a cross-origin state-changing request, so
34
- // the all-absent case can only come from a NON-browser client (the test harness, a server-to-server
35
- // import, automation) — which carries no ambient cookies and therefore cannot mount CSRF. The real
36
- // defenses are SameSite=Strict on the session cookie + the Origin/Referer checks above; failing closed
37
- // here adds no protection and breaks legitimate authenticated API clients.
38
- return false
39
- }
@@ -1,36 +0,0 @@
1
- import type { Grant, Role } from './policy'
2
-
3
- // A process-singleton registry of EXTRA authorization grants, contributed by layers/extensions at boot
4
- // (server plugin) on top of the hardcoded `POLICY`. This is the seam that lets an opt-in layer deliberately
5
- // open a narrow hole in the default-deny guard — e.g. the proofing extension granting `anonymous → write →
6
- // proofing` for its public back-channel. Inert until something registers: with an empty registry the guard
7
- // behaves exactly as the hardcoded policy. The map is INJECTED into the (pure) guard, not read from inside
8
- // it (symmetric with `publicResources`), so the policy stays deterministically node-testable. Foundation
9
- // for a future configurable Users & Roles system.
10
- const registered: Partial<Record<Role, Grant[]>> = {}
11
-
12
- /** Add an extra grant for a role. Deliberate + explicit — it widens what that role may do. */
13
- export function registerAccessGrant(role: Role, grant: Grant): void {
14
- // Defense-in-depth: the public (anonymous) must never be handed a wildcard or draft-read through this
15
- // seam — a legitimate public back-channel always names ONE specific resource and reads published-only.
16
- // CRUCIAL: the evaluator (`resolveAccess`) defaults an OMITTED read scope to 'all' via `(scope ?? 'all')`,
17
- // so an unscoped anonymous read would silently resolve to draft-read. The validator must use the SAME
18
- // default, or a "safe"-looking `{action:'read', resource:'x'}` grant leaks drafts to anonymous visitors.
19
- // (Write grants carry no read scope — `resolveAccess` only upgrades readScope for read grants — so an
20
- // omitted scope on a write is fine, e.g. the proofing back-channel's anonymous write.)
21
- const anonymousDraftRead = role === 'anonymous' && grant.action === 'read' && (grant.scope ?? 'all') === 'all'
22
- if (role === 'anonymous' && (grant.resource === '*' || anonymousDraftRead)) {
23
- throw new Error(`refusing an over-broad anonymous grant (${grant.action} ${grant.resource}${grant.scope ? ` scope=${grant.scope}` : ''}): name a specific resource and grant read with an explicit scope: 'published' (never draft-read)`)
24
- }
25
- (registered[role] ??= []).push(grant)
26
- }
27
-
28
- /** The full role → extra-grants map, for the guard to consult alongside `POLICY`. */
29
- export function registeredGrants(): Readonly<Partial<Record<Role, Grant[]>>> {
30
- return registered
31
- }
32
-
33
- /** Test helper: drop all registered grants (the registry is a module singleton). */
34
- export function clearAccessGrants(): void {
35
- for (const role of Object.keys(registered) as Role[]) Reflect.deleteProperty(registered, role)
36
- }
@@ -1,44 +0,0 @@
1
- import { isCrossSiteWrite, type CsrfHeaders } from './csrf'
2
- import { verifySession } from '../../../auth/server/utils/session'
3
- import { resolveAccess, actionForMethod, resourceForPath, isBootstrapPath, isPublicRenderPath, type Principal, type Grant, type Role } from './policy'
4
-
5
- export interface AccessInput {
6
- method: string
7
- path: string
8
- csrf: CsrfHeaders
9
- cookie: string | undefined
10
- secret: string
11
- nowMs: number
12
- isPrerender: boolean
13
- }
14
-
15
- export type AccessDecision =
16
- | { allow: true; principal: Principal; readScope: 'published' | 'all' }
17
- | { allow: false; status: number; message: string }
18
-
19
- export function derivePrincipal(input: AccessInput): Principal {
20
- if (input.isPrerender) return { userId: 'renderer', role: 'renderer' }
21
- if (verifySession(input.secret, input.cookie, input.nowMs).valid) return { userId: 'admin', role: 'admin' }
22
- return { userId: null, role: 'anonymous' }
23
- }
24
-
25
- export function evaluateAccess(input: AccessInput, publicResources: readonly string[] = [], registeredGrants: Readonly<Partial<Record<Role, Grant[]>>> = {}): AccessDecision {
26
- // CSRF first: every unsafe method (incl. login) must be same-site.
27
- if (actionForMethod(input.method) === 'write' && isCrossSiteWrite(input.csrf)) {
28
- return { allow: false, status: 403, message: 'Cross-origin write rejected' }
29
- }
30
- const principal = derivePrincipal(input)
31
- // Auth bootstrap (login/session) is always permitted so the SPA can authenticate. Scope still follows
32
- // the principal (mirrors isPublicRenderPath below) — an anonymous caller never gets draft-read scope.
33
- if (isBootstrapPath(input.method, input.path)) {
34
- return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
35
- }
36
- // The public render entry is readable by everyone; the scope follows the principal so an admin can
37
- // preview drafts while anonymous (and the static render) stay published-only.
38
- if (isPublicRenderPath(input.method, input.path)) {
39
- return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
40
- }
41
- const { allowed, readScope } = resolveAccess(principal, actionForMethod(input.method), resourceForPath(input.path), publicResources, registeredGrants[principal.role] ?? [])
42
- if (!allowed) return { allow: false, status: 401, message: 'Authentication required' }
43
- return { allow: true, principal, readScope }
44
- }
@@ -1,79 +0,0 @@
1
- export type AllowlistMode = 'enforce' | 'log' | 'off'
2
-
3
- /** A parsed IPv4 CIDR as `network base & mask`, both uint32. */
4
- export interface Cidr {
5
- base: number
6
- mask: number
7
- }
8
-
9
- /**
10
- * Resolve the gate mode from `KESTREL_IP_ALLOWLIST_MODE` given the raw list.
11
- *
12
- * An empty list means there is nothing to enforce → `off`. When a list IS present, an unset/unknown mode
13
- * defaults to `enforce`: a configured allow-list that silently let everyone through is the exact legacy
14
- * footgun we are replacing. `log` is the opt-in calibration mode (observe, never block) used to confirm the
15
- * resolved client IP + `KESTREL_TRUST_PROXY` depth before enforcing.
16
- */
17
- export function allowlistMode(modeRaw: string | undefined, listRaw: string | undefined): AllowlistMode {
18
- if (!listRaw || !listRaw.trim()) return 'off'
19
- const m = (modeRaw ?? '').trim().toLowerCase()
20
- if (m === 'off' || m === 'disabled' || m === 'false') return 'off'
21
- if (m === 'log' || m === 'log-only' || m === 'dry-run') return 'log'
22
- return 'enforce'
23
- }
24
-
25
- /** `1.2.3.4` / `::ffff:1.2.3.4` → uint32; `null` for out-of-range, real IPv6, or non-IPv4 input. */
26
- export function ipv4ToInt(ip: string): number | null {
27
- let s = ip.trim()
28
- const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(s)
29
- if (mapped) s = mapped[1]!
30
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
31
- if (!m) return null
32
- let n = 0
33
- for (let i = 1; i <= 4; i++) {
34
- const octet = Number(m[i])
35
- if (octet > 255) return null
36
- n = n * 256 + octet
37
- }
38
- return n >>> 0
39
- }
40
-
41
- function parseCidr(token: string): Cidr | null {
42
- const slash = token.indexOf('/')
43
- const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
44
- if (base === null) return null
45
- // `Number()` alone would widen a malformed prefix into a mask: '' → 0 → 0.0.0.0/0, and '0x10'/'1e1' → 16/10.
46
- const bitsRaw = slash === -1 ? '32' : token.slice(slash + 1).trim()
47
- if (!/^\d+$/.test(bitsRaw)) return null
48
- const bits = Number(bitsRaw)
49
- if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
50
- const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
51
- return { base: (base & mask) >>> 0, mask }
52
- }
53
-
54
- /**
55
- * Parse the allow-list env into CIDRs, tolerant of the legacy nginx block so it can be pasted verbatim:
56
- * newline/comma/semicolon separated, `# comments` and a leading `allow`/`set_real_ip_from` keyword stripped.
57
- * Invalid tokens are dropped, not thrown — one bad line must not disable the whole gate. `onReject`, when
58
- * given, is called with each dropped token (this parser is IPv4-only, so an IPv6 CIDR pasted into an
59
- * otherwise-valid list is silently a no-op unless the caller surfaces it).
60
- */
61
- export function parseAllowlist(raw: string | undefined, onReject?: (token: string) => void): Cidr[] {
62
- if (!raw) return []
63
- const out: Cidr[] = []
64
- for (const part of raw.split(/[\n,;]+/)) {
65
- const token = part.replace(/#.*$/, '').trim().replace(/^(?:allow|set_real_ip_from)\s+/i, '').trim()
66
- if (!token) continue
67
- const cidr = parseCidr(token)
68
- if (cidr) out.push(cidr)
69
- else onReject?.(token)
70
- }
71
- return out
72
- }
73
-
74
- /** True when `ip` falls in any CIDR. Fails closed: a non-IPv4 address or empty list never matches. */
75
- export function ipAllowed(ip: string, cidrs: Cidr[]): boolean {
76
- const n = ipv4ToInt(ip)
77
- if (n === null) return false
78
- return cidrs.some((c) => ((n & c.mask) >>> 0) === c.base)
79
- }
@@ -1,98 +0,0 @@
1
- export type Role = 'admin' | 'renderer' | 'anonymous'
2
- export type Action = 'read' | 'write'
3
- export interface Principal { userId: string | null; role: Role }
4
- export interface Grant { action: Action; resource: string; scope?: 'published' | 'all' }
5
- export interface AccessResult { allowed: boolean; readScope: 'published' | 'all' }
6
-
7
- /** Hardcoded authorization policy. Role -> grants. `resource: '*'` matches any. */
8
- export const POLICY: Record<Role, Grant[]> = {
9
- admin: [{ action: 'write', resource: '*' }, { action: 'read', resource: '*' }],
10
- // The renderer is ONLY ever the build-time prerender or the runtime publisher; both produce the
11
- // public static site, which is published-only by definition. Scope its read to 'published' so the
12
- // generic /api/<collection> reads never leak drafts into the generated HTML (admin live preview is
13
- // unaffected — it renders under the admin principal, scope 'all').
14
- renderer: [{ action: 'read', resource: '*', scope: 'published' }],
15
- // Anonymous published-read is data-driven from the registry (every page-like collection), not a
16
- // literal allow-list — see the `publicResources` branch in `resolveAccess`.
17
- anonymous: [],
18
- }
19
-
20
- export function resolveAccess(principal: Principal, action: Action, resource: string, publicResources: readonly string[] = [], extraGrants: readonly Grant[] = []): AccessResult {
21
- let allowed = false
22
- // readScope reflects the principal's READ permission for this resource, INDEPENDENT of the requested
23
- // action, and is fail-closed to 'published': a granted WRITE must never silently confer draft-read
24
- // ('all'). Only an explicit read grant with scope 'all' (i.e. admin) upgrades it.
25
- let readScope: 'published' | 'all' = 'published'
26
- // Hardcoded policy first, then any registry-contributed grants for this role (the opt-in seam). Extra
27
- // grants only ever ADD permissions for the role they were registered under — they can't revoke.
28
- for (const g of [...(POLICY[principal.role] ?? []), ...extraGrants]) {
29
- if (g.resource !== '*' && g.resource !== resource) continue
30
- if (g.action === action) allowed = true
31
- if (g.action === 'read' && (g.scope ?? 'all') === 'all') readScope = 'all' // a draft-read grant wins
32
- }
33
- // An anonymous visitor gets published read on every page-like collection (the registry-driven public
34
- // set), so a consumer's own pageLike collection is reachable without editing a literal allow-list.
35
- // Published-only and gated to the public set — never widens to drafts or non-pageLike collections.
36
- if (!allowed && principal.role === 'anonymous' && action === 'read' && publicResources.includes(resource)) {
37
- allowed = true
38
- readScope = 'published'
39
- }
40
- return { allowed, readScope }
41
- }
42
-
43
- /**
44
- * Whether an anonymous visitor may read a collection — i.e. whether its pages are publicly reachable.
45
- * The single source of truth for "what belongs in a public sitemap": a sitemap must list only URLs an
46
- * unauthenticated crawler can actually fetch, so the sitemap filters pageLike collections through this.
47
- */
48
- export function isPubliclyReadable(resource: string, publicResources: readonly string[] = []): boolean {
49
- return resolveAccess({ userId: null, role: 'anonymous' }, 'read', resource, publicResources).allowed
50
- }
51
-
52
- /**
53
- * Whether a read at this `readScope` must be limited to published rows. Fail-CLOSED: only the explicit
54
- * `'all'` scope (admin / live preview) sees drafts; anything else — `'published'`, an unexpected value,
55
- * or a missing scope (a guard regression) — stays published-only. Mirrors the public render path so the
56
- * two read entry points can't drift to opposite defaults.
57
- */
58
- export function publishedOnlyForScope(readScope: string | undefined): boolean {
59
- return readScope !== 'all'
60
- }
61
-
62
- const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
63
- export function actionForMethod(method: string): Action {
64
- return SAFE_METHODS.has(method.toUpperCase()) ? 'read' : 'write'
65
- }
66
-
67
- /**
68
- * Resource for an /api path: the collection segment, except the tooling sub-routes
69
- * (`/options`, `/translations`, `/:id/translations`, `/:id/dead-refs`) map to a distinct
70
- * `<collection>/<tool>` resource so the public content grant (read on the bare collection) never covers
71
- * admin-only tooling — a pageLike collection's `translations` / `dead-refs` maps must NOT be readable by
72
- * anonymous visitors. The group-keyed and per-record translations routes share one resource: they expose
73
- * the same sibling ids (drafts included), so they must be gated identically.
74
- */
75
- export function resourceForPath(path: string): string {
76
- const segs = (path.split('?')[0] ?? '').split('/').filter(Boolean)
77
- if (segs[0] !== 'api' || !segs[1]) return ''
78
- if (segs[2] === 'options' || segs[2] === 'translations') return `${segs[1]}/${segs[2]}`
79
- if (segs[3] === 'translations' || segs[3] === 'dead-refs') return `${segs[1]}/${segs[3]}`
80
- return segs[1]
81
- }
82
-
83
- /** Auth bootstrap endpoints, always permitted so the SPA can authenticate under default-deny. */
84
- export function isBootstrapPath(method: string, path: string): boolean {
85
- const clean = path.split('?')[0]
86
- const m = method.toUpperCase()
87
- return (m === 'POST' && clean === '/api/auth/login') || (m === 'GET' && clean === '/api/auth/session')
88
- }
89
-
90
- /**
91
- * The public render entry (`GET /api/route`) the live page resolver calls. Readable by everyone — it is
92
- * the runtime equivalent of serving a static page — but the read SCOPE follows the principal: anonymous
93
- * gets published-only, an authenticated admin gets 'all' so the handler can surface a draft preview.
94
- * The handler itself re-forces published-only for any static-generation render (prerender / publisher).
95
- */
96
- export function isPublicRenderPath(method: string, path: string): boolean {
97
- return method.toUpperCase() === 'GET' && path.split('?')[0] === '/api/route'
98
- }
@@ -1,11 +0,0 @@
1
- import { allCollections } from '../../../core/server/utils/registry'
2
-
3
- /**
4
- * Collections an anonymous visitor may read (published only): every page-like collection. The single,
5
- * registry-driven source for public reachability — consumed by the access guard (runtime serving) and
6
- * the sitemap (which URLs to advertise), so a consumer's own pageLike collection is public without
7
- * editing a literal allow-list. Page-like ⟺ has a `path`, i.e. it is routable to a static URL.
8
- */
9
- export function publicReadableResources(): string[] {
10
- return allCollections().filter((c) => c.def.pageLike).map((c) => c.def.name)
11
- }
@@ -1,41 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
-
3
- /**
4
- * Marks an in-process request chain as the runtime publisher's render, so the access guard grants it the
5
- * same `renderer` principal that build-time prerender gets. The publisher's render does a nested
6
- * `$fetch('/api/route')`, which is NOT a public endpoint; without this it would 401 at runtime. Set via
7
- * AsyncLocalStorage — never a header — so an external HTTP request can't forge renderer access (and
8
- * resolvePage still enforces published-only, so drafts never reach the output regardless).
9
- */
10
- const ctx = new AsyncLocalStorage<true>()
11
-
12
- /** Run a publish render (and its nested API fetches) as the renderer principal. */
13
- export function runAsRenderer<T>(fn: () => T): T {
14
- return ctx.run(true, fn)
15
- }
16
-
17
- /** True when executing inside a publisher render, so the access guard grants the renderer principal. */
18
- export function isRendererContext(): boolean {
19
- return ctx.getStore() === true
20
- }
21
-
22
- const stageGate = new AsyncLocalStorage<true>()
23
-
24
- /**
25
- * Marks the current request's async context as classified by the stage IP gate, so the in-process
26
- * sub-requests it spawns (the SSR page's own `$fetch`, the editor preview) are not gated again — they
27
- * reach the middleware stack on a mocked socket with no peer address and could never match a CIDR.
28
- *
29
- * `enterWith` rather than `run` because a middleware has no handle on the rest of its own request; the
30
- * mark is therefore reachable only from work descending from a request the gate already admitted, which
31
- * is what makes it unforgeable — unlike "this request has no socket peer", a property whole listeners
32
- * (unix domain sockets) have for every external client.
33
- */
34
- export function markStageGatePassed(): void {
35
- stageGate.enterWith(true)
36
- }
37
-
38
- /** True inside a request whose originating request was already classified by the stage IP gate. */
39
- export function isStageGatePassedContext(): boolean {
40
- return stageGate.getStore() === true
41
- }
@@ -1,15 +0,0 @@
1
- import { createError, type H3Event } from 'h3'
2
-
3
- /**
4
- * Per-handler write-authorization backstop. The access-guard middleware is the PRIMARY enforcement, but
5
- * it is gated on an `/api/` path-prefix string and is the only check — a future mutating route mounted
6
- * outside `/api/` (a `server/routes/*` action, a webhook), or any guard-vs-router path divergence, would
7
- * otherwise run unauthenticated. Every mutating handler calls this so authorization never depends solely
8
- * on that prefix heuristic. Throws 401 unless the resolved principal is the admin (the only role with
9
- * write grants — `renderer` is read-only, `anonymous` has no grants).
10
- */
11
- export function requireAdmin(event: H3Event): void {
12
- if (event.context.principal?.role !== 'admin') {
13
- throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
14
- }
15
- }
@@ -1,200 +0,0 @@
1
- import { describe, it, expect, beforeEach, vi } from 'vitest'
2
- import { useState } from '#imports'
3
- import { flushPromises } from '@vue/test-utils'
4
- import { readBody } from 'h3'
5
- import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
6
- import RecordPage from './[id].vue'
7
-
8
- const thingsSchema = {
9
- name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
10
- blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
11
- fields: { title: { type: 'text', required: true, unique: false } },
12
- }
13
- // Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
14
- const articlesSchema = {
15
- name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
16
- blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
17
- fields: { title: { type: 'text', required: true, unique: false } },
18
- }
19
- registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
20
- registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
21
-
22
- let patched: Record<string, unknown> | null = null
23
- let posted: Record<string, unknown> | null = null
24
- registerEndpoint('/api/things/1', async (event) => {
25
- if (event.method === 'PATCH') { patched = await readBody(event); return { id: 1, ...patched } }
26
- return { id: 1, title: 'Existing' }
27
- })
28
- // A record whose title field is blank — the heading must fall back to the generic "#id" phrase.
29
- registerEndpoint('/api/things/2', () => ({ id: 2, title: ' ' }))
30
- registerEndpoint('/api/things', async (event) => {
31
- if (event.method === 'POST') { posted = await readBody(event); return { id: 7, ...posted } }
32
- return { data: [], total: 0, page: 1, perPage: 25 }
33
- })
34
-
35
- // The editor's Delete flows through the shared batch endpoint + the referrer-aggregate preview.
36
- let bulkBody: Record<string, unknown> | null = null
37
- registerEndpoint('/api/things/bulk', async (event) => {
38
- bulkBody = await readBody(event)
39
- return { action: bulkBody!.action, count: 1, ids: bulkBody!.ids }
40
- })
41
- registerEndpoint('/api/references/referrers', () => ({ counts: {} }))
42
-
43
- // Route + navigation are mocked so the page reads collection/id from `h.params` and navigations are captured.
44
- const h = vi.hoisted(() => ({ params: { collection: 'things', id: '1' }, nav: [] as unknown[] }))
45
- mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
46
- mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
47
-
48
- beforeEach(() => {
49
- h.params = { collection: 'things', id: '1' }
50
- h.nav.length = 0
51
- patched = null
52
- posted = null
53
- bulkBody = null
54
- useState('kestrel-collections').value = null
55
- useState('kestrel-blocks').value = null
56
- })
57
-
58
- const settle = async () => {
59
- await new Promise((r) => setTimeout(r, 20))
60
- await flushPromises()
61
- }
62
-
63
- describe('record editor page header', () => {
64
- it('merges Save, Cancel and Delete into the record head, each with an icon', async () => {
65
- const w = await mountSuspended(RecordPage)
66
- await flushPromises()
67
-
68
- const head = w.find('.record__head')
69
- expect(head.exists()).toBe(true)
70
- const buttons = head.findAll('.ui-button')
71
- expect(buttons.map((b) => b.text())).toEqual(expect.arrayContaining(['Save', 'Cancel', 'Delete']))
72
- for (const b of buttons) expect(b.find('.ui-icon').exists()).toBe(true)
73
- expect(w.find('.editor__actions').exists()).toBe(false)
74
-
75
- // Cancel carries real button chrome — it sits beside the solid Delete/Save, not as bare text…
76
- expect(buttons.find((b) => b.text() === 'Cancel')!.classes()).toContain('ui-button--secondary')
77
- // …while the icon-only tools (undo/redo/open) stay quiet ghosts.
78
- const iconOnly = buttons.filter((b) => b.text() === '')
79
- expect(iconOnly.length).toBeGreaterThan(0)
80
- for (const b of iconOnly) expect(b.classes()).toContain('ui-button--ghost')
81
- })
82
-
83
- it('titles the header with the singular label, not the raw (plural) route param', async () => {
84
- h.params = { collection: 'things', id: 'new' }
85
- const wNew = await mountSuspended(RecordPage)
86
- await flushPromises()
87
- expect(wNew.find('.record__title').text()).toBe('New Thing')
88
- expect(wNew.find('.record__back').text()).toContain('Things')
89
- // The generic phrase is title-cased; a real record title would not be.
90
- expect(wNew.find('.record__title').classes()).toContain('record__title--generic')
91
- })
92
-
93
- it('keeps the back link on the heading row (one line, so the panes keep the vertical space)', async () => {
94
- const w = await mountSuspended(RecordPage)
95
- await flushPromises()
96
- expect(w.find('.record__head .record__back').exists()).toBe(true)
97
- })
98
-
99
- it('titles a saved record with its own title — an id means nothing to an editor', async () => {
100
- h.params = { collection: 'things', id: '1' }
101
- const wEdit = await mountSuspended(RecordPage)
102
- await settle()
103
- expect(wEdit.find('.record__title').text()).toBe('Existing')
104
- expect(wEdit.find('.record__title').classes()).not.toContain('record__title--generic')
105
- })
106
-
107
- it('falls back to "Edit {collection} #{id}" when the record has no usable title', async () => {
108
- h.params = { collection: 'things', id: '2' }
109
- const wEdit = await mountSuspended(RecordPage)
110
- await settle()
111
- expect(wEdit.find('.record__title').text()).toBe('Edit Thing #2')
112
- expect(wEdit.find('.record__title').classes()).toContain('record__title--generic')
113
- })
114
-
115
- it('uses the collection\'s explicit label.new for the create heading (no generic "New X")', async () => {
116
- h.params = { collection: 'articles', id: 'new' }
117
- const w = await mountSuspended(RecordPage)
118
- await flushPromises()
119
- expect(w.find('.record__title').text()).toBe('Compose article')
120
- })
121
-
122
- it('wires the header Save button to submit the editor form', async () => {
123
- const w = await mountSuspended(RecordPage)
124
- await flushPromises()
125
- const save = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Save')!
126
- expect(save.attributes('type')).toBe('submit')
127
- expect(save.attributes('form')).toBe('record-editor')
128
- expect(w.find('form.editor').attributes('id')).toBe('record-editor')
129
- })
130
-
131
- it('shows the back link with a centered SVG arrow (no text arrow glyph)', async () => {
132
- const w = await mountSuspended(RecordPage)
133
- await flushPromises()
134
- const back = w.find('.record__back')
135
- expect(back.exists()).toBe(true)
136
- expect(back.find('.ui-icon').exists()).toBe(true)
137
- expect(back.text()).not.toContain('←')
138
- })
139
-
140
- it('saving an existing record stays on the page — Save only saves', async () => {
141
- const w = await mountSuspended(RecordPage)
142
- await flushPromises()
143
- await w.find('form.editor').trigger('submit')
144
- await settle()
145
- expect(patched).toMatchObject({ title: 'Existing' })
146
- expect(h.nav).toEqual([])
147
- })
148
-
149
- it('saving a NEW record moves to its own editor, not back to the list', async () => {
150
- h.params = { collection: 'things', id: 'new' }
151
- const w = await mountSuspended(RecordPage)
152
- await flushPromises()
153
- await w.findAll('input')[0]!.setValue('Fresh')
154
- await w.find('form.editor').trigger('submit')
155
- await settle()
156
- expect(posted).toMatchObject({ title: 'Fresh' })
157
- // navigates to the created record's editor (id 7), never to the list path
158
- expect(h.nav).toContain('/admin/things/7')
159
- expect(h.nav).not.toContain('/admin/things')
160
- })
161
-
162
- it('deletes through the confirm dialog (shared batch op) and navigates back to the list', async () => {
163
- const w = await mountSuspended(RecordPage)
164
- await settle()
165
- // the header Delete opens the dialog rather than window.confirm
166
- const del = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Delete')!
167
- await del.trigger('click')
168
- await settle()
169
- expect(w.find('.ui-dialog__content').exists()).toBe(true)
170
- // confirming posts the bulk delete for this one id and then navigates to the list
171
- const confirm = w.findAll('.ui-dialog__content .ui-button').find((b) => /^delete$/i.test(b.text().trim()))!
172
- await confirm.trigger('click')
173
- await settle()
174
- expect(bulkBody).toEqual({ action: 'delete', ids: [1] })
175
- expect(h.nav).toContain('/admin/things')
176
- })
177
-
178
- it('a native beforeunload (tab close / reload) is blocked only while there are unsaved changes', async () => {
179
- const w = await mountSuspended(RecordPage)
180
- await flushPromises()
181
-
182
- // pristine → unload is NOT blocked
183
- const clean = new Event('beforeunload', { cancelable: true })
184
- window.dispatchEvent(clean)
185
- expect(clean.defaultPrevented).toBe(false)
186
-
187
- // edit a field → dirty → unload IS blocked (browser shows its native prompt)
188
- await w.findAll('input')[0]!.setValue('Changed')
189
- await settle()
190
- const dirty = new Event('beforeunload', { cancelable: true })
191
- window.dispatchEvent(dirty)
192
- expect(dirty.defaultPrevented).toBe(true)
193
-
194
- // unmounting removes the listener (no leak across pages)
195
- w.unmount()
196
- const afterUnmount = new Event('beforeunload', { cancelable: true })
197
- window.dispatchEvent(afterUnmount)
198
- expect(afterUnmount.defaultPrevented).toBe(false)
199
- })
200
- })