@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
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { getRequestIP, getRequestHeader } from 'h3'
|
|
2
|
-
import type { H3Event } from 'h3'
|
|
3
|
-
|
|
4
|
-
type Env = Record<string, string | undefined>
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* How many reverse-proxy hops to trust in front of the app, parsed from `KESTREL_TRUST_PROXY`.
|
|
8
|
-
*
|
|
9
|
-
* Default **0** == trust nothing == ignore `X-Forwarded-For` entirely (without a proxy in front, XFF is
|
|
10
|
-
* fully attacker-controlled). `true`/`on`/`yes`/`1` == one trusted proxy; a positive integer == that many
|
|
11
|
-
* chained trusted proxies (e.g. CDN → load balancer). Anything else (`false`/`0`/blank/garbage) == 0.
|
|
12
|
-
*/
|
|
13
|
-
export function trustProxyDepth(env: Env = process.env): number {
|
|
14
|
-
const raw = (env.KESTREL_TRUST_PROXY ?? '').trim().toLowerCase()
|
|
15
|
-
if (!raw) return 0
|
|
16
|
-
if (raw === 'true' || raw === 'on' || raw === 'yes') return 1
|
|
17
|
-
const n = Number(raw)
|
|
18
|
-
return Number.isInteger(n) && n > 0 ? n : 0
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Pick the client address from an `X-Forwarded-For` chain, counting `depth` hops from the RIGHT.
|
|
23
|
-
*
|
|
24
|
-
* XFF is appended left-to-right (`client, proxy1, proxy2`), so the entries a client can forge sit on the
|
|
25
|
-
* LEFT and the right-most entry is the address our nearest trusted proxy actually observed. With one
|
|
26
|
-
* trusted proxy (depth 1) we take the right-most entry; with N chained trusted proxies we skip the N-1
|
|
27
|
-
* proxy hops and take the Nth-from-right. (h3's own `getRequestIP({ xForwardedFor: true })` takes the
|
|
28
|
-
* LEFT-most — spoofable — value, which is exactly why we don't use it.)
|
|
29
|
-
*
|
|
30
|
-
* If the chain is shorter than the trusted depth, it didn't traverse the expected proxies → untrustworthy,
|
|
31
|
-
* so we return `undefined` and let the caller fall back to the socket peer rather than honour a forged value.
|
|
32
|
-
*/
|
|
33
|
-
export function forwardedForHop(header: string | undefined, depth: number): string | undefined {
|
|
34
|
-
if (depth < 1) return undefined
|
|
35
|
-
const parts = (header ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
36
|
-
const idx = parts.length - depth
|
|
37
|
-
return idx >= 0 ? parts[idx] : undefined
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* The client IP used to key the login throttle.
|
|
42
|
-
*
|
|
43
|
-
* Trusting `X-Forwarded-For` unconditionally lets a direct client rotate the header to dodge the per-IP
|
|
44
|
-
* limit, so by default we key on the socket peer address and ignore XFF. Only when `KESTREL_TRUST_PROXY`
|
|
45
|
-
* declares a trusted proxy depth do we honour the correct (right-most) XFF hop; a missing/too-short chain
|
|
46
|
-
* still falls back to the peer.
|
|
47
|
-
*/
|
|
48
|
-
export function clientIp(event: H3Event, env: Env = process.env): string {
|
|
49
|
-
const depth = trustProxyDepth(env)
|
|
50
|
-
if (depth > 0) {
|
|
51
|
-
const forwarded = forwardedForHop(getRequestHeader(event, 'x-forwarded-for'), depth)
|
|
52
|
-
if (forwarded) return forwarded
|
|
53
|
-
}
|
|
54
|
-
return getRequestIP(event) ?? 'unknown'
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Expand a syntactically valid IPv6 address (optionally `::`-compressed) into its 8 hextets, or `null`. */
|
|
58
|
-
function ipv6Hextets(ip: string): string[] | null {
|
|
59
|
-
const compressAt = ip.indexOf('::')
|
|
60
|
-
if (compressAt === -1) {
|
|
61
|
-
const groups = ip.split(':')
|
|
62
|
-
return groups.length === 8 ? groups : null
|
|
63
|
-
}
|
|
64
|
-
if (ip.indexOf('::', compressAt + 1) !== -1) return null // more than one '::' is not valid IPv6
|
|
65
|
-
const left = ip.slice(0, compressAt)
|
|
66
|
-
const right = ip.slice(compressAt + 2)
|
|
67
|
-
const leftGroups = left === '' ? [] : left.split(':')
|
|
68
|
-
const rightGroups = right === '' ? [] : right.split(':')
|
|
69
|
-
const missing = 8 - leftGroups.length - rightGroups.length
|
|
70
|
-
if (missing < 0) return null
|
|
71
|
-
return [...leftGroups, ...Array(missing).fill('0'), ...rightGroups]
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The key used to bucket the login throttle: an IPv4 (and IPv4-mapped `::ffff:a.b.c.d`) address stays a
|
|
76
|
-
* full /32 — NAT already shares those legitimately, so widening the bucket there would throttle unrelated
|
|
77
|
-
* users behind the same NAT. A real IPv6 address is coarsened to its first four hextets (a /64), the block
|
|
78
|
-
* an ISP typically routes to a single customer — otherwise an attacker on a routed /64 binds a fresh source
|
|
79
|
-
* address per request and `recentFails` never sees the same key twice, so the throttle bounds nothing.
|
|
80
|
-
* Anything that isn't well-formed IPv4 or IPv6 (including the `unknown` fallback) is returned unchanged,
|
|
81
|
-
* which is only ever as permissive as the pre-existing full-address key.
|
|
82
|
-
*/
|
|
83
|
-
export function throttleKey(ip: string): string {
|
|
84
|
-
if (ip.includes('.') || !ip.includes(':')) return ip
|
|
85
|
-
const hextets = ipv6Hextets(ip)
|
|
86
|
-
if (!hextets) return ip
|
|
87
|
-
return hextets.slice(0, 4).join(':') + '::/64'
|
|
88
|
-
}
|
|
@@ -1,126 +0,0 @@
|
|
|
1
|
-
import { createError } from 'h3'
|
|
2
|
-
|
|
3
|
-
export const MAX_LOGIN_BODY = 4096
|
|
4
|
-
export const MAX_INFLIGHT_HASHES = 4
|
|
5
|
-
|
|
6
|
-
export function assertBodyLimit(contentLength: string | undefined, maxBytes = MAX_LOGIN_BODY): void {
|
|
7
|
-
const len = Number(contentLength)
|
|
8
|
-
// A missing/garbage Content-Length means a chunked transfer, which readBody would buffer with no
|
|
9
|
-
// ceiling — refuse it. The only legitimate login body is a tiny JSON object with a known length.
|
|
10
|
-
if (contentLength === undefined || !Number.isFinite(len) || len < 0) {
|
|
11
|
-
throw createError({ statusCode: 411, statusMessage: 'Length required' })
|
|
12
|
-
}
|
|
13
|
-
if (len > maxBytes) {
|
|
14
|
-
throw createError({ statusCode: 413, statusMessage: 'Payload too large' })
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const MAX_LOGIN_FAILS = 10
|
|
19
|
-
export const LOGIN_WINDOW_MS = 15 * 60 * 1000
|
|
20
|
-
// Hard ceiling on the number of distinct IPs tracked at once. A login POST is anonymously reachable and
|
|
21
|
-
// seeds an entry BEFORE the expensive scrypt runs, so without a bound a spray from many distinct source
|
|
22
|
-
// addresses (e.g. a routed IPv6 /64 or a botnet) could grow this map without limit and OOM the process.
|
|
23
|
-
// Generous vs any real login volume for a single-admin CMS; eviction (below) keeps memory bounded.
|
|
24
|
-
export const MAX_TRACKED_IPS = 10_000
|
|
25
|
-
|
|
26
|
-
// Per-IP failed-attempt timestamps (in-memory, per-process: resets on restart, fine for a
|
|
27
|
-
// single-instance private CMS). Bounds online brute-force; the admin password entropy is the
|
|
28
|
-
// primary defense. The keying IP comes from `clientIp` (socket peer by default; trusted
|
|
29
|
-
// X-Forwarded-For hop only when KESTREL_TRUST_PROXY is set). Bounded by MAX_TRACKED_IPS + eviction.
|
|
30
|
-
const loginFails = new Map<string, number[]>()
|
|
31
|
-
|
|
32
|
-
function recentFails(ip: string, nowMs: number): number[] {
|
|
33
|
-
const fresh = (loginFails.get(ip) ?? []).filter((t) => nowMs - t < LOGIN_WINDOW_MS)
|
|
34
|
-
// DELETE rather than persist an empty array: an IP whose failures have all aged out (or that only ever
|
|
35
|
-
// hit the read-path lockout check) must not leave a permanent entry — otherwise a distinct-IP flood of
|
|
36
|
-
// one-shot addresses grows the map forever (every key would be reclaimed only by a *successful* login).
|
|
37
|
-
if (fresh.length === 0) loginFails.delete(ip)
|
|
38
|
-
else loginFails.set(ip, fresh)
|
|
39
|
-
return fresh
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Reclaim every entry whose newest failure has aged past the window (a cheap sweep run under pressure). */
|
|
43
|
-
function evictExpired(nowMs: number): void {
|
|
44
|
-
for (const [ip, times] of loginFails) {
|
|
45
|
-
const newest = times[times.length - 1]
|
|
46
|
-
if (newest === undefined || nowMs - newest >= LOGIN_WINDOW_MS) loginFails.delete(ip)
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function assertNotLockedOut(ip: string, nowMs: number): void {
|
|
51
|
-
if (recentFails(ip, nowMs).length >= MAX_LOGIN_FAILS) {
|
|
52
|
-
throw createError({ statusCode: 429, statusMessage: 'Too many attempts, try again later' })
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function recordFailedLogin(ip: string, nowMs: number): void {
|
|
57
|
-
const fails = recentFails(ip, nowMs)
|
|
58
|
-
fails.push(nowMs)
|
|
59
|
-
loginFails.set(ip, fails)
|
|
60
|
-
// Enforce the ceiling: first reclaim anything already expired, then, if still over budget, evict the
|
|
61
|
-
// oldest-inserted entries (Map preserves insertion order → FIFO). Evicting a live entry merely resets that
|
|
62
|
-
// IP's throttle — acceptable under memory pressure; the primary brute-force defense is password entropy.
|
|
63
|
-
if (loginFails.size > MAX_TRACKED_IPS) {
|
|
64
|
-
evictExpired(nowMs)
|
|
65
|
-
while (loginFails.size > MAX_TRACKED_IPS) {
|
|
66
|
-
const oldest = loginFails.keys().next().value
|
|
67
|
-
if (oldest === undefined) break
|
|
68
|
-
loginFails.delete(oldest)
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function clearLoginFailures(ip: string): void {
|
|
74
|
-
loginFails.delete(ip)
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Test/introspection helpers: current tracked-IP count and a full reset (the store is a module singleton). */
|
|
78
|
-
export function loginFailIpCount(): number {
|
|
79
|
-
return loginFails.size
|
|
80
|
-
}
|
|
81
|
-
export function clearAllLoginFailures(): void {
|
|
82
|
-
loginFails.clear()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* Atomically reserve a login attempt: assert the IP isn't locked out AND record the attempt in one
|
|
87
|
-
* synchronous step (no await between the check and the record). This makes the throttle a true
|
|
88
|
-
* reservation — concurrent in-flight guesses, each awaiting a slow scrypt verify, all count toward the
|
|
89
|
-
* cap up front, instead of every one passing the gate on the same pre-increment count. The handler
|
|
90
|
-
* clears the reservation on a successful login; a failed login leaves it recorded.
|
|
91
|
-
*/
|
|
92
|
-
export function reserveLoginAttempt(ip: string, nowMs: number): void {
|
|
93
|
-
assertNotLockedOut(ip, nowMs)
|
|
94
|
-
recordFailedLogin(ip, nowMs)
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Undo a `reserveLoginAttempt` reservation for an outage no caller could have caused or guessed past (no
|
|
99
|
-
* admin hash configured) — otherwise a misconfigured deployment leaves the admin throttled once they fix
|
|
100
|
-
* it. Deliberately NOT used for attacker-triggerable rejections: charging those is what makes a flood run
|
|
101
|
-
* into the lockout. Removes one entry equal to `nowMs` (there is at most one per call, recorded
|
|
102
|
-
* synchronously by the reservation this undoes; entries are interchangeable by value, so it doesn't
|
|
103
|
-
* matter which one a concurrent same-millisecond request added).
|
|
104
|
-
*/
|
|
105
|
-
export function releaseLoginAttempt(ip: string, nowMs: number): void {
|
|
106
|
-
const fails = loginFails.get(ip)
|
|
107
|
-
if (!fails) return
|
|
108
|
-
const idx = fails.lastIndexOf(nowMs)
|
|
109
|
-
if (idx === -1) return
|
|
110
|
-
fails.splice(idx, 1)
|
|
111
|
-
if (fails.length === 0) loginFails.delete(ip)
|
|
112
|
-
else loginFails.set(ip, fails)
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let inFlight = 0
|
|
116
|
-
|
|
117
|
-
export function acquireHashSlot(): void {
|
|
118
|
-
if (inFlight >= MAX_INFLIGHT_HASHES) {
|
|
119
|
-
throw createError({ statusCode: 503, statusMessage: 'Busy, try again' })
|
|
120
|
-
}
|
|
121
|
-
inFlight++
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function releaseHashSlot(): void {
|
|
125
|
-
if (inFlight > 0) inFlight--
|
|
126
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
-
import { promisify } from 'node:util'
|
|
3
|
-
|
|
4
|
-
const scryptAsync = promisify(scrypt) as (
|
|
5
|
-
password: string | Buffer,
|
|
6
|
-
salt: string | Buffer,
|
|
7
|
-
keylen: number,
|
|
8
|
-
options: { N: number; r: number; p: number; maxmem: number },
|
|
9
|
-
) => Promise<Buffer>
|
|
10
|
-
|
|
11
|
-
const N = 2 ** 17
|
|
12
|
-
const R = 8
|
|
13
|
-
const P = 1
|
|
14
|
-
const KEYLEN = 64
|
|
15
|
-
const MAXMEM = 256 * 1024 * 1024
|
|
16
|
-
|
|
17
|
-
export async function hashPassword(password: string): Promise<string> {
|
|
18
|
-
const salt = randomBytes(16)
|
|
19
|
-
const hash = await scryptAsync(password, salt, KEYLEN, { N, r: R, p: P, maxmem: MAXMEM })
|
|
20
|
-
return `scrypt$${N}$${R}$${P}$${salt.toString('base64url')}$${hash.toString('base64url')}`
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
|
|
24
|
-
const parts = stored.split('$')
|
|
25
|
-
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
|
|
26
|
-
const n = Number(parts[1])
|
|
27
|
-
const r = Number(parts[2])
|
|
28
|
-
const p = Number(parts[3])
|
|
29
|
-
if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false
|
|
30
|
-
|
|
31
|
-
let salt: Buffer
|
|
32
|
-
let expected: Buffer
|
|
33
|
-
try {
|
|
34
|
-
salt = Buffer.from(parts[4], 'base64url')
|
|
35
|
-
expected = Buffer.from(parts[5], 'base64url')
|
|
36
|
-
} catch {
|
|
37
|
-
return false
|
|
38
|
-
}
|
|
39
|
-
if (expected.length === 0) return false
|
|
40
|
-
|
|
41
|
-
let actual: Buffer
|
|
42
|
-
try {
|
|
43
|
-
actual = await scryptAsync(password, salt, expected.length, { N: n, r, p, maxmem: MAXMEM })
|
|
44
|
-
} catch {
|
|
45
|
-
return false
|
|
46
|
-
}
|
|
47
|
-
if (actual.length !== expected.length) {
|
|
48
|
-
timingSafeEqual(actual, actual)
|
|
49
|
-
return false
|
|
50
|
-
}
|
|
51
|
-
return timingSafeEqual(actual, expected)
|
|
52
|
-
}
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
import { getCookie, setCookie, type H3Event } from 'h3'
|
|
2
|
-
import { signSession, verifySession, sessionSettings, shouldRefreshSession } from './session'
|
|
3
|
-
|
|
4
|
-
/** Shared session-cookie attributes; only `maxAge` differs between set and clear. */
|
|
5
|
-
const baseCookieOptions = (secure: boolean) => ({
|
|
6
|
-
httpOnly: true,
|
|
7
|
-
secure,
|
|
8
|
-
sameSite: 'strict' as const,
|
|
9
|
-
path: '/',
|
|
10
|
-
})
|
|
11
|
-
|
|
12
|
-
export function setAuthSession(event: H3Event): number {
|
|
13
|
-
const { secret, maxAge, secureCookies, cookieName } = sessionSettings()
|
|
14
|
-
const exp = Date.now() + maxAge * 1000
|
|
15
|
-
setCookie(event, cookieName, signSession(secret, exp), { ...baseCookieOptions(secureCookies), maxAge })
|
|
16
|
-
return exp
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function clearAuthSession(event: H3Event): void {
|
|
20
|
-
const { secureCookies, cookieName } = sessionSettings()
|
|
21
|
-
setCookie(event, cookieName, '', { ...baseCookieOptions(secureCookies), maxAge: 0 })
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function getAuthSession(event: H3Event): { authenticated: boolean; exp?: number } {
|
|
25
|
-
const { secret, cookieName } = sessionSettings()
|
|
26
|
-
const result = verifySession(secret, getCookie(event, cookieName), Date.now())
|
|
27
|
-
return result.valid ? { authenticated: true, exp: result.exp } : { authenticated: false }
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Sliding-expiry: on an authenticated request whose session has passed the halfway point of the idle
|
|
32
|
-
* window, re-issue the cookie with a fresh exp — so activity keeps the admin logged in and inactivity
|
|
33
|
-
* longer than `maxAge` auto-logs-out (the cookie AND the embedded exp lapse). No-op when there is no valid
|
|
34
|
-
* session cookie (anonymous / public requests), so it is safe to call unconditionally on every request.
|
|
35
|
-
*/
|
|
36
|
-
export function refreshAuthSession(event: H3Event): void {
|
|
37
|
-
const { secret, maxAge, secureCookies, cookieName } = sessionSettings()
|
|
38
|
-
const now = Date.now()
|
|
39
|
-
const current = verifySession(secret, getCookie(event, cookieName), now)
|
|
40
|
-
if (!current.valid || current.exp === undefined) return
|
|
41
|
-
if (!shouldRefreshSession(current.exp, now, maxAge * 1000)) return
|
|
42
|
-
const exp = now + maxAge * 1000
|
|
43
|
-
setCookie(event, cookieName, signSession(secret, exp), { ...baseCookieOptions(secureCookies), maxAge })
|
|
44
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs'
|
|
2
|
-
import { dirname, join } from 'node:path'
|
|
3
|
-
import { resolveServerKestrel, serverRuntimeConfig } from '../../../core/server/utils/server-config'
|
|
4
|
-
|
|
5
|
-
// The session REVOCATION epoch: a monotonic counter folded into the signing key (see deriveSigningKey), so
|
|
6
|
-
// bumping it invalidates every outstanding token at once — the stateless model's server-side "hard logout".
|
|
7
|
-
// Persisted to a file in the data dir so it survives a restart. Cached in-process, but the cache is
|
|
8
|
-
// mtime-validated on read, so a bump by a SIBLING process (a `db:migrate` task, a second worker, a CLI
|
|
9
|
-
// force-logout) is observed on the next request instead of only after a restart.
|
|
10
|
-
|
|
11
|
-
let cached: number | undefined
|
|
12
|
-
let cachedMtimeMs = -1
|
|
13
|
-
|
|
14
|
-
/** Where the epoch persists: `KESTREL_SESSION_EPOCH_FILE`, else next to the DB. Null for a `:memory:` DB
|
|
15
|
-
* (tests) → the epoch still works in-process, it just doesn't survive a restart. */
|
|
16
|
-
function epochPath(): string | null {
|
|
17
|
-
const override = process.env.KESTREL_SESSION_EPOCH_FILE?.trim()
|
|
18
|
-
if (override) return override
|
|
19
|
-
// Resolve the DB the same way useDb() does — the consumer's `kestrel: { db }` via runtimeConfig first,
|
|
20
|
-
// Kestrel's own config + env only as the non-Nitro fallback. Landing anywhere else would put revocation
|
|
21
|
-
// state outside the volume the operator persists, so a restart would resurrect revoked sessions.
|
|
22
|
-
const fromRc = serverRuntimeConfig()?.kestrel as { dbPath?: string } | undefined
|
|
23
|
-
const dbPath = fromRc?.dbPath || resolveServerKestrel().dbPath
|
|
24
|
-
if (!dbPath || dbPath === ':memory:') return null
|
|
25
|
-
return join(dirname(dbPath), '.kestrel-session-epoch')
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** `null` means "couldn't tell" (unreadable, or present but not a valid epoch) — distinct from the genuine
|
|
29
|
-
* ENOENT baseline of 0, so a caller never mistakes "the file is broken" for "never revoked". */
|
|
30
|
-
function readEpoch(path: string): number | null {
|
|
31
|
-
let raw: string
|
|
32
|
-
try {
|
|
33
|
-
raw = readFileSync(path, 'utf8')
|
|
34
|
-
} catch (error) {
|
|
35
|
-
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') return 0
|
|
36
|
-
console.error('[kestrel auth] could not read the session epoch file (keeping the last known epoch):', (error as Error)?.message ?? error)
|
|
37
|
-
return null
|
|
38
|
-
}
|
|
39
|
-
const n = parseInt(raw.trim(), 10)
|
|
40
|
-
if (Number.isFinite(n) && n >= 0) return n
|
|
41
|
-
console.error(`[kestrel auth] session epoch file has unreadable content, ignoring it (keeping the last known epoch): ${JSON.stringify(raw.slice(0, 40))}`)
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** The file's mtime in ms, or -1 when absent/unreadable (a stable sentinel distinct from any real mtime). */
|
|
46
|
-
function epochMtime(path: string): number {
|
|
47
|
-
try {
|
|
48
|
-
return statSync(path).mtimeMs
|
|
49
|
-
} catch {
|
|
50
|
-
return -1
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The current epoch. Cached in-process, but re-read from the file whenever its mtime moved — so a
|
|
55
|
-
* cross-process bump takes effect on the next call, not only after a restart. The stat is one cheap
|
|
56
|
-
* syscall per request (no content read on the common unchanged path). */
|
|
57
|
-
export function currentSessionEpoch(): number {
|
|
58
|
-
const path = epochPath()
|
|
59
|
-
if (!path) {
|
|
60
|
-
// No persistence (`:memory:`): the in-process bump is the only source of truth.
|
|
61
|
-
if (cached === undefined) cached = 0
|
|
62
|
-
return cached
|
|
63
|
-
}
|
|
64
|
-
const mtime = epochMtime(path)
|
|
65
|
-
if (cached === undefined || mtime !== cachedMtimeMs) {
|
|
66
|
-
// A `null` read (broken file) must never regress an already-higher in-process value — take the max
|
|
67
|
-
// rather than adopting it outright, the same guard bumpSessionEpoch already applies to its own re-read.
|
|
68
|
-
cached = Math.max(cached ?? 0, readEpoch(path) ?? 0)
|
|
69
|
-
cachedMtimeMs = mtime
|
|
70
|
-
}
|
|
71
|
-
return cached
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Revoke every currently-issued session (logout / force-logout): increment + persist the epoch so the
|
|
75
|
-
* derived signing key changes and all outstanding tokens fail verification immediately. Best-effort
|
|
76
|
-
* persistence — a failed write still revokes for this process's lifetime (the in-process bump). */
|
|
77
|
-
export function bumpSessionEpoch(): void {
|
|
78
|
-
const next = currentSessionEpoch() + 1
|
|
79
|
-
cached = next
|
|
80
|
-
const path = epochPath()
|
|
81
|
-
if (!path) return
|
|
82
|
-
try {
|
|
83
|
-
mkdirSync(dirname(path), { recursive: true })
|
|
84
|
-
writeFileSync(path, String(next), 'utf8')
|
|
85
|
-
// Re-read AFTER writing and take the max: a sibling process may have written a HIGHER epoch between our
|
|
86
|
-
// write and this read. The epoch is monotonic, so max is correct — and it closes the write→stat TOCTOU
|
|
87
|
-
// where adopting only our own mtime+value could permanently mask a concurrent revocation.
|
|
88
|
-
cached = Math.max(next, readEpoch(path) ?? 0)
|
|
89
|
-
cachedMtimeMs = epochMtime(path)
|
|
90
|
-
} catch (error) {
|
|
91
|
-
console.error('[kestrel auth] could not persist session epoch (revocation holds for this process only):', (error as Error)?.message ?? error)
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/** Test hook: drop the in-process cache so a test controls the epoch via its file/env. */
|
|
96
|
-
export function _resetSessionEpochCache(): void { cached = undefined; cachedMtimeMs = -1 }
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
|
2
|
-
import { currentSessionEpoch } from './session-epoch'
|
|
3
|
-
|
|
4
|
-
export function deriveSigningKey(rawSecret: string, passwordHash?: string, epoch = 0): string {
|
|
5
|
-
// The `epoch` is a server-side revocation counter: bumping it (on logout / force-logout) changes the key
|
|
6
|
-
// so every previously-issued token fails verification immediately — the stateless model's "hard logout".
|
|
7
|
-
return createHmac('sha256', rawSecret).update('kestrel-session-v1').update(passwordHash ?? '').update(`e${epoch}`).digest('base64url')
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export function signSession(secret: string, expMs: number): string {
|
|
11
|
-
const payload = Buffer.from(JSON.stringify({ exp: expMs })).toString('base64url')
|
|
12
|
-
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
13
|
-
return `${payload}.${sig}`
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function verifySession(secret: string, value: string | undefined, nowMs: number): { valid: boolean; exp?: number } {
|
|
17
|
-
if (!value || typeof value !== 'string') return { valid: false }
|
|
18
|
-
const dot = value.indexOf('.')
|
|
19
|
-
if (dot <= 0 || dot === value.length - 1) return { valid: false }
|
|
20
|
-
const payload = value.slice(0, dot)
|
|
21
|
-
const sig = value.slice(dot + 1)
|
|
22
|
-
const expected = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
23
|
-
const sigBuf = Buffer.from(sig)
|
|
24
|
-
const expBuf = Buffer.from(expected)
|
|
25
|
-
if (sigBuf.length !== expBuf.length || !timingSafeEqual(sigBuf, expBuf)) return { valid: false }
|
|
26
|
-
let exp: unknown
|
|
27
|
-
try {
|
|
28
|
-
exp = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).exp
|
|
29
|
-
} catch {
|
|
30
|
-
return { valid: false }
|
|
31
|
-
}
|
|
32
|
-
if (typeof exp !== 'number' || exp <= nowMs) return { valid: false }
|
|
33
|
-
return { valid: true, exp }
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Sliding-expiry decision: refresh the session cookie once its remaining lifetime drops below HALF the
|
|
38
|
-
* idle window. Activity within the window keeps the session alive (each refresh resets exp to now+maxAge);
|
|
39
|
-
* inactivity longer than the window lets it expire → the admin must log in again. The halfway threshold
|
|
40
|
-
* bounds Set-Cookie churn to at most once per half-window instead of on every request.
|
|
41
|
-
*/
|
|
42
|
-
export function shouldRefreshSession(exp: number, nowMs: number, maxAgeMs: number): boolean {
|
|
43
|
-
return exp - nowMs < maxAgeMs / 2
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let devSecret: string | undefined
|
|
47
|
-
|
|
48
|
-
export interface SessionSettings {
|
|
49
|
-
secret: string
|
|
50
|
-
maxAge: number
|
|
51
|
-
secureCookies: boolean
|
|
52
|
-
cookieName: string
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function sessionSettings({ prerender = import.meta.prerender === true } = {}): SessionSettings {
|
|
56
|
-
// Treat anything that isn't an EXPLICIT dev signal as production for these safeguards, so a deployment
|
|
57
|
-
// that simply omits NODE_ENV (a common slip when launching `.output/server/index.mjs`) is hardened —
|
|
58
|
-
// not silently downgraded to dev, which would tolerate a missing secret + non-Secure cookies. Vitest
|
|
59
|
-
// sets NODE_ENV=test; KESTREL_DEV=1 is an explicit escape hatch for other dev contexts.
|
|
60
|
-
const explicitDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' || process.env.KESTREL_DEV === '1'
|
|
61
|
-
const isProd = !explicitDev
|
|
62
|
-
const secureCookies = process.env.KESTREL_SECURE_COOKIES !== 'false'
|
|
63
|
-
// `nuxt generate` prerenders every page through `/api/route`, which passes the access guard and lands
|
|
64
|
-
// here with NODE_ENV=production. A prerender request never issues a cookie, so enforcing the flag there
|
|
65
|
-
// only means a dev `.env` silently drops pages from the static output.
|
|
66
|
-
if (isProd && !secureCookies && !prerender) {
|
|
67
|
-
throw new Error('KESTREL_SECURE_COOKIES=false is not allowed in production')
|
|
68
|
-
}
|
|
69
|
-
const rawMaxAge = Number(process.env.KESTREL_SESSION_MAX_AGE)
|
|
70
|
-
const maxAge = Number.isFinite(rawMaxAge) && rawMaxAge > 0 ? rawMaxAge : 604800
|
|
71
|
-
let secret = process.env.KESTREL_SESSION_SECRET
|
|
72
|
-
if (!secret) {
|
|
73
|
-
if (isProd) throw new Error('KESTREL_SESSION_SECRET is required in production')
|
|
74
|
-
if (!devSecret) {
|
|
75
|
-
devSecret = randomBytes(32).toString('base64url')
|
|
76
|
-
console.warn('[kestrel auth] KESTREL_SESSION_SECRET not set — using a random per-process dev secret; sessions reset on restart')
|
|
77
|
-
}
|
|
78
|
-
secret = devSecret
|
|
79
|
-
} else if (isProd && Buffer.byteLength(secret) < 32) {
|
|
80
|
-
throw new Error('KESTREL_SESSION_SECRET must be at least 32 bytes in production')
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
secret: deriveSigningKey(secret, process.env.KESTREL_ADMIN_PASSWORD_HASH, currentSessionEpoch()),
|
|
84
|
-
maxAge,
|
|
85
|
-
secureCookies,
|
|
86
|
-
cookieName: secureCookies ? '__Host-kestrel_session' : 'kestrel_session',
|
|
87
|
-
}
|
|
88
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { buildCollection } from '../../../fields/server/utils/buildCollection'
|
|
2
|
-
import { defineCollection } from '../../../core/server/utils/defineCollection'
|
|
3
|
-
|
|
4
|
-
const built = buildCollection(defineCollection({
|
|
5
|
-
name: 'pages',
|
|
6
|
-
mode: 'multi',
|
|
7
|
-
translatable: true,
|
|
8
|
-
pageLike: true,
|
|
9
|
-
seo: true,
|
|
10
|
-
blocks: { enabled: true },
|
|
11
|
-
status: true,
|
|
12
|
-
builtin: true,
|
|
13
|
-
label: { singular: { en: 'Page', de: 'Seite' }, plural: { en: 'Pages', de: 'Seiten' }, new: { en: 'New Page', de: 'Neue Seite' } },
|
|
14
|
-
icon: 'file-text',
|
|
15
|
-
fields: { title: { type: 'text', required: true } },
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
export const pages = built.table
|
|
19
|
-
export default built
|
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { fieldIs } from '../../../core/server/utils/defineCollection'
|
|
2
|
-
import type { FieldPopulator } from '../../../core/server/utils/populate'
|
|
3
|
-
import { memoDuringPrerender } from '../../../core/server/utils/prerender-memo'
|
|
4
|
-
import { memoResolver } from '../../../core/server/utils/resolve-scope'
|
|
5
|
-
|
|
6
|
-
/** Fetches a related record FULLY populated (its own media / links / nested relations resolved), or null
|
|
7
|
-
* when the id is stale / deleted / draft. Injected so the populator stays pure + unit-testable; the plugin
|
|
8
|
-
* wires the real `getOne`. `depth` is the ALREADY-decremented depth for the related read (the cycle guard). */
|
|
9
|
-
export type ResolveRecord = (
|
|
10
|
-
collection: string,
|
|
11
|
-
id: number,
|
|
12
|
-
depth: number,
|
|
13
|
-
locale: string,
|
|
14
|
-
publicOnly: boolean,
|
|
15
|
-
) => Record<string, unknown> | null
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Run a related-record fetch, mapping `getOne`'s 404 (a stale / deleted / draft target — the intended
|
|
19
|
-
* "skip this reference" signal) to `null`, but letting ANY OTHER error propagate. Swallowing every error
|
|
20
|
-
* here would drop a relation on a real fault (a DB error, a downstream populator throwing on malformed
|
|
21
|
-
* stored data) while the page still renders + records `success` — violating fail-loud. Pure + testable.
|
|
22
|
-
*/
|
|
23
|
-
export function skipMissing(fetch: () => Record<string, unknown>): Record<string, unknown> | null {
|
|
24
|
-
try {
|
|
25
|
-
return fetch()
|
|
26
|
-
} catch (e) {
|
|
27
|
-
if ((e as { statusCode?: number })?.statusCode === 404) return null
|
|
28
|
-
throw e
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The `relation` field populator: expands a relation id (single, `${name}Id` in columns / bare in props)
|
|
34
|
-
* or id array (many, always bare) into the fully-populated related record(s) under a `$<name>` sibling,
|
|
35
|
-
* leaving the raw id column intact — so write round-trips and the admin relation-picker keep working.
|
|
36
|
-
* Stale / deleted / draft ids are skipped (single → null, many → filtered out) so one bad reference never
|
|
37
|
-
* fails the whole read. The related read passes `ctx.depth - 1`; `populateRow` bails at depth 0, so a
|
|
38
|
-
* relation cycle terminates. Registered per-type via `registerFieldPopulator('relation', …)`; the shared
|
|
39
|
-
* field-tree walker drives it over top-level fields, block props, slots, and repeater entries.
|
|
40
|
-
*
|
|
41
|
-
* Under `ctx.publicOnly` a relation into a collection `isPublicCollection` rejects is left unexpanded
|
|
42
|
-
* (raw id only, NO `$<name>` sibling at all — a relation field targets exactly one collection, so a
|
|
43
|
-
* `many` relation is all-or-nothing): expansion must not reach a record the caller could not have
|
|
44
|
-
* requested directly. The check runs BEFORE `resolve`, so a withheld target never enters the memo.
|
|
45
|
-
*/
|
|
46
|
-
export function buildRelationFieldPopulator(
|
|
47
|
-
resolveRecord: ResolveRecord,
|
|
48
|
-
isPublicCollection: (collection: string) => boolean,
|
|
49
|
-
): FieldPopulator {
|
|
50
|
-
// The same target (collection+id+depth+locale+scope) is resolved once — build-wide during a generate run
|
|
51
|
-
// (memoDuringPrerender), request-/publish-run-wide via the resolve scope (which also budgets the
|
|
52
|
-
// distinct fan-out of one live request and replays read-tags on hits, so publish deps stay complete).
|
|
53
|
-
// memoResolver OUTERMOST: the per-scope budget verdict must stay scope-local. If memoDuringPrerender
|
|
54
|
-
// wrapped memoResolver, a build-wide memoize would cache a budget-skip `null` and poison every later
|
|
55
|
-
// page of a `nuxt generate`. With this order the build-wide memo only ever caches REAL resolver results.
|
|
56
|
-
// `publicOnly` is part of the key because the same record populates DIFFERENTLY under it (its own
|
|
57
|
-
// non-public relations are withheld) — sharing one entry would serve one scope's record to the other.
|
|
58
|
-
const key = (collection: string, id: number, depth: number, locale: string, publicOnly: boolean) => `rel:${collection}:${id}:${depth}:${locale}:${publicOnly}`
|
|
59
|
-
const resolve = memoResolver(memoDuringPrerender(resolveRecord, key), key)
|
|
60
|
-
return (bag, key, field, ctx, keyMode) => {
|
|
61
|
-
if (!fieldIs(field, 'relation')) return
|
|
62
|
-
const collection = field.relation.collection
|
|
63
|
-
const publicOnly = ctx.publicOnly === true
|
|
64
|
-
if (publicOnly && !isPublicCollection(collection)) return
|
|
65
|
-
const depth = ctx.depth - 1
|
|
66
|
-
if (field.relation.many) {
|
|
67
|
-
const ids = bag[key]
|
|
68
|
-
if (Array.isArray(ids)) {
|
|
69
|
-
bag['$' + key] = ids
|
|
70
|
-
.filter((n): n is number => typeof n === 'number')
|
|
71
|
-
.map((id) => resolve(collection, id, depth, ctx.locale, publicOnly))
|
|
72
|
-
.filter((r): r is Record<string, unknown> => r != null)
|
|
73
|
-
}
|
|
74
|
-
} else {
|
|
75
|
-
const id = bag[keyMode === 'columns' ? `${key}Id` : key]
|
|
76
|
-
if (typeof id === 'number') bag['$' + key] = resolve(collection, id, depth, ctx.locale, publicOnly)
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
// The batch operations the bulk endpoint accepts — the single source of truth shared by the server
|
|
2
|
-
// handler (`bulk.post.ts`) and any client that builds a bulk request. Pure, so the allow-list is
|
|
3
|
-
// unit-testable without a Nitro context.
|
|
4
|
-
export const BULK_ACTIONS = ['delete', 'publish', 'unpublish', 'duplicate'] as const
|
|
5
|
-
export type BulkAction = typeof BULK_ACTIONS[number]
|
|
6
|
-
|
|
7
|
-
export function isBulkAction(value: unknown): value is BulkAction {
|
|
8
|
-
return typeof value === 'string' && (BULK_ACTIONS as readonly string[]).includes(value)
|
|
9
|
-
}
|