@michaelthielemann/kestrel 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (341) hide show
  1. package/README.md +35 -110
  2. package/kestrel.config.ts +1 -1
  3. package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
  4. package/layers/access/server/middleware/access-guard.ts +25 -23
  5. package/layers/access/server/types.d.ts +1 -2
  6. package/layers/admin/app/components/AdminAccount.vue +1 -1
  7. package/layers/admin/app/components/BlockFields.vue +1 -2
  8. package/layers/admin/app/components/BlockPreview.vue +17 -5
  9. package/layers/admin/app/components/BlocksBody.vue +1 -1
  10. package/layers/admin/app/components/CollectionEditor.vue +35 -16
  11. package/layers/admin/app/components/CollectionList.vue +34 -3
  12. package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
  13. package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
  14. package/layers/admin/app/components/CollectionListPager.vue +1 -1
  15. package/layers/admin/app/components/CollectionListTable.vue +41 -2
  16. package/layers/admin/app/components/EditorStatus.vue +8 -7
  17. package/layers/admin/app/components/LocaleBar.vue +3 -1
  18. package/layers/admin/app/components/PageFields.vue +2 -4
  19. package/layers/admin/app/components/SeoFields.vue +2 -4
  20. package/layers/admin/app/components/SingletonEditor.vue +3 -2
  21. package/layers/admin/app/composables/useAuth.ts +3 -3
  22. package/layers/admin/app/composables/useBlockTree.ts +1 -1
  23. package/layers/admin/app/composables/useBlocks.ts +1 -1
  24. package/layers/admin/app/composables/useCollectionOps.ts +45 -8
  25. package/layers/admin/app/composables/useCollections.ts +1 -2
  26. package/layers/admin/app/composables/useEditForm.ts +35 -17
  27. package/layers/admin/app/composables/useLinkResolver.ts +1 -1
  28. package/layers/admin/app/composables/useListBatchActions.ts +20 -3
  29. package/layers/admin/app/composables/useListColumns.ts +1 -1
  30. package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
  31. package/layers/admin/app/composables/useListRows.ts +7 -2
  32. package/layers/admin/app/composables/useListUrlState.ts +1 -1
  33. package/layers/admin/app/composables/usePublishStatus.ts +2 -2
  34. package/layers/admin/app/layouts/admin.vue +0 -1
  35. package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
  36. package/layers/admin/app/pages/admin/index.vue +1 -1
  37. package/layers/admin/app/pages/admin/references.vue +2 -2
  38. package/layers/admin/app/utils/block-tree.ts +1 -1
  39. package/layers/admin/app/utils/collection-ops.ts +28 -3
  40. package/layers/admin/app/utils/edit-form.ts +34 -2
  41. package/layers/admin/app/utils/editor-expose.ts +2 -0
  42. package/layers/admin/app/utils/editor-form-context.ts +1 -2
  43. package/layers/admin/app/utils/field-keys.ts +1 -1
  44. package/layers/admin/app/utils/list-columns.ts +3 -2
  45. package/layers/admin/app/utils/list-query.ts +2 -2
  46. package/layers/admin/app/utils/populate-blocks.ts +2 -2
  47. package/layers/admin/app/utils/reauth.ts +5 -6
  48. package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
  49. package/layers/collections/server/plugins/01.register.ts +9 -12
  50. package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
  51. package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
  52. package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
  53. package/layers/core/modules/auto-discovery/index.ts +68 -11
  54. package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
  55. package/layers/core/modules/auto-discovery/scan.ts +77 -0
  56. package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
  57. package/layers/core/modules/component-namespace/index.ts +24 -0
  58. package/layers/core/modules/kestrel/index.ts +6 -1
  59. package/layers/core/modules/plugin-order/index.ts +69 -0
  60. package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
  61. package/layers/core/nuxt.config.ts +1 -0
  62. package/layers/core/server/api/[...path].ts +98 -0
  63. package/layers/core/server/plugins/00.config.ts +12 -0
  64. package/layers/core/server/plugins/00.migrate.ts +1 -0
  65. package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
  66. package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
  67. package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
  68. package/layers/core/server/plugins/02.schema-sync.ts +2 -7
  69. package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
  70. package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
  71. package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
  72. package/layers/core/server/tasks/db/migrate-module.ts +83 -0
  73. package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
  74. package/layers/core/server/tasks/db/migrate.ts +2 -6
  75. package/layers/core/server/utils/server-config.ts +66 -3
  76. package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
  77. package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
  78. package/layers/media/app/components/KestrelImg.vue +3 -3
  79. package/layers/media/app/components/MediaLibrary.vue +2 -2
  80. package/layers/media/app/components/field/Media.vue +1 -1
  81. package/layers/media/app/composables/useMediaResolver.ts +1 -1
  82. package/layers/media/app/composables/useMediaUpload.ts +1 -1
  83. package/layers/media/app/composables/useMediaVariant.ts +2 -2
  84. package/layers/media/app/utils/ai-disclosure.ts +1 -1
  85. package/layers/media/app/utils/library.ts +1 -1
  86. package/layers/media/app/utils/ops.ts +1 -3
  87. package/layers/media/app/utils/picture-model.ts +2 -2
  88. package/layers/media/app/utils/variant-name.ts +1 -1
  89. package/layers/media/server/middleware/ondemand-variants.ts +6 -5
  90. package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
  91. package/layers/media/server/plugins/02.register-media.ts +10 -7
  92. package/layers/media/server/plugins/04.variant-capture.ts +12 -8
  93. package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
  94. package/layers/media/server/tasks/media/backfill.ts +3 -4
  95. package/layers/public/app/components/KestrelLink.vue +1 -1
  96. package/layers/public/app/pages/[...slug].vue +1 -1
  97. package/layers/public/app/pages/__kestrel/preview.vue +2 -2
  98. package/layers/public/app/utils/json-ld.ts +1 -1
  99. package/layers/public/app/utils/link-href.ts +1 -1
  100. package/layers/public/app/utils/page-head.ts +1 -1
  101. package/layers/public/modules/deploy-output/index.ts +4 -6
  102. package/layers/public/modules/prerender-routes/index.ts +4 -4
  103. package/layers/public/modules/prerender-routes/routes.ts +1 -1
  104. package/layers/public/modules/prune-media/index.ts +2 -2
  105. package/layers/public/server/delivery-live/serve.ts +111 -0
  106. package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
  107. package/layers/public/server/pipelines/index.ts +23 -0
  108. package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
  109. package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
  110. package/layers/public/server/plugins/02.register-links.ts +2 -3
  111. package/layers/public/server/plugins/03.redirects.ts +41 -23
  112. package/layers/public/server/plugins/05.plan-publish.ts +5 -0
  113. package/layers/public/server/plugins/zz.publish.ts +79 -24
  114. package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
  115. package/layers/public/server/routes/llms.txt.get.ts +4 -2
  116. package/layers/public/server/routes/redirects.json.get.ts +2 -2
  117. package/layers/public/server/routes/robots.txt.get.ts +2 -0
  118. package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
  119. package/layers/public/server/tasks/publish/run.ts +9 -4
  120. package/layers/public/server/utils/publish/render-live.ts +26 -0
  121. package/layers/ui/app/components/field/Choice.vue +1 -1
  122. package/layers/ui/app/components/field/Datetime.vue +1 -1
  123. package/layers/ui/app/components/field/Json.vue +1 -2
  124. package/layers/ui/app/components/field/Layout.vue +2 -4
  125. package/layers/ui/app/components/field/Link.vue +1 -1
  126. package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
  127. package/layers/ui/app/components/field/Number.vue +2 -2
  128. package/layers/ui/app/components/field/Relation.vue +1 -1
  129. package/layers/ui/app/components/field/Repeater.vue +1 -3
  130. package/layers/ui/app/components/field/Slug.vue +2 -3
  131. package/layers/ui/app/components/field/Text.vue +1 -1
  132. package/layers/ui/app/components/ui/Richtext.vue +1 -1
  133. package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
  134. package/layers/ui/app/composables/useLinkField.ts +1 -1
  135. package/layers/ui/app/composables/useRepeater.ts +1 -1
  136. package/layers/ui/app/i18n/de.ts +7 -7
  137. package/layers/ui/app/i18n/en.ts +7 -39
  138. package/layers/ui/app/utils/field-component.ts +1 -1
  139. package/layers/ui/app/utils/field-registry.ts +1 -1
  140. package/layers/ui/app/utils/icons.ts +2 -6
  141. package/layers/ui/app/utils/localized.ts +1 -1
  142. package/nuxt.config.ts +1 -1
  143. package/package.json +53 -19
  144. package/scripts/consumer-template-ci.mjs +157 -0
  145. package/scripts/coverage-all.mjs +163 -0
  146. package/scripts/dashboard.mjs +89 -0
  147. package/scripts/hash-password.mjs +1 -1
  148. package/scripts/lib/coverage-model.mjs +51 -0
  149. package/scripts/port-graph.mjs +350 -0
  150. package/scripts/typecheck.mjs +11 -0
  151. package/templates/starter/app/blocks/Prose.vue +1 -1
  152. package/layers/access/server/utils/csrf.ts +0 -39
  153. package/layers/access/server/utils/grant-registry.ts +0 -36
  154. package/layers/access/server/utils/guard.ts +0 -44
  155. package/layers/access/server/utils/ip-allowlist.ts +0 -79
  156. package/layers/access/server/utils/policy.ts +0 -98
  157. package/layers/access/server/utils/public-resources.ts +0 -11
  158. package/layers/access/server/utils/render-context.ts +0 -41
  159. package/layers/access/server/utils/require-admin.ts +0 -15
  160. package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
  161. package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
  162. package/layers/auth/server/api/auth/login.post.ts +0 -38
  163. package/layers/auth/server/api/auth/logout.post.ts +0 -9
  164. package/layers/auth/server/api/auth/session.get.ts +0 -1
  165. package/layers/auth/server/utils/admin-credential.ts +0 -42
  166. package/layers/auth/server/utils/client-ip.ts +0 -88
  167. package/layers/auth/server/utils/login-throttle.ts +0 -126
  168. package/layers/auth/server/utils/password.ts +0 -52
  169. package/layers/auth/server/utils/session-cookie.ts +0 -44
  170. package/layers/auth/server/utils/session-epoch.ts +0 -96
  171. package/layers/auth/server/utils/session.ts +0 -88
  172. package/layers/collections/server/collections/pages.ts +0 -19
  173. package/layers/collections/server/utils/populate-relations.ts +0 -79
  174. package/layers/core/app/utils/bulk-actions.ts +0 -9
  175. package/layers/core/app/utils/filter-ops.ts +0 -98
  176. package/layers/core/app/utils/list-limits.ts +0 -27
  177. package/layers/core/app/utils/locale-path.ts +0 -12
  178. package/layers/core/app/utils/slugify.ts +0 -16
  179. package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
  180. package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
  181. package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
  182. package/layers/core/server/api/[collection]/[id].get.ts +0 -11
  183. package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
  184. package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
  185. package/layers/core/server/api/[collection]/index.get.ts +0 -24
  186. package/layers/core/server/api/[collection]/index.post.ts +0 -8
  187. package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
  188. package/layers/core/server/api/[collection]/index.put.ts +0 -24
  189. package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
  190. package/layers/core/server/api/[collection]/options.get.ts +0 -18
  191. package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
  192. package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
  193. package/layers/core/server/api/[collection]/translations.get.ts +0 -25
  194. package/layers/core/server/api/collections/[name].get.ts +0 -4
  195. package/layers/core/server/api/collections/index.get.ts +0 -3
  196. package/layers/core/server/api/references/broken.get.ts +0 -9
  197. package/layers/core/server/api/references/referrers.get.ts +0 -24
  198. package/layers/core/server/database/record-refs.ts +0 -20
  199. package/layers/core/server/plugins/03.record-refs.ts +0 -12
  200. package/layers/core/server/schema/bootstrap.ts +0 -41
  201. package/layers/core/server/schema/desired.ts +0 -99
  202. package/layers/core/server/schema/dev-sync.ts +0 -23
  203. package/layers/core/server/schema/dialect.ts +0 -61
  204. package/layers/core/server/schema/diff.ts +0 -110
  205. package/layers/core/server/schema/introspect.ts +0 -68
  206. package/layers/core/server/schema/model.ts +0 -61
  207. package/layers/core/server/schema/render-sqlite.ts +0 -89
  208. package/layers/core/server/schema/sync.ts +0 -201
  209. package/layers/core/server/utils/blocks.ts +0 -6
  210. package/layers/core/server/utils/collection-types.ts +0 -28
  211. package/layers/core/server/utils/crud.ts +0 -578
  212. package/layers/core/server/utils/db.ts +0 -26
  213. package/layers/core/server/utils/defineCollection.ts +0 -213
  214. package/layers/core/server/utils/duplicate.ts +0 -162
  215. package/layers/core/server/utils/field-layout.ts +0 -125
  216. package/layers/core/server/utils/filter-predicate.ts +0 -101
  217. package/layers/core/server/utils/http.ts +0 -50
  218. package/layers/core/server/utils/if-unmodified.ts +0 -13
  219. package/layers/core/server/utils/kestrel-config.ts +0 -451
  220. package/layers/core/server/utils/key-lock.ts +0 -33
  221. package/layers/core/server/utils/locale.ts +0 -53
  222. package/layers/core/server/utils/page-route.ts +0 -58
  223. package/layers/core/server/utils/page-slug.ts +0 -127
  224. package/layers/core/server/utils/picker.ts +0 -82
  225. package/layers/core/server/utils/populate.ts +0 -50
  226. package/layers/core/server/utils/prerender-memo.ts +0 -23
  227. package/layers/core/server/utils/read-capture.ts +0 -37
  228. package/layers/core/server/utils/record-ref-index.ts +0 -200
  229. package/layers/core/server/utils/registry.ts +0 -24
  230. package/layers/core/server/utils/resolve-scope.ts +0 -79
  231. package/layers/core/server/utils/seo.ts +0 -42
  232. package/layers/core/server/utils/serialize-collection.ts +0 -147
  233. package/layers/core/server/utils/sql.ts +0 -2
  234. package/layers/core/server/utils/storage.local.ts +0 -117
  235. package/layers/core/server/utils/storage.s3.ts +0 -197
  236. package/layers/core/server/utils/storage.ts +0 -90
  237. package/layers/core/server/utils/write-effects.ts +0 -40
  238. package/layers/core/server/utils/write-events.ts +0 -31
  239. package/layers/fields/app/utils/condition.ts +0 -71
  240. package/layers/fields/app/utils/define-block.ts +0 -13
  241. package/layers/fields/app/utils/field-constraints.ts +0 -17
  242. package/layers/fields/app/utils/field-meta.ts +0 -33
  243. package/layers/fields/app/utils/field-validate.ts +0 -75
  244. package/layers/fields/app/utils/field-value.ts +0 -7
  245. package/layers/fields/app/utils/richtext-links.ts +0 -48
  246. package/layers/fields/server/api/blocks/index.get.ts +0 -9
  247. package/layers/fields/server/field-registry/index.ts +0 -249
  248. package/layers/fields/server/field-registry/naming.ts +0 -10
  249. package/layers/fields/server/field-registry/sanitize.ts +0 -43
  250. package/layers/fields/server/field-registry/types.ts +0 -19
  251. package/layers/fields/server/utils/block-ids.ts +0 -43
  252. package/layers/fields/server/utils/block-populate.ts +0 -41
  253. package/layers/fields/server/utils/buildCollection.ts +0 -90
  254. package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
  255. package/layers/fields/server/utils/buildTable.ts +0 -113
  256. package/layers/fields/server/utils/conditional-required.ts +0 -25
  257. package/layers/fields/server/utils/defineBlock.ts +0 -86
  258. package/layers/fields/server/utils/defineFieldType.ts +0 -33
  259. package/layers/fields/server/utils/extract-refs.ts +0 -152
  260. package/layers/fields/server/utils/field-populate.ts +0 -68
  261. package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
  262. package/layers/media/server/api/media/[id].delete.ts +0 -18
  263. package/layers/media/server/api/media/[id].get.ts +0 -10
  264. package/layers/media/server/api/media/[id].patch.test.ts +0 -66
  265. package/layers/media/server/api/media/[id].patch.ts +0 -65
  266. package/layers/media/server/api/media/backfill.post.ts +0 -18
  267. package/layers/media/server/api/media/copy.post.ts +0 -7
  268. package/layers/media/server/api/media/delete.post.ts +0 -13
  269. package/layers/media/server/api/media/folders.post.ts +0 -18
  270. package/layers/media/server/api/media/index.get.ts +0 -15
  271. package/layers/media/server/api/media/index.post.ts +0 -173
  272. package/layers/media/server/api/media/library.get.ts +0 -17
  273. package/layers/media/server/api/media/move.post.ts +0 -7
  274. package/layers/media/server/api/media/rename.post.ts +0 -7
  275. package/layers/media/server/api/media/resolve.get.ts +0 -13
  276. package/layers/media/server/collections/media-settings.ts +0 -25
  277. package/layers/media/server/collections/media.ts +0 -43
  278. package/layers/media/server/database/folders.ts +0 -10
  279. package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
  280. package/layers/media/server/schema-tables/folders.ts +0 -4
  281. package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
  282. package/layers/media/server/utils/ai-signal-detect.ts +0 -155
  283. package/layers/media/server/utils/backfill.ts +0 -144
  284. package/layers/media/server/utils/derive.ts +0 -59
  285. package/layers/media/server/utils/folder-paths.ts +0 -42
  286. package/layers/media/server/utils/folders.ts +0 -12
  287. package/layers/media/server/utils/library.ts +0 -91
  288. package/layers/media/server/utils/media-cleanup.ts +0 -27
  289. package/layers/media/server/utils/media-enabled.ts +0 -23
  290. package/layers/media/server/utils/media-ops.ts +0 -118
  291. package/layers/media/server/utils/media-write.ts +0 -13
  292. package/layers/media/server/utils/naming.ts +0 -52
  293. package/layers/media/server/utils/ondemand.ts +0 -69
  294. package/layers/media/server/utils/persist-upload.ts +0 -62
  295. package/layers/media/server/utils/populate.ts +0 -47
  296. package/layers/media/server/utils/record.ts +0 -71
  297. package/layers/media/server/utils/relocate-ops.ts +0 -341
  298. package/layers/media/server/utils/relocate-plan.ts +0 -63
  299. package/layers/media/server/utils/resolve.ts +0 -98
  300. package/layers/media/server/utils/sanitize-svg.ts +0 -12
  301. package/layers/media/server/utils/sniff.ts +0 -68
  302. package/layers/media/server/utils/storage-relocate.ts +0 -139
  303. package/layers/media/server/utils/translations.ts +0 -19
  304. package/layers/media/server/utils/usages.ts +0 -66
  305. package/layers/media/server/utils/variant-capture.ts +0 -70
  306. package/layers/media/server/utils/variants.ts +0 -123
  307. package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
  308. package/layers/public/server/api/links/resolve.get.ts +0 -19
  309. package/layers/public/server/api/preview.get.ts +0 -28
  310. package/layers/public/server/api/preview.post.ts +0 -93
  311. package/layers/public/server/api/publish-status.get.ts +0 -58
  312. package/layers/public/server/api/publish.post.ts +0 -84
  313. package/layers/public/server/api/route.get.ts +0 -54
  314. package/layers/public/server/collections/redirects.ts +0 -75
  315. package/layers/public/server/collections/site.ts +0 -44
  316. package/layers/public/server/database/publish-deps.ts +0 -12
  317. package/layers/public/server/database/publish-status.ts +0 -19
  318. package/layers/public/server/schema-tables/publish-deps.ts +0 -4
  319. package/layers/public/server/schema-tables/publish-status.ts +0 -4
  320. package/layers/public/server/utils/link-resolve.ts +0 -38
  321. package/layers/public/server/utils/llms-full.ts +0 -125
  322. package/layers/public/server/utils/llms.ts +0 -52
  323. package/layers/public/server/utils/page-resolve.ts +0 -208
  324. package/layers/public/server/utils/populate-links.ts +0 -66
  325. package/layers/public/server/utils/preview-token.ts +0 -109
  326. package/layers/public/server/utils/publish/coalesce.ts +0 -22
  327. package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
  328. package/layers/public/server/utils/publish/deps.ts +0 -87
  329. package/layers/public/server/utils/publish/invalidation.ts +0 -225
  330. package/layers/public/server/utils/publish/pending.ts +0 -74
  331. package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
  332. package/layers/public/server/utils/publish/publish-status.ts +0 -72
  333. package/layers/public/server/utils/publish/publisher.ts +0 -362
  334. package/layers/public/server/utils/publish/queue.ts +0 -67
  335. package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
  336. package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
  337. package/layers/public/server/utils/publish/route-for-record.ts +0 -16
  338. package/layers/public/server/utils/publish/route-keys.ts +0 -13
  339. package/layers/public/server/utils/richtext-markdown.ts +0 -260
  340. package/layers/public/server/utils/site-url.ts +0 -40
  341. package/layers/public/server/utils/sitemap.ts +0 -99
@@ -1,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 }
@@ -1,88 +0,0 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
2
- import { currentSessionEpoch } from './session-epoch'
3
-
4
- export function deriveSigningKey(rawSecret: string, passwordHash?: string, epoch = 0): string {
5
- // The `epoch` is a server-side revocation counter: bumping it (on logout / force-logout) changes the key
6
- // so every previously-issued token fails verification immediately — the stateless model's "hard logout".
7
- return createHmac('sha256', rawSecret).update('kestrel-session-v1').update(passwordHash ?? '').update(`e${epoch}`).digest('base64url')
8
- }
9
-
10
- export function signSession(secret: string, expMs: number): string {
11
- const payload = Buffer.from(JSON.stringify({ exp: expMs })).toString('base64url')
12
- const sig = createHmac('sha256', secret).update(payload).digest('base64url')
13
- return `${payload}.${sig}`
14
- }
15
-
16
- export function verifySession(secret: string, value: string | undefined, nowMs: number): { valid: boolean; exp?: number } {
17
- if (!value || typeof value !== 'string') return { valid: false }
18
- const dot = value.indexOf('.')
19
- if (dot <= 0 || dot === value.length - 1) return { valid: false }
20
- const payload = value.slice(0, dot)
21
- const sig = value.slice(dot + 1)
22
- const expected = createHmac('sha256', secret).update(payload).digest('base64url')
23
- const sigBuf = Buffer.from(sig)
24
- const expBuf = Buffer.from(expected)
25
- if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return { valid: false }
26
- let exp: unknown
27
- try {
28
- exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp
29
- } catch {
30
- return { valid: false }
31
- }
32
- if (typeof exp !== 'number' || exp <= nowMs) return { valid: false }
33
- return { valid: true, exp }
34
- }
35
-
36
- /**
37
- * Sliding-expiry decision: refresh the session cookie once its remaining lifetime drops below HALF the
38
- * idle window. Activity within the window keeps the session alive (each refresh resets exp to now+maxAge);
39
- * inactivity longer than the window lets it expire → the admin must log in again. The halfway threshold
40
- * bounds Set-Cookie churn to at most once per half-window instead of on every request.
41
- */
42
- export function shouldRefreshSession(exp: number, nowMs: number, maxAgeMs: number): boolean {
43
- return exp - nowMs < maxAgeMs / 2
44
- }
45
-
46
- let devSecret: string | undefined
47
-
48
- export interface SessionSettings {
49
- secret: string
50
- maxAge: number
51
- secureCookies: boolean
52
- cookieName: string
53
- }
54
-
55
- export function sessionSettings({ prerender = import.meta.prerender === true } = {}): SessionSettings {
56
- // Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
57
- // that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
58
- // not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
59
- // sets NODE_ENV=test; KESTREL_DEV=1 is an explicit escape hatch for other dev contexts.
60
- const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
61
- const isProd = !explicitDev
62
- const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
63
- // `nuxt generate` prerenders every page through `/api/route`, which passes the access guard and lands
64
- // here with NODE_ENV=production. A prerender request never issues a cookie, so enforcing the flag there
65
- // only means a dev `.env` silently drops pages from the static output.
66
- if (isProd && !secureCookies && !prerender) {
67
- throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
68
- }
69
- const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
70
- const maxAge = Number.isFinite(rawMaxAge) && rawMaxAge > 0 ? rawMaxAge : 604800
71
- let secret = process.env.KESTREL_SESSION_SECRET
72
- if (!secret) {
73
- if (isProd) throw new Error('KESTREL_SESSION_SECRET is required in production')
74
- if (!devSecret) {
75
- devSecret = randomBytes(32).toString('base64url')
76
- console.warn('[kestrel auth] KESTREL_SESSION_SECRET not set — using a random per-process dev secret; sessions reset on restart')
77
- }
78
- secret = devSecret
79
- } else if (isProd && Buffer.byteLength(secret) < 32) {
80
- throw new Error('KESTREL_SESSION_SECRET must be at least 32 bytes in production')
81
- }
82
- return {
83
- secret: deriveSigningKey(secret, process.env.KESTREL_ADMIN_PASSWORD_HASH, currentSessionEpoch()),
84
- maxAge,
85
- secureCookies,
86
- cookieName: secureCookies ? '__Host-kestrel_session' : 'kestrel_session',
87
- }
88
- }
@@ -1,19 +0,0 @@
1
- import { buildCollection } from '../../../fields/server/utils/buildCollection'
2
- import { defineCollection } from '../../../core/server/utils/defineCollection'
3
-
4
- const built = buildCollection(defineCollection({
5
- name: 'pages',
6
- mode: 'multi',
7
- translatable: true,
8
- pageLike: true,
9
- seo: true,
10
- blocks: { enabled: true },
11
- status: true,
12
- builtin: true,
13
- label: { singular: { en: 'Page', de: 'Seite' }, plural: { en: 'Pages', de: 'Seiten' }, new: { en: 'New Page', de: 'Neue Seite' } },
14
- icon: 'file-text',
15
- fields: { title: { type: 'text', required: true } },
16
- }))
17
-
18
- export const pages = built.table
19
- export default built
@@ -1,79 +0,0 @@
1
- import { fieldIs } from '../../../core/server/utils/defineCollection'
2
- import type { FieldPopulator } from '../../../core/server/utils/populate'
3
- import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
4
- import { memoResolver } from '../../../core/server/utils/resolve-scope'
5
-
6
- /** Fetches a related record FULLY populated (its own media / links / nested relations resolved), or null
7
- * when the id is stale / deleted / draft. Injected so the populator stays pure + unit-testable; the plugin
8
- * wires the real `getOne`. `depth` is the ALREADY-decremented depth for the related read (the cycle guard). */
9
- export type ResolveRecord = (
10
- collection: string,
11
- id: number,
12
- depth: number,
13
- locale: string,
14
- publicOnly: boolean,
15
- ) => Record<string, unknown> | null
16
-
17
- /**
18
- * Run a related-record fetch, mapping `getOne`'s 404 (a stale / deleted / draft target — the intended
19
- * "skip this reference" signal) to `null`, but letting ANY OTHER error propagate. Swallowing every error
20
- * here would drop a relation on a real fault (a DB error, a downstream populator throwing on malformed
21
- * stored data) while the page still renders + records `success` — violating fail-loud. Pure + testable.
22
- */
23
- export function skipMissing(fetch: () => Record<string, unknown>): Record<string, unknown> | null {
24
- try {
25
- return fetch()
26
- } catch (e) {
27
- if ((e as { statusCode?: number })?.statusCode === 404) return null
28
- throw e
29
- }
30
- }
31
-
32
- /**
33
- * The `relation` field populator: expands a relation id (single, `${name}Id` in columns / bare in props)
34
- * or id array (many, always bare) into the fully-populated related record(s) under a `$<name>` sibling,
35
- * leaving the raw id column intact — so write round-trips and the admin relation-picker keep working.
36
- * Stale / deleted / draft ids are skipped (single → null, many → filtered out) so one bad reference never
37
- * fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
38
- * relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
39
- * field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
40
- *
41
- * Under `ctx.publicOnly` a relation into a collection `isPublicCollection` rejects is left unexpanded
42
- * (raw id only, NO `$<name>` sibling at all — a relation field targets exactly one collection, so a
43
- * `many` relation is all-or-nothing): expansion must not reach a record the caller could not have
44
- * requested directly. The check runs BEFORE `resolve`, so a withheld target never enters the memo.
45
- */
46
- export function buildRelationFieldPopulator(
47
- resolveRecord: ResolveRecord,
48
- isPublicCollection: (collection: string) => boolean,
49
- ): FieldPopulator {
50
- // The same target (collection+id+depth+locale+scope) is resolved once — build-wide during a generate run
51
- // (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
52
- // distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
53
- // memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
54
- // wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
55
- // page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
56
- // `publicOnly` is part of the key because the same record populates DIFFERENTLY under it (its own
57
- // non-public relations are withheld) — sharing one entry would serve one scope's record to the other.
58
- const key = (collection: string, id: number, depth: number, locale: string, publicOnly: boolean) => `rel:${collection}:${id}:${depth}:${locale}:${publicOnly}`
59
- const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
60
- return (bag, key, field, ctx, keyMode) => {
61
- if (!fieldIs(field, 'relation')) return
62
- const collection = field.relation.collection
63
- const publicOnly = ctx.publicOnly === true
64
- if (publicOnly && !isPublicCollection(collection)) return
65
- const depth = ctx.depth - 1
66
- if (field.relation.many) {
67
- const ids = bag[key]
68
- if (Array.isArray(ids)) {
69
- bag['$' + key] = ids
70
- .filter((n): n is number => typeof n === 'number')
71
- .map((id) => resolve(collection, id, depth, ctx.locale, publicOnly))
72
- .filter((r): r is Record<string, unknown> => r != null)
73
- }
74
- } else {
75
- const id = bag[keyMode === 'columns' ? `${key}Id` : key]
76
- if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale, publicOnly)
77
- }
78
- }
79
- }
@@ -1,9 +0,0 @@
1
- // The batch operations the bulk endpoint accepts — the single source of truth shared by the server
2
- // handler (`bulk.post.ts`) and any client that builds a bulk request. Pure, so the allow-list is
3
- // unit-testable without a Nitro context.
4
- export const BULK_ACTIONS = ['delete', 'publish', 'unpublish', 'duplicate'] as const
5
- export type BulkAction = typeof BULK_ACTIONS[number]
6
-
7
- export function isBulkAction(value: unknown): value is BulkAction {
8
- return typeof value === 'string' && (BULK_ACTIONS as readonly string[]).includes(value)
9
- }