@michaelthielemann/kestrel 1.2.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/LICENSE +201 -0
- package/NOTICE +12 -0
- package/README.md +234 -0
- package/kestrel.config.ts +28 -0
- package/layers/access/nuxt.config.ts +5 -0
- package/layers/access/server/middleware/00.ip-allowlist.ts +67 -0
- package/layers/access/server/middleware/access-guard.ts +30 -0
- package/layers/access/server/types.d.ts +8 -0
- package/layers/access/server/utils/csrf.ts +39 -0
- package/layers/access/server/utils/grant-registry.ts +36 -0
- package/layers/access/server/utils/guard.ts +44 -0
- package/layers/access/server/utils/ip-allowlist.ts +76 -0
- package/layers/access/server/utils/policy.ts +98 -0
- package/layers/access/server/utils/public-resources.ts +11 -0
- package/layers/access/server/utils/render-context.ts +41 -0
- package/layers/access/server/utils/require-admin.ts +15 -0
- package/layers/admin/app/components/AdminAccount.vue +212 -0
- package/layers/admin/app/components/AdminNav.vue +44 -0
- package/layers/admin/app/components/BlockFields.vue +68 -0
- package/layers/admin/app/components/BlockPreview.vue +401 -0
- package/layers/admin/app/components/BlockTree.vue +433 -0
- package/layers/admin/app/components/BlocksBody.vue +162 -0
- package/layers/admin/app/components/CollectionDeleteDialog.vue +38 -0
- package/layers/admin/app/components/CollectionEditor.vue +203 -0
- package/layers/admin/app/components/CollectionList.vue +1091 -0
- package/layers/admin/app/components/EditorStatus.vue +199 -0
- package/layers/admin/app/components/EditorUnsupported.vue +28 -0
- package/layers/admin/app/components/FieldsBody.vue +23 -0
- package/layers/admin/app/components/LocaleBar.vue +105 -0
- package/layers/admin/app/components/PageFields.vue +101 -0
- package/layers/admin/app/components/PageFieldsPane.vue +39 -0
- package/layers/admin/app/components/SeoFields.vue +169 -0
- package/layers/admin/app/components/SingletonEditor.vue +92 -0
- package/layers/admin/app/composables/useAuth.ts +68 -0
- package/layers/admin/app/composables/useBlockTree.ts +95 -0
- package/layers/admin/app/composables/useBlocks.ts +7 -0
- package/layers/admin/app/composables/useCachedResource.ts +35 -0
- package/layers/admin/app/composables/useCollectionOps.ts +53 -0
- package/layers/admin/app/composables/useCollections.ts +7 -0
- package/layers/admin/app/composables/useContentLocales.ts +23 -0
- package/layers/admin/app/composables/useEditForm.ts +370 -0
- package/layers/admin/app/composables/useLinkResolver.ts +40 -0
- package/layers/admin/app/composables/useListColumns.ts +53 -0
- package/layers/admin/app/composables/useListUrlState.ts +53 -0
- package/layers/admin/app/composables/usePendingPublishPoll.ts +15 -0
- package/layers/admin/app/composables/usePreviewViewport.ts +30 -0
- package/layers/admin/app/composables/usePublishStatus.ts +98 -0
- package/layers/admin/app/composables/useRailCollapsed.ts +14 -0
- package/layers/admin/app/composables/useTheme.ts +36 -0
- package/layers/admin/app/composables/useUnsavedGuard.ts +22 -0
- package/layers/admin/app/layouts/admin.vue +267 -0
- package/layers/admin/app/middleware/admin-auth.ts +4 -0
- package/layers/admin/app/pages/admin/[collection]/[id].vue +202 -0
- package/layers/admin/app/pages/admin/[collection]/index.vue +62 -0
- package/layers/admin/app/pages/admin/index.vue +118 -0
- package/layers/admin/app/pages/admin/login.vue +68 -0
- package/layers/admin/app/pages/admin/media.vue +28 -0
- package/layers/admin/app/pages/admin/references.vue +146 -0
- package/layers/admin/app/plugins/admin-style-guard.client.ts +76 -0
- package/layers/admin/app/plugins/reauth.client.ts +20 -0
- package/layers/admin/app/utils/admin-style-guard.ts +20 -0
- package/layers/admin/app/utils/block-tree.ts +214 -0
- package/layers/admin/app/utils/collection-ops.ts +44 -0
- package/layers/admin/app/utils/dead-refs.ts +30 -0
- package/layers/admin/app/utils/edit-form.ts +223 -0
- package/layers/admin/app/utils/editor-expose.ts +26 -0
- package/layers/admin/app/utils/editor-form-context.ts +83 -0
- package/layers/admin/app/utils/editor-registry.ts +20 -0
- package/layers/admin/app/utils/field-keys.ts +11 -0
- package/layers/admin/app/utils/list-columns.ts +141 -0
- package/layers/admin/app/utils/list-query.ts +96 -0
- package/layers/admin/app/utils/nav-active.ts +9 -0
- package/layers/admin/app/utils/populate-blocks.ts +288 -0
- package/layers/admin/app/utils/preview-channel.ts +62 -0
- package/layers/admin/app/utils/preview-viewport.ts +80 -0
- package/layers/admin/app/utils/reauth.ts +68 -0
- package/layers/admin/app/utils/record-title.ts +15 -0
- package/layers/admin/app/utils/register-builtin-editors.ts +9 -0
- package/layers/admin/app/utils/safe-redirect.ts +12 -0
- package/layers/admin/nuxt.config.ts +17 -0
- package/layers/auth/nuxt.config.ts +1 -0
- package/layers/auth/server/api/auth/login.post.ts +38 -0
- package/layers/auth/server/api/auth/logout.post.ts +9 -0
- package/layers/auth/server/api/auth/session.get.ts +1 -0
- package/layers/auth/server/utils/admin-credential.ts +42 -0
- package/layers/auth/server/utils/client-ip.ts +88 -0
- package/layers/auth/server/utils/login-throttle.ts +126 -0
- package/layers/auth/server/utils/password.ts +52 -0
- package/layers/auth/server/utils/session-cookie.ts +44 -0
- package/layers/auth/server/utils/session-epoch.ts +96 -0
- package/layers/auth/server/utils/session.ts +85 -0
- package/layers/collections/nuxt.config.ts +1 -0
- package/layers/collections/server/collections/pages.ts +19 -0
- package/layers/collections/server/plugins/01.register.ts +32 -0
- package/layers/collections/server/plugins/02.register-relation-populate.ts +17 -0
- package/layers/collections/server/utils/populate-relations.ts +66 -0
- package/layers/core/app/utils/bulk-actions.ts +9 -0
- package/layers/core/app/utils/filter-ops.ts +98 -0
- package/layers/core/app/utils/list-limits.ts +27 -0
- package/layers/core/app/utils/locale-path.ts +12 -0
- package/layers/core/app/utils/slugify.ts +16 -0
- package/layers/core/modules/auto-discovery/extract-block.ts +176 -0
- package/layers/core/modules/auto-discovery/index.ts +44 -0
- package/layers/core/modules/auto-discovery/scan.ts +78 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +12 -0
- package/layers/core/modules/kestrel/index.ts +86 -0
- package/layers/core/nuxt.config.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +8 -0
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +4 -0
- package/layers/core/server/api/[collection]/[id].delete.ts +5 -0
- package/layers/core/server/api/[collection]/[id].get.ts +8 -0
- package/layers/core/server/api/[collection]/[id].patch.ts +8 -0
- package/layers/core/server/api/[collection]/bulk.post.ts +37 -0
- package/layers/core/server/api/[collection]/index.get.ts +19 -0
- package/layers/core/server/api/[collection]/index.post.ts +8 -0
- package/layers/core/server/api/[collection]/index.put.ts +7 -0
- package/layers/core/server/api/[collection]/options.get.ts +18 -0
- package/layers/core/server/api/[collection]/translations.get.ts +25 -0
- package/layers/core/server/api/collections/[name].get.ts +4 -0
- package/layers/core/server/api/collections/index.get.ts +3 -0
- package/layers/core/server/api/references/broken.get.ts +3 -0
- package/layers/core/server/api/references/referrers.get.ts +24 -0
- package/layers/core/server/database/record-refs.ts +20 -0
- package/layers/core/server/plugins/00.migrate.ts +11 -0
- package/layers/core/server/plugins/02.schema-sync.ts +63 -0
- package/layers/core/server/plugins/03.record-refs.ts +12 -0
- package/layers/core/server/schema/bootstrap.ts +41 -0
- package/layers/core/server/schema/desired.ts +99 -0
- package/layers/core/server/schema/dev-sync.ts +23 -0
- package/layers/core/server/schema/dialect.ts +59 -0
- package/layers/core/server/schema/diff.ts +110 -0
- package/layers/core/server/schema/introspect.ts +67 -0
- package/layers/core/server/schema/model.ts +61 -0
- package/layers/core/server/schema/render-sqlite.ts +89 -0
- package/layers/core/server/schema/sync.ts +201 -0
- package/layers/core/server/tasks/db/migrate.ts +54 -0
- package/layers/core/server/utils/blocks.ts +6 -0
- package/layers/core/server/utils/collection-types.ts +27 -0
- package/layers/core/server/utils/crud.ts +577 -0
- package/layers/core/server/utils/db.ts +26 -0
- package/layers/core/server/utils/defineCollection.ts +207 -0
- package/layers/core/server/utils/duplicate.ts +162 -0
- package/layers/core/server/utils/field-layout.ts +125 -0
- package/layers/core/server/utils/filter-predicate.ts +101 -0
- package/layers/core/server/utils/http.ts +50 -0
- package/layers/core/server/utils/if-unmodified.ts +13 -0
- package/layers/core/server/utils/kestrel-config.ts +410 -0
- package/layers/core/server/utils/key-lock.ts +33 -0
- package/layers/core/server/utils/locale.ts +53 -0
- package/layers/core/server/utils/page-route.ts +58 -0
- package/layers/core/server/utils/page-slug.ts +127 -0
- package/layers/core/server/utils/picker.ts +82 -0
- package/layers/core/server/utils/populate.ts +48 -0
- package/layers/core/server/utils/prerender-memo.ts +23 -0
- package/layers/core/server/utils/read-capture.ts +37 -0
- package/layers/core/server/utils/record-ref-index.ts +200 -0
- package/layers/core/server/utils/registry.ts +24 -0
- package/layers/core/server/utils/resolve-scope.ts +79 -0
- package/layers/core/server/utils/seo.ts +24 -0
- package/layers/core/server/utils/serialize-collection.ts +149 -0
- package/layers/core/server/utils/server-config.ts +18 -0
- package/layers/core/server/utils/sql.ts +2 -0
- package/layers/core/server/utils/storage.local.ts +117 -0
- package/layers/core/server/utils/storage.s3.ts +197 -0
- package/layers/core/server/utils/storage.ts +90 -0
- package/layers/core/server/utils/write-events.ts +31 -0
- package/layers/fields/app/utils/condition.ts +71 -0
- package/layers/fields/app/utils/define-block.ts +13 -0
- package/layers/fields/app/utils/field-constraints.ts +17 -0
- package/layers/fields/app/utils/field-factories.ts +129 -0
- package/layers/fields/app/utils/field-meta.ts +33 -0
- package/layers/fields/app/utils/field-validate.ts +75 -0
- package/layers/fields/app/utils/field-value.ts +7 -0
- package/layers/fields/app/utils/richtext-links.ts +48 -0
- package/layers/fields/nuxt.config.ts +1 -0
- package/layers/fields/server/api/blocks/index.get.ts +9 -0
- package/layers/fields/server/field-registry/index.ts +248 -0
- package/layers/fields/server/field-registry/naming.ts +10 -0
- package/layers/fields/server/field-registry/sanitize.ts +40 -0
- package/layers/fields/server/field-registry/types.ts +19 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +11 -0
- package/layers/fields/server/utils/block-ids.ts +43 -0
- package/layers/fields/server/utils/block-populate.ts +41 -0
- package/layers/fields/server/utils/buildCollection.ts +88 -0
- package/layers/fields/server/utils/buildFieldSchema.ts +22 -0
- package/layers/fields/server/utils/buildTable.ts +107 -0
- package/layers/fields/server/utils/conditional-required.ts +25 -0
- package/layers/fields/server/utils/defineBlock.ts +86 -0
- package/layers/fields/server/utils/defineFieldType.ts +33 -0
- package/layers/fields/server/utils/extract-refs.ts +152 -0
- package/layers/fields/server/utils/field-populate.ts +68 -0
- package/layers/media/app/components/KestrelImg.vue +65 -0
- package/layers/media/app/components/MediaConflictDialog.vue +35 -0
- package/layers/media/app/components/MediaDeleteDialog.vue +52 -0
- package/layers/media/app/components/MediaGrid.vue +113 -0
- package/layers/media/app/components/MediaImage.vue +70 -0
- package/layers/media/app/components/MediaLibrary.vue +451 -0
- package/layers/media/app/components/MediaNewFolderDialog.vue +32 -0
- package/layers/media/app/components/MediaPathBar.vue +163 -0
- package/layers/media/app/components/MediaPicker.vue +19 -0
- package/layers/media/app/components/MediaRenameDialog.vue +32 -0
- package/layers/media/app/components/MediaTable.vue +75 -0
- package/layers/media/app/components/MediaThumb.vue +25 -0
- package/layers/media/app/components/MediaToolbar.vue +90 -0
- package/layers/media/app/components/MediaUploadDialog.vue +87 -0
- package/layers/media/app/components/MediaViewer.vue +88 -0
- package/layers/media/app/components/field/Media.vue +261 -0
- package/layers/media/app/composables/useBlurUp.ts +55 -0
- package/layers/media/app/composables/useMediaClipboard.ts +14 -0
- package/layers/media/app/composables/useMediaContextMenu.ts +56 -0
- package/layers/media/app/composables/useMediaDnd.ts +56 -0
- package/layers/media/app/composables/useMediaLibrary.ts +151 -0
- package/layers/media/app/composables/useMediaOps.ts +50 -0
- package/layers/media/app/composables/useMediaRelocate.ts +39 -0
- package/layers/media/app/composables/useMediaResolver.ts +50 -0
- package/layers/media/app/composables/useMediaUpload.ts +93 -0
- package/layers/media/app/composables/useMediaVariant.ts +34 -0
- package/layers/media/app/plugins/field-media.client.ts +6 -0
- package/layers/media/app/utils/dnd.ts +69 -0
- package/layers/media/app/utils/library.ts +79 -0
- package/layers/media/app/utils/ops.ts +62 -0
- package/layers/media/app/utils/picture-model.ts +125 -0
- package/layers/media/app/utils/thumbhash.ts +19 -0
- package/layers/media/app/utils/variant-name.ts +20 -0
- package/layers/media/nuxt.config.ts +3 -0
- package/layers/media/server/api/media/[id]/usages.get.ts +8 -0
- package/layers/media/server/api/media/[id].delete.ts +18 -0
- package/layers/media/server/api/media/[id].get.ts +10 -0
- package/layers/media/server/api/media/[id].patch.ts +41 -0
- package/layers/media/server/api/media/backfill.post.ts +18 -0
- package/layers/media/server/api/media/copy.post.ts +7 -0
- package/layers/media/server/api/media/delete.post.ts +13 -0
- package/layers/media/server/api/media/folders.post.ts +18 -0
- package/layers/media/server/api/media/index.get.ts +15 -0
- package/layers/media/server/api/media/index.post.ts +157 -0
- package/layers/media/server/api/media/library.get.ts +17 -0
- package/layers/media/server/api/media/move.post.ts +7 -0
- package/layers/media/server/api/media/rename.post.ts +7 -0
- package/layers/media/server/api/media/resolve.get.ts +13 -0
- package/layers/media/server/collections/media-settings.ts +25 -0
- package/layers/media/server/collections/media.ts +29 -0
- package/layers/media/server/database/folders.ts +10 -0
- package/layers/media/server/middleware/ondemand-variants.ts +37 -0
- package/layers/media/server/plugins/02.register-media.ts +19 -0
- package/layers/media/server/plugins/03.media-cleanup.ts +20 -0
- package/layers/media/server/plugins/04.variant-capture.ts +23 -0
- package/layers/media/server/schema-tables/folders.ts +4 -0
- package/layers/media/server/tasks/media/backfill.ts +18 -0
- package/layers/media/server/utils/backfill.ts +132 -0
- package/layers/media/server/utils/derive.ts +59 -0
- package/layers/media/server/utils/folder-paths.ts +42 -0
- package/layers/media/server/utils/folders.ts +12 -0
- package/layers/media/server/utils/library.ts +90 -0
- package/layers/media/server/utils/media-cleanup.ts +27 -0
- package/layers/media/server/utils/media-enabled.ts +23 -0
- package/layers/media/server/utils/media-ops.ts +118 -0
- package/layers/media/server/utils/media-write.ts +13 -0
- package/layers/media/server/utils/naming.ts +52 -0
- package/layers/media/server/utils/ondemand.ts +69 -0
- package/layers/media/server/utils/persist-upload.ts +62 -0
- package/layers/media/server/utils/populate.ts +47 -0
- package/layers/media/server/utils/record.ts +63 -0
- package/layers/media/server/utils/relocate-ops.ts +341 -0
- package/layers/media/server/utils/relocate-plan.ts +63 -0
- package/layers/media/server/utils/resolve.ts +86 -0
- package/layers/media/server/utils/sanitize-svg.ts +12 -0
- package/layers/media/server/utils/sniff.ts +68 -0
- package/layers/media/server/utils/storage-relocate.ts +139 -0
- package/layers/media/server/utils/translations.ts +19 -0
- package/layers/media/server/utils/usages.ts +66 -0
- package/layers/media/server/utils/variant-capture.ts +70 -0
- package/layers/media/server/utils/variants.ts +108 -0
- package/layers/public/app/app.vue +5 -0
- package/layers/public/app/components/BlockRenderer.vue +56 -0
- package/layers/public/app/components/KestrelLink.vue +26 -0
- package/layers/public/app/components/KestrelPreviewBridge.vue +133 -0
- package/layers/public/app/composables/public-page.ts +24 -0
- package/layers/public/app/error.vue +12 -0
- package/layers/public/app/layouts/default.vue +5 -0
- package/layers/public/app/pages/[...slug].vue +157 -0
- package/layers/public/app/pages/__kestrel/preview.vue +34 -0
- package/layers/public/app/utils/block-edit-context.ts +16 -0
- package/layers/public/app/utils/link-href.ts +46 -0
- package/layers/public/app/utils/page-head.ts +84 -0
- package/layers/public/app/utils/preview-protocol.ts +81 -0
- package/layers/public/app/utils/public-route.ts +46 -0
- package/layers/public/modules/deploy-output/deploy-output.ts +370 -0
- package/layers/public/modules/deploy-output/index.ts +82 -0
- package/layers/public/modules/prerender-routes/discover.ts +37 -0
- package/layers/public/modules/prerender-routes/index.ts +85 -0
- package/layers/public/modules/prerender-routes/routes.ts +16 -0
- package/layers/public/modules/prune-media/index.ts +48 -0
- package/layers/public/modules/prune-media/prune-media.ts +81 -0
- package/layers/public/nuxt.config.ts +11 -0
- package/layers/public/server/api/links/resolve.get.ts +19 -0
- package/layers/public/server/api/publish-status.get.ts +44 -0
- package/layers/public/server/api/route.get.ts +18 -0
- package/layers/public/server/database/publish-deps.ts +12 -0
- package/layers/public/server/database/publish-status.ts +19 -0
- package/layers/public/server/plugins/02.register-links.ts +17 -0
- package/layers/public/server/plugins/zz.publish.ts +63 -0
- package/layers/public/server/routes/llms.txt.get.ts +72 -0
- package/layers/public/server/routes/robots.txt.get.ts +8 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +66 -0
- package/layers/public/server/schema-tables/publish-deps.ts +4 -0
- package/layers/public/server/schema-tables/publish-status.ts +4 -0
- package/layers/public/server/tasks/publish/run.ts +26 -0
- package/layers/public/server/utils/link-resolve.ts +35 -0
- package/layers/public/server/utils/llms.ts +39 -0
- package/layers/public/server/utils/page-resolve.ts +86 -0
- package/layers/public/server/utils/populate-links.ts +66 -0
- package/layers/public/server/utils/publish/coalesce.ts +22 -0
- package/layers/public/server/utils/publish/deps-persistence.ts +43 -0
- package/layers/public/server/utils/publish/deps.ts +87 -0
- package/layers/public/server/utils/publish/invalidation.ts +141 -0
- package/layers/public/server/utils/publish/publish-status.ts +54 -0
- package/layers/public/server/utils/publish/publisher.ts +291 -0
- package/layers/public/server/utils/publish/queue.ts +67 -0
- package/layers/public/server/utils/publish/route-for-record.ts +16 -0
- package/layers/public/server/utils/publish/route-keys.ts +13 -0
- package/layers/public/server/utils/site-url.ts +32 -0
- package/layers/public/server/utils/sitemap.ts +97 -0
- package/layers/ui/app/assets/scss/_base.scss +16 -0
- package/layers/ui/app/assets/scss/_datepicker.scss +82 -0
- package/layers/ui/app/assets/scss/_mixins.scss +50 -0
- package/layers/ui/app/assets/scss/_reset.scss +65 -0
- package/layers/ui/app/assets/scss/_tokens.scss +183 -0
- package/layers/ui/app/assets/scss/main.scss +4 -0
- package/layers/ui/app/components/field/Boolean.vue +30 -0
- package/layers/ui/app/components/field/Choice.vue +68 -0
- package/layers/ui/app/components/field/Datetime.vue +96 -0
- package/layers/ui/app/components/field/DeadRefNote.vue +28 -0
- package/layers/ui/app/components/field/Json.vue +63 -0
- package/layers/ui/app/components/field/Layout.vue +140 -0
- package/layers/ui/app/components/field/Link.vue +112 -0
- package/layers/ui/app/components/field/LinkInternalPicker.vue +95 -0
- package/layers/ui/app/components/field/Number.vue +30 -0
- package/layers/ui/app/components/field/Relation.vue +49 -0
- package/layers/ui/app/components/field/Renderer.vue +30 -0
- package/layers/ui/app/components/field/Repeater.vue +460 -0
- package/layers/ui/app/components/field/Richtext.vue +26 -0
- package/layers/ui/app/components/field/Slug.vue +66 -0
- package/layers/ui/app/components/field/Text.vue +35 -0
- package/layers/ui/app/components/field/Unsupported.vue +34 -0
- package/layers/ui/app/components/ui/Alert.vue +50 -0
- package/layers/ui/app/components/ui/Brand.vue +22 -0
- package/layers/ui/app/components/ui/Button.vue +116 -0
- package/layers/ui/app/components/ui/ButtonGroup.vue +96 -0
- package/layers/ui/app/components/ui/Checkbox.vue +52 -0
- package/layers/ui/app/components/ui/CheckboxGroup.vue +50 -0
- package/layers/ui/app/components/ui/Combobox.vue +343 -0
- package/layers/ui/app/components/ui/DatePicker.vue +92 -0
- package/layers/ui/app/components/ui/DateRangePicker.vue +100 -0
- package/layers/ui/app/components/ui/Dialog.vue +107 -0
- package/layers/ui/app/components/ui/EmptyState.vue +46 -0
- package/layers/ui/app/components/ui/Field.vue +57 -0
- package/layers/ui/app/components/ui/Fieldset.vue +64 -0
- package/layers/ui/app/components/ui/Icon.vue +44 -0
- package/layers/ui/app/components/ui/Menu.vue +54 -0
- package/layers/ui/app/components/ui/NumberInput.vue +173 -0
- package/layers/ui/app/components/ui/Richtext.vue +119 -0
- package/layers/ui/app/components/ui/RichtextToolbar.vue +253 -0
- package/layers/ui/app/components/ui/Select.vue +51 -0
- package/layers/ui/app/components/ui/TextInput.vue +110 -0
- package/layers/ui/app/components/ui/Textarea.vue +38 -0
- package/layers/ui/app/components/ui/TimeInput.vue +17 -0
- package/layers/ui/app/components/ui/Toasts.vue +103 -0
- package/layers/ui/app/components/ui/Tooltip.vue +45 -0
- package/layers/ui/app/composables/useAdminLang.ts +8 -0
- package/layers/ui/app/composables/useDragReorder.ts +41 -0
- package/layers/ui/app/composables/useEchoGuard.ts +28 -0
- package/layers/ui/app/composables/useLinkField.ts +70 -0
- package/layers/ui/app/composables/useRecordOptions.ts +84 -0
- package/layers/ui/app/composables/useRepeater.ts +99 -0
- package/layers/ui/app/composables/useT.ts +32 -0
- package/layers/ui/app/composables/useToast.ts +46 -0
- package/layers/ui/app/i18n/de.ts +406 -0
- package/layers/ui/app/i18n/en.ts +440 -0
- package/layers/ui/app/utils/boolean-field.ts +10 -0
- package/layers/ui/app/utils/field-component.ts +22 -0
- package/layers/ui/app/utils/field-empty.ts +21 -0
- package/layers/ui/app/utils/field-registry.ts +36 -0
- package/layers/ui/app/utils/humanize.ts +14 -0
- package/layers/ui/app/utils/icons.ts +147 -0
- package/layers/ui/app/utils/iso-date.ts +18 -0
- package/layers/ui/app/utils/localized.ts +13 -0
- package/layers/ui/app/utils/record-context.ts +15 -0
- package/layers/ui/app/utils/reorder.ts +8 -0
- package/layers/ui/app/utils/richtext-preserve-class.ts +31 -0
- package/layers/ui/app/utils/useFieldA11y.ts +40 -0
- package/layers/ui/nuxt.config.ts +5 -0
- package/nuxt.config.ts +105 -0
- package/package.json +122 -0
- package/scripts/hash-password.mjs +26 -0
- package/scripts/typecheck.mjs +51 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { safeRedirect } from './safe-redirect'
|
|
2
|
+
|
|
3
|
+
// The auth bootstrap endpoints the server always permits (login/session/logout). A 401 from these is
|
|
4
|
+
// not a stale session — a wrong-password login 401s — so it must not trigger a re-auth redirect. Matched
|
|
5
|
+
// exactly (not by `/api/auth/` prefix) so a guarded endpoint under a consumer collection named `auth`
|
|
6
|
+
// (e.g. `/api/auth/5`) still re-authenticates.
|
|
7
|
+
const BOOTSTRAP_PATHS = new Set(['/api/auth/login', '/api/auth/session', '/api/auth/logout'])
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Decide whether a failed `$fetch` should bounce the user to the login screen, and to which URL.
|
|
11
|
+
*
|
|
12
|
+
* Returns the login redirect target for a 401 raised by a guarded `/api/*` call made from inside the
|
|
13
|
+
* admin SPA — but stays null for:
|
|
14
|
+
* - non-401 statuses and non-`/api` requests (nothing to re-authenticate);
|
|
15
|
+
* - the auth-bootstrap endpoints (`/api/auth/*`): a wrong-password login 401s and must stay put;
|
|
16
|
+
* - any location that isn't a safe admin redirect target — which also rules out the login page itself
|
|
17
|
+
* (no redirect loop) and public/SSG pages (the static site never logs in).
|
|
18
|
+
*/
|
|
19
|
+
export function reauthTarget(input: { status: number; url: string; currentPath: string }): string | null {
|
|
20
|
+
if (input.status !== 401) return null
|
|
21
|
+
const path = apiPath(input.url)
|
|
22
|
+
if (!path.startsWith('/api/') || BOOTSTRAP_PATHS.has(path)) return null
|
|
23
|
+
// The current location must itself be a valid place to return to. `safeRedirect` rejects the login
|
|
24
|
+
// page and any non-admin path, so this single check guards both the loop and the public site.
|
|
25
|
+
const back = safeRedirect(input.currentPath)
|
|
26
|
+
if (!back) return null
|
|
27
|
+
return `/admin/login?redirect=${encodeURIComponent(back)}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function apiPath(url: string): string {
|
|
31
|
+
// Tolerate absolute URLs (SSR/base-prefixed) and relative ones alike; fall back to the raw string.
|
|
32
|
+
try { return new URL(url, 'http://localhost').pathname } catch { return url }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Minimal shape of the ofetch `onResponseError` context this interceptor reads. */
|
|
36
|
+
export interface ReauthContext {
|
|
37
|
+
request?: string | { url?: string } | URL
|
|
38
|
+
response?: { status?: number }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build an `onResponseError` handler that re-authenticates on a stale-session 401. Pure dependency
|
|
43
|
+
* injection (current path / reset / navigate) keeps it unit-testable away from the Nuxt runtime; the
|
|
44
|
+
* client plugin wires it to the real router, `useAuth().reset` and `navigateTo`.
|
|
45
|
+
*/
|
|
46
|
+
export function makeReauthInterceptor(deps: {
|
|
47
|
+
currentPath: () => string
|
|
48
|
+
reset: () => void
|
|
49
|
+
navigate: (to: string) => void
|
|
50
|
+
}) {
|
|
51
|
+
return (ctx: ReauthContext): void => {
|
|
52
|
+
const target = reauthTarget({
|
|
53
|
+
status: ctx.response?.status ?? 0,
|
|
54
|
+
url: requestUrl(ctx.request),
|
|
55
|
+
currentPath: deps.currentPath(),
|
|
56
|
+
})
|
|
57
|
+
if (!target) return
|
|
58
|
+
deps.reset()
|
|
59
|
+
deps.navigate(target)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function requestUrl(request: ReauthContext['request']): string {
|
|
64
|
+
if (typeof request === 'string') return request
|
|
65
|
+
if (request instanceof URL) return request.href
|
|
66
|
+
if (request && typeof request === 'object' && typeof request.url === 'string') return request.url
|
|
67
|
+
return ''
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The text the editor header shows for a saved record: the `title` text field if present, else the first
|
|
3
|
+
* text field — the SAME pick `slugSourceValue` (core/page-slug) derives the auto-slug from, so the heading
|
|
4
|
+
* and the URL never disagree about which field is "the title".
|
|
5
|
+
*
|
|
6
|
+
* Returns `''` when there is no such field or its value is blank/non-string; the header then falls back to
|
|
7
|
+
* the generic "Edit {collection} #{id}" (an id is all a brand-new record has).
|
|
8
|
+
*/
|
|
9
|
+
export function recordTitle(fields: Record<string, { type: string }>, values: Record<string, unknown>): string {
|
|
10
|
+
const entries = Object.entries(fields)
|
|
11
|
+
const pick = entries.find(([k, f]) => k === 'title' && f.type === 'text') ?? entries.find(([, f]) => f.type === 'text')
|
|
12
|
+
if (!pick) return ''
|
|
13
|
+
const v = values[pick[0]]
|
|
14
|
+
return typeof v === 'string' ? v.trim() : ''
|
|
15
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { registerCollectionEditor } from './editor-registry'
|
|
2
|
+
import FieldsBody from '../components/FieldsBody.vue'
|
|
3
|
+
import BlocksBody from '../components/BlocksBody.vue'
|
|
4
|
+
|
|
5
|
+
// Built-in editor bodies. Registered as an import side-effect (CollectionEditor imports this module), so
|
|
6
|
+
// they are present before any editor renders — the same guarantee the field registry's static map gives.
|
|
7
|
+
// Extensions add their own types via a `*.client.ts` plugin (auto-discovered through `extends`).
|
|
8
|
+
registerCollectionEditor('fields', FieldsBody)
|
|
9
|
+
registerCollectionEditor('blocks', BlocksBody)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validate a post-login redirect target. Only local `/admin` paths are allowed —
|
|
3
|
+
* never external/protocol-relative URLs, and never `/admin/login` (no redirect loop).
|
|
4
|
+
*/
|
|
5
|
+
export function safeRedirect(target: unknown): string | null {
|
|
6
|
+
if (typeof target !== 'string') return null
|
|
7
|
+
if (target.startsWith('//')) return null
|
|
8
|
+
const local = target === '/admin' || target.startsWith('/admin/') || target.startsWith('/admin?')
|
|
9
|
+
if (!local) return null
|
|
10
|
+
if (target === '/admin/login' || target.startsWith('/admin/login?') || target.startsWith('/admin/login/')) return null
|
|
11
|
+
return target
|
|
12
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export default defineNuxtConfig({
|
|
2
|
+
routeRules: {
|
|
3
|
+
// The admin is a client-rendered SPA.
|
|
4
|
+
'/admin/**': { ssr: false },
|
|
5
|
+
},
|
|
6
|
+
modules: [
|
|
7
|
+
// The field-widget gallery (/admin/gallery) is a dev-only showcase — strip it from the
|
|
8
|
+
// production admin SPA so it never ships to end users.
|
|
9
|
+
(_options, nuxt) => {
|
|
10
|
+
nuxt.hook('pages:extend', (pages) => {
|
|
11
|
+
if (nuxt.options.dev) return
|
|
12
|
+
const i = pages.findIndex((p) => p.file?.endsWith('/admin/gallery.vue'))
|
|
13
|
+
if (i !== -1) pages.splice(i, 1)
|
|
14
|
+
})
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default defineNuxtConfig({})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export default defineEventHandler(async (event) => {
|
|
2
|
+
const ip = clientIp(event)
|
|
3
|
+
const key = throttleKey(ip)
|
|
4
|
+
const now = Date.now()
|
|
5
|
+
assertBodyLimit(getRequestHeader(event, 'content-length'))
|
|
6
|
+
// Reserve the attempt up front (assert-not-locked + record, atomically — no await in between) so
|
|
7
|
+
// concurrent in-flight guesses from the same IP all count toward the cap before the slow hash runs.
|
|
8
|
+
// The failure is already recorded here; a successful login clears it below.
|
|
9
|
+
reserveLoginAttempt(key, now)
|
|
10
|
+
const body = await readBody(event)
|
|
11
|
+
const password = typeof body?.password === 'string' ? body.password : ''
|
|
12
|
+
|
|
13
|
+
let stored: string
|
|
14
|
+
try {
|
|
15
|
+
// No hash configured → login is impossible: surface a distinct 503 instead of a confusing 401.
|
|
16
|
+
stored = requireAdminHash()
|
|
17
|
+
} catch (error) {
|
|
18
|
+
// A server-side misconfiguration nobody can guess their way past: refund the reservation so the admin
|
|
19
|
+
// isn't still locked out once they set the hash. The slot-busy 503 below stays billable by contrast —
|
|
20
|
+
// it IS attacker-triggerable, and charging it is what makes a flood run into the lockout.
|
|
21
|
+
releaseLoginAttempt(key, now)
|
|
22
|
+
throw error
|
|
23
|
+
}
|
|
24
|
+
acquireHashSlot()
|
|
25
|
+
let ok: boolean
|
|
26
|
+
try {
|
|
27
|
+
ok = await verifyPassword(password, stored)
|
|
28
|
+
} finally {
|
|
29
|
+
releaseHashSlot()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!ok) {
|
|
33
|
+
throw createError({ statusCode: 401, statusMessage: 'Invalid credentials' })
|
|
34
|
+
}
|
|
35
|
+
clearLoginFailures(key)
|
|
36
|
+
const exp = setAuthSession(event)
|
|
37
|
+
return { ok: true, exp }
|
|
38
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export default defineEventHandler((event) => {
|
|
2
|
+
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
+
// Clear the cookie for THIS client AND bump the revocation epoch so any other copy of the token (a synced
|
|
4
|
+
// backup, a leaked log, another device) is invalidated server-side immediately — a real logout, not just a
|
|
5
|
+
// cookie clear. Single admin ⇒ logout = logout-everywhere, which is the expected behaviour.
|
|
6
|
+
clearAuthSession(event)
|
|
7
|
+
bumpSessionEpoch()
|
|
8
|
+
return { ok: true }
|
|
9
|
+
})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default defineEventHandler((event) => getAuthSession(event))
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createError } from 'h3'
|
|
2
|
+
|
|
3
|
+
type Env = Record<string, string | undefined>
|
|
4
|
+
|
|
5
|
+
/** The configured admin password hash, or `undefined` when `KESTREL_ADMIN_PASSWORD_HASH` is unset/empty. */
|
|
6
|
+
export function adminPasswordHash(env: Env = process.env): string | undefined {
|
|
7
|
+
const stored = env.KESTREL_ADMIN_PASSWORD_HASH
|
|
8
|
+
return typeof stored === 'string' && stored.length > 0 ? stored : undefined
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let warned = false
|
|
12
|
+
function warnNotConfigured(): void {
|
|
13
|
+
if (warned) return
|
|
14
|
+
warned = true
|
|
15
|
+
console.warn(
|
|
16
|
+
'[kestrel auth] admin login is not configured — set KESTREL_ADMIN_PASSWORD_HASH to enable sign-in',
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the admin password hash for a login attempt, or fail loudly when it is unset.
|
|
22
|
+
*
|
|
23
|
+
* A missing `KESTREL_ADMIN_PASSWORD_HASH` means login is *impossible*, not that a credential was wrong —
|
|
24
|
+
* answering with the generic 401 makes an operator who simply forgot to set the hash chase a phantom
|
|
25
|
+
* "wrong password". So we surface a distinct **503** (plus a one-time server warning) for that case only.
|
|
26
|
+
*
|
|
27
|
+
* Security: this distinguishes solely the *unconfigured* server. A configured server still answers every
|
|
28
|
+
* bad credential with an opaque 401, so it never leaks which part (user/password) failed. Short-circuiting
|
|
29
|
+
* here is safe — a 503 already states plainly that login is unconfigured, so there is no timing signal to
|
|
30
|
+
* protect.
|
|
31
|
+
*/
|
|
32
|
+
export function requireAdminHash(env: Env = process.env, warn: () => void = warnNotConfigured): string {
|
|
33
|
+
const stored = adminPasswordHash(env)
|
|
34
|
+
if (!stored) {
|
|
35
|
+
warn()
|
|
36
|
+
throw createError({
|
|
37
|
+
statusCode: 503,
|
|
38
|
+
statusMessage: 'admin login is not configured (set KESTREL_ADMIN_PASSWORD_HASH)',
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
return stored
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
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 }
|