@michaelthielemann/kestrel 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -110
- package/kestrel.config.ts +1 -1
- package/layers/access/server/middleware/00.ip-allowlist.ts +3 -18
- package/layers/access/server/middleware/access-guard.ts +25 -23
- package/layers/access/server/types.d.ts +1 -2
- package/layers/admin/app/components/AdminAccount.vue +1 -1
- package/layers/admin/app/components/BlockFields.vue +1 -2
- package/layers/admin/app/components/BlockPreview.vue +17 -5
- package/layers/admin/app/components/BlocksBody.vue +1 -1
- package/layers/admin/app/components/CollectionEditor.vue +35 -16
- package/layers/admin/app/components/CollectionList.vue +34 -3
- package/layers/admin/app/components/CollectionListBulkBar.vue +17 -3
- package/layers/admin/app/components/CollectionListFilterPanel.vue +1 -1
- package/layers/admin/app/components/CollectionListPager.vue +1 -1
- package/layers/admin/app/components/CollectionListTable.vue +41 -2
- package/layers/admin/app/components/EditorStatus.vue +8 -7
- package/layers/admin/app/components/LocaleBar.vue +3 -1
- package/layers/admin/app/components/PageFields.vue +2 -4
- package/layers/admin/app/components/SeoFields.vue +2 -4
- package/layers/admin/app/components/SingletonEditor.vue +3 -2
- package/layers/admin/app/composables/useAuth.ts +3 -3
- package/layers/admin/app/composables/useBlockTree.ts +1 -1
- package/layers/admin/app/composables/useBlocks.ts +1 -1
- package/layers/admin/app/composables/useCollectionOps.ts +45 -8
- package/layers/admin/app/composables/useCollections.ts +1 -2
- package/layers/admin/app/composables/useEditForm.ts +35 -17
- package/layers/admin/app/composables/useLinkResolver.ts +1 -1
- package/layers/admin/app/composables/useListBatchActions.ts +20 -3
- package/layers/admin/app/composables/useListColumns.ts +1 -1
- package/layers/admin/app/composables/useListFilterDraft.ts +3 -2
- package/layers/admin/app/composables/useListRows.ts +7 -2
- package/layers/admin/app/composables/useListUrlState.ts +1 -1
- package/layers/admin/app/composables/usePublishStatus.ts +2 -2
- package/layers/admin/app/layouts/admin.vue +0 -1
- package/layers/admin/app/pages/admin/[collection]/[id].vue +5 -4
- package/layers/admin/app/pages/admin/index.vue +1 -1
- package/layers/admin/app/pages/admin/references.vue +2 -2
- package/layers/admin/app/utils/block-tree.ts +1 -1
- package/layers/admin/app/utils/collection-ops.ts +28 -3
- package/layers/admin/app/utils/edit-form.ts +34 -2
- package/layers/admin/app/utils/editor-expose.ts +2 -0
- package/layers/admin/app/utils/editor-form-context.ts +1 -2
- package/layers/admin/app/utils/field-keys.ts +1 -1
- package/layers/admin/app/utils/list-columns.ts +3 -2
- package/layers/admin/app/utils/list-query.ts +2 -2
- package/layers/admin/app/utils/populate-blocks.ts +2 -2
- package/layers/admin/app/utils/reauth.ts +5 -6
- package/layers/auth/server/plugins/01.register-auth-pipelines.ts +7 -0
- package/layers/collections/server/plugins/01.register.ts +9 -12
- package/layers/collections/server/plugins/02.register-relation-populate.ts +5 -7
- package/layers/{fields → core}/app/utils/field-factories.ts +1 -1
- package/layers/core/modules/auto-discovery/extract-block.ts +3 -3
- package/layers/core/modules/auto-discovery/index.ts +68 -11
- package/layers/core/modules/auto-discovery/package-registry.ts +19 -0
- package/layers/core/modules/auto-discovery/scan.ts +77 -0
- package/layers/core/modules/auto-discovery/virtual.d.ts +5 -0
- package/layers/core/modules/component-namespace/index.ts +24 -0
- package/layers/core/modules/kestrel/index.ts +6 -1
- package/layers/core/modules/plugin-order/index.ts +69 -0
- package/layers/core/modules/plugin-order/plugin-order.ts +273 -0
- package/layers/core/nuxt.config.ts +1 -0
- package/layers/core/server/api/[...path].ts +98 -0
- package/layers/core/server/plugins/00.config.ts +12 -0
- package/layers/core/server/plugins/00.migrate.ts +1 -0
- package/layers/core/server/plugins/01.register-introspection-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-openapi-pipeline.ts +5 -0
- package/layers/core/server/plugins/01.register-tooling-pipelines.ts +5 -0
- package/layers/core/server/plugins/02.schema-sync.ts +2 -7
- package/layers/core/server/plugins/04.outbox-worker.ts +31 -0
- package/layers/core/server/plugins/05.reindex-refs.ts +5 -0
- package/layers/core/server/routes/__kestrel/dashboard.get.ts +29 -0
- package/layers/core/server/tasks/db/migrate-module.ts +83 -0
- package/layers/core/server/tasks/db/migrate-revisions.ts +20 -0
- package/layers/core/server/tasks/db/migrate.ts +2 -6
- package/layers/core/server/utils/server-config.ts +66 -3
- package/layers/fields/server/plugins/01.register-blocks-pipeline.ts +7 -0
- package/layers/fields/server/plugins/01.register-field-populate.ts +2 -2
- package/layers/media/app/components/KestrelImg.vue +3 -3
- package/layers/media/app/components/MediaLibrary.vue +2 -2
- package/layers/media/app/components/field/Media.vue +1 -1
- package/layers/media/app/composables/useMediaResolver.ts +1 -1
- package/layers/media/app/composables/useMediaUpload.ts +1 -1
- package/layers/media/app/composables/useMediaVariant.ts +2 -2
- package/layers/media/app/utils/ai-disclosure.ts +1 -1
- package/layers/media/app/utils/library.ts +1 -1
- package/layers/media/app/utils/ops.ts +1 -3
- package/layers/media/app/utils/picture-model.ts +2 -2
- package/layers/media/app/utils/variant-name.ts +1 -1
- package/layers/media/server/middleware/ondemand-variants.ts +6 -5
- package/layers/media/server/plugins/01.register-media-pipelines.ts +7 -0
- package/layers/media/server/plugins/02.register-media.ts +10 -7
- package/layers/media/server/plugins/04.variant-capture.ts +12 -8
- package/layers/media/server/plugins/05.media-cleanup.ts +5 -0
- package/layers/media/server/tasks/media/backfill.ts +3 -4
- package/layers/public/app/components/KestrelLink.vue +1 -1
- package/layers/public/app/pages/[...slug].vue +1 -1
- package/layers/public/app/pages/__kestrel/preview.vue +2 -2
- package/layers/public/app/utils/json-ld.ts +1 -1
- package/layers/public/app/utils/link-href.ts +1 -1
- package/layers/public/app/utils/page-head.ts +1 -1
- package/layers/public/modules/deploy-output/index.ts +4 -6
- package/layers/public/modules/prerender-routes/index.ts +4 -4
- package/layers/public/modules/prerender-routes/routes.ts +1 -1
- package/layers/public/modules/prune-media/index.ts +2 -2
- package/layers/public/server/delivery-live/serve.ts +111 -0
- package/layers/public/server/middleware/delivery-live-catchall.ts +7 -0
- package/layers/public/server/pipelines/index.ts +23 -0
- package/layers/public/server/plugins/00.ensure-snapshot-triggers.ts +14 -0
- package/layers/public/server/plugins/01.register-public-pipelines.ts +7 -0
- package/layers/public/server/plugins/02.register-links.ts +2 -3
- package/layers/public/server/plugins/03.redirects.ts +41 -23
- package/layers/public/server/plugins/05.plan-publish.ts +5 -0
- package/layers/public/server/plugins/zz.publish.ts +79 -24
- package/layers/public/server/routes/llms-full.txt.get.ts +5 -5
- package/layers/public/server/routes/llms.txt.get.ts +4 -2
- package/layers/public/server/routes/redirects.json.get.ts +2 -2
- package/layers/public/server/routes/robots.txt.get.ts +2 -0
- package/layers/public/server/routes/sitemap.xml.get.ts +16 -6
- package/layers/public/server/tasks/publish/run.ts +9 -4
- package/layers/public/server/utils/publish/render-live.ts +26 -0
- package/layers/ui/app/components/field/Choice.vue +1 -1
- package/layers/ui/app/components/field/Datetime.vue +1 -1
- package/layers/ui/app/components/field/Json.vue +1 -2
- package/layers/ui/app/components/field/Layout.vue +2 -4
- package/layers/ui/app/components/field/Link.vue +1 -1
- package/layers/ui/app/components/field/LinkInternalPicker.vue +3 -3
- package/layers/ui/app/components/field/Number.vue +2 -2
- package/layers/ui/app/components/field/Relation.vue +1 -1
- package/layers/ui/app/components/field/Repeater.vue +6 -8
- package/layers/ui/app/components/field/Slug.vue +2 -3
- package/layers/ui/app/components/field/Text.vue +1 -1
- package/layers/ui/app/components/ui/Button.vue +2 -2
- package/layers/ui/app/components/ui/ButtonGroup.vue +2 -2
- package/layers/ui/app/components/ui/Checkbox.vue +2 -2
- package/layers/ui/app/components/ui/Combobox.vue +5 -5
- package/layers/ui/app/components/ui/DatePicker.vue +2 -2
- package/layers/ui/app/components/ui/DateRangePicker.vue +2 -2
- package/layers/ui/app/components/ui/NumberInput.vue +4 -4
- package/layers/ui/app/components/ui/Richtext.vue +1 -1
- package/layers/ui/app/components/ui/RichtextToolbar.vue +1 -3
- package/layers/ui/app/components/ui/Select.vue +2 -2
- package/layers/ui/app/components/ui/TextInput.vue +4 -4
- package/layers/ui/app/components/ui/Textarea.vue +2 -2
- package/layers/ui/app/components/ui/TimeInput.vue +2 -2
- package/layers/ui/app/components/ui/Toasts.vue +3 -3
- package/layers/ui/app/composables/useLinkField.ts +1 -1
- package/layers/ui/app/composables/useRepeater.ts +1 -1
- package/layers/ui/app/i18n/de.ts +7 -7
- package/layers/ui/app/i18n/en.ts +7 -39
- package/layers/ui/app/utils/field-component.ts +1 -1
- package/layers/ui/app/utils/field-registry.ts +1 -1
- package/layers/ui/app/utils/icons.ts +2 -6
- package/layers/ui/app/utils/localized.ts +1 -1
- package/nuxt.config.ts +1 -1
- package/package.json +53 -19
- package/scripts/consumer-template-ci.mjs +157 -0
- package/scripts/coverage-all.mjs +163 -0
- package/scripts/dashboard.mjs +89 -0
- package/scripts/hash-password.mjs +1 -1
- package/scripts/lib/coverage-model.mjs +51 -0
- package/scripts/port-graph.mjs +350 -0
- package/scripts/typecheck.mjs +11 -0
- package/templates/starter/app/blocks/Prose.vue +1 -1
- package/layers/access/server/utils/csrf.ts +0 -39
- package/layers/access/server/utils/grant-registry.ts +0 -36
- package/layers/access/server/utils/guard.ts +0 -44
- package/layers/access/server/utils/ip-allowlist.ts +0 -79
- package/layers/access/server/utils/policy.ts +0 -98
- package/layers/access/server/utils/public-resources.ts +0 -11
- package/layers/access/server/utils/render-context.ts +0 -41
- package/layers/access/server/utils/require-admin.ts +0 -15
- package/layers/admin/app/pages/admin/[collection]/[id].nuxt.test.ts +0 -200
- package/layers/admin/app/pages/admin/[collection]/publish-preview.nuxt.test.ts +0 -142
- package/layers/auth/server/api/auth/login.post.ts +0 -38
- package/layers/auth/server/api/auth/logout.post.ts +0 -9
- package/layers/auth/server/api/auth/session.get.ts +0 -1
- package/layers/auth/server/utils/admin-credential.ts +0 -42
- package/layers/auth/server/utils/client-ip.ts +0 -88
- package/layers/auth/server/utils/login-throttle.ts +0 -126
- package/layers/auth/server/utils/password.ts +0 -52
- package/layers/auth/server/utils/session-cookie.ts +0 -44
- package/layers/auth/server/utils/session-epoch.ts +0 -96
- package/layers/auth/server/utils/session.ts +0 -88
- package/layers/collections/server/collections/pages.ts +0 -19
- package/layers/collections/server/utils/populate-relations.ts +0 -79
- package/layers/core/app/utils/bulk-actions.ts +0 -9
- package/layers/core/app/utils/filter-ops.ts +0 -98
- package/layers/core/app/utils/list-limits.ts +0 -27
- package/layers/core/app/utils/locale-path.ts +0 -12
- package/layers/core/app/utils/slugify.ts +0 -16
- package/layers/core/server/api/[collection]/[id]/dead-refs.get.ts +0 -8
- package/layers/core/server/api/[collection]/[id]/translations.get.ts +0 -4
- package/layers/core/server/api/[collection]/[id].delete.ts +0 -5
- package/layers/core/server/api/[collection]/[id].get.ts +0 -11
- package/layers/core/server/api/[collection]/[id].patch.ts +0 -8
- package/layers/core/server/api/[collection]/bulk.post.ts +0 -37
- package/layers/core/server/api/[collection]/index.get.ts +0 -24
- package/layers/core/server/api/[collection]/index.post.ts +0 -8
- package/layers/core/server/api/[collection]/index.put.test.ts +0 -76
- package/layers/core/server/api/[collection]/index.put.ts +0 -24
- package/layers/core/server/api/[collection]/options.get.test.ts +0 -60
- package/layers/core/server/api/[collection]/options.get.ts +0 -18
- package/layers/core/server/api/[collection]/populate-scope.test.ts +0 -119
- package/layers/core/server/api/[collection]/translations.get.test.ts +0 -90
- package/layers/core/server/api/[collection]/translations.get.ts +0 -25
- package/layers/core/server/api/collections/[name].get.ts +0 -4
- package/layers/core/server/api/collections/index.get.ts +0 -3
- package/layers/core/server/api/references/broken.get.ts +0 -9
- package/layers/core/server/api/references/referrers.get.ts +0 -24
- package/layers/core/server/database/record-refs.ts +0 -20
- package/layers/core/server/plugins/03.record-refs.ts +0 -12
- package/layers/core/server/schema/bootstrap.ts +0 -41
- package/layers/core/server/schema/desired.ts +0 -99
- package/layers/core/server/schema/dev-sync.ts +0 -23
- package/layers/core/server/schema/dialect.ts +0 -61
- package/layers/core/server/schema/diff.ts +0 -110
- package/layers/core/server/schema/introspect.ts +0 -68
- package/layers/core/server/schema/model.ts +0 -61
- package/layers/core/server/schema/render-sqlite.ts +0 -89
- package/layers/core/server/schema/sync.ts +0 -201
- package/layers/core/server/utils/blocks.ts +0 -6
- package/layers/core/server/utils/collection-types.ts +0 -28
- package/layers/core/server/utils/crud.ts +0 -578
- package/layers/core/server/utils/db.ts +0 -26
- package/layers/core/server/utils/defineCollection.ts +0 -213
- package/layers/core/server/utils/duplicate.ts +0 -162
- package/layers/core/server/utils/field-layout.ts +0 -125
- package/layers/core/server/utils/filter-predicate.ts +0 -101
- package/layers/core/server/utils/http.ts +0 -50
- package/layers/core/server/utils/if-unmodified.ts +0 -13
- package/layers/core/server/utils/kestrel-config.ts +0 -451
- package/layers/core/server/utils/key-lock.ts +0 -33
- package/layers/core/server/utils/locale.ts +0 -53
- package/layers/core/server/utils/page-route.ts +0 -58
- package/layers/core/server/utils/page-slug.ts +0 -127
- package/layers/core/server/utils/picker.ts +0 -82
- package/layers/core/server/utils/populate.ts +0 -50
- package/layers/core/server/utils/prerender-memo.ts +0 -23
- package/layers/core/server/utils/read-capture.ts +0 -37
- package/layers/core/server/utils/record-ref-index.ts +0 -200
- package/layers/core/server/utils/registry.ts +0 -24
- package/layers/core/server/utils/resolve-scope.ts +0 -79
- package/layers/core/server/utils/seo.ts +0 -42
- package/layers/core/server/utils/serialize-collection.ts +0 -147
- package/layers/core/server/utils/sql.ts +0 -2
- package/layers/core/server/utils/storage.local.ts +0 -117
- package/layers/core/server/utils/storage.s3.ts +0 -197
- package/layers/core/server/utils/storage.ts +0 -90
- package/layers/core/server/utils/write-effects.ts +0 -40
- package/layers/core/server/utils/write-events.ts +0 -31
- package/layers/fields/app/utils/condition.ts +0 -71
- package/layers/fields/app/utils/define-block.ts +0 -13
- package/layers/fields/app/utils/field-constraints.ts +0 -17
- package/layers/fields/app/utils/field-meta.ts +0 -33
- package/layers/fields/app/utils/field-validate.ts +0 -75
- package/layers/fields/app/utils/field-value.ts +0 -7
- package/layers/fields/app/utils/richtext-links.ts +0 -48
- package/layers/fields/server/api/blocks/index.get.ts +0 -9
- package/layers/fields/server/field-registry/index.ts +0 -249
- package/layers/fields/server/field-registry/naming.ts +0 -10
- package/layers/fields/server/field-registry/sanitize.ts +0 -43
- package/layers/fields/server/field-registry/types.ts +0 -19
- package/layers/fields/server/utils/block-ids.ts +0 -43
- package/layers/fields/server/utils/block-populate.ts +0 -41
- package/layers/fields/server/utils/buildCollection.ts +0 -90
- package/layers/fields/server/utils/buildFieldSchema.ts +0 -22
- package/layers/fields/server/utils/buildTable.ts +0 -113
- package/layers/fields/server/utils/conditional-required.ts +0 -25
- package/layers/fields/server/utils/defineBlock.ts +0 -86
- package/layers/fields/server/utils/defineFieldType.ts +0 -33
- package/layers/fields/server/utils/extract-refs.ts +0 -152
- package/layers/fields/server/utils/field-populate.ts +0 -68
- package/layers/media/server/api/media/[id]/usages.get.ts +0 -8
- package/layers/media/server/api/media/[id].delete.ts +0 -18
- package/layers/media/server/api/media/[id].get.ts +0 -10
- package/layers/media/server/api/media/[id].patch.test.ts +0 -66
- package/layers/media/server/api/media/[id].patch.ts +0 -65
- package/layers/media/server/api/media/backfill.post.ts +0 -18
- package/layers/media/server/api/media/copy.post.ts +0 -7
- package/layers/media/server/api/media/delete.post.ts +0 -13
- package/layers/media/server/api/media/folders.post.ts +0 -18
- package/layers/media/server/api/media/index.get.ts +0 -15
- package/layers/media/server/api/media/index.post.ts +0 -173
- package/layers/media/server/api/media/library.get.ts +0 -17
- package/layers/media/server/api/media/move.post.ts +0 -7
- package/layers/media/server/api/media/rename.post.ts +0 -7
- package/layers/media/server/api/media/resolve.get.ts +0 -13
- package/layers/media/server/collections/media-settings.ts +0 -25
- package/layers/media/server/collections/media.ts +0 -43
- package/layers/media/server/database/folders.ts +0 -10
- package/layers/media/server/plugins/03.media-cleanup.ts +0 -20
- package/layers/media/server/schema-tables/folders.ts +0 -4
- package/layers/media/server/utils/ai-disclosure-enabled.ts +0 -16
- package/layers/media/server/utils/ai-signal-detect.ts +0 -155
- package/layers/media/server/utils/backfill.ts +0 -144
- package/layers/media/server/utils/derive.ts +0 -59
- package/layers/media/server/utils/folder-paths.ts +0 -42
- package/layers/media/server/utils/folders.ts +0 -12
- package/layers/media/server/utils/library.ts +0 -91
- package/layers/media/server/utils/media-cleanup.ts +0 -27
- package/layers/media/server/utils/media-enabled.ts +0 -23
- package/layers/media/server/utils/media-ops.ts +0 -118
- package/layers/media/server/utils/media-write.ts +0 -13
- package/layers/media/server/utils/naming.ts +0 -52
- package/layers/media/server/utils/ondemand.ts +0 -69
- package/layers/media/server/utils/persist-upload.ts +0 -62
- package/layers/media/server/utils/populate.ts +0 -47
- package/layers/media/server/utils/record.ts +0 -71
- package/layers/media/server/utils/relocate-ops.ts +0 -341
- package/layers/media/server/utils/relocate-plan.ts +0 -63
- package/layers/media/server/utils/resolve.ts +0 -98
- package/layers/media/server/utils/sanitize-svg.ts +0 -12
- package/layers/media/server/utils/sniff.ts +0 -68
- package/layers/media/server/utils/storage-relocate.ts +0 -139
- package/layers/media/server/utils/translations.ts +0 -19
- package/layers/media/server/utils/usages.ts +0 -66
- package/layers/media/server/utils/variant-capture.ts +0 -70
- package/layers/media/server/utils/variants.ts +0 -123
- package/layers/public/modules/deploy-output/deploy-output.ts +0 -384
- package/layers/public/server/api/links/resolve.get.ts +0 -19
- package/layers/public/server/api/preview.get.ts +0 -28
- package/layers/public/server/api/preview.post.ts +0 -93
- package/layers/public/server/api/publish-status.get.ts +0 -58
- package/layers/public/server/api/publish.post.ts +0 -84
- package/layers/public/server/api/route.get.ts +0 -54
- package/layers/public/server/collections/redirects.ts +0 -75
- package/layers/public/server/collections/site.ts +0 -44
- package/layers/public/server/database/publish-deps.ts +0 -12
- package/layers/public/server/database/publish-status.ts +0 -19
- package/layers/public/server/schema-tables/publish-deps.ts +0 -4
- package/layers/public/server/schema-tables/publish-status.ts +0 -4
- package/layers/public/server/utils/link-resolve.ts +0 -38
- package/layers/public/server/utils/llms-full.ts +0 -125
- package/layers/public/server/utils/llms.ts +0 -52
- package/layers/public/server/utils/page-resolve.ts +0 -208
- package/layers/public/server/utils/populate-links.ts +0 -66
- package/layers/public/server/utils/preview-token.ts +0 -109
- package/layers/public/server/utils/publish/coalesce.ts +0 -22
- package/layers/public/server/utils/publish/deps-persistence.ts +0 -43
- package/layers/public/server/utils/publish/deps.ts +0 -87
- package/layers/public/server/utils/publish/invalidation.ts +0 -225
- package/layers/public/server/utils/publish/pending.ts +0 -74
- package/layers/public/server/utils/publish/publish-runtime.ts +0 -28
- package/layers/public/server/utils/publish/publish-status.ts +0 -72
- package/layers/public/server/utils/publish/publisher.ts +0 -362
- package/layers/public/server/utils/publish/queue.ts +0 -67
- package/layers/public/server/utils/publish/redirect-rules.ts +0 -221
- package/layers/public/server/utils/publish/redirects-artifact.ts +0 -20
- package/layers/public/server/utils/publish/route-for-record.ts +0 -16
- package/layers/public/server/utils/publish/route-keys.ts +0 -13
- package/layers/public/server/utils/richtext-markdown.ts +0 -260
- package/layers/public/server/utils/site-url.ts +0 -40
- package/layers/public/server/utils/sitemap.ts +0 -99
|
@@ -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
|
-
}
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import type { SerializedField } from '../../server/utils/serialize-collection' // type-only, erased client-side
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The single shared source of truth for list filtering: which normalized "kind" a field/meta column maps
|
|
5
|
-
* to, and which operators that kind allows. Both the server (validation + predicate building) and the admin
|
|
6
|
-
* UI (operator select + typed value control) import this — so the allowed-operators-per-field-type table is
|
|
7
|
-
* defined exactly once. Kept in `core/app/utils` (the server-and-client shared home) and free of any DB or
|
|
8
|
-
* column-naming detail: each side maps its own column keys.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
export type FilterOp = 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte' | 'contains' | 'notContains'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* A field's storage/semantics bucket for filtering. `text`/`richtext` do substring LIKE; `stringSet`/`idSet`
|
|
15
|
-
* are JSON-array membership (multi choice / many relation+media); `ref` is a single FK id (eq/ne only);
|
|
16
|
-
* `enum`/`boolean` are equality-only; `number`/`datetime` support the full comparison set.
|
|
17
|
-
*/
|
|
18
|
-
export type FilterKind = 'number' | 'text' | 'datetime' | 'boolean' | 'enum' | 'ref' | 'richtext' | 'stringSet' | 'idSet'
|
|
19
|
-
|
|
20
|
-
const CMP = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte'] as const
|
|
21
|
-
|
|
22
|
-
export const OPS_BY_KIND: Record<FilterKind, readonly FilterOp[]> = {
|
|
23
|
-
number: CMP,
|
|
24
|
-
datetime: CMP,
|
|
25
|
-
text: ['eq', 'ne', 'contains'],
|
|
26
|
-
richtext: ['contains'], // `contains` matches the stored HTML SOURCE (searching 'p' can hit a '<p>' tag) —
|
|
27
|
-
// a stripped shadow column / FTS index is a later enhancement (documented caveat).
|
|
28
|
-
boolean: ['eq', 'ne'],
|
|
29
|
-
enum: ['eq', 'ne'],
|
|
30
|
-
ref: ['eq', 'ne'], // a single relation/media FK id — lt/gt on an id is meaningless.
|
|
31
|
-
stringSet: ['contains', 'notContains'],
|
|
32
|
-
idSet: ['contains', 'notContains'],
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** The operator a column's filter row defaults to in the admin UI. `datetime` defaults to `gte` ("on or
|
|
36
|
-
* after") — `eq` on a millisecond timestamp practically never matches, so it made the filter useless. The
|
|
37
|
-
* admin always emits the operator explicitly, so this does NOT change the wire parser's separate
|
|
38
|
-
* absent-operator default (a bare `filter[field]` still parses as `eq`, URLs round-trip unchanged). */
|
|
39
|
-
export const DEFAULT_OP: Record<FilterKind, FilterOp> = {
|
|
40
|
-
number: 'eq', datetime: 'gte', text: 'eq', boolean: 'eq', enum: 'eq', ref: 'eq',
|
|
41
|
-
richtext: 'contains', stringSet: 'contains', idSet: 'contains',
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ALL_OPS = new Set<string>(['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'contains', 'notContains'])
|
|
45
|
-
|
|
46
|
-
/** Whether a raw wire token is a known operator at all (parse-time guard → a clean 400 for garbage). */
|
|
47
|
-
export const isFilterOp = (s: string): s is FilterOp => ALL_OPS.has(s)
|
|
48
|
-
|
|
49
|
-
/** Whether `op` is allowed for a column of this kind (list-time guard → a clean 400, never a 500). */
|
|
50
|
-
export const opAllowed = (kind: FilterKind, op: FilterOp): boolean => OPS_BY_KIND[kind].includes(op)
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* The wire key for a filter clause: `filter[field]` or `filter[field][op]`. `[^\]]+` keeps the field/op
|
|
54
|
-
* tokens bracket-free; the optional second group is the operator (absent → `eq`, so a bare
|
|
55
|
-
* `filter[status]=x` stays backward compatible). Defined ONCE here (the shared client+server home) so the
|
|
56
|
-
* admin URL parser (`parseListQuery`) and the server wire parser (`parseFilter`) use exactly one regex.
|
|
57
|
-
*/
|
|
58
|
-
export const FILTER_RE = /^filter\[([^\]]+)\](?:\[([^\]]+)\])?$/
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* The FilterKind for a field, or `null` when it is not filterable (link / json / repeater / range-datetime).
|
|
62
|
-
* Unknown consumer types fall back to the conservative `text` (equality + substring on their scalar column).
|
|
63
|
-
*/
|
|
64
|
-
export function fieldFilterKind(f: SerializedField): FilterKind | null {
|
|
65
|
-
switch (f.type) {
|
|
66
|
-
case 'text':
|
|
67
|
-
case 'slug':
|
|
68
|
-
return 'text'
|
|
69
|
-
case 'richtext':
|
|
70
|
-
return 'richtext'
|
|
71
|
-
case 'number':
|
|
72
|
-
return 'number'
|
|
73
|
-
case 'boolean':
|
|
74
|
-
return 'boolean'
|
|
75
|
-
case 'datetime':
|
|
76
|
-
return f.options?.range ? null : 'datetime'
|
|
77
|
-
case 'choice':
|
|
78
|
-
return f.options?.multiple ? 'stringSet' : 'enum'
|
|
79
|
-
case 'relation':
|
|
80
|
-
case 'media':
|
|
81
|
-
return f.single ? 'ref' : 'idSet'
|
|
82
|
-
case 'link':
|
|
83
|
-
case 'json':
|
|
84
|
-
case 'repeater':
|
|
85
|
-
return null
|
|
86
|
-
default:
|
|
87
|
-
return 'text'
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The FilterKind of each filterable meta/system column (keyed by its wire key). */
|
|
92
|
-
export const META_FILTER_KIND: Record<string, FilterKind> = {
|
|
93
|
-
id: 'number',
|
|
94
|
-
path: 'text',
|
|
95
|
-
status: 'enum',
|
|
96
|
-
createdAt: 'datetime',
|
|
97
|
-
updatedAt: 'datetime',
|
|
98
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
// The single source of truth for collection-list page sizes and, derived from them, the bulk-operation id
|
|
2
|
-
// cap. Lives in `core/app/utils` (the established server+client shared home — same as `filter-ops.ts`) so
|
|
3
|
-
// the admin list UI, the `list()` query engine, and the `bulk` endpoint all agree on ONE set of limits
|
|
4
|
-
// instead of each hardcoding its own.
|
|
5
|
-
|
|
6
|
-
/** The page sizes offered in the list's per-page selector. */
|
|
7
|
-
export const PER_PAGE_OPTIONS = [25, 50, 100, 250, 500] as const
|
|
8
|
-
|
|
9
|
-
/** The page size a list falls back to when none (or a garbage value) is supplied. */
|
|
10
|
-
export const DEFAULT_PER_PAGE = 25
|
|
11
|
-
|
|
12
|
-
/** The largest page a single list request may return — also the ceiling `clampPerPage` clamps to. */
|
|
13
|
-
export const MAX_PER_PAGE = 500
|
|
14
|
-
|
|
15
|
-
/** Coerce an untrusted page size to a sane integer: a non-finite input (undefined / NaN / Infinity / a
|
|
16
|
-
* non-numeric string) falls back to `DEFAULT_PER_PAGE`; anything else is floored and clamped to
|
|
17
|
-
* `[1, MAX_PER_PAGE]`. Guards against `LIMIT NaN` (which SQLite binds as "no limit") — an unauthenticated
|
|
18
|
-
* cap-bypass / DoS lever. */
|
|
19
|
-
export function clampPerPage(n: unknown): number {
|
|
20
|
-
const v = Number(n)
|
|
21
|
-
if (!Number.isFinite(v)) return DEFAULT_PER_PAGE
|
|
22
|
-
return Math.min(MAX_PER_PAGE, Math.max(1, Math.floor(v)))
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** A bulk op can never carry more ids than one page can show (you select what you can see), so the id cap
|
|
26
|
-
* is DERIVED from the max page size, not guessed. */
|
|
27
|
-
export const MAX_BULK_IDS = MAX_PER_PAGE
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The public route path for a page row: other locales are prefixed with `/<locale>`; the primary locale
|
|
3
|
-
* is unprefixed unless `prefixPrimary` is set (then it is prefixed too, root `/` → `/<primary>`). Single
|
|
4
|
-
* source of truth for the URL scheme shared by the prerender routes, the sitemap, `populate-links`, the
|
|
5
|
-
* `[...slug].vue` resolution, the slug-uniqueness check, and the admin SEO preview. Pure, so it is
|
|
6
|
-
* unit-testable and safe to import from both server and app code.
|
|
7
|
-
*/
|
|
8
|
-
export function localePath(path: string, locale: string, primaryLocale: string, prefixPrimary = false): string {
|
|
9
|
-
const p = path.startsWith('/') ? path : `/${path}`
|
|
10
|
-
if (locale === primaryLocale && !prefixPrimary) return p
|
|
11
|
-
return p === '/' ? `/${locale}` : `/${locale}${p}`
|
|
12
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Turn arbitrary text (a record title) into a url-safe slug: lowercase, fold diacritics to ASCII
|
|
3
|
-
* (`Über` → `uber`, `Café` → `cafe`) via NFKD + combining-mark removal, then collapse every run of
|
|
4
|
-
* non-`[a-z0-9]` into a single hyphen and trim the ends. Letters with no ASCII fold (ß, CJK, …) act as
|
|
5
|
-
* separators — Kestrel does NO language-specific transliteration, so the result is predictable. Returns
|
|
6
|
-
* `''` for blank / symbol-only input (the caller decides: fall back to the manual slug, or reject). Pure.
|
|
7
|
-
*/
|
|
8
|
-
export function slugify(input: string): string {
|
|
9
|
-
if (typeof input !== 'string') return ''
|
|
10
|
-
return input
|
|
11
|
-
.normalize('NFKD')
|
|
12
|
-
.replace(/\p{M}/gu, '') // strip combining diacritical marks left by NFKD (é → e, ü → u)
|
|
13
|
-
.toLowerCase()
|
|
14
|
-
.replace(/[^a-z0-9]+/g, '-')
|
|
15
|
-
.replace(/^-+|-+$/g, '')
|
|
16
|
-
}
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// Per-record stale-reference map for the editor: every reference this record holds that now points at a
|
|
2
|
-
// deleted or unpublished target, with its field/block location + reason. Derived on read (auto-clears when
|
|
3
|
-
// the link is fixed or the target restored). Fetched non-blocking alongside the editor load, like the
|
|
4
|
-
// translations map.
|
|
5
|
-
export default defineEventHandler((event) => {
|
|
6
|
-
const collection = requireCollection(event)
|
|
7
|
-
return recordDeadRefs(useDb(), collection, requireId(event))
|
|
8
|
-
})
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler((event) => {
|
|
2
|
-
const collection = requireCollection(event)
|
|
3
|
-
const query = getQuery(event)
|
|
4
|
-
const depth = Number(query.depth ?? 0)
|
|
5
|
-
const locale = query.locale as string | undefined
|
|
6
|
-
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
7
|
-
// See index.get.ts: the public-set restriction follows the ROLE, so the renderer keeps full population;
|
|
8
|
-
// a missing principal fails closed onto it.
|
|
9
|
-
const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
|
|
10
|
-
return getOne(useDb(), collection, requireId(event), depth, locale, publishedOnly, publicOnly)
|
|
11
|
-
})
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const id = requireId(event)
|
|
5
|
-
const body = await readBody(event)
|
|
6
|
-
// Optimistic concurrency: the editor sends the baseline it loaded; a stale save is refused with 409.
|
|
7
|
-
return update(useDb(), collection, id, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
8
|
-
})
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import { MAX_BULK_IDS } from '../../../app/utils/list-limits'
|
|
2
|
-
import { BULK_ACTIONS, isBulkAction, type BulkAction } from '../../../app/utils/bulk-actions'
|
|
3
|
-
|
|
4
|
-
// The ONE command endpoint for collection batch operations — the single wire surface behind both the
|
|
5
|
-
// per-row Quick Actions and the selection Bulk bar (a row action IS a bulk action with one id). POST is
|
|
6
|
-
// classified 'write' on the `{collection}` resource by the access guard (resourceForPath('/api/{c}/bulk')
|
|
7
|
-
// === '{c}'), so CSRF + per-collection policy apply exactly as for a single-record write — no bypass.
|
|
8
|
-
// A literal `bulk.post.ts` segment beats the dynamic `[id]` route (no `[id].post.ts` exists), so no clash.
|
|
9
|
-
//
|
|
10
|
-
// body: { action: 'delete' | 'publish' | 'unpublish' | 'duplicate', ids: number[] }
|
|
11
|
-
// 200: { action, count, ids } (for 'duplicate', `ids` are the CREATED ids)
|
|
12
|
-
//
|
|
13
|
-
// delete + publish/unpublish are all-or-nothing (an unknown id 404s before any write); duplicate is a
|
|
14
|
-
// sequential row action. All batching lives in crud.ts — this handler is a thin parse-and-dispatch.
|
|
15
|
-
|
|
16
|
-
function parseBulkBody(body: unknown): { action: BulkAction; ids: number[] } {
|
|
17
|
-
const action = (body as { action?: unknown })?.action
|
|
18
|
-
if (!isBulkAction(action)) {
|
|
19
|
-
throw createError({ statusCode: 400, statusMessage: `Invalid bulk action: ${String(action)} (expected one of ${BULK_ACTIONS.join(', ')})` })
|
|
20
|
-
}
|
|
21
|
-
const ids = parseIdList((body as { ids?: unknown })?.ids, MAX_BULK_IDS)
|
|
22
|
-
return { action, ids }
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export default defineEventHandler(async (event) => {
|
|
26
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
27
|
-
const collection = requireCollection(event)
|
|
28
|
-
const { action, ids } = parseBulkBody(await readBody(event))
|
|
29
|
-
const db = useDb()
|
|
30
|
-
|
|
31
|
-
if (action === 'delete') return { action, ...removeMany(db, collection, ids) }
|
|
32
|
-
if (action === 'duplicate') {
|
|
33
|
-
const created = await duplicateMany(db, collection, ids)
|
|
34
|
-
return { action, count: created.length, ids: created.map((r) => r.id as number) }
|
|
35
|
-
}
|
|
36
|
-
return { action, ...setStatusMany(db, collection, ids, action === 'publish' ? 'published' : 'draft') }
|
|
37
|
-
})
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler((event) => {
|
|
2
|
-
const collection = requireCollection(event)
|
|
3
|
-
const query = getQuery(event)
|
|
4
|
-
const db = useDb()
|
|
5
|
-
const publishedOnly = publishedOnlyForScope(event.context.readScope)
|
|
6
|
-
// Keyed on the ROLE, not the read scope: the renderer reads published-only too, but it produces the
|
|
7
|
-
// static site and must still see every relation the output embeds. Only a visitor the guard scopes to
|
|
8
|
-
// the public collection set is barred from reaching further through a populated relation. Fail-CLOSED
|
|
9
|
-
// on a missing principal, like `publishedOnlyForScope` above — an absent one is a guard regression.
|
|
10
|
-
const publicOnly = (event.context.principal?.role ?? 'anonymous') === 'anonymous'
|
|
11
|
-
|
|
12
|
-
if (collection.def.mode === 'single') {
|
|
13
|
-
return getSingleton(db, collection, query.locale as string | undefined, publishedOnly, query.depth ? Number(query.depth) : 0, publicOnly)
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return list(db, collection, {
|
|
17
|
-
locale: query.locale as string | undefined,
|
|
18
|
-
sort: query.sort as string | undefined,
|
|
19
|
-
page: query.page ? Number(query.page) : undefined,
|
|
20
|
-
perPage: query.perPage ? Number(query.perPage) : undefined,
|
|
21
|
-
filter: parseFilter(query as Record<string, unknown>),
|
|
22
|
-
depth: query.depth ? Number(query.depth) : 0,
|
|
23
|
-
}, publishedOnly, publicOnly)
|
|
24
|
-
})
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const body = await readBody(event)
|
|
5
|
-
const row = create(useDb(), collection, body)
|
|
6
|
-
setResponseStatus(event, 201)
|
|
7
|
-
return row
|
|
8
|
-
})
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
-
import { createError } from 'h3'
|
|
3
|
-
import Database from 'better-sqlite3'
|
|
4
|
-
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
5
|
-
import { buildCollection } from '../../../../fields/server/utils/buildCollection'
|
|
6
|
-
import { defineCollection } from '../../utils/defineCollection'
|
|
7
|
-
import { putSingleton } from '../../utils/crud'
|
|
8
|
-
import { requireCollection } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { clearWriteListeners } from '../../utils/write-events'
|
|
11
|
-
import { registerWriteEffect, clearWriteEffects, runWriteEffects } from '../../utils/write-effects'
|
|
12
|
-
import { desiredSchema } from '../../schema/desired'
|
|
13
|
-
import { diffSchema } from '../../schema/diff'
|
|
14
|
-
import { renderSqlite } from '../../schema/render-sqlite'
|
|
15
|
-
|
|
16
|
-
const settings = buildCollection(defineCollection({
|
|
17
|
-
name: 'settings', mode: 'single',
|
|
18
|
-
fields: { title: { type: 'text', required: true } },
|
|
19
|
-
}))
|
|
20
|
-
|
|
21
|
-
interface FakeEvent { query: Record<string, unknown>; body: unknown; context: { params: Record<string, string> } }
|
|
22
|
-
|
|
23
|
-
let db: ReturnType<typeof drizzle>
|
|
24
|
-
|
|
25
|
-
// Bind the auto-imported helpers to the REAL implementations, so this proves the actual wiring rather
|
|
26
|
-
// than a stub (same rationale as options.get.test.ts).
|
|
27
|
-
Object.assign(globalThis, {
|
|
28
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
29
|
-
createError,
|
|
30
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
31
|
-
readBody: (event: FakeEvent) => Promise.resolve(event.body),
|
|
32
|
-
useDb: () => db,
|
|
33
|
-
requireAdmin: () => {},
|
|
34
|
-
requireCollection,
|
|
35
|
-
readIfUnmodifiedSince: () => undefined,
|
|
36
|
-
putSingleton,
|
|
37
|
-
runWriteEffects,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
const handler = (await import('./index.put')).default as unknown as (event: FakeEvent) => Promise<Record<string, unknown>>
|
|
41
|
-
const put = (collection: string, body: unknown) => handler({ query: {}, body, context: { params: { collection } } })
|
|
42
|
-
|
|
43
|
-
beforeEach(() => {
|
|
44
|
-
const sqlite = new Database(':memory:')
|
|
45
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([settings.table]), {}))) sqlite.exec(stmt)
|
|
46
|
-
db = drizzle(sqlite)
|
|
47
|
-
clearRegistry()
|
|
48
|
-
registerCollection(settings)
|
|
49
|
-
clearWriteListeners()
|
|
50
|
-
clearWriteEffects()
|
|
51
|
-
})
|
|
52
|
-
|
|
53
|
-
describe('PUT /api/[collection] — singleton save', () => {
|
|
54
|
-
it('writes the row and returns it', async () => {
|
|
55
|
-
await expect(put('settings', { title: 'Hello' })).resolves.toMatchObject({ title: 'Hello' })
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
it('runs post-write effects with the saved row', async () => {
|
|
59
|
-
const seen: unknown[] = []
|
|
60
|
-
registerWriteEffect((e) => { seen.push([e.def.name, e.row.title]) })
|
|
61
|
-
await put('settings', { title: 'Hello' })
|
|
62
|
-
expect(seen).toEqual([['settings', 'Hello']])
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('fails the save when an effect rejects — a stale side effect must not report success', async () => {
|
|
66
|
-
registerWriteEffect(() => { throw createError({ statusCode: 500, statusMessage: 'artifact is stale' }) })
|
|
67
|
-
await expect(put('settings', { title: 'Hello' })).rejects.toMatchObject({ statusCode: 500, statusMessage: 'artifact is stale' })
|
|
68
|
-
})
|
|
69
|
-
|
|
70
|
-
it('does not run effects when validation rejects the body', async () => {
|
|
71
|
-
let ran = false
|
|
72
|
-
registerWriteEffect(() => { ran = true })
|
|
73
|
-
await expect(put('settings', { title: '' })).rejects.toMatchObject({ statusCode: 400 })
|
|
74
|
-
expect(ran).toBe(false)
|
|
75
|
-
})
|
|
76
|
-
})
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
export default defineEventHandler(async (event) => {
|
|
2
|
-
requireAdmin(event) // write-authorization backstop (defense-in-depth; see require-admin.ts)
|
|
3
|
-
const collection = requireCollection(event)
|
|
4
|
-
const query = getQuery(event)
|
|
5
|
-
const body = await readBody(event)
|
|
6
|
-
const saved = putSingleton(useDb(), collection, query.locale as string | undefined, body, { expectedUpdatedAt: readIfUnmodifiedSince(event) })
|
|
7
|
-
// Awaited, and a rejection is the save's response: a singleton whose write has an external side effect
|
|
8
|
-
// (the redirects artifact the edge serves) is not really saved until that side effect landed, and the
|
|
9
|
-
// write-event bus swallows throws by design. See write-effects.ts.
|
|
10
|
-
try {
|
|
11
|
-
await runWriteEffects(collection.def, saved)
|
|
12
|
-
} catch (error) {
|
|
13
|
-
// The row IS committed and its `updatedAt` bumped, but the client only rebaselines on success — so
|
|
14
|
-
// hand the new baseline back with the error, or the retry these failures ask for would 409 on a
|
|
15
|
-
// precondition that is stale by construction.
|
|
16
|
-
const err = error as { statusCode?: number; statusMessage?: string; data?: Record<string, unknown> }
|
|
17
|
-
throw createError({
|
|
18
|
-
statusCode: err.statusCode ?? 500,
|
|
19
|
-
statusMessage: err.statusMessage ?? 'The record was saved but a follow-up step failed',
|
|
20
|
-
data: { ...(err.data ?? {}), savedUpdatedAt: new Date(saved.updatedAt as string | number).getTime() },
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
return saved
|
|
24
|
-
})
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
-
import { createError } from 'h3'
|
|
3
|
-
import Database from 'better-sqlite3'
|
|
4
|
-
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
5
|
-
import { buildCollection } from '../../../../fields/server/utils/buildCollection'
|
|
6
|
-
import { defineCollection } from '../../utils/defineCollection'
|
|
7
|
-
import { create } from '../../utils/crud'
|
|
8
|
-
import { requireCollection, parseIdList } from '../../utils/http'
|
|
9
|
-
import { clearRegistry, registerCollection } from '../../utils/registry'
|
|
10
|
-
import { pickerOptions } from '../../utils/picker'
|
|
11
|
-
import { desiredSchema } from '../../schema/desired'
|
|
12
|
-
import { diffSchema } from '../../schema/diff'
|
|
13
|
-
import { renderSqlite } from '../../schema/render-sqlite'
|
|
14
|
-
|
|
15
|
-
const posts = buildCollection(defineCollection({
|
|
16
|
-
name: 'posts', mode: 'multi', translatable: false,
|
|
17
|
-
fields: { title: { type: 'text', required: true } },
|
|
18
|
-
}))
|
|
19
|
-
|
|
20
|
-
interface FakeEvent { query: Record<string, unknown>; context: { params: Record<string, string>; readScope?: string } }
|
|
21
|
-
|
|
22
|
-
let db: ReturnType<typeof drizzle>
|
|
23
|
-
|
|
24
|
-
// Same rationale as translations.get.test.ts: bind the handler's auto-imported helpers to the REAL
|
|
25
|
-
// implementations so this proves the actual wiring, not a stub the server does not have.
|
|
26
|
-
Object.assign(globalThis, {
|
|
27
|
-
defineEventHandler: (handler: unknown) => handler,
|
|
28
|
-
createError,
|
|
29
|
-
getQuery: (event: FakeEvent) => event.query,
|
|
30
|
-
useDb: () => db,
|
|
31
|
-
requireCollection,
|
|
32
|
-
parseIdList,
|
|
33
|
-
pickerOptions,
|
|
34
|
-
publishedOnlyForScope: () => false,
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
const handler = (await import('./options.get')).default as unknown as (event: FakeEvent) => ReturnType<typeof pickerOptions>
|
|
38
|
-
const get = (collection: string, query: Record<string, unknown>) => handler({ query, context: { params: { collection } } })
|
|
39
|
-
|
|
40
|
-
beforeEach(() => {
|
|
41
|
-
const sqlite = new Database(':memory:')
|
|
42
|
-
for (const stmt of renderSqlite(diffSchema(desiredSchema([posts.table]), {}))) sqlite.exec(stmt)
|
|
43
|
-
db = drizzle(sqlite)
|
|
44
|
-
clearRegistry()
|
|
45
|
-
registerCollection(posts)
|
|
46
|
-
})
|
|
47
|
-
afterEach(() => clearRegistry())
|
|
48
|
-
|
|
49
|
-
describe('GET /api/{collection}/options?ids=', () => {
|
|
50
|
-
it('resolves more than 100 ids in one request instead of truncating', () => {
|
|
51
|
-
const ids = Array.from({ length: 150 }, (_, i) => (create(db, posts, { title: `T${i}` }) as { id: number }).id)
|
|
52
|
-
const r = get('posts', { ids: ids.join(',') })
|
|
53
|
-
expect(r.data.length).toBe(150)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('400s (never silently truncates) an ids list over the shared bulk cap', () => {
|
|
57
|
-
const ids = Array.from({ length: 501 }, (_, i) => i + 1)
|
|
58
|
-
expect(() => get('posts', { ids: ids.join(',') })).toThrowError(expect.objectContaining({ statusCode: 400 }))
|
|
59
|
-
})
|
|
60
|
-
})
|