@michaelthielemann/kestrel 3.0.1 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +69 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +92 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +1 -3
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +160 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- 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()
|
package/scripts/typecheck.mjs
CHANGED
|
@@ -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
|
|
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
|
-
}
|