@michaelthielemann/kestrel 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -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 +6 -8
- 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/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -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 +157 -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
|
@@ -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
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
export type Role = 'admin' | 'renderer' | 'anonymous'
|
|
2
|
-
export type Action = 'read' | 'write'
|
|
3
|
-
export interface Principal { userId: string | null; role: Role }
|
|
4
|
-
export interface Grant { action: Action; resource: string; scope?: 'published' | 'all' }
|
|
5
|
-
export interface AccessResult { allowed: boolean; readScope: 'published' | 'all' }
|
|
6
|
-
|
|
7
|
-
/** Hardcoded authorization policy. Role -> grants. `resource: '*'` matches any. */
|
|
8
|
-
export const POLICY: Record<Role, Grant[]> = {
|
|
9
|
-
admin: [{ action: 'write', resource: '*' }, { action: 'read', resource: '*' }],
|
|
10
|
-
// The renderer is ONLY ever the build-time prerender or the runtime publisher; both produce the
|
|
11
|
-
// public static site, which is published-only by definition. Scope its read to 'published' so the
|
|
12
|
-
// generic /api/<collection> reads never leak drafts into the generated HTML (admin live preview is
|
|
13
|
-
// unaffected — it renders under the admin principal, scope 'all').
|
|
14
|
-
renderer: [{ action: 'read', resource: '*', scope: 'published' }],
|
|
15
|
-
// Anonymous published-read is data-driven from the registry (every page-like collection), not a
|
|
16
|
-
// literal allow-list — see the `publicResources` branch in `resolveAccess`.
|
|
17
|
-
anonymous: [],
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function resolveAccess(principal: Principal, action: Action, resource: string, publicResources: readonly string[] = [], extraGrants: readonly Grant[] = []): AccessResult {
|
|
21
|
-
let allowed = false
|
|
22
|
-
// readScope reflects the principal's READ permission for this resource, INDEPENDENT of the requested
|
|
23
|
-
// action, and is fail-closed to 'published': a granted WRITE must never silently confer draft-read
|
|
24
|
-
// ('all'). Only an explicit read grant with scope 'all' (i.e. admin) upgrades it.
|
|
25
|
-
let readScope: 'published' | 'all' = 'published'
|
|
26
|
-
// Hardcoded policy first, then any registry-contributed grants for this role (the opt-in seam). Extra
|
|
27
|
-
// grants only ever ADD permissions for the role they were registered under — they can't revoke.
|
|
28
|
-
for (const g of [...(POLICY[principal.role] ?? []), ...extraGrants]) {
|
|
29
|
-
if (g.resource !== '*' && g.resource !== resource) continue
|
|
30
|
-
if (g.action === action) allowed = true
|
|
31
|
-
if (g.action === 'read' && (g.scope ?? 'all') === 'all') readScope = 'all' // a draft-read grant wins
|
|
32
|
-
}
|
|
33
|
-
// An anonymous visitor gets published read on every page-like collection (the registry-driven public
|
|
34
|
-
// set), so a consumer's own pageLike collection is reachable without editing a literal allow-list.
|
|
35
|
-
// Published-only and gated to the public set — never widens to drafts or non-pageLike collections.
|
|
36
|
-
if (!allowed && principal.role === 'anonymous' && action === 'read' && publicResources.includes(resource)) {
|
|
37
|
-
allowed = true
|
|
38
|
-
readScope = 'published'
|
|
39
|
-
}
|
|
40
|
-
return { allowed, readScope }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Whether an anonymous visitor may read a collection — i.e. whether its pages are publicly reachable.
|
|
45
|
-
* The single source of truth for "what belongs in a public sitemap": a sitemap must list only URLs an
|
|
46
|
-
* unauthenticated crawler can actually fetch, so the sitemap filters pageLike collections through this.
|
|
47
|
-
*/
|
|
48
|
-
export function isPubliclyReadable(resource: string, publicResources: readonly string[] = []): boolean {
|
|
49
|
-
return resolveAccess({ userId: null, role: 'anonymous' }, 'read', resource, publicResources).allowed
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Whether a read at this `readScope` must be limited to published rows. Fail-CLOSED: only the explicit
|
|
54
|
-
* `'all'` scope (admin / live preview) sees drafts; anything else — `'published'`, an unexpected value,
|
|
55
|
-
* or a missing scope (a guard regression) — stays published-only. Mirrors the public render path so the
|
|
56
|
-
* two read entry points can't drift to opposite defaults.
|
|
57
|
-
*/
|
|
58
|
-
export function publishedOnlyForScope(readScope: string | undefined): boolean {
|
|
59
|
-
return readScope !== 'all'
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
63
|
-
export function actionForMethod(method: string): Action {
|
|
64
|
-
return SAFE_METHODS.has(method.toUpperCase()) ? 'read' : 'write'
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Resource for an /api path: the collection segment, except the tooling sub-routes
|
|
69
|
-
* (`/options`, `/translations`, `/:id/translations`, `/:id/dead-refs`) map to a distinct
|
|
70
|
-
* `<collection>/<tool>` resource so the public content grant (read on the bare collection) never covers
|
|
71
|
-
* admin-only tooling — a pageLike collection's `translations` / `dead-refs` maps must NOT be readable by
|
|
72
|
-
* anonymous visitors. The group-keyed and per-record translations routes share one resource: they expose
|
|
73
|
-
* the same sibling ids (drafts included), so they must be gated identically.
|
|
74
|
-
*/
|
|
75
|
-
export function resourceForPath(path: string): string {
|
|
76
|
-
const segs = (path.split('?')[0] ?? '').split('/').filter(Boolean)
|
|
77
|
-
if (segs[0] !== 'api' || !segs[1]) return ''
|
|
78
|
-
if (segs[2] === 'options' || segs[2] === 'translations') return `${segs[1]}/${segs[2]}`
|
|
79
|
-
if (segs[3] === 'translations' || segs[3] === 'dead-refs') return `${segs[1]}/${segs[3]}`
|
|
80
|
-
return segs[1]
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** Auth bootstrap endpoints, always permitted so the SPA can authenticate under default-deny. */
|
|
84
|
-
export function isBootstrapPath(method: string, path: string): boolean {
|
|
85
|
-
const clean = path.split('?')[0]
|
|
86
|
-
const m = method.toUpperCase()
|
|
87
|
-
return (m === 'POST' && clean === '/api/auth/login') || (m === 'GET' && clean === '/api/auth/session')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* The public render entry (`GET /api/route`) the live page resolver calls. Readable by everyone — it is
|
|
92
|
-
* the runtime equivalent of serving a static page — but the read SCOPE follows the principal: anonymous
|
|
93
|
-
* gets published-only, an authenticated admin gets 'all' so the handler can surface a draft preview.
|
|
94
|
-
* The handler itself re-forces published-only for any static-generation render (prerender / publisher).
|
|
95
|
-
*/
|
|
96
|
-
export function isPublicRenderPath(method: string, path: string): boolean {
|
|
97
|
-
return method.toUpperCase() === 'GET' && path.split('?')[0] === '/api/route'
|
|
98
|
-
}
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
import { allCollections } from '../../../core/server/utils/registry'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Collections an anonymous visitor may read (published only): every page-like collection. The single,
|
|
5
|
-
* registry-driven source for public reachability — consumed by the access guard (runtime serving) and
|
|
6
|
-
* the sitemap (which URLs to advertise), so a consumer's own pageLike collection is public without
|
|
7
|
-
* editing a literal allow-list. Page-like ⟺ has a `path`, i.e. it is routable to a static URL.
|
|
8
|
-
*/
|
|
9
|
-
export function publicReadableResources(): string[] {
|
|
10
|
-
return allCollections().filter((c) => c.def.pageLike).map((c) => c.def.name)
|
|
11
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Marks an in-process request chain as the runtime publisher's render, so the access guard grants it the
|
|
5
|
-
* same `renderer` principal that build-time prerender gets. The publisher's render does a nested
|
|
6
|
-
* `$fetch('/api/route')`, which is NOT a public endpoint; without this it would 401 at runtime. Set via
|
|
7
|
-
* AsyncLocalStorage — never a header — so an external HTTP request can't forge renderer access (and
|
|
8
|
-
* resolvePage still enforces published-only, so drafts never reach the output regardless).
|
|
9
|
-
*/
|
|
10
|
-
const ctx = new AsyncLocalStorage<true>()
|
|
11
|
-
|
|
12
|
-
/** Run a publish render (and its nested API fetches) as the renderer principal. */
|
|
13
|
-
export function runAsRenderer<T>(fn: () => T): T {
|
|
14
|
-
return ctx.run(true, fn)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** True when executing inside a publisher render, so the access guard grants the renderer principal. */
|
|
18
|
-
export function isRendererContext(): boolean {
|
|
19
|
-
return ctx.getStore() === true
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const stageGate = new AsyncLocalStorage<true>()
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Marks the current request's async context as classified by the stage IP gate, so the in-process
|
|
26
|
-
* sub-requests it spawns (the SSR page's own `$fetch`, the editor preview) are not gated again — they
|
|
27
|
-
* reach the middleware stack on a mocked socket with no peer address and could never match a CIDR.
|
|
28
|
-
*
|
|
29
|
-
* `enterWith` rather than `run` because a middleware has no handle on the rest of its own request; the
|
|
30
|
-
* mark is therefore reachable only from work descending from a request the gate already admitted, which
|
|
31
|
-
* is what makes it unforgeable — unlike "this request has no socket peer", a property whole listeners
|
|
32
|
-
* (unix domain sockets) have for every external client.
|
|
33
|
-
*/
|
|
34
|
-
export function markStageGatePassed(): void {
|
|
35
|
-
stageGate.enterWith(true)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** True inside a request whose originating request was already classified by the stage IP gate. */
|
|
39
|
-
export function isStageGatePassedContext(): boolean {
|
|
40
|
-
return stageGate.getStore() === true
|
|
41
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import { createError, type H3Event } from 'h3'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Per-handler write-authorization backstop. The access-guard middleware is the PRIMARY enforcement, but
|
|
5
|
-
* it is gated on an `/api/` path-prefix string and is the only check — a future mutating route mounted
|
|
6
|
-
* outside `/api/` (a `server/routes/*` action, a webhook), or any guard-vs-router path divergence, would
|
|
7
|
-
* otherwise run unauthenticated. Every mutating handler calls this so authorization never depends solely
|
|
8
|
-
* on that prefix heuristic. Throws 401 unless the resolved principal is the admin (the only role with
|
|
9
|
-
* write grants — `renderer` is read-only, `anonymous` has no grants).
|
|
10
|
-
*/
|
|
11
|
-
export function requireAdmin(event: H3Event): void {
|
|
12
|
-
if (event.context.principal?.role !== 'admin') {
|
|
13
|
-
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' })
|
|
14
|
-
}
|
|
15
|
-
}
|
|
@@ -1,200 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
-
import { useState } from '#imports'
|
|
3
|
-
import { flushPromises } from '@vue/test-utils'
|
|
4
|
-
import { readBody } from 'h3'
|
|
5
|
-
import { mountSuspended, registerEndpoint, mockNuxtImport } from '@nuxt/test-utils/runtime'
|
|
6
|
-
import RecordPage from './[id].vue'
|
|
7
|
-
|
|
8
|
-
const thingsSchema = {
|
|
9
|
-
name: 'things', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
10
|
-
blocks: { enabled: false }, label: { singular: 'Thing', plural: 'Things' },
|
|
11
|
-
fields: { title: { type: 'text', required: true, unique: false } },
|
|
12
|
-
}
|
|
13
|
-
// Carries an explicit `label.new` — the create heading must use it verbatim, not the generic template.
|
|
14
|
-
const articlesSchema = {
|
|
15
|
-
name: 'articles', mode: 'multi', translatable: false, pageLike: false, seo: false, status: false,
|
|
16
|
-
blocks: { enabled: false }, label: { singular: 'Article', plural: 'Articles', new: 'Compose article' },
|
|
17
|
-
fields: { title: { type: 'text', required: true, unique: false } },
|
|
18
|
-
}
|
|
19
|
-
registerEndpoint('/api/collections', () => ({ data: [thingsSchema, articlesSchema] }))
|
|
20
|
-
registerEndpoint('/api/articles', () => ({ data: [], total: 0, page: 1, perPage: 25 }))
|
|
21
|
-
|
|
22
|
-
let patched: Record<string, unknown> | null = null
|
|
23
|
-
let posted: Record<string, unknown> | null = null
|
|
24
|
-
registerEndpoint('/api/things/1', async (event) => {
|
|
25
|
-
if (event.method === 'PATCH') { patched = await readBody(event); return { id: 1, ...patched } }
|
|
26
|
-
return { id: 1, title: 'Existing' }
|
|
27
|
-
})
|
|
28
|
-
// A record whose title field is blank — the heading must fall back to the generic "#id" phrase.
|
|
29
|
-
registerEndpoint('/api/things/2', () => ({ id: 2, title: ' ' }))
|
|
30
|
-
registerEndpoint('/api/things', async (event) => {
|
|
31
|
-
if (event.method === 'POST') { posted = await readBody(event); return { id: 7, ...posted } }
|
|
32
|
-
return { data: [], total: 0, page: 1, perPage: 25 }
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
// The editor's Delete flows through the shared batch endpoint + the referrer-aggregate preview.
|
|
36
|
-
let bulkBody: Record<string, unknown> | null = null
|
|
37
|
-
registerEndpoint('/api/things/bulk', async (event) => {
|
|
38
|
-
bulkBody = await readBody(event)
|
|
39
|
-
return { action: bulkBody!.action, count: 1, ids: bulkBody!.ids }
|
|
40
|
-
})
|
|
41
|
-
registerEndpoint('/api/references/referrers', () => ({ counts: {} }))
|
|
42
|
-
|
|
43
|
-
// Route + navigation are mocked so the page reads collection/id from `h.params` and navigations are captured.
|
|
44
|
-
const h = vi.hoisted(() => ({ params: { collection: 'things', id: '1' }, nav: [] as unknown[] }))
|
|
45
|
-
mockNuxtImport('useRoute', () => () => ({ params: h.params, query: {}, fullPath: `/admin/${h.params.collection}/${h.params.id}` }))
|
|
46
|
-
mockNuxtImport('navigateTo', () => (to: unknown) => { h.nav.push(to); return Promise.resolve() })
|
|
47
|
-
|
|
48
|
-
beforeEach(() => {
|
|
49
|
-
h.params = { collection: 'things', id: '1' }
|
|
50
|
-
h.nav.length = 0
|
|
51
|
-
patched = null
|
|
52
|
-
posted = null
|
|
53
|
-
bulkBody = null
|
|
54
|
-
useState('kestrel-collections').value = null
|
|
55
|
-
useState('kestrel-blocks').value = null
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
const settle = async () => {
|
|
59
|
-
await new Promise((r) => setTimeout(r, 20))
|
|
60
|
-
await flushPromises()
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
describe('record editor page header', () => {
|
|
64
|
-
it('merges Save, Cancel and Delete into the record head, each with an icon', async () => {
|
|
65
|
-
const w = await mountSuspended(RecordPage)
|
|
66
|
-
await flushPromises()
|
|
67
|
-
|
|
68
|
-
const head = w.find('.record__head')
|
|
69
|
-
expect(head.exists()).toBe(true)
|
|
70
|
-
const buttons = head.findAll('.ui-button')
|
|
71
|
-
expect(buttons.map((b) => b.text())).toEqual(expect.arrayContaining(['Save', 'Cancel', 'Delete']))
|
|
72
|
-
for (const b of buttons) expect(b.find('.ui-icon').exists()).toBe(true)
|
|
73
|
-
expect(w.find('.editor__actions').exists()).toBe(false)
|
|
74
|
-
|
|
75
|
-
// Cancel carries real button chrome — it sits beside the solid Delete/Save, not as bare text…
|
|
76
|
-
expect(buttons.find((b) => b.text() === 'Cancel')!.classes()).toContain('ui-button--secondary')
|
|
77
|
-
// …while the icon-only tools (undo/redo/open) stay quiet ghosts.
|
|
78
|
-
const iconOnly = buttons.filter((b) => b.text() === '')
|
|
79
|
-
expect(iconOnly.length).toBeGreaterThan(0)
|
|
80
|
-
for (const b of iconOnly) expect(b.classes()).toContain('ui-button--ghost')
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('titles the header with the singular label, not the raw (plural) route param', async () => {
|
|
84
|
-
h.params = { collection: 'things', id: 'new' }
|
|
85
|
-
const wNew = await mountSuspended(RecordPage)
|
|
86
|
-
await flushPromises()
|
|
87
|
-
expect(wNew.find('.record__title').text()).toBe('New Thing')
|
|
88
|
-
expect(wNew.find('.record__back').text()).toContain('Things')
|
|
89
|
-
// The generic phrase is title-cased; a real record title would not be.
|
|
90
|
-
expect(wNew.find('.record__title').classes()).toContain('record__title--generic')
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
it('keeps the back link on the heading row (one line, so the panes keep the vertical space)', async () => {
|
|
94
|
-
const w = await mountSuspended(RecordPage)
|
|
95
|
-
await flushPromises()
|
|
96
|
-
expect(w.find('.record__head .record__back').exists()).toBe(true)
|
|
97
|
-
})
|
|
98
|
-
|
|
99
|
-
it('titles a saved record with its own title — an id means nothing to an editor', async () => {
|
|
100
|
-
h.params = { collection: 'things', id: '1' }
|
|
101
|
-
const wEdit = await mountSuspended(RecordPage)
|
|
102
|
-
await settle()
|
|
103
|
-
expect(wEdit.find('.record__title').text()).toBe('Existing')
|
|
104
|
-
expect(wEdit.find('.record__title').classes()).not.toContain('record__title--generic')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('falls back to "Edit {collection} #{id}" when the record has no usable title', async () => {
|
|
108
|
-
h.params = { collection: 'things', id: '2' }
|
|
109
|
-
const wEdit = await mountSuspended(RecordPage)
|
|
110
|
-
await settle()
|
|
111
|
-
expect(wEdit.find('.record__title').text()).toBe('Edit Thing #2')
|
|
112
|
-
expect(wEdit.find('.record__title').classes()).toContain('record__title--generic')
|
|
113
|
-
})
|
|
114
|
-
|
|
115
|
-
it('uses the collection\'s explicit label.new for the create heading (no generic "New X")', async () => {
|
|
116
|
-
h.params = { collection: 'articles', id: 'new' }
|
|
117
|
-
const w = await mountSuspended(RecordPage)
|
|
118
|
-
await flushPromises()
|
|
119
|
-
expect(w.find('.record__title').text()).toBe('Compose article')
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
it('wires the header Save button to submit the editor form', async () => {
|
|
123
|
-
const w = await mountSuspended(RecordPage)
|
|
124
|
-
await flushPromises()
|
|
125
|
-
const save = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Save')!
|
|
126
|
-
expect(save.attributes('type')).toBe('submit')
|
|
127
|
-
expect(save.attributes('form')).toBe('record-editor')
|
|
128
|
-
expect(w.find('form.editor').attributes('id')).toBe('record-editor')
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('shows the back link with a centered SVG arrow (no text arrow glyph)', async () => {
|
|
132
|
-
const w = await mountSuspended(RecordPage)
|
|
133
|
-
await flushPromises()
|
|
134
|
-
const back = w.find('.record__back')
|
|
135
|
-
expect(back.exists()).toBe(true)
|
|
136
|
-
expect(back.find('.ui-icon').exists()).toBe(true)
|
|
137
|
-
expect(back.text()).not.toContain('←')
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
it('saving an existing record stays on the page — Save only saves', async () => {
|
|
141
|
-
const w = await mountSuspended(RecordPage)
|
|
142
|
-
await flushPromises()
|
|
143
|
-
await w.find('form.editor').trigger('submit')
|
|
144
|
-
await settle()
|
|
145
|
-
expect(patched).toMatchObject({ title: 'Existing' })
|
|
146
|
-
expect(h.nav).toEqual([])
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
it('saving a NEW record moves to its own editor, not back to the list', async () => {
|
|
150
|
-
h.params = { collection: 'things', id: 'new' }
|
|
151
|
-
const w = await mountSuspended(RecordPage)
|
|
152
|
-
await flushPromises()
|
|
153
|
-
await w.findAll('input')[0]!.setValue('Fresh')
|
|
154
|
-
await w.find('form.editor').trigger('submit')
|
|
155
|
-
await settle()
|
|
156
|
-
expect(posted).toMatchObject({ title: 'Fresh' })
|
|
157
|
-
// navigates to the created record's editor (id 7), never to the list path
|
|
158
|
-
expect(h.nav).toContain('/admin/things/7')
|
|
159
|
-
expect(h.nav).not.toContain('/admin/things')
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
it('deletes through the confirm dialog (shared batch op) and navigates back to the list', async () => {
|
|
163
|
-
const w = await mountSuspended(RecordPage)
|
|
164
|
-
await settle()
|
|
165
|
-
// the header Delete opens the dialog rather than window.confirm
|
|
166
|
-
const del = w.findAll('.record__head .ui-button').find((b) => b.text() === 'Delete')!
|
|
167
|
-
await del.trigger('click')
|
|
168
|
-
await settle()
|
|
169
|
-
expect(w.find('.ui-dialog__content').exists()).toBe(true)
|
|
170
|
-
// confirming posts the bulk delete for this one id and then navigates to the list
|
|
171
|
-
const confirm = w.findAll('.ui-dialog__content .ui-button').find((b) => /^delete$/i.test(b.text().trim()))!
|
|
172
|
-
await confirm.trigger('click')
|
|
173
|
-
await settle()
|
|
174
|
-
expect(bulkBody).toEqual({ action: 'delete', ids: [1] })
|
|
175
|
-
expect(h.nav).toContain('/admin/things')
|
|
176
|
-
})
|
|
177
|
-
|
|
178
|
-
it('a native beforeunload (tab close / reload) is blocked only while there are unsaved changes', async () => {
|
|
179
|
-
const w = await mountSuspended(RecordPage)
|
|
180
|
-
await flushPromises()
|
|
181
|
-
|
|
182
|
-
// pristine → unload is NOT blocked
|
|
183
|
-
const clean = new Event('beforeunload', { cancelable: true })
|
|
184
|
-
window.dispatchEvent(clean)
|
|
185
|
-
expect(clean.defaultPrevented).toBe(false)
|
|
186
|
-
|
|
187
|
-
// edit a field → dirty → unload IS blocked (browser shows its native prompt)
|
|
188
|
-
await w.findAll('input')[0]!.setValue('Changed')
|
|
189
|
-
await settle()
|
|
190
|
-
const dirty = new Event('beforeunload', { cancelable: true })
|
|
191
|
-
window.dispatchEvent(dirty)
|
|
192
|
-
expect(dirty.defaultPrevented).toBe(true)
|
|
193
|
-
|
|
194
|
-
// unmounting removes the listener (no leak across pages)
|
|
195
|
-
w.unmount()
|
|
196
|
-
const afterUnmount = new Event('beforeunload', { cancelable: true })
|
|
197
|
-
window.dispatchEvent(afterUnmount)
|
|
198
|
-
expect(afterUnmount.defaultPrevented).toBe(false)
|
|
199
|
-
})
|
|
200
|
-
})
|