@michaelthielemann/kestrel 3.0.1 → 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 (341) 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 +1 -3
  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/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -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
- })
@@ -1,142 +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
- // A pageLike, status-bearing collection — the page-builder case the Save/Publish split is for.
9
- const pagesSchema = {
10
- name: 'pages', mode: 'multi', translatable: false, pageLike: true, seo: false, status: true,
11
- blocks: { enabled: false }, label: { singular: 'Page', plural: 'Pages' },
12
- fields: { title: { type: 'text', required: true, unique: false } },
13
- }
14
- registerEndpoint('/api/collections', () => ({ data: [pagesSchema] }))
15
-
16
- const calls = vi.hoisted(() => ({
17
- publishOnSave: false,
18
- writes: [] as Record<string, unknown>[],
19
- publishes: [] as Record<string, unknown>[],
20
- tickets: [] as Record<string, unknown>[],
21
- }))
22
-
23
- registerEndpoint('/api/pages/1', async (event) => {
24
- if (event.method === 'PATCH') {
25
- const b = await readBody(event)
26
- calls.writes.push(b)
27
- return { id: 1, ...b, updatedAt: '2026-08-13T10:00:00.000Z' }
28
- }
29
- return { id: 1, title: 'Existing', path: '/existing', status: 'draft', updatedAt: '2026-08-13T09:00:00.000Z' }
30
- })
31
- registerEndpoint('/api/pages', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
32
- registerEndpoint('/api/pages/1/dead-refs', () => [])
33
- registerEndpoint('/api/publish-status', () => ({ route: '/existing', status: 'success', pending: !calls.publishOnSave, generates: true, publishOnSave: calls.publishOnSave, updatedAt: '2026-08-13T09:30:00.000Z' }))
34
- registerEndpoint('/api/publish', async (event) => {
35
- calls.publishes.push(await readBody(event))
36
- return { queued: true, generates: true, routes: ['/existing'], pruned: [], drafts: [] }
37
- })
38
- registerEndpoint('/api/preview', async (event) => {
39
- if (event.method === 'POST') {
40
- calls.tickets.push(await readBody(event))
41
- return { token: 'pv_abc', expiresAt: Date.now() + 600_000 }
42
- }
43
- return null
44
- })
45
-
46
- const h = vi.hoisted(() => ({ params: { collection: 'pages', id: '1' }, nav: [] as unknown[] }))
47
- mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
48
- mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
49
-
50
- let opened: Array<{ url: string }>
51
- let lastTab: { location: { replace: (url: string) => void }; close: () => void } | null
52
-
53
- beforeEach(() => {
54
- h.params = { collection: 'pages', id: '1' }
55
- h.nav.length = 0
56
- calls.publishOnSave = false
57
- calls.writes.length = 0
58
- calls.publishes.length = 0
59
- calls.tickets.length = 0
60
- opened = []
61
- lastTab = null
62
- useState('kestrel-collections').value = null
63
- useState('kestrel-blocks').value = null
64
- vi.spyOn(window, 'open').mockImplementation(((url?: string) => {
65
- // The real code opens a blank tab synchronously and redirects it once the ticket is minted.
66
- if (url) { opened.push({ url }); return null }
67
- lastTab = { location: { replace: (to: string) => opened.push({ url: to }) }, close: () => {} }
68
- return lastTab as unknown as Window
69
- }) as typeof window.open)
70
- })
71
-
72
- const settle = async () => {
73
- await new Promise((r) => setTimeout(r, 20))
74
- await flushPromises()
75
- }
76
- const button = (w: Awaited<ReturnType<typeof mountSuspended>>, label: string) =>
77
- w.findAll('.ui-button').find((b) => b.text() === label)
78
-
79
- describe('record editor — Save and Publish are separate actions', () => {
80
- it('offers Publish next to Save', async () => {
81
- const w = await mountSuspended(RecordPage)
82
- await flushPromises()
83
- const labels = w.find('.record__actions').findAll('.ui-button').map((b) => b.text()).filter(Boolean)
84
- expect(labels).toContain('Publish')
85
- expect(labels.indexOf('Publish')).toBe(labels.indexOf('Save') + 1)
86
- })
87
-
88
- // `output.publishOnSave` is the way back to the pre-2.0 model, where a save republished on its own.
89
- it('drops the Publish button when the consumer turned the split off', async () => {
90
- calls.publishOnSave = true
91
- const w = await mountSuspended(RecordPage)
92
- await settle()
93
- expect(w.find('.record__actions').findAll('.ui-button').map((b) => b.text())).not.toContain('Publish')
94
- })
95
-
96
- it('Save writes the record and asks for no publish at all', async () => {
97
- const w = await mountSuspended(RecordPage)
98
- await flushPromises()
99
- await w.find('input[type="text"]').setValue('Edited')
100
- // The header Save is a `type=submit` bound to the form by id — which only the real browser follows.
101
- await w.find('form.editor').trigger('submit')
102
- await settle()
103
- expect(calls.writes).toHaveLength(1)
104
- expect(calls.writes[0]).toMatchObject({ title: 'Edited', status: 'draft' }) // status untouched by a save
105
- expect(calls.publishes).toEqual([])
106
- })
107
-
108
- it('Publish saves first, promotes the draft, and then writes the static output', async () => {
109
- const w = await mountSuspended(RecordPage)
110
- await flushPromises()
111
- await w.find('input[type="text"]').setValue('Ready to go')
112
- await button(w, 'Publish')!.trigger('click')
113
- await settle()
114
- expect(calls.writes[0]).toMatchObject({ title: 'Ready to go', status: 'published' })
115
- expect(calls.publishes).toEqual([{ collection: 'pages', id: 1 }])
116
- })
117
- })
118
-
119
- describe('record editor — previewing in a new tab', () => {
120
- it('opens the saved URL directly when there is nothing unsaved', async () => {
121
- const w = await mountSuspended(RecordPage)
122
- await flushPromises()
123
- const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Open in new tab')!
124
- await preview.trigger('click')
125
- await settle()
126
- expect(opened.map((o) => o.url)).toContain('/existing')
127
- expect(calls.tickets).toEqual([])
128
- })
129
-
130
- it('mints a ticket for unsaved changes instead of saving them', async () => {
131
- const w = await mountSuspended(RecordPage)
132
- await flushPromises()
133
- await w.find('input[type="text"]').setValue('Not saved')
134
- await flushPromises()
135
- const preview = w.findAll('.ui-button').find((b) => b.attributes('aria-label') === 'Preview unsaved changes in a new tab')!
136
- await preview.trigger('click')
137
- await settle()
138
- expect(calls.writes).toEqual([]) // the whole point: no save
139
- expect(calls.tickets[0]).toMatchObject({ collection: 'pages', id: 1, values: { title: 'Not saved' } })
140
- expect(opened.at(-1)!.url).toBe('/existing?kestrel-preview-token=pv_abc')
141
- })
142
- })
@@ -1,38 +0,0 @@
1
- export default defineEventHandler(async (event) => {
2
- const ip = clientIp(event)
3
- const key = throttleKey(ip)
4
- const now = Date.now()
5
- assertBodyLimit(getRequestHeader(event, 'content-length'))
6
- // Reserve the attempt up front (assert-not-locked + record, atomically — no await in between) so
7
- // concurrent in-flight guesses from the same IP all count toward the cap before the slow hash runs.
8
- // The failure is already recorded here; a successful login clears it below.
9
- reserveLoginAttempt(key, now)
10
- const body = await readBody(event)
11
- const password = typeof body?.password === 'string' ? body.password : ''
12
-
13
- let stored: string
14
- try {
15
- // No hash configured → login is impossible: surface a distinct 503 instead of a confusing 401.
16
- stored = requireAdminHash()
17
- } catch (error) {
18
- // A server-side misconfiguration nobody can guess their way past: refund the reservation so the admin
19
- // isn't still locked out once they set the hash. The slot-busy 503 below stays billable by contrast —
20
- // it IS attacker-triggerable, and charging it is what makes a flood run into the lockout.
21
- releaseLoginAttempt(key, now)
22
- throw error
23
- }
24
- acquireHashSlot()
25
- let ok: boolean
26
- try {
27
- ok = await verifyPassword(password, stored)
28
- } finally {
29
- releaseHashSlot()
30
- }
31
-
32
- if (!ok) {
33
- throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
34
- }
35
- clearLoginFailures(key)
36
- const exp = setAuthSession(event)
37
- return { ok: true, exp }
38
- })
@@ -1,9 +0,0 @@
1
- export default defineEventHandler((event) => {
2
- requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
3
- // Clear the cookie for THIS client AND bump the revocation epoch so any other copy of the token (a synced
4
- // backup, a leaked log, another device) is invalidated server-side immediately — a real logout, not just a
5
- // cookie clear. Single admin ⇒ logout = logout-everywhere, which is the expected behaviour.
6
- clearAuthSession(event)
7
- bumpSessionEpoch()
8
- return { ok: true }
9
- })
@@ -1 +0,0 @@
1
- export default defineEventHandler((event) => getAuthSession(event))
@@ -1,42 +0,0 @@
1
- import { createError } from 'h3'
2
-
3
- type Env = Record<string, string | undefined>
4
-
5
- /** The configured admin password hash, or `undefined` when `KESTREL_ADMIN_PASSWORD_HASH` is unset/empty. */
6
- export function adminPasswordHash(env: Env = process.env): string | undefined {
7
- const stored = env.KESTREL_ADMIN_PASSWORD_HASH
8
- return typeof stored === 'string' && stored.length > 0 ? stored : undefined
9
- }
10
-
11
- let warned = false
12
- function warnNotConfigured(): void {
13
- if (warned) return
14
- warned = true
15
- console.warn(
16
- '[kestrel auth] admin login is not configured — set KESTREL_ADMIN_PASSWORD_HASH to enable sign-in',
17
- )
18
- }
19
-
20
- /**
21
- * Resolve the admin password hash for a login attempt, or fail loudly when it is unset.
22
- *
23
- * A missing `KESTREL_ADMIN_PASSWORD_HASH` means login is *impossible*, not that a credential was wrong —
24
- * answering with the generic 401 makes an operator who simply forgot to set the hash chase a phantom
25
- * "wrong password". So we surface a distinct **503** (plus a one-time server warning) for that case only.
26
- *
27
- * Security: this distinguishes solely the *unconfigured* server. A configured server still answers every
28
- * bad credential with an opaque 401, so it never leaks which part (user/password) failed. Short-circuiting
29
- * here is safe — a 503 already states plainly that login is unconfigured, so there is no timing signal to
30
- * protect.
31
- */
32
- export function requireAdminHash(env: Env = process.env, warn: () => void = warnNotConfigured): string {
33
- const stored = adminPasswordHash(env)
34
- if (!stored) {
35
- warn()
36
- throw createError({
37
- statusCode: 503,
38
- statusMessage: 'admin login is not configured (set KESTREL_ADMIN_PASSWORD_HASH)',
39
- })
40
- }
41
- return stored
42
- }