@michaelthielemann/kestrel 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (353) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +6 -8
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Button.vue +2 -2
  133. package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
  134. package/layers/ui/app/components/ui/Checkbox.vue +2 -2
  135. package/layers/ui/app/components/ui/Combobox.vue +5 -5
  136. package/layers/ui/app/components/ui/DatePicker.vue +2 -2
  137. package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
  138. package/layers/ui/app/components/ui/NumberInput.vue +4 -4
  139. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  140. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  141. package/layers/ui/app/components/ui/Select.vue +2 -2
  142. package/layers/ui/app/components/ui/TextInput.vue +4 -4
  143. package/layers/ui/app/components/ui/Textarea.vue +2 -2
  144. package/layers/ui/app/components/ui/TimeInput.vue +2 -2
  145. package/layers/ui/app/components/ui/Toasts.vue +3 -3
  146. package/layers/ui/app/composables/useLinkField.ts +1 -1
  147. package/layers/ui/app/composables/useRepeater.ts +1 -1
  148. package/layers/ui/app/i18n/de.ts +7 -7
  149. package/layers/ui/app/i18n/en.ts +7 -39
  150. package/layers/ui/app/utils/field-component.ts +1 -1
  151. package/layers/ui/app/utils/field-registry.ts +1 -1
  152. package/layers/ui/app/utils/icons.ts +2 -6
  153. package/layers/ui/app/utils/localized.ts +1 -1
  154. package/nuxt.config.ts +1 -1
  155. package/package.json +53 -19
  156. package/scripts/consumer-template-ci.mjs +157 -0
  157. package/scripts/coverage-all.mjs +163 -0
  158. package/scripts/dashboard.mjs +89 -0
  159. package/scripts/hash-password.mjs +1 -1
  160. package/scripts/lib/coverage-model.mjs +51 -0
  161. package/scripts/port-graph.mjs +350 -0
  162. package/scripts/typecheck.mjs +11 -0
  163. package/templates/starter/app/blocks/Prose.vue +1 -1
  164. package/layers/access/server/utils/csrf.ts +0 -39
  165. package/layers/access/server/utils/grant-registry.ts +0 -36
  166. package/layers/access/server/utils/guard.ts +0 -44
  167. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  168. package/layers/access/server/utils/policy.ts +0 -98
  169. package/layers/access/server/utils/public-resources.ts +0 -11
  170. package/layers/access/server/utils/render-context.ts +0 -41
  171. package/layers/access/server/utils/require-admin.ts +0 -15
  172. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  173. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  174. package/layers/auth/server/api/auth/login.post.ts +0 -38
  175. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  176. package/layers/auth/server/api/auth/session.get.ts +0 -1
  177. package/layers/auth/server/utils/admin-credential.ts +0 -42
  178. package/layers/auth/server/utils/client-ip.ts +0 -88
  179. package/layers/auth/server/utils/login-throttle.ts +0 -126
  180. package/layers/auth/server/utils/password.ts +0 -52
  181. package/layers/auth/server/utils/session-cookie.ts +0 -44
  182. package/layers/auth/server/utils/session-epoch.ts +0 -96
  183. package/layers/auth/server/utils/session.ts +0 -88
  184. package/layers/collections/server/collections/pages.ts +0 -19
  185. package/layers/collections/server/utils/populate-relations.ts +0 -79
  186. package/layers/core/app/utils/bulk-actions.ts +0 -9
  187. package/layers/core/app/utils/filter-ops.ts +0 -98
  188. package/layers/core/app/utils/list-limits.ts +0 -27
  189. package/layers/core/app/utils/locale-path.ts +0 -12
  190. package/layers/core/app/utils/slugify.ts +0 -16
  191. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  192. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  193. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  194. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  195. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  196. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  197. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  198. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  199. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  200. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  201. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  202. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  203. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  204. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  205. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  206. package/layers/core/server/api/collections/[name].get.ts +0 -4
  207. package/layers/core/server/api/collections/index.get.ts +0 -3
  208. package/layers/core/server/api/references/broken.get.ts +0 -9
  209. package/layers/core/server/api/references/referrers.get.ts +0 -24
  210. package/layers/core/server/database/record-refs.ts +0 -20
  211. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  212. package/layers/core/server/schema/bootstrap.ts +0 -41
  213. package/layers/core/server/schema/desired.ts +0 -99
  214. package/layers/core/server/schema/dev-sync.ts +0 -23
  215. package/layers/core/server/schema/dialect.ts +0 -61
  216. package/layers/core/server/schema/diff.ts +0 -110
  217. package/layers/core/server/schema/introspect.ts +0 -68
  218. package/layers/core/server/schema/model.ts +0 -61
  219. package/layers/core/server/schema/render-sqlite.ts +0 -89
  220. package/layers/core/server/schema/sync.ts +0 -201
  221. package/layers/core/server/utils/blocks.ts +0 -6
  222. package/layers/core/server/utils/collection-types.ts +0 -28
  223. package/layers/core/server/utils/crud.ts +0 -578
  224. package/layers/core/server/utils/db.ts +0 -26
  225. package/layers/core/server/utils/defineCollection.ts +0 -213
  226. package/layers/core/server/utils/duplicate.ts +0 -162
  227. package/layers/core/server/utils/field-layout.ts +0 -125
  228. package/layers/core/server/utils/filter-predicate.ts +0 -101
  229. package/layers/core/server/utils/http.ts +0 -50
  230. package/layers/core/server/utils/if-unmodified.ts +0 -13
  231. package/layers/core/server/utils/kestrel-config.ts +0 -451
  232. package/layers/core/server/utils/key-lock.ts +0 -33
  233. package/layers/core/server/utils/locale.ts +0 -53
  234. package/layers/core/server/utils/page-route.ts +0 -58
  235. package/layers/core/server/utils/page-slug.ts +0 -127
  236. package/layers/core/server/utils/picker.ts +0 -82
  237. package/layers/core/server/utils/populate.ts +0 -50
  238. package/layers/core/server/utils/prerender-memo.ts +0 -23
  239. package/layers/core/server/utils/read-capture.ts +0 -37
  240. package/layers/core/server/utils/record-ref-index.ts +0 -200
  241. package/layers/core/server/utils/registry.ts +0 -24
  242. package/layers/core/server/utils/resolve-scope.ts +0 -79
  243. package/layers/core/server/utils/seo.ts +0 -42
  244. package/layers/core/server/utils/serialize-collection.ts +0 -147
  245. package/layers/core/server/utils/sql.ts +0 -2
  246. package/layers/core/server/utils/storage.local.ts +0 -117
  247. package/layers/core/server/utils/storage.s3.ts +0 -197
  248. package/layers/core/server/utils/storage.ts +0 -90
  249. package/layers/core/server/utils/write-effects.ts +0 -40
  250. package/layers/core/server/utils/write-events.ts +0 -31
  251. package/layers/fields/app/utils/condition.ts +0 -71
  252. package/layers/fields/app/utils/define-block.ts +0 -13
  253. package/layers/fields/app/utils/field-constraints.ts +0 -17
  254. package/layers/fields/app/utils/field-meta.ts +0 -33
  255. package/layers/fields/app/utils/field-validate.ts +0 -75
  256. package/layers/fields/app/utils/field-value.ts +0 -7
  257. package/layers/fields/app/utils/richtext-links.ts +0 -48
  258. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  259. package/layers/fields/server/field-registry/index.ts +0 -249
  260. package/layers/fields/server/field-registry/naming.ts +0 -10
  261. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  262. package/layers/fields/server/field-registry/types.ts +0 -19
  263. package/layers/fields/server/utils/block-ids.ts +0 -43
  264. package/layers/fields/server/utils/block-populate.ts +0 -41
  265. package/layers/fields/server/utils/buildCollection.ts +0 -90
  266. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  267. package/layers/fields/server/utils/buildTable.ts +0 -113
  268. package/layers/fields/server/utils/conditional-required.ts +0 -25
  269. package/layers/fields/server/utils/defineBlock.ts +0 -86
  270. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  271. package/layers/fields/server/utils/extract-refs.ts +0 -152
  272. package/layers/fields/server/utils/field-populate.ts +0 -68
  273. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  274. package/layers/media/server/api/media/[id].delete.ts +0 -18
  275. package/layers/media/server/api/media/[id].get.ts +0 -10
  276. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  277. package/layers/media/server/api/media/[id].patch.ts +0 -65
  278. package/layers/media/server/api/media/backfill.post.ts +0 -18
  279. package/layers/media/server/api/media/copy.post.ts +0 -7
  280. package/layers/media/server/api/media/delete.post.ts +0 -13
  281. package/layers/media/server/api/media/folders.post.ts +0 -18
  282. package/layers/media/server/api/media/index.get.ts +0 -15
  283. package/layers/media/server/api/media/index.post.ts +0 -173
  284. package/layers/media/server/api/media/library.get.ts +0 -17
  285. package/layers/media/server/api/media/move.post.ts +0 -7
  286. package/layers/media/server/api/media/rename.post.ts +0 -7
  287. package/layers/media/server/api/media/resolve.get.ts +0 -13
  288. package/layers/media/server/collections/media-settings.ts +0 -25
  289. package/layers/media/server/collections/media.ts +0 -43
  290. package/layers/media/server/database/folders.ts +0 -10
  291. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  292. package/layers/media/server/schema-tables/folders.ts +0 -4
  293. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  294. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  295. package/layers/media/server/utils/backfill.ts +0 -144
  296. package/layers/media/server/utils/derive.ts +0 -59
  297. package/layers/media/server/utils/folder-paths.ts +0 -42
  298. package/layers/media/server/utils/folders.ts +0 -12
  299. package/layers/media/server/utils/library.ts +0 -91
  300. package/layers/media/server/utils/media-cleanup.ts +0 -27
  301. package/layers/media/server/utils/media-enabled.ts +0 -23
  302. package/layers/media/server/utils/media-ops.ts +0 -118
  303. package/layers/media/server/utils/media-write.ts +0 -13
  304. package/layers/media/server/utils/naming.ts +0 -52
  305. package/layers/media/server/utils/ondemand.ts +0 -69
  306. package/layers/media/server/utils/persist-upload.ts +0 -62
  307. package/layers/media/server/utils/populate.ts +0 -47
  308. package/layers/media/server/utils/record.ts +0 -71
  309. package/layers/media/server/utils/relocate-ops.ts +0 -341
  310. package/layers/media/server/utils/relocate-plan.ts +0 -63
  311. package/layers/media/server/utils/resolve.ts +0 -98
  312. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  313. package/layers/media/server/utils/sniff.ts +0 -68
  314. package/layers/media/server/utils/storage-relocate.ts +0 -139
  315. package/layers/media/server/utils/translations.ts +0 -19
  316. package/layers/media/server/utils/usages.ts +0 -66
  317. package/layers/media/server/utils/variant-capture.ts +0 -70
  318. package/layers/media/server/utils/variants.ts +0 -123
  319. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  320. package/layers/public/server/api/links/resolve.get.ts +0 -19
  321. package/layers/public/server/api/preview.get.ts +0 -28
  322. package/layers/public/server/api/preview.post.ts +0 -93
  323. package/layers/public/server/api/publish-status.get.ts +0 -58
  324. package/layers/public/server/api/publish.post.ts +0 -84
  325. package/layers/public/server/api/route.get.ts +0 -54
  326. package/layers/public/server/collections/redirects.ts +0 -75
  327. package/layers/public/server/collections/site.ts +0 -44
  328. package/layers/public/server/database/publish-deps.ts +0 -12
  329. package/layers/public/server/database/publish-status.ts +0 -19
  330. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  331. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  332. package/layers/public/server/utils/link-resolve.ts +0 -38
  333. package/layers/public/server/utils/llms-full.ts +0 -125
  334. package/layers/public/server/utils/llms.ts +0 -52
  335. package/layers/public/server/utils/page-resolve.ts +0 -208
  336. package/layers/public/server/utils/populate-links.ts +0 -66
  337. package/layers/public/server/utils/preview-token.ts +0 -109
  338. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  339. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  340. package/layers/public/server/utils/publish/deps.ts +0 -87
  341. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  342. package/layers/public/server/utils/publish/pending.ts +0 -74
  343. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  344. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  345. package/layers/public/server/utils/publish/publisher.ts +0 -362
  346. package/layers/public/server/utils/publish/queue.ts +0 -67
  347. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  348. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  349. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  350. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  351. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  352. package/layers/public/server/utils/site-url.ts +0 -40
  353. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,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
- }
@@ -1,88 +0,0 @@
1
- import { getRequestIP, getRequestHeader } from 'h3'
2
- import type { H3Event } from 'h3'
3
-
4
- type Env = Record<string, string | undefined>
5
-
6
- /**
7
- * How many reverse-proxy hops to trust in front of the app, parsed from `KESTREL_TRUST_PROXY`.
8
- *
9
- * Default **0** == trust nothing == ignore `X-Forwarded-For` entirely (without a proxy in front, XFF is
10
- * fully attacker-controlled). `true`/`on`/`yes`/`1` == one trusted proxy; a positive integer == that many
11
- * chained trusted proxies (e.g. CDN → load balancer). Anything else (`false`/`0`/blank/garbage) == 0.
12
- */
13
- export function trustProxyDepth(env: Env = process.env): number {
14
- const raw = (env.KESTREL_TRUST_PROXY ?? '').trim().toLowerCase()
15
- if (!raw) return 0
16
- if (raw === 'true' || raw === 'on' || raw === 'yes') return 1
17
- const n = Number(raw)
18
- return Number.isInteger(n) && n > 0 ? n : 0
19
- }
20
-
21
- /**
22
- * Pick the client address from an `X-Forwarded-For` chain, counting `depth` hops from the RIGHT.
23
- *
24
- * XFF is appended left-to-right (`client, proxy1, proxy2`), so the entries a client can forge sit on the
25
- * LEFT and the right-most entry is the address our nearest trusted proxy actually observed. With one
26
- * trusted proxy (depth 1) we take the right-most entry; with N chained trusted proxies we skip the N-1
27
- * proxy hops and take the Nth-from-right. (h3's own `getRequestIP({ xForwardedFor: true })` takes the
28
- * LEFT-most — spoofable — value, which is exactly why we don't use it.)
29
- *
30
- * If the chain is shorter than the trusted depth, it didn't traverse the expected proxies → untrustworthy,
31
- * so we return `undefined` and let the caller fall back to the socket peer rather than honour a forged value.
32
- */
33
- export function forwardedForHop(header: string | undefined, depth: number): string | undefined {
34
- if (depth < 1) return undefined
35
- const parts = (header ?? '').split(',').map((s) => s.trim()).filter(Boolean)
36
- const idx = parts.length - depth
37
- return idx >= 0 ? parts[idx] : undefined
38
- }
39
-
40
- /**
41
- * The client IP used to key the login throttle.
42
- *
43
- * Trusting `X-Forwarded-For` unconditionally lets a direct client rotate the header to dodge the per-IP
44
- * limit, so by default we key on the socket peer address and ignore XFF. Only when `KESTREL_TRUST_PROXY`
45
- * declares a trusted proxy depth do we honour the correct (right-most) XFF hop; a missing/too-short chain
46
- * still falls back to the peer.
47
- */
48
- export function clientIp(event: H3Event, env: Env = process.env): string {
49
- const depth = trustProxyDepth(env)
50
- if (depth > 0) {
51
- const forwarded = forwardedForHop(getRequestHeader(event, 'x-forwarded-for'), depth)
52
- if (forwarded) return forwarded
53
- }
54
- return getRequestIP(event) ?? 'unknown'
55
- }
56
-
57
- /** Expand a syntactically valid IPv6 address (optionally `::`-compressed) into its 8 hextets, or `null`. */
58
- function ipv6Hextets(ip: string): string[] | null {
59
- const compressAt = ip.indexOf('::')
60
- if (compressAt === -1) {
61
- const groups = ip.split(':')
62
- return groups.length === 8 ? groups : null
63
- }
64
- if (ip.indexOf('::', compressAt + 1) !== -1) return null // more than one '::' is not valid IPv6
65
- const left = ip.slice(0, compressAt)
66
- const right = ip.slice(compressAt + 2)
67
- const leftGroups = left === '' ? [] : left.split(':')
68
- const rightGroups = right === '' ? [] : right.split(':')
69
- const missing = 8 - leftGroups.length - rightGroups.length
70
- if (missing < 0) return null
71
- return [...leftGroups, ...Array(missing).fill('0'), ...rightGroups]
72
- }
73
-
74
- /**
75
- * The key used to bucket the login throttle: an IPv4 (and IPv4-mapped `::ffff:a.b.c.d`) address stays a
76
- * full /32 — NAT already shares those legitimately, so widening the bucket there would throttle unrelated
77
- * users behind the same NAT. A real IPv6 address is coarsened to its first four hextets (a /64), the block
78
- * an ISP typically routes to a single customer — otherwise an attacker on a routed /64 binds a fresh source
79
- * address per request and `recentFails` never sees the same key twice, so the throttle bounds nothing.
80
- * Anything that isn't well-formed IPv4 or IPv6 (including the `unknown` fallback) is returned unchanged,
81
- * which is only ever as permissive as the pre-existing full-address key.
82
- */
83
- export function throttleKey(ip: string): string {
84
- if (ip.includes('.') || !ip.includes(':')) return ip
85
- const hextets = ipv6Hextets(ip)
86
- if (!hextets) return ip
87
- return hextets.slice(0, 4).join(':') + '::/64'
88
- }
@@ -1,126 +0,0 @@
1
- import { createError } from 'h3'
2
-
3
- export const MAX_LOGIN_BODY = 4096
4
- export const MAX_INFLIGHT_HASHES = 4
5
-
6
- export function assertBodyLimit(contentLength: string | undefined, maxBytes = MAX_LOGIN_BODY): void {
7
- const len = Number(contentLength)
8
- // A missing/garbage Content-Length means a chunked transfer, which readBody would buffer with no
9
- // ceiling — refuse it. The only legitimate login body is a tiny JSON object with a known length.
10
- if (contentLength === undefined || !Number.isFinite(len) || len < 0) {
11
- throw createError({ statusCode: 411, statusMessage: 'Length required' })
12
- }
13
- if (len > maxBytes) {
14
- throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
15
- }
16
- }
17
-
18
- export const MAX_LOGIN_FAILS = 10
19
- export const LOGIN_WINDOW_MS = 15 * 60 * 1000
20
- // Hard ceiling on the number of distinct IPs tracked at once. A login POST is anonymously reachable and
21
- // seeds an entry BEFORE the expensive scrypt runs, so without a bound a spray from many distinct source
22
- // addresses (e.g. a routed IPv6 /64 or a botnet) could grow this map without limit and OOM the process.
23
- // Generous vs any real login volume for a single-admin CMS; eviction (below) keeps memory bounded.
24
- export const MAX_TRACKED_IPS = 10_000
25
-
26
- // Per-IP failed-attempt timestamps (in-memory, per-process: resets on restart, fine for a
27
- // single-instance private CMS). Bounds online brute-force; the admin password entropy is the
28
- // primary defense. The keying IP comes from `clientIp` (socket peer by default; trusted
29
- // X-Forwarded-For hop only when KESTREL_TRUST_PROXY is set). Bounded by MAX_TRACKED_IPS + eviction.
30
- const loginFails = new Map<string, number[]>()
31
-
32
- function recentFails(ip: string, nowMs: number): number[] {
33
- const fresh = (loginFails.get(ip) ?? []).filter((t) => nowMs - t < LOGIN_WINDOW_MS)
34
- // DELETE rather than persist an empty array: an IP whose failures have all aged out (or that only ever
35
- // hit the read-path lockout check) must not leave a permanent entry — otherwise a distinct-IP flood of
36
- // one-shot addresses grows the map forever (every key would be reclaimed only by a *successful* login).
37
- if (fresh.length === 0) loginFails.delete(ip)
38
- else loginFails.set(ip, fresh)
39
- return fresh
40
- }
41
-
42
- /** Reclaim every entry whose newest failure has aged past the window (a cheap sweep run under pressure). */
43
- function evictExpired(nowMs: number): void {
44
- for (const [ip, times] of loginFails) {
45
- const newest = times[times.length - 1]
46
- if (newest === undefined || nowMs - newest >= LOGIN_WINDOW_MS) loginFails.delete(ip)
47
- }
48
- }
49
-
50
- export function assertNotLockedOut(ip: string, nowMs: number): void {
51
- if (recentFails(ip, nowMs).length >= MAX_LOGIN_FAILS) {
52
- throw createError({ statusCode: 429, statusMessage: 'Too many attempts, try again later' })
53
- }
54
- }
55
-
56
- export function recordFailedLogin(ip: string, nowMs: number): void {
57
- const fails = recentFails(ip, nowMs)
58
- fails.push(nowMs)
59
- loginFails.set(ip, fails)
60
- // Enforce the ceiling: first reclaim anything already expired, then, if still over budget, evict the
61
- // oldest-inserted entries (Map preserves insertion order → FIFO). Evicting a live entry merely resets that
62
- // IP's throttle — acceptable under memory pressure; the primary brute-force defense is password entropy.
63
- if (loginFails.size > MAX_TRACKED_IPS) {
64
- evictExpired(nowMs)
65
- while (loginFails.size > MAX_TRACKED_IPS) {
66
- const oldest = loginFails.keys().next().value
67
- if (oldest === undefined) break
68
- loginFails.delete(oldest)
69
- }
70
- }
71
- }
72
-
73
- export function clearLoginFailures(ip: string): void {
74
- loginFails.delete(ip)
75
- }
76
-
77
- /** Test/introspection helpers: current tracked-IP count and a full reset (the store is a module singleton). */
78
- export function loginFailIpCount(): number {
79
- return loginFails.size
80
- }
81
- export function clearAllLoginFailures(): void {
82
- loginFails.clear()
83
- }
84
-
85
- /**
86
- * Atomically reserve a login attempt: assert the IP isn't locked out AND record the attempt in one
87
- * synchronous step (no await between the check and the record). This makes the throttle a true
88
- * reservation — concurrent in-flight guesses, each awaiting a slow scrypt verify, all count toward the
89
- * cap up front, instead of every one passing the gate on the same pre-increment count. The handler
90
- * clears the reservation on a successful login; a failed login leaves it recorded.
91
- */
92
- export function reserveLoginAttempt(ip: string, nowMs: number): void {
93
- assertNotLockedOut(ip, nowMs)
94
- recordFailedLogin(ip, nowMs)
95
- }
96
-
97
- /**
98
- * Undo a `reserveLoginAttempt` reservation for an outage no caller could have caused or guessed past (no
99
- * admin hash configured) — otherwise a misconfigured deployment leaves the admin throttled once they fix
100
- * it. Deliberately NOT used for attacker-triggerable rejections: charging those is what makes a flood run
101
- * into the lockout. Removes one entry equal to `nowMs` (there is at most one per call, recorded
102
- * synchronously by the reservation this undoes; entries are interchangeable by value, so it doesn't
103
- * matter which one a concurrent same-millisecond request added).
104
- */
105
- export function releaseLoginAttempt(ip: string, nowMs: number): void {
106
- const fails = loginFails.get(ip)
107
- if (!fails) return
108
- const idx = fails.lastIndexOf(nowMs)
109
- if (idx === -1) return
110
- fails.splice(idx, 1)
111
- if (fails.length === 0) loginFails.delete(ip)
112
- else loginFails.set(ip, fails)
113
- }
114
-
115
- let inFlight = 0
116
-
117
- export function acquireHashSlot(): void {
118
- if (inFlight >= MAX_INFLIGHT_HASHES) {
119
- throw createError({ statusCode: 503, statusMessage: 'Busy, try again' })
120
- }
121
- inFlight++
122
- }
123
-
124
- export function releaseHashSlot(): void {
125
- if (inFlight > 0) inFlight--
126
- }
@@ -1,52 +0,0 @@
1
- import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'
2
- import { promisify } from 'node:util'
3
-
4
- const scryptAsync = promisify(scrypt) as (
5
- password: string | Buffer,
6
- salt: string | Buffer,
7
- keylen: number,
8
- options: { N: number; r: number; p: number; maxmem: number },
9
- ) => Promise<Buffer>
10
-
11
- const N = 2 ** 17
12
- const R = 8
13
- const P = 1
14
- const KEYLEN = 64
15
- const MAXMEM = 256 * 1024 * 1024
16
-
17
- export async function hashPassword(password: string): Promise<string> {
18
- const salt = randomBytes(16)
19
- const hash = await scryptAsync(password, salt, KEYLEN, { N, r: R, p: P, maxmem: MAXMEM })
20
- return `scrypt$${N}$${R}$${P}$${salt.toString('base64url')}$${hash.toString('base64url')}`
21
- }
22
-
23
- export async function verifyPassword(password: string, stored: string): Promise<boolean> {
24
- const parts = stored.split('$')
25
- if (parts.length !== 6 || parts[0] !== 'scrypt') return false
26
- const n = Number(parts[1])
27
- const r = Number(parts[2])
28
- const p = Number(parts[3])
29
- if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false
30
-
31
- let salt: Buffer
32
- let expected: Buffer
33
- try {
34
- salt = Buffer.from(parts[4], 'base64url')
35
- expected = Buffer.from(parts[5], 'base64url')
36
- } catch {
37
- return false
38
- }
39
- if (expected.length === 0) return false
40
-
41
- let actual: Buffer
42
- try {
43
- actual = await scryptAsync(password, salt, expected.length, { N: n, r, p, maxmem: MAXMEM })
44
- } catch {
45
- return false
46
- }
47
- if (actual.length !== expected.length) {
48
- timingSafeEqual(actual, actual)
49
- return false
50
- }
51
- return timingSafeEqual(actual, expected)
52
- }
@@ -1,44 +0,0 @@
1
- import { getCookie, setCookie, type H3Event } from 'h3'
2
- import { signSession, verifySession, sessionSettings, shouldRefreshSession } from './session'
3
-
4
- /** Shared session-cookie attributes; only `maxAge` differs between set and clear. */
5
- const baseCookieOptions = (secure: boolean) => ({
6
- httpOnly: true,
7
- secure,
8
- sameSite: 'strict' as const,
9
- path: '/',
10
- })
11
-
12
- export function setAuthSession(event: H3Event): number {
13
- const { secret, maxAge, secureCookies, cookieName } = sessionSettings()
14
- const exp = Date.now() + maxAge * 1000
15
- setCookie(event, cookieName, signSession(secret, exp), { ...baseCookieOptions(secureCookies), maxAge })
16
- return exp
17
- }
18
-
19
- export function clearAuthSession(event: H3Event): void {
20
- const { secureCookies, cookieName } = sessionSettings()
21
- setCookie(event, cookieName, '', { ...baseCookieOptions(secureCookies), maxAge: 0 })
22
- }
23
-
24
- export function getAuthSession(event: H3Event): { authenticated: boolean; exp?: number } {
25
- const { secret, cookieName } = sessionSettings()
26
- const result = verifySession(secret, getCookie(event, cookieName), Date.now())
27
- return result.valid ? { authenticated: true, exp: result.exp } : { authenticated: false }
28
- }
29
-
30
- /**
31
- * Sliding-expiry: on an authenticated request whose session has passed the halfway point of the idle
32
- * window, re-issue the cookie with a fresh exp — so activity keeps the admin logged in and inactivity
33
- * longer than `maxAge` auto-logs-out (the cookie AND the embedded exp lapse). No-op when there is no valid
34
- * session cookie (anonymous / public requests), so it is safe to call unconditionally on every request.
35
- */
36
- export function refreshAuthSession(event: H3Event): void {
37
- const { secret, maxAge, secureCookies, cookieName } = sessionSettings()
38
- const now = Date.now()
39
- const current = verifySession(secret, getCookie(event, cookieName), now)
40
- if (!current.valid || current.exp === undefined) return
41
- if (!shouldRefreshSession(current.exp, now, maxAge * 1000)) return
42
- const exp = now + maxAge * 1000
43
- setCookie(event, cookieName, signSession(secret, exp), { ...baseCookieOptions(secureCookies), maxAge })
44
- }
@@ -1,96 +0,0 @@
1
- import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
2
- import { dirname, join } from 'node:path'
3
- import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
4
-
5
- // The session REVOCATION epoch: a monotonic counter folded into the signing key (see deriveSigningKey), so
6
- // bumping it invalidates every outstanding token at once — the stateless model's server-side "hard logout".
7
- // Persisted to a file in the data dir so it survives a restart. Cached in-process, but the cache is
8
- // mtime-validated on read, so a bump by a SIBLING process (a `db:migrate` task, a second worker, a CLI
9
- // force-logout) is observed on the next request instead of only after a restart.
10
-
11
- let cached: number | undefined
12
- let cachedMtimeMs = -1
13
-
14
- /** Where the epoch persists: `KESTREL_SESSION_EPOCH_FILE`, else next to the DB. Null for a `:memory:` DB
15
- * (tests) → the epoch still works in-process, it just doesn't survive a restart. */
16
- function epochPath(): string | null {
17
- const override = process.env.KESTREL_SESSION_EPOCH_FILE?.trim()
18
- if (override) return override
19
- // Resolve the DB the same way useDb() does — the consumer's `kestrel: { db }` via runtimeConfig first,
20
- // Kestrel's own config + env only as the non-Nitro fallback. Landing anywhere else would put revocation
21
- // state outside the volume the operator persists, so a restart would resurrect revoked sessions.
22
- const fromRc = serverRuntimeConfig()?.kestrel as { dbPath?: string } | undefined
23
- const dbPath = fromRc?.dbPath || resolveServerKestrel().dbPath
24
- if (!dbPath || dbPath === ':memory:') return null
25
- return join(dirname(dbPath), '.kestrel-session-epoch')
26
- }
27
-
28
- /** `null` means "couldn't tell" (unreadable, or present but not a valid epoch) — distinct from the genuine
29
- * ENOENT baseline of 0, so a caller never mistakes "the file is broken" for "never revoked". */
30
- function readEpoch(path: string): number | null {
31
- let raw: string
32
- try {
33
- raw = readFileSync(path, 'utf8')
34
- } catch (error) {
35
- if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return 0
36
- console.error('[kestrel auth] could not read the session epoch file (keeping the last known epoch):', (error as Error)?.message ?? error)
37
- return null
38
- }
39
- const n = parseInt(raw.trim(), 10)
40
- if (Number.isFinite(n) && n >= 0) return n
41
- console.error(`[kestrel auth] session epoch file has unreadable content, ignoring it (keeping the last known epoch): ${JSON.stringify(raw.slice(0, 40))}`)
42
- return null
43
- }
44
-
45
- /** The file's mtime in ms, or -1 when absent/unreadable (a stable sentinel distinct from any real mtime). */
46
- function epochMtime(path: string): number {
47
- try {
48
- return statSync(path).mtimeMs
49
- } catch {
50
- return -1
51
- }
52
- }
53
-
54
- /** The current epoch. Cached in-process, but re-read from the file whenever its mtime moved — so a
55
- * cross-process bump takes effect on the next call, not only after a restart. The stat is one cheap
56
- * syscall per request (no content read on the common unchanged path). */
57
- export function currentSessionEpoch(): number {
58
- const path = epochPath()
59
- if (!path) {
60
- // No persistence (`:memory:`): the in-process bump is the only source of truth.
61
- if (cached === undefined) cached = 0
62
- return cached
63
- }
64
- const mtime = epochMtime(path)
65
- if (cached === undefined || mtime !== cachedMtimeMs) {
66
- // A `null` read (broken file) must never regress an already-higher in-process value — take the max
67
- // rather than adopting it outright, the same guard bumpSessionEpoch already applies to its own re-read.
68
- cached = Math.max(cached ?? 0, readEpoch(path) ?? 0)
69
- cachedMtimeMs = mtime
70
- }
71
- return cached
72
- }
73
-
74
- /** Revoke every currently-issued session (logout / force-logout): increment + persist the epoch so the
75
- * derived signing key changes and all outstanding tokens fail verification immediately. Best-effort
76
- * persistence — a failed write still revokes for this process's lifetime (the in-process bump). */
77
- export function bumpSessionEpoch(): void {
78
- const next = currentSessionEpoch() + 1
79
- cached = next
80
- const path = epochPath()
81
- if (!path) return
82
- try {
83
- mkdirSync(dirname(path), { recursive: true })
84
- writeFileSync(path, String(next), 'utf8')
85
- // Re-read AFTER writing and take the max: a sibling process may have written a HIGHER epoch between our
86
- // write and this read. The epoch is monotonic, so max is correct — and it closes the write→stat TOCTOU
87
- // where adopting only our own mtime+value could permanently mask a concurrent revocation.
88
- cached = Math.max(next, readEpoch(path) ?? 0)
89
- cachedMtimeMs = epochMtime(path)
90
- } catch (error) {
91
- console.error('[kestrel auth] could not persist session epoch (revocation holds for this process only):', (error as Error)?.message ?? error)
92
- }
93
- }
94
-
95
- /** Test hook: drop the in-process cache so a test controls the epoch via its file/env. */
96
- export function _resetSessionEpochCache(): void { cached = undefined; cachedMtimeMs = -1 }