@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
@@ -0,0 +1,350 @@
1
+ #!/usr/bin/env node
2
+ // Port-graph extraction (experiment): for every exported function in packages/kestrel-core/src whose
3
+ // return type resolves to `Effect.Effect<A, E, R>`, the R (requirements) channel becomes that function's
4
+ // port list. Every `Layer.succeed`/`Layer.effect`/`Layer.scoped` call becomes an adapter binding from the
5
+ // layer's containing function to the port it provides. Nodes reuse graphify-out/graph.json's id scheme so
6
+ // the two graphs join; a node not already present there is synthesized with the same scheme and flagged.
7
+ //
8
+ // The R channel alone cannot answer "does persist always follow validate" — this codebase passes the
9
+ // database as a plain function parameter, not a Context.Tag requirement, so R is `never` on every step.
10
+ // To make that invariant askable as a graph query at all, this script also extracts each `definePipeline`
11
+ // call's literal `steps: [...]` array (resolving each step-factory call to the runtime step name registered
12
+ // via `syncStep(name, ...)`) into an ordered step chain, and exposes `stepOrderRespectsInvariant` for
13
+ // querying it.
14
+ import ts from 'typescript'
15
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
16
+ import { dirname, join, relative } from 'node:path'
17
+ import { fileURLToPath, pathToFileURL } from 'node:url'
18
+
19
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
20
+ const corePkg = join(root, 'packages/kestrel-core')
21
+ const graphOutDir = join(root, 'graphify-out')
22
+
23
+ // --- graph id scheme (mirrors graphify's own slugging, see graphify-out/graph.json) ---
24
+
25
+ function slugFile(relFile) {
26
+ return relFile.replace(/\.[^/.]+$/, '').replace(/[^A-Za-z0-9]+/g, '_').toLowerCase()
27
+ }
28
+
29
+ function slugSymbol(name) {
30
+ return name.replace(/\(\)$/, '').toLowerCase().replace(/[^a-z0-9]/g, '')
31
+ }
32
+
33
+ function loadExistingGraph() {
34
+ const graphPath = join(graphOutDir, 'graph.json')
35
+ if (!existsSync(graphPath)) return { nodes: [], byFileAndLabel: new Map() }
36
+ const graph = JSON.parse(readFileSync(graphPath, 'utf8'))
37
+ const byFileAndLabel = new Map()
38
+ for (const n of graph.nodes) byFileAndLabel.set(`${n.source_file}::${n.label}`, n)
39
+ return { nodes: graph.nodes, byFileAndLabel }
40
+ }
41
+
42
+ /** Reuses the existing graph's node id for (relFile, name) when present; otherwise synthesizes one with
43
+ * the same scheme and marks it so a future graphify run can be checked against it. */
44
+ function nodeIdFor(existing, relFile, name) {
45
+ const found = existing.byFileAndLabel.get(`${relFile}::${name}()`)
46
+ if (found) return { id: found.id, synthesized: false }
47
+ return { id: `${slugFile(relFile)}_${slugSymbol(name)}`, synthesized: true }
48
+ }
49
+
50
+ function loadProgram() {
51
+ const tsconfigPath = join(corePkg, 'tsconfig.json')
52
+ const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile)
53
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, corePkg)
54
+ const fileNames = parsed.fileNames.filter((f) => !/\.test\.ts$/.test(f) && !f.includes(`${corePkg}/test/`))
55
+ const program = ts.createProgram(fileNames, { ...parsed.options, noEmit: true })
56
+ return { program, fileNames }
57
+ }
58
+
59
+ function hasExportModifier(node) {
60
+ return (ts.getCombinedModifierFlags(node) & ts.ModifierFlags.Export) !== 0
61
+ }
62
+
63
+ function isEffectReturnType(type) {
64
+ const target = type.aliasSymbol ?? type.target?.symbol ?? type.symbol
65
+ return target?.name === 'Effect' && !!(type).typeArguments
66
+ }
67
+
68
+ /** Flattens a union of `Context.Tag`-shaped types (or a bare one) into distinct display names, dropping
69
+ * `never` (the honest "no requirements" answer, not an omission). */
70
+ function portNamesFromRType(checker, rType) {
71
+ if (!rType) return []
72
+ const parts = rType.isUnion?.() ? rType.types : [rType]
73
+ const names = new Set()
74
+ for (const t of parts) {
75
+ const text = checker.typeToString(t)
76
+ if (text === 'never' || text === 'unknown') continue
77
+ names.add(text)
78
+ }
79
+ return [...names]
80
+ }
81
+
82
+ /** Visits every exported function-like declaration (function declarations and `export const f = (...) =>`)
83
+ * in a source file, at the top level and one level of nesting (namespaces are not used in this codebase). */
84
+ function* exportedFunctions(sf) {
85
+ for (const stmt of sf.statements) {
86
+ if (ts.isFunctionDeclaration(stmt) && hasExportModifier(stmt) && stmt.name) {
87
+ yield { name: stmt.name.text, decl: stmt }
88
+ } else if (ts.isVariableStatement(stmt) && hasExportModifier(stmt)) {
89
+ for (const d of stmt.declarationList.declarations) {
90
+ if (ts.isIdentifier(d.name) && d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))) {
91
+ yield { name: d.name.text, decl: d.initializer }
92
+ }
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ function extractFunctionPorts(program, existing) {
99
+ const checker = program.getTypeChecker()
100
+ const functions = []
101
+ let scanned = 0
102
+ for (const sf of program.getSourceFiles()) {
103
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
104
+ const relFile = relative(root, sf.fileName)
105
+ for (const { name, decl } of exportedFunctions(sf)) {
106
+ scanned++
107
+ const sig = checker.getSignatureFromDeclaration(decl)
108
+ if (!sig) continue
109
+ const returnType = checker.getReturnTypeOfSignature(sig)
110
+ if (!isEffectReturnType(returnType)) continue
111
+ const typeArgs = checker.getTypeArguments(returnType)
112
+ const rType = typeArgs[2]
113
+ const ports = portNamesFromRType(checker, rType)
114
+ const { id, synthesized } = nodeIdFor(existing, relFile, name)
115
+ functions.push({ id, name, file: relFile, ports, synthesized })
116
+ }
117
+ }
118
+ return { functions, scanned }
119
+ }
120
+
121
+ function containingFunctionName(node) {
122
+ let cur = node.parent
123
+ while (cur) {
124
+ if ((ts.isFunctionDeclaration(cur) || ts.isFunctionExpression(cur) || ts.isArrowFunction(cur)) ) {
125
+ if (ts.isFunctionDeclaration(cur) && cur.name) return cur.name.text
126
+ if (ts.isVariableDeclaration(cur.parent) && ts.isIdentifier(cur.parent.name)) return cur.parent.name.text
127
+ }
128
+ cur = cur.parent
129
+ }
130
+ return null
131
+ }
132
+
133
+ /** Resolves a `Context.GenericTag<X>(...)` (or `Context.Tag<X, ...>`) expression, following one level of
134
+ * identifier indirection, to the service type name `X`. */
135
+ function resolveTagTypeName(checker, expr) {
136
+ let target = expr
137
+ if (ts.isIdentifier(expr)) {
138
+ const sym = checker.getSymbolAtLocation(expr)
139
+ const decl = sym?.valueDeclaration
140
+ if (decl && ts.isVariableDeclaration(decl) && decl.initializer) target = decl.initializer
141
+ }
142
+ if (ts.isCallExpression(target) && target.typeArguments?.length) {
143
+ return target.typeArguments[0].getText()
144
+ }
145
+ return null
146
+ }
147
+
148
+ const LAYER_CONSTRUCTORS = new Set(['succeed', 'effect', 'scoped', 'sync'])
149
+
150
+ function extractLayerBindings(program, existing) {
151
+ const checker = program.getTypeChecker()
152
+ const bindings = []
153
+ for (const sf of program.getSourceFiles()) {
154
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
155
+ const relFile = relative(root, sf.fileName)
156
+ const visit = (node) => {
157
+ if (
158
+ ts.isCallExpression(node)
159
+ && ts.isPropertyAccessExpression(node.expression)
160
+ && ts.isIdentifier(node.expression.expression)
161
+ && node.expression.expression.text === 'Layer'
162
+ && LAYER_CONSTRUCTORS.has(node.expression.name.text)
163
+ && node.arguments.length > 0
164
+ ) {
165
+ const tagName = resolveTagTypeName(checker, node.arguments[0])
166
+ if (tagName) {
167
+ const fnName = containingFunctionName(node) ?? '<module scope>'
168
+ const { id, synthesized } = nodeIdFor(existing, relFile, fnName)
169
+ bindings.push({
170
+ layerFnId: id,
171
+ layerFnName: fnName,
172
+ file: relFile,
173
+ line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1,
174
+ constructor: node.expression.name.text,
175
+ port: tagName,
176
+ synthesized,
177
+ })
178
+ }
179
+ }
180
+ ts.forEachChild(node, visit)
181
+ }
182
+ visit(sf)
183
+ }
184
+ return bindings
185
+ }
186
+
187
+ // --- Pipeline step-order extraction (makes the validate-before-persist invariant askable as a graph query) ---
188
+
189
+ /** Maps every step-factory function name (e.g. `validateCreateStep`) to the runtime step name it registers
190
+ * via `syncStep('<name>', ...)` / `asyncStep('<name>', ...)`. A factory with more than one such call inside
191
+ * keeps only the first — every factory in this codebase registers exactly one step. */
192
+ function buildStepNameMap(program) {
193
+ const map = new Map()
194
+ for (const sf of program.getSourceFiles()) {
195
+ if (!sf.fileName.includes('/pipeline/steps/') || sf.fileName.includes('/node_modules/')) continue
196
+ for (const { name, decl } of exportedFunctions(sf)) {
197
+ let stepName = null
198
+ const visit = (node) => {
199
+ if (stepName) return
200
+ if (
201
+ ts.isCallExpression(node)
202
+ && ts.isIdentifier(node.expression)
203
+ && (node.expression.text === 'syncStep' || node.expression.text === 'asyncStep')
204
+ && node.arguments[0] && ts.isStringLiteral(node.arguments[0])
205
+ ) {
206
+ stepName = node.arguments[0].text
207
+ return
208
+ }
209
+ ts.forEachChild(node, visit)
210
+ }
211
+ visit(decl)
212
+ if (stepName) map.set(name, stepName)
213
+ }
214
+ }
215
+ return map
216
+ }
217
+
218
+ function extractPipelineStepChains(program) {
219
+ const stepNameMap = buildStepNameMap(program)
220
+ const pipelines = []
221
+ for (const sf of program.getSourceFiles()) {
222
+ if (!sf.fileName.startsWith(corePkg) || sf.fileName.includes('/node_modules/')) continue
223
+ const relFile = relative(root, sf.fileName)
224
+ const visit = (node) => {
225
+ if (
226
+ ts.isCallExpression(node)
227
+ && ts.isIdentifier(node.expression)
228
+ && node.expression.text === 'definePipeline'
229
+ && node.arguments[0] && ts.isObjectLiteralExpression(node.arguments[0])
230
+ ) {
231
+ const obj = node.arguments[0]
232
+ const nameProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'name')
233
+ const stepsProp = obj.properties.find((p) => ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === 'steps')
234
+ if (nameProp && ts.isStringLiteral(nameProp.initializer) && stepsProp && ts.isArrayLiteralExpression(stepsProp.initializer)) {
235
+ const steps = []
236
+ for (const el of stepsProp.initializer.elements) {
237
+ // `steps: deleteSteps` (an identifier referring to a separately declared array) is skipped —
238
+ // rollback/duplicate/delete are not in VALIDATING_WRITE_OPS, so the invariant query never needs it.
239
+ if (ts.isCallExpression(el) && ts.isIdentifier(el.expression)) {
240
+ const factoryName = el.expression.text
241
+ steps.push(stepNameMap.get(factoryName) ?? factoryName)
242
+ } else if (ts.isCallExpression(el) && ts.isPropertyAccessExpression(el.expression)) {
243
+ steps.push(el.expression.name.text)
244
+ }
245
+ }
246
+ pipelines.push({ name: nameProp.initializer.text, file: relFile, line: sf.getLineAndCharacterOfPosition(node.getStart()).line + 1, steps })
247
+ }
248
+ }
249
+ ts.forEachChild(node, visit)
250
+ }
251
+ visit(sf)
252
+ }
253
+ return pipelines
254
+ }
255
+
256
+ /** The validate-before-persist invariant, as a pure query over an ordered step-name chain: `validate` must occur, `persist`
257
+ * must occur, and `validate` must precede `persist`. Mirrors test/architecture/pipeline-invariants.test.ts'
258
+ * registry-based check, but over the statically extracted `definePipeline` step array instead of a
259
+ * runtime-composed `PipelineDescriptor`. */
260
+ export function stepOrderRespectsInvariant(stepNames) {
261
+ const validateAt = stepNames.indexOf('validate')
262
+ const persistAt = stepNames.indexOf('persist')
263
+ if (validateAt < 0 || persistAt < 0) return false
264
+ return validateAt < persistAt
265
+ }
266
+
267
+ export const VALIDATING_WRITE_OPS = new Set(['createOne', 'createMany', 'updateOne', 'updateMany'])
268
+
269
+ export function extractPortGraph() {
270
+ const { program } = loadProgram()
271
+ const existing = loadExistingGraph()
272
+ const { functions, scanned } = extractFunctionPorts(program, existing)
273
+ const bindings = extractLayerBindings(program, existing)
274
+ const pipelines = extractPipelineStepChains(program)
275
+ return { functions, scanned, bindings, pipelines }
276
+ }
277
+
278
+ function buildEmittedGraph({ functions, bindings, pipelines }) {
279
+ const nodes = []
280
+ const links = []
281
+ const seenNodes = new Set()
282
+ const addNode = (node) => {
283
+ if (seenNodes.has(node.id)) return
284
+ seenNodes.add(node.id)
285
+ nodes.push(node)
286
+ }
287
+
288
+ for (const fn of functions) {
289
+ addNode({ id: fn.id, label: `${fn.name}()`, kind: 'function', source_file: fn.file, synthesized: fn.synthesized })
290
+ for (const port of fn.ports) {
291
+ const portId = `port_${slugSymbol(port)}`
292
+ addNode({ id: portId, label: port, kind: 'port' })
293
+ links.push({ relation: 'requires', source: fn.id, target: portId, source_file: fn.file })
294
+ }
295
+ }
296
+
297
+ for (const b of bindings) {
298
+ addNode({ id: b.layerFnId, label: `${b.layerFnName}()`, kind: 'function', source_file: b.file, synthesized: b.synthesized })
299
+ const portId = `port_${slugSymbol(b.port)}`
300
+ addNode({ id: portId, label: b.port, kind: 'port' })
301
+ links.push({ relation: 'provides', source: b.layerFnId, target: portId, source_file: b.file, source_location: `L${b.line}`, layer_constructor: b.constructor })
302
+ }
303
+
304
+ for (const [i, p] of pipelines.entries()) {
305
+ const pipelineId = `pipeline_${slugFile(p.file)}_${slugSymbol(p.name)}_${i}`
306
+ addNode({ id: pipelineId, label: `${p.name} (pipeline)`, kind: 'pipeline', source_file: p.file, source_location: `L${p.line}` })
307
+ p.steps.forEach((stepName, order) => {
308
+ const stepId = `pipeline_step_${slugSymbol(stepName)}`
309
+ addNode({ id: stepId, label: stepName, kind: 'pipeline_step' })
310
+ links.push({ relation: 'pipeline_step', source: pipelineId, target: stepId, order, source_file: p.file })
311
+ })
312
+ }
313
+
314
+ return { nodes, links }
315
+ }
316
+
317
+ function main() {
318
+ const { functions, scanned, bindings, pipelines } = extractPortGraph()
319
+ const nonEmpty = functions.filter((f) => f.ports.length > 0)
320
+ const totalPorts = new Set(functions.flatMap((f) => f.ports)).size
321
+
322
+ const emitted = buildEmittedGraph({ functions, bindings, pipelines })
323
+ const reportDir = join(root, 'reports/graph')
324
+ mkdirSync(reportDir, { recursive: true })
325
+ const outPath = join(reportDir, 'port-graph.json')
326
+ writeFileSync(outPath, JSON.stringify({ generated_at: new Date().toISOString(), nodes: emitted.nodes, links: emitted.links }, null, 2))
327
+
328
+ console.log(`port-graph: scanned ${scanned} exported functions in packages/kestrel-core/src`)
329
+ console.log(`port-graph: ${functions.length} have an Effect.Effect<A, E, R> return type`)
330
+ console.log(`port-graph: ${nonEmpty.length} have a non-empty (non-never) R channel; ${totalPorts} distinct ports found`)
331
+ console.log(`port-graph: ${bindings.length} Layer composition(s) -> adapter binding(s) found`)
332
+ console.log(`port-graph: ${pipelines.length} definePipeline() call(s) with a literal steps array extracted`)
333
+ console.log(`port-graph: wrote ${emitted.nodes.length} nodes, ${emitted.links.length} links to ${relative(root, outPath)}`)
334
+
335
+ if (nonEmpty.length === 0) {
336
+ console.log('port-graph: honest finding — every scanned function has R = never (DB/services are plain')
337
+ console.log('port-graph: parameters here, not Context.Tag requirements), so the R-channel port list is')
338
+ console.log('port-graph: empty by construction. The pipeline_step chains above make the validate-before-persist')
339
+ console.log('port-graph: invariant askable as a graph query at all; see test/architecture/port-graph-cross-check.test.ts.')
340
+ }
341
+
342
+ for (const p of pipelines) {
343
+ if (!VALIDATING_WRITE_OPS.has(p.name)) continue
344
+ const ok = stepOrderRespectsInvariant(p.steps)
345
+ console.log(`port-graph: pipeline "${p.name}" (${p.file}:L${p.line}) validate-before-persist = ${ok}`)
346
+ }
347
+ }
348
+
349
+ const isMain = process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url
350
+ if (isMain) main()
@@ -44,6 +44,17 @@ pass('server (Nitro) project', 'tsc', ['--noEmit', '-p', gateConfig])
44
44
  // 3. App / `.vue` / modules / config — vue-tsc via `nuxt typecheck` (needs the committed root tsconfig.json).
45
45
  pass('app + .vue (vue-tsc)', 'nuxt', ['typecheck'])
46
46
 
47
+ // 4. Standalone workspace packages have their own tsconfig, outside the playground project. Globbed by
48
+ // pnpm's own filter (topological order, every current and future `@michaelthielemann/kestrel-*` package, `create-kestrel`
49
+ // excluded by name) rather than a hand-written list per package — the class of drift a hand list invites
50
+ // (a package silently missing from this one script) is the exact failure mode this rail exists to avoid.
51
+ console.log('› typechecking packages (@michaelthielemann/kestrel-*)…')
52
+ try {
53
+ execFileSync('pnpm', ['--filter', '@michaelthielemann/kestrel-*', '-r', 'typecheck'], { cwd: root, stdio: 'inherit', env })
54
+ } catch {
55
+ failed.push('packages (@michaelthielemann/kestrel-*)')
56
+ }
57
+
47
58
  if (failed.length) {
48
59
  console.error(`\n✗ typecheck failed: ${failed.join(', ')}`)
49
60
  process.exit(1)
@@ -8,6 +8,6 @@ defineBlock({ label: { en: 'Prose' }, icon: 'file-text' })
8
8
 
9
9
  <template>
10
10
  <!-- richtext is sanitized server-side on write -->
11
- <!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in layers/fields/server/field-registry/index.ts -->
11
+ <!-- eslint-disable-next-line vue/no-v-html -- sanitized via sanitizeRichtext (sanitize-html allowlist) in packages/kestrel-fields/src/server/field-registry/index.ts -->
12
12
  <div class="block-prose" v-html="body" />
13
13
  </template>
@@ -1,39 +0,0 @@
1
- export interface CsrfHeaders {
2
- secFetchSite?: string | null
3
- origin?: string | null
4
- referer?: string | null
5
- host?: string | null
6
- }
7
-
8
- function hostOf(url: string | null | undefined): string | null {
9
- if (!url) return null
10
- try {
11
- return new URL(url).host
12
- } catch {
13
- return null
14
- }
15
- }
16
-
17
- export function isCrossSiteWrite(h: CsrfHeaders): boolean {
18
- if (h.secFetchSite) {
19
- return !(h.secFetchSite === 'same-origin' || h.secFetchSite === 'none')
20
- }
21
- // hostOf() lowercases per the URL spec; the raw Host header does not, so normalize both
22
- // sides — host comparison is case-insensitive (RFC 3986).
23
- const host = (h.host ?? '').toLowerCase()
24
- // The literal opaque origin `null` (a browser signal from a sandboxed iframe, a data: URL, or some
25
- // redirect chains) is unparseable by new URL, so it must NOT fall through to the all-absent "allow"
26
- // path — per spec it is cross-origin. Treat it as cross-site.
27
- if (h.origin === 'null') return true
28
- const originHost = hostOf(h.origin)
29
- if (originHost !== null) return originHost !== host
30
- const refererHost = hostOf(h.referer)
31
- if (refererHost !== null) return refererHost !== host
32
- // A write carrying NONE of Sec-Fetch-Site / Origin / Referer is treated as same-site (allowed). A
33
- // browser ALWAYS attaches Origin and/or Sec-Fetch-Site to a cross-origin state-changing request, so
34
- // the all-absent case can only come from a NON-browser client (the test harness, a server-to-server
35
- // import, automation) — which carries no ambient cookies and therefore cannot mount CSRF. The real
36
- // defenses are SameSite=Strict on the session cookie + the Origin/Referer checks above; failing closed
37
- // here adds no protection and breaks legitimate authenticated API clients.
38
- return false
39
- }
@@ -1,36 +0,0 @@
1
- import type { Grant, Role } from './policy'
2
-
3
- // A process-singleton registry of EXTRA authorization grants, contributed by layers/extensions at boot
4
- // (server plugin) on top of the hardcoded `POLICY`. This is the seam that lets an opt-in layer deliberately
5
- // open a narrow hole in the default-deny guard — e.g. the proofing extension granting `anonymous → write →
6
- // proofing` for its public back-channel. Inert until something registers: with an empty registry the guard
7
- // behaves exactly as the hardcoded policy. The map is INJECTED into the (pure) guard, not read from inside
8
- // it (symmetric with `publicResources`), so the policy stays deterministically node-testable. Foundation
9
- // for a future configurable Users & Roles system.
10
- const registered: Partial<Record<Role, Grant[]>> = {}
11
-
12
- /** Add an extra grant for a role. Deliberate + explicit — it widens what that role may do. */
13
- export function registerAccessGrant(role: Role, grant: Grant): void {
14
- // Defense-in-depth: the public (anonymous) must never be handed a wildcard or draft-read through this
15
- // seam — a legitimate public back-channel always names ONE specific resource and reads published-only.
16
- // CRUCIAL: the evaluator (`resolveAccess`) defaults an OMITTED read scope to 'all' via `(scope ?? 'all')`,
17
- // so an unscoped anonymous read would silently resolve to draft-read. The validator must use the SAME
18
- // default, or a "safe"-looking `{action:'read', resource:'x'}` grant leaks drafts to anonymous visitors.
19
- // (Write grants carry no read scope — `resolveAccess` only upgrades readScope for read grants — so an
20
- // omitted scope on a write is fine, e.g. the proofing back-channel's anonymous write.)
21
- const anonymousDraftRead = role === 'anonymous' && grant.action === 'read' && (grant.scope ?? 'all') === 'all'
22
- if (role === 'anonymous' && (grant.resource === '*' || anonymousDraftRead)) {
23
- throw new Error(`refusing an over-broad anonymous grant (${grant.action} ${grant.resource}${grant.scope ? ` scope=${grant.scope}` : ''}): name a specific resource and grant read with an explicit scope: 'published' (never draft-read)`)
24
- }
25
- (registered[role] ??= []).push(grant)
26
- }
27
-
28
- /** The full role → extra-grants map, for the guard to consult alongside `POLICY`. */
29
- export function registeredGrants(): Readonly<Partial<Record<Role, Grant[]>>> {
30
- return registered
31
- }
32
-
33
- /** Test helper: drop all registered grants (the registry is a module singleton). */
34
- export function clearAccessGrants(): void {
35
- for (const role of Object.keys(registered) as Role[]) Reflect.deleteProperty(registered, role)
36
- }
@@ -1,44 +0,0 @@
1
- import { isCrossSiteWrite, type CsrfHeaders } from './csrf'
2
- import { verifySession } from '../../../auth/server/utils/session'
3
- import { resolveAccess, actionForMethod, resourceForPath, isBootstrapPath, isPublicRenderPath, type Principal, type Grant, type Role } from './policy'
4
-
5
- export interface AccessInput {
6
- method: string
7
- path: string
8
- csrf: CsrfHeaders
9
- cookie: string | undefined
10
- secret: string
11
- nowMs: number
12
- isPrerender: boolean
13
- }
14
-
15
- export type AccessDecision =
16
- | { allow: true; principal: Principal; readScope: 'published' | 'all' }
17
- | { allow: false; status: number; message: string }
18
-
19
- export function derivePrincipal(input: AccessInput): Principal {
20
- if (input.isPrerender) return { userId: 'renderer', role: 'renderer' }
21
- if (verifySession(input.secret, input.cookie, input.nowMs).valid) return { userId: 'admin', role: 'admin' }
22
- return { userId: null, role: 'anonymous' }
23
- }
24
-
25
- export function evaluateAccess(input: AccessInput, publicResources: readonly string[] = [], registeredGrants: Readonly<Partial<Record<Role, Grant[]>>> = {}): AccessDecision {
26
- // CSRF first: every unsafe method (incl. login) must be same-site.
27
- if (actionForMethod(input.method) === 'write' && isCrossSiteWrite(input.csrf)) {
28
- return { allow: false, status: 403, message: 'Cross-origin write rejected' }
29
- }
30
- const principal = derivePrincipal(input)
31
- // Auth bootstrap (login/session) is always permitted so the SPA can authenticate. Scope still follows
32
- // the principal (mirrors isPublicRenderPath below) — an anonymous caller never gets draft-read scope.
33
- if (isBootstrapPath(input.method, input.path)) {
34
- return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
35
- }
36
- // The public render entry is readable by everyone; the scope follows the principal so an admin can
37
- // preview drafts while anonymous (and the static render) stay published-only.
38
- if (isPublicRenderPath(input.method, input.path)) {
39
- return { allow: true, principal, readScope: principal.role === 'anonymous' ? 'published' : 'all' }
40
- }
41
- const { allowed, readScope } = resolveAccess(principal, actionForMethod(input.method), resourceForPath(input.path), publicResources, registeredGrants[principal.role] ?? [])
42
- if (!allowed) return { allow: false, status: 401, message: 'Authentication required' }
43
- return { allow: true, principal, readScope }
44
- }
@@ -1,79 +0,0 @@
1
- export type AllowlistMode = 'enforce' | 'log' | 'off'
2
-
3
- /** A parsed IPv4 CIDR as `network base & mask`, both uint32. */
4
- export interface Cidr {
5
- base: number
6
- mask: number
7
- }
8
-
9
- /**
10
- * Resolve the gate mode from `KESTREL_IP_ALLOWLIST_MODE` given the raw list.
11
- *
12
- * An empty list means there is nothing to enforce → `off`. When a list IS present, an unset/unknown mode
13
- * defaults to `enforce`: a configured allow-list that silently let everyone through is the exact legacy
14
- * footgun we are replacing. `log` is the opt-in calibration mode (observe, never block) used to confirm the
15
- * resolved client IP + `KESTREL_TRUST_PROXY` depth before enforcing.
16
- */
17
- export function allowlistMode(modeRaw: string | undefined, listRaw: string | undefined): AllowlistMode {
18
- if (!listRaw || !listRaw.trim()) return 'off'
19
- const m = (modeRaw ?? '').trim().toLowerCase()
20
- if (m === 'off' || m === 'disabled' || m === 'false') return 'off'
21
- if (m === 'log' || m === 'log-only' || m === 'dry-run') return 'log'
22
- return 'enforce'
23
- }
24
-
25
- /** `1.2.3.4` / `::ffff:1.2.3.4` → uint32; `null` for out-of-range, real IPv6, or non-IPv4 input. */
26
- export function ipv4ToInt(ip: string): number | null {
27
- let s = ip.trim()
28
- const mapped = /^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i.exec(s)
29
- if (mapped) s = mapped[1]!
30
- const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s)
31
- if (!m) return null
32
- let n = 0
33
- for (let i = 1; i <= 4; i++) {
34
- const octet = Number(m[i])
35
- if (octet > 255) return null
36
- n = n * 256 + octet
37
- }
38
- return n >>> 0
39
- }
40
-
41
- function parseCidr(token: string): Cidr | null {
42
- const slash = token.indexOf('/')
43
- const base = ipv4ToInt(slash === -1 ? token : token.slice(0, slash))
44
- if (base === null) return null
45
- // `Number()` alone would widen a malformed prefix into a mask: '' → 0 → 0.0.0.0/0, and '0x10'/'1e1' → 16/10.
46
- const bitsRaw = slash === -1 ? '32' : token.slice(slash + 1).trim()
47
- if (!/^\d+$/.test(bitsRaw)) return null
48
- const bits = Number(bitsRaw)
49
- if (!Number.isInteger(bits) || bits < 0 || bits > 32) return null
50
- const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0
51
- return { base: (base & mask) >>> 0, mask }
52
- }
53
-
54
- /**
55
- * Parse the allow-list env into CIDRs, tolerant of the legacy nginx block so it can be pasted verbatim:
56
- * newline/comma/semicolon separated, `# comments` and a leading `allow`/`set_real_ip_from` keyword stripped.
57
- * Invalid tokens are dropped, not thrown — one bad line must not disable the whole gate. `onReject`, when
58
- * given, is called with each dropped token (this parser is IPv4-only, so an IPv6 CIDR pasted into an
59
- * otherwise-valid list is silently a no-op unless the caller surfaces it).
60
- */
61
- export function parseAllowlist(raw: string | undefined, onReject?: (token: string) => void): Cidr[] {
62
- if (!raw) return []
63
- const out: Cidr[] = []
64
- for (const part of raw.split(/[\n,;]+/)) {
65
- const token = part.replace(/#.*$/, '').trim().replace(/^(?:allow|set_real_ip_from)\s+/i, '').trim()
66
- if (!token) continue
67
- const cidr = parseCidr(token)
68
- if (cidr) out.push(cidr)
69
- else onReject?.(token)
70
- }
71
- return out
72
- }
73
-
74
- /** True when `ip` falls in any CIDR. Fails closed: a non-IPv4 address or empty list never matches. */
75
- export function ipAllowed(ip: string, cidrs: Cidr[]): boolean {
76
- const n = ipv4ToInt(ip)
77
- if (n === null) return false
78
- return cidrs.some((c) => ((n & c.mask) >>> 0) === c.base)
79
- }