@michaelthielemann/kestrel 1.2.1

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 (395) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +12 -0
  3. package/README.md +234 -0
  4. package/kestrel.config.ts +28 -0
  5. package/layers/access/nuxt.config.ts +5 -0
  6. package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
  7. package/layers/access/server/middleware/access-guard.ts +30 -0
  8. package/layers/access/server/types.d.ts +8 -0
  9. package/layers/access/server/utils/csrf.ts +39 -0
  10. package/layers/access/server/utils/grant-registry.ts +36 -0
  11. package/layers/access/server/utils/guard.ts +44 -0
  12. package/layers/access/server/utils/ip-allowlist.ts +76 -0
  13. package/layers/access/server/utils/policy.ts +98 -0
  14. package/layers/access/server/utils/public-resources.ts +11 -0
  15. package/layers/access/server/utils/render-context.ts +41 -0
  16. package/layers/access/server/utils/require-admin.ts +15 -0
  17. package/layers/admin/app/components/AdminAccount.vue +212 -0
  18. package/layers/admin/app/components/AdminNav.vue +44 -0
  19. package/layers/admin/app/components/BlockFields.vue +68 -0
  20. package/layers/admin/app/components/BlockPreview.vue +401 -0
  21. package/layers/admin/app/components/BlockTree.vue +433 -0
  22. package/layers/admin/app/components/BlocksBody.vue +162 -0
  23. package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
  24. package/layers/admin/app/components/CollectionEditor.vue +203 -0
  25. package/layers/admin/app/components/CollectionList.vue +1091 -0
  26. package/layers/admin/app/components/EditorStatus.vue +199 -0
  27. package/layers/admin/app/components/EditorUnsupported.vue +28 -0
  28. package/layers/admin/app/components/FieldsBody.vue +23 -0
  29. package/layers/admin/app/components/LocaleBar.vue +105 -0
  30. package/layers/admin/app/components/PageFields.vue +101 -0
  31. package/layers/admin/app/components/PageFieldsPane.vue +39 -0
  32. package/layers/admin/app/components/SeoFields.vue +169 -0
  33. package/layers/admin/app/components/SingletonEditor.vue +92 -0
  34. package/layers/admin/app/composables/useAuth.ts +68 -0
  35. package/layers/admin/app/composables/useBlockTree.ts +95 -0
  36. package/layers/admin/app/composables/useBlocks.ts +7 -0
  37. package/layers/admin/app/composables/useCachedResource.ts +35 -0
  38. package/layers/admin/app/composables/useCollectionOps.ts +53 -0
  39. package/layers/admin/app/composables/useCollections.ts +7 -0
  40. package/layers/admin/app/composables/useContentLocales.ts +23 -0
  41. package/layers/admin/app/composables/useEditForm.ts +370 -0
  42. package/layers/admin/app/composables/useLinkResolver.ts +40 -0
  43. package/layers/admin/app/composables/useListColumns.ts +53 -0
  44. package/layers/admin/app/composables/useListUrlState.ts +53 -0
  45. package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
  46. package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
  47. package/layers/admin/app/composables/usePublishStatus.ts +98 -0
  48. package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
  49. package/layers/admin/app/composables/useTheme.ts +36 -0
  50. package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
  51. package/layers/admin/app/layouts/admin.vue +267 -0
  52. package/layers/admin/app/middleware/admin-auth.ts +4 -0
  53. package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
  54. package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
  55. package/layers/admin/app/pages/admin/index.vue +118 -0
  56. package/layers/admin/app/pages/admin/login.vue +68 -0
  57. package/layers/admin/app/pages/admin/media.vue +28 -0
  58. package/layers/admin/app/pages/admin/references.vue +146 -0
  59. package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
  60. package/layers/admin/app/plugins/reauth.client.ts +20 -0
  61. package/layers/admin/app/utils/admin-style-guard.ts +20 -0
  62. package/layers/admin/app/utils/block-tree.ts +214 -0
  63. package/layers/admin/app/utils/collection-ops.ts +44 -0
  64. package/layers/admin/app/utils/dead-refs.ts +30 -0
  65. package/layers/admin/app/utils/edit-form.ts +223 -0
  66. package/layers/admin/app/utils/editor-expose.ts +26 -0
  67. package/layers/admin/app/utils/editor-form-context.ts +83 -0
  68. package/layers/admin/app/utils/editor-registry.ts +20 -0
  69. package/layers/admin/app/utils/field-keys.ts +11 -0
  70. package/layers/admin/app/utils/list-columns.ts +141 -0
  71. package/layers/admin/app/utils/list-query.ts +96 -0
  72. package/layers/admin/app/utils/nav-active.ts +9 -0
  73. package/layers/admin/app/utils/populate-blocks.ts +288 -0
  74. package/layers/admin/app/utils/preview-channel.ts +62 -0
  75. package/layers/admin/app/utils/preview-viewport.ts +80 -0
  76. package/layers/admin/app/utils/reauth.ts +68 -0
  77. package/layers/admin/app/utils/record-title.ts +15 -0
  78. package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
  79. package/layers/admin/app/utils/safe-redirect.ts +12 -0
  80. package/layers/admin/nuxt.config.ts +17 -0
  81. package/layers/auth/nuxt.config.ts +1 -0
  82. package/layers/auth/server/api/auth/login.post.ts +38 -0
  83. package/layers/auth/server/api/auth/logout.post.ts +9 -0
  84. package/layers/auth/server/api/auth/session.get.ts +1 -0
  85. package/layers/auth/server/utils/admin-credential.ts +42 -0
  86. package/layers/auth/server/utils/client-ip.ts +88 -0
  87. package/layers/auth/server/utils/login-throttle.ts +126 -0
  88. package/layers/auth/server/utils/password.ts +52 -0
  89. package/layers/auth/server/utils/session-cookie.ts +44 -0
  90. package/layers/auth/server/utils/session-epoch.ts +96 -0
  91. package/layers/auth/server/utils/session.ts +85 -0
  92. package/layers/collections/nuxt.config.ts +1 -0
  93. package/layers/collections/server/collections/pages.ts +19 -0
  94. package/layers/collections/server/plugins/01.register.ts +32 -0
  95. package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
  96. package/layers/collections/server/utils/populate-relations.ts +66 -0
  97. package/layers/core/app/utils/bulk-actions.ts +9 -0
  98. package/layers/core/app/utils/filter-ops.ts +98 -0
  99. package/layers/core/app/utils/list-limits.ts +27 -0
  100. package/layers/core/app/utils/locale-path.ts +12 -0
  101. package/layers/core/app/utils/slugify.ts +16 -0
  102. package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
  103. package/layers/core/modules/auto-discovery/index.ts +44 -0
  104. package/layers/core/modules/auto-discovery/scan.ts +78 -0
  105. package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
  106. package/layers/core/modules/kestrel/index.ts +86 -0
  107. package/layers/core/nuxt.config.ts +8 -0
  108. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
  109. package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
  110. package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
  111. package/layers/core/server/api/[collection]/[id].get.ts +8 -0
  112. package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
  113. package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
  114. package/layers/core/server/api/[collection]/index.get.ts +19 -0
  115. package/layers/core/server/api/[collection]/index.post.ts +8 -0
  116. package/layers/core/server/api/[collection]/index.put.ts +7 -0
  117. package/layers/core/server/api/[collection]/options.get.ts +18 -0
  118. package/layers/core/server/api/[collection]/translations.get.ts +25 -0
  119. package/layers/core/server/api/collections/[name].get.ts +4 -0
  120. package/layers/core/server/api/collections/index.get.ts +3 -0
  121. package/layers/core/server/api/references/broken.get.ts +3 -0
  122. package/layers/core/server/api/references/referrers.get.ts +24 -0
  123. package/layers/core/server/database/record-refs.ts +20 -0
  124. package/layers/core/server/plugins/00.migrate.ts +11 -0
  125. package/layers/core/server/plugins/02.schema-sync.ts +63 -0
  126. package/layers/core/server/plugins/03.record-refs.ts +12 -0
  127. package/layers/core/server/schema/bootstrap.ts +41 -0
  128. package/layers/core/server/schema/desired.ts +99 -0
  129. package/layers/core/server/schema/dev-sync.ts +23 -0
  130. package/layers/core/server/schema/dialect.ts +59 -0
  131. package/layers/core/server/schema/diff.ts +110 -0
  132. package/layers/core/server/schema/introspect.ts +67 -0
  133. package/layers/core/server/schema/model.ts +61 -0
  134. package/layers/core/server/schema/render-sqlite.ts +89 -0
  135. package/layers/core/server/schema/sync.ts +201 -0
  136. package/layers/core/server/tasks/db/migrate.ts +54 -0
  137. package/layers/core/server/utils/blocks.ts +6 -0
  138. package/layers/core/server/utils/collection-types.ts +27 -0
  139. package/layers/core/server/utils/crud.ts +577 -0
  140. package/layers/core/server/utils/db.ts +26 -0
  141. package/layers/core/server/utils/defineCollection.ts +207 -0
  142. package/layers/core/server/utils/duplicate.ts +162 -0
  143. package/layers/core/server/utils/field-layout.ts +125 -0
  144. package/layers/core/server/utils/filter-predicate.ts +101 -0
  145. package/layers/core/server/utils/http.ts +50 -0
  146. package/layers/core/server/utils/if-unmodified.ts +13 -0
  147. package/layers/core/server/utils/kestrel-config.ts +410 -0
  148. package/layers/core/server/utils/key-lock.ts +33 -0
  149. package/layers/core/server/utils/locale.ts +53 -0
  150. package/layers/core/server/utils/page-route.ts +58 -0
  151. package/layers/core/server/utils/page-slug.ts +127 -0
  152. package/layers/core/server/utils/picker.ts +82 -0
  153. package/layers/core/server/utils/populate.ts +48 -0
  154. package/layers/core/server/utils/prerender-memo.ts +23 -0
  155. package/layers/core/server/utils/read-capture.ts +37 -0
  156. package/layers/core/server/utils/record-ref-index.ts +200 -0
  157. package/layers/core/server/utils/registry.ts +24 -0
  158. package/layers/core/server/utils/resolve-scope.ts +79 -0
  159. package/layers/core/server/utils/seo.ts +24 -0
  160. package/layers/core/server/utils/serialize-collection.ts +149 -0
  161. package/layers/core/server/utils/server-config.ts +18 -0
  162. package/layers/core/server/utils/sql.ts +2 -0
  163. package/layers/core/server/utils/storage.local.ts +117 -0
  164. package/layers/core/server/utils/storage.s3.ts +197 -0
  165. package/layers/core/server/utils/storage.ts +90 -0
  166. package/layers/core/server/utils/write-events.ts +31 -0
  167. package/layers/fields/app/utils/condition.ts +71 -0
  168. package/layers/fields/app/utils/define-block.ts +13 -0
  169. package/layers/fields/app/utils/field-constraints.ts +17 -0
  170. package/layers/fields/app/utils/field-factories.ts +129 -0
  171. package/layers/fields/app/utils/field-meta.ts +33 -0
  172. package/layers/fields/app/utils/field-validate.ts +75 -0
  173. package/layers/fields/app/utils/field-value.ts +7 -0
  174. package/layers/fields/app/utils/richtext-links.ts +48 -0
  175. package/layers/fields/nuxt.config.ts +1 -0
  176. package/layers/fields/server/api/blocks/index.get.ts +9 -0
  177. package/layers/fields/server/field-registry/index.ts +248 -0
  178. package/layers/fields/server/field-registry/naming.ts +10 -0
  179. package/layers/fields/server/field-registry/sanitize.ts +40 -0
  180. package/layers/fields/server/field-registry/types.ts +19 -0
  181. package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
  182. package/layers/fields/server/utils/block-ids.ts +43 -0
  183. package/layers/fields/server/utils/block-populate.ts +41 -0
  184. package/layers/fields/server/utils/buildCollection.ts +88 -0
  185. package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
  186. package/layers/fields/server/utils/buildTable.ts +107 -0
  187. package/layers/fields/server/utils/conditional-required.ts +25 -0
  188. package/layers/fields/server/utils/defineBlock.ts +86 -0
  189. package/layers/fields/server/utils/defineFieldType.ts +33 -0
  190. package/layers/fields/server/utils/extract-refs.ts +152 -0
  191. package/layers/fields/server/utils/field-populate.ts +68 -0
  192. package/layers/media/app/components/KestrelImg.vue +65 -0
  193. package/layers/media/app/components/MediaConflictDialog.vue +35 -0
  194. package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
  195. package/layers/media/app/components/MediaGrid.vue +113 -0
  196. package/layers/media/app/components/MediaImage.vue +70 -0
  197. package/layers/media/app/components/MediaLibrary.vue +451 -0
  198. package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
  199. package/layers/media/app/components/MediaPathBar.vue +163 -0
  200. package/layers/media/app/components/MediaPicker.vue +19 -0
  201. package/layers/media/app/components/MediaRenameDialog.vue +32 -0
  202. package/layers/media/app/components/MediaTable.vue +75 -0
  203. package/layers/media/app/components/MediaThumb.vue +25 -0
  204. package/layers/media/app/components/MediaToolbar.vue +90 -0
  205. package/layers/media/app/components/MediaUploadDialog.vue +87 -0
  206. package/layers/media/app/components/MediaViewer.vue +88 -0
  207. package/layers/media/app/components/field/Media.vue +261 -0
  208. package/layers/media/app/composables/useBlurUp.ts +55 -0
  209. package/layers/media/app/composables/useMediaClipboard.ts +14 -0
  210. package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
  211. package/layers/media/app/composables/useMediaDnd.ts +56 -0
  212. package/layers/media/app/composables/useMediaLibrary.ts +151 -0
  213. package/layers/media/app/composables/useMediaOps.ts +50 -0
  214. package/layers/media/app/composables/useMediaRelocate.ts +39 -0
  215. package/layers/media/app/composables/useMediaResolver.ts +50 -0
  216. package/layers/media/app/composables/useMediaUpload.ts +93 -0
  217. package/layers/media/app/composables/useMediaVariant.ts +34 -0
  218. package/layers/media/app/plugins/field-media.client.ts +6 -0
  219. package/layers/media/app/utils/dnd.ts +69 -0
  220. package/layers/media/app/utils/library.ts +79 -0
  221. package/layers/media/app/utils/ops.ts +62 -0
  222. package/layers/media/app/utils/picture-model.ts +125 -0
  223. package/layers/media/app/utils/thumbhash.ts +19 -0
  224. package/layers/media/app/utils/variant-name.ts +20 -0
  225. package/layers/media/nuxt.config.ts +3 -0
  226. package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
  227. package/layers/media/server/api/media/[id].delete.ts +18 -0
  228. package/layers/media/server/api/media/[id].get.ts +10 -0
  229. package/layers/media/server/api/media/[id].patch.ts +41 -0
  230. package/layers/media/server/api/media/backfill.post.ts +18 -0
  231. package/layers/media/server/api/media/copy.post.ts +7 -0
  232. package/layers/media/server/api/media/delete.post.ts +13 -0
  233. package/layers/media/server/api/media/folders.post.ts +18 -0
  234. package/layers/media/server/api/media/index.get.ts +15 -0
  235. package/layers/media/server/api/media/index.post.ts +157 -0
  236. package/layers/media/server/api/media/library.get.ts +17 -0
  237. package/layers/media/server/api/media/move.post.ts +7 -0
  238. package/layers/media/server/api/media/rename.post.ts +7 -0
  239. package/layers/media/server/api/media/resolve.get.ts +13 -0
  240. package/layers/media/server/collections/media-settings.ts +25 -0
  241. package/layers/media/server/collections/media.ts +29 -0
  242. package/layers/media/server/database/folders.ts +10 -0
  243. package/layers/media/server/middleware/ondemand-variants.ts +37 -0
  244. package/layers/media/server/plugins/02.register-media.ts +19 -0
  245. package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
  246. package/layers/media/server/plugins/04.variant-capture.ts +23 -0
  247. package/layers/media/server/schema-tables/folders.ts +4 -0
  248. package/layers/media/server/tasks/media/backfill.ts +18 -0
  249. package/layers/media/server/utils/backfill.ts +132 -0
  250. package/layers/media/server/utils/derive.ts +59 -0
  251. package/layers/media/server/utils/folder-paths.ts +42 -0
  252. package/layers/media/server/utils/folders.ts +12 -0
  253. package/layers/media/server/utils/library.ts +90 -0
  254. package/layers/media/server/utils/media-cleanup.ts +27 -0
  255. package/layers/media/server/utils/media-enabled.ts +23 -0
  256. package/layers/media/server/utils/media-ops.ts +118 -0
  257. package/layers/media/server/utils/media-write.ts +13 -0
  258. package/layers/media/server/utils/naming.ts +52 -0
  259. package/layers/media/server/utils/ondemand.ts +69 -0
  260. package/layers/media/server/utils/persist-upload.ts +62 -0
  261. package/layers/media/server/utils/populate.ts +47 -0
  262. package/layers/media/server/utils/record.ts +63 -0
  263. package/layers/media/server/utils/relocate-ops.ts +341 -0
  264. package/layers/media/server/utils/relocate-plan.ts +63 -0
  265. package/layers/media/server/utils/resolve.ts +86 -0
  266. package/layers/media/server/utils/sanitize-svg.ts +12 -0
  267. package/layers/media/server/utils/sniff.ts +68 -0
  268. package/layers/media/server/utils/storage-relocate.ts +139 -0
  269. package/layers/media/server/utils/translations.ts +19 -0
  270. package/layers/media/server/utils/usages.ts +66 -0
  271. package/layers/media/server/utils/variant-capture.ts +70 -0
  272. package/layers/media/server/utils/variants.ts +108 -0
  273. package/layers/public/app/app.vue +5 -0
  274. package/layers/public/app/components/BlockRenderer.vue +56 -0
  275. package/layers/public/app/components/KestrelLink.vue +26 -0
  276. package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
  277. package/layers/public/app/composables/public-page.ts +24 -0
  278. package/layers/public/app/error.vue +12 -0
  279. package/layers/public/app/layouts/default.vue +5 -0
  280. package/layers/public/app/pages/[...slug].vue +157 -0
  281. package/layers/public/app/pages/__kestrel/preview.vue +34 -0
  282. package/layers/public/app/utils/block-edit-context.ts +16 -0
  283. package/layers/public/app/utils/link-href.ts +46 -0
  284. package/layers/public/app/utils/page-head.ts +84 -0
  285. package/layers/public/app/utils/preview-protocol.ts +81 -0
  286. package/layers/public/app/utils/public-route.ts +46 -0
  287. package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
  288. package/layers/public/modules/deploy-output/index.ts +82 -0
  289. package/layers/public/modules/prerender-routes/discover.ts +37 -0
  290. package/layers/public/modules/prerender-routes/index.ts +85 -0
  291. package/layers/public/modules/prerender-routes/routes.ts +16 -0
  292. package/layers/public/modules/prune-media/index.ts +48 -0
  293. package/layers/public/modules/prune-media/prune-media.ts +81 -0
  294. package/layers/public/nuxt.config.ts +11 -0
  295. package/layers/public/server/api/links/resolve.get.ts +19 -0
  296. package/layers/public/server/api/publish-status.get.ts +44 -0
  297. package/layers/public/server/api/route.get.ts +18 -0
  298. package/layers/public/server/database/publish-deps.ts +12 -0
  299. package/layers/public/server/database/publish-status.ts +19 -0
  300. package/layers/public/server/plugins/02.register-links.ts +17 -0
  301. package/layers/public/server/plugins/zz.publish.ts +63 -0
  302. package/layers/public/server/routes/llms.txt.get.ts +72 -0
  303. package/layers/public/server/routes/robots.txt.get.ts +8 -0
  304. package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
  305. package/layers/public/server/schema-tables/publish-deps.ts +4 -0
  306. package/layers/public/server/schema-tables/publish-status.ts +4 -0
  307. package/layers/public/server/tasks/publish/run.ts +26 -0
  308. package/layers/public/server/utils/link-resolve.ts +35 -0
  309. package/layers/public/server/utils/llms.ts +39 -0
  310. package/layers/public/server/utils/page-resolve.ts +86 -0
  311. package/layers/public/server/utils/populate-links.ts +66 -0
  312. package/layers/public/server/utils/publish/coalesce.ts +22 -0
  313. package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
  314. package/layers/public/server/utils/publish/deps.ts +87 -0
  315. package/layers/public/server/utils/publish/invalidation.ts +141 -0
  316. package/layers/public/server/utils/publish/publish-status.ts +54 -0
  317. package/layers/public/server/utils/publish/publisher.ts +291 -0
  318. package/layers/public/server/utils/publish/queue.ts +67 -0
  319. package/layers/public/server/utils/publish/route-for-record.ts +16 -0
  320. package/layers/public/server/utils/publish/route-keys.ts +13 -0
  321. package/layers/public/server/utils/site-url.ts +32 -0
  322. package/layers/public/server/utils/sitemap.ts +97 -0
  323. package/layers/ui/app/assets/scss/_base.scss +16 -0
  324. package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
  325. package/layers/ui/app/assets/scss/_mixins.scss +50 -0
  326. package/layers/ui/app/assets/scss/_reset.scss +65 -0
  327. package/layers/ui/app/assets/scss/_tokens.scss +183 -0
  328. package/layers/ui/app/assets/scss/main.scss +4 -0
  329. package/layers/ui/app/components/field/Boolean.vue +30 -0
  330. package/layers/ui/app/components/field/Choice.vue +68 -0
  331. package/layers/ui/app/components/field/Datetime.vue +96 -0
  332. package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
  333. package/layers/ui/app/components/field/Json.vue +63 -0
  334. package/layers/ui/app/components/field/Layout.vue +140 -0
  335. package/layers/ui/app/components/field/Link.vue +112 -0
  336. package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
  337. package/layers/ui/app/components/field/Number.vue +30 -0
  338. package/layers/ui/app/components/field/Relation.vue +49 -0
  339. package/layers/ui/app/components/field/Renderer.vue +30 -0
  340. package/layers/ui/app/components/field/Repeater.vue +460 -0
  341. package/layers/ui/app/components/field/Richtext.vue +26 -0
  342. package/layers/ui/app/components/field/Slug.vue +66 -0
  343. package/layers/ui/app/components/field/Text.vue +35 -0
  344. package/layers/ui/app/components/field/Unsupported.vue +34 -0
  345. package/layers/ui/app/components/ui/Alert.vue +50 -0
  346. package/layers/ui/app/components/ui/Brand.vue +22 -0
  347. package/layers/ui/app/components/ui/Button.vue +116 -0
  348. package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
  349. package/layers/ui/app/components/ui/Checkbox.vue +52 -0
  350. package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
  351. package/layers/ui/app/components/ui/Combobox.vue +343 -0
  352. package/layers/ui/app/components/ui/DatePicker.vue +92 -0
  353. package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
  354. package/layers/ui/app/components/ui/Dialog.vue +107 -0
  355. package/layers/ui/app/components/ui/EmptyState.vue +46 -0
  356. package/layers/ui/app/components/ui/Field.vue +57 -0
  357. package/layers/ui/app/components/ui/Fieldset.vue +64 -0
  358. package/layers/ui/app/components/ui/Icon.vue +44 -0
  359. package/layers/ui/app/components/ui/Menu.vue +54 -0
  360. package/layers/ui/app/components/ui/NumberInput.vue +173 -0
  361. package/layers/ui/app/components/ui/Richtext.vue +119 -0
  362. package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
  363. package/layers/ui/app/components/ui/Select.vue +51 -0
  364. package/layers/ui/app/components/ui/TextInput.vue +110 -0
  365. package/layers/ui/app/components/ui/Textarea.vue +38 -0
  366. package/layers/ui/app/components/ui/TimeInput.vue +17 -0
  367. package/layers/ui/app/components/ui/Toasts.vue +103 -0
  368. package/layers/ui/app/components/ui/Tooltip.vue +45 -0
  369. package/layers/ui/app/composables/useAdminLang.ts +8 -0
  370. package/layers/ui/app/composables/useDragReorder.ts +41 -0
  371. package/layers/ui/app/composables/useEchoGuard.ts +28 -0
  372. package/layers/ui/app/composables/useLinkField.ts +70 -0
  373. package/layers/ui/app/composables/useRecordOptions.ts +84 -0
  374. package/layers/ui/app/composables/useRepeater.ts +99 -0
  375. package/layers/ui/app/composables/useT.ts +32 -0
  376. package/layers/ui/app/composables/useToast.ts +46 -0
  377. package/layers/ui/app/i18n/de.ts +406 -0
  378. package/layers/ui/app/i18n/en.ts +440 -0
  379. package/layers/ui/app/utils/boolean-field.ts +10 -0
  380. package/layers/ui/app/utils/field-component.ts +22 -0
  381. package/layers/ui/app/utils/field-empty.ts +21 -0
  382. package/layers/ui/app/utils/field-registry.ts +36 -0
  383. package/layers/ui/app/utils/humanize.ts +14 -0
  384. package/layers/ui/app/utils/icons.ts +147 -0
  385. package/layers/ui/app/utils/iso-date.ts +18 -0
  386. package/layers/ui/app/utils/localized.ts +13 -0
  387. package/layers/ui/app/utils/record-context.ts +15 -0
  388. package/layers/ui/app/utils/reorder.ts +8 -0
  389. package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
  390. package/layers/ui/app/utils/useFieldA11y.ts +40 -0
  391. package/layers/ui/nuxt.config.ts +5 -0
  392. package/nuxt.config.ts +105 -0
  393. package/package.json +122 -0
  394. package/scripts/hash-password.mjs +26 -0
  395. package/scripts/typecheck.mjs +51 -0
@@ -0,0 +1,68 @@
1
+ import { safeRedirect } from './safe-redirect'
2
+
3
+ // The auth bootstrap endpoints the server always permits (login/session/logout). A 401 from these is
4
+ // not a stale session — a wrong-password login 401s — so it must not trigger a re-auth redirect. Matched
5
+ // exactly (not by `/api/auth/` prefix) so a guarded endpoint under a consumer collection named `auth`
6
+ // (e.g. `/api/auth/5`) still re-authenticates.
7
+ const BOOTSTRAP_PATHS = new Set(['/api/auth/login', '/api/auth/session', '/api/auth/logout'])
8
+
9
+ /**
10
+ * Decide whether a failed `$fetch` should bounce the user to the login screen, and to which URL.
11
+ *
12
+ * Returns the login redirect target for a 401 raised by a guarded `/api/*` call made from inside the
13
+ * admin SPA — but stays null for:
14
+ * - non-401 statuses and non-`/api` requests (nothing to re-authenticate);
15
+ * - the auth-bootstrap endpoints (`/api/auth/*`): a wrong-password login 401s and must stay put;
16
+ * - any location that isn't a safe admin redirect target — which also rules out the login page itself
17
+ * (no redirect loop) and public/SSG pages (the static site never logs in).
18
+ */
19
+ export function reauthTarget(input: { status: number; url: string; currentPath: string }): string | null {
20
+ if (input.status !== 401) return null
21
+ const path = apiPath(input.url)
22
+ if (!path.startsWith('/api/') || BOOTSTRAP_PATHS.has(path)) return null
23
+ // The current location must itself be a valid place to return to. `safeRedirect` rejects the login
24
+ // page and any non-admin path, so this single check guards both the loop and the public site.
25
+ const back = safeRedirect(input.currentPath)
26
+ if (!back) return null
27
+ return `/admin/login?redirect=${encodeURIComponent(back)}`
28
+ }
29
+
30
+ function apiPath(url: string): string {
31
+ // Tolerate absolute URLs (SSR/base-prefixed) and relative ones alike; fall back to the raw string.
32
+ try { return new URL(url, 'http://localhost').pathname } catch { return url }
33
+ }
34
+
35
+ /** Minimal shape of the ofetch `onResponseError` context this interceptor reads. */
36
+ export interface ReauthContext {
37
+ request?: string | { url?: string } | URL
38
+ response?: { status?: number }
39
+ }
40
+
41
+ /**
42
+ * Build an `onResponseError` handler that re-authenticates on a stale-session 401. Pure dependency
43
+ * injection (current path / reset / navigate) keeps it unit-testable away from the Nuxt runtime; the
44
+ * client plugin wires it to the real router, `useAuth().reset` and `navigateTo`.
45
+ */
46
+ export function makeReauthInterceptor(deps: {
47
+ currentPath: () => string
48
+ reset: () => void
49
+ navigate: (to: string) => void
50
+ }) {
51
+ return (ctx: ReauthContext): void => {
52
+ const target = reauthTarget({
53
+ status: ctx.response?.status ?? 0,
54
+ url: requestUrl(ctx.request),
55
+ currentPath: deps.currentPath(),
56
+ })
57
+ if (!target) return
58
+ deps.reset()
59
+ deps.navigate(target)
60
+ }
61
+ }
62
+
63
+ function requestUrl(request: ReauthContext['request']): string {
64
+ if (typeof request === 'string') return request
65
+ if (request instanceof URL) return request.href
66
+ if (request && typeof request === 'object' && typeof request.url === 'string') return request.url
67
+ return ''
68
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The text the editor header shows for a saved record: the `title` text field if present, else the first
3
+ * text field — the SAME pick `slugSourceValue` (core/page-slug) derives the auto-slug from, so the heading
4
+ * and the URL never disagree about which field is "the title".
5
+ *
6
+ * Returns `''` when there is no such field or its value is blank/non-string; the header then falls back to
7
+ * the generic "Edit {collection} #{id}" (an id is all a brand-new record has).
8
+ */
9
+ export function recordTitle(fields: Record<string, { type: string }>, values: Record<string, unknown>): string {
10
+ const entries = Object.entries(fields)
11
+ const pick = entries.find(([k, f]) => k === 'title' && f.type === 'text') ?? entries.find(([, f]) => f.type === 'text')
12
+ if (!pick) return ''
13
+ const v = values[pick[0]]
14
+ return typeof v === 'string' ? v.trim() : ''
15
+ }
@@ -0,0 +1,9 @@
1
+ import { registerCollectionEditor } from './editor-registry'
2
+ import FieldsBody from '../components/FieldsBody.vue'
3
+ import BlocksBody from '../components/BlocksBody.vue'
4
+
5
+ // Built-in editor bodies. Registered as an import side-effect (CollectionEditor imports this module), so
6
+ // they are present before any editor renders — the same guarantee the field registry's static map gives.
7
+ // Extensions add their own types via a `*.client.ts` plugin (auto-discovered through `extends`).
8
+ registerCollectionEditor('fields', FieldsBody)
9
+ registerCollectionEditor('blocks', BlocksBody)
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Validate a post-login redirect target. Only local `/admin` paths are allowed —
3
+ * never external/protocol-relative URLs, and never `/admin/login` (no redirect loop).
4
+ */
5
+ export function safeRedirect(target: unknown): string | null {
6
+ if (typeof target !== 'string') return null
7
+ if (target.startsWith('//')) return null
8
+ const local = target === '/admin' || target.startsWith('/admin/') || target.startsWith('/admin?')
9
+ if (!local) return null
10
+ if (target === '/admin/login' || target.startsWith('/admin/login?') || target.startsWith('/admin/login/')) return null
11
+ return target
12
+ }
@@ -0,0 +1,17 @@
1
+ export default defineNuxtConfig({
2
+ routeRules: {
3
+ // The admin is a client-rendered SPA.
4
+ '/admin/**': { ssr: false },
5
+ },
6
+ modules: [
7
+ // The field-widget gallery (/admin/gallery) is a dev-only showcase — strip it from the
8
+ // production admin SPA so it never ships to end users.
9
+ (_options, nuxt) => {
10
+ nuxt.hook('pages:extend', (pages) => {
11
+ if (nuxt.options.dev) return
12
+ const i = pages.findIndex((p) => p.file?.endsWith('/admin/gallery.vue'))
13
+ if (i !== -1) pages.splice(i, 1)
14
+ })
15
+ },
16
+ ],
17
+ })
@@ -0,0 +1 @@
1
+ export default defineNuxtConfig({})
@@ -0,0 +1,38 @@
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
+ })
@@ -0,0 +1,9 @@
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
+ })
@@ -0,0 +1 @@
1
+ export default defineEventHandler((event) => getAuthSession(event))
@@ -0,0 +1,42 @@
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
+ }
@@ -0,0 +1,88 @@
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
+ }
@@ -0,0 +1,126 @@
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
+ }
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,44 @@
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
+ }
@@ -0,0 +1,96 @@
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 }