emdash 0.19.0 → 0.20.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/dist/{adapters-C5AWLJSD.d.mts → adapters-BzIHV3sw.d.mts} +1 -1
- package/dist/{adapters-C5AWLJSD.d.mts.map → adapters-BzIHV3sw.d.mts.map} +1 -1
- package/dist/{allowed-origins-CyYLEJkp.mjs → allowed-origins-B1u7Qnvg.mjs} +2 -2
- package/dist/{allowed-origins-CyYLEJkp.mjs.map → allowed-origins-B1u7Qnvg.mjs.map} +1 -1
- package/dist/api/route-utils.d.mts +3 -3
- package/dist/api/route-utils.mjs +5 -5
- package/dist/api/schemas/index.d.mts +1 -1
- package/dist/api/schemas/index.mjs +2 -2
- package/dist/{api-BZ6bhjYs.mjs → api-DStv36ik.mjs} +36 -5
- package/dist/api-DStv36ik.mjs.map +1 -0
- package/dist/{api-tokens-VrXNiNvV.mjs → api-tokens-DPfhPu5V.mjs} +2 -2
- package/dist/{api-tokens-VrXNiNvV.mjs.map → api-tokens-DPfhPu5V.mjs.map} +1 -1
- package/dist/{apply-hQkKKBCf.mjs → apply-Dr7snAMT.mjs} +7 -7
- package/dist/{apply-hQkKKBCf.mjs.map → apply-Dr7snAMT.mjs.map} +1 -1
- package/dist/astro/index.d.mts +10 -10
- package/dist/astro/index.mjs +3 -3
- package/dist/astro/middleware/auth.d.mts +9 -9
- package/dist/astro/middleware/auth.mjs +4 -4
- package/dist/astro/middleware/redirect.mjs +1 -1
- package/dist/astro/middleware/request-context.mjs +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +63 -112
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/routes/api/admin/allowed-domains/_domain_.mjs +2 -2
- package/dist/astro/routes/api/admin/allowed-domains/index.mjs +2 -2
- package/dist/astro/routes/api/admin/api-tokens/_id_.mjs +2 -2
- package/dist/astro/routes/api/admin/api-tokens/index.mjs +2 -2
- package/dist/astro/routes/api/admin/byline-fields/_slug_/usage.mjs +2 -2
- package/dist/astro/routes/api/admin/byline-fields/_slug_.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/index.mjs +4 -4
- package/dist/astro/routes/api/admin/byline-fields/reorder.mjs +4 -4
- package/dist/astro/routes/api/admin/bylines/_id_/index.mjs +6 -6
- package/dist/astro/routes/api/admin/bylines/_id_/translations.mjs +6 -6
- package/dist/astro/routes/api/admin/bylines/index.mjs +6 -6
- package/dist/astro/routes/api/admin/comments/_id_/status.mjs +6 -6
- package/dist/astro/routes/api/admin/comments/_id_.mjs +2 -2
- package/dist/astro/routes/api/admin/comments/bulk.mjs +4 -4
- package/dist/astro/routes/api/admin/comments/counts.mjs +2 -2
- package/dist/astro/routes/api/admin/comments/index.mjs +4 -4
- package/dist/astro/routes/api/admin/hooks/exclusive/_hookName_.mjs +1 -1
- package/dist/astro/routes/api/admin/hooks/exclusive/index.mjs +1 -1
- package/dist/astro/routes/api/admin/oauth-clients/_id_.mjs +1 -1
- package/dist/astro/routes/api/admin/oauth-clients/index.mjs +1 -1
- package/dist/astro/routes/api/admin/plugins/_id_/disable.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/_id_/enable.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/_id_/index.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/_id_/uninstall.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/_id_/update.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/index.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/icon.mjs +1 -1
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/index.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/marketplace/_id_/install.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/marketplace/index.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/registry/_id_/uninstall.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/registry/_id_/update.mjs +15 -15
- package/dist/astro/routes/api/admin/plugins/registry/artifact.mjs +14 -14
- package/dist/astro/routes/api/admin/plugins/registry/install.mjs +15 -15
- package/dist/astro/routes/api/admin/plugins/updates.mjs +14 -14
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/index.mjs +14 -14
- package/dist/astro/routes/api/admin/themes/marketplace/_id_/thumbnail.mjs +1 -1
- package/dist/astro/routes/api/admin/themes/marketplace/index.mjs +14 -14
- package/dist/astro/routes/api/admin/users/_id_/index.mjs +2 -2
- package/dist/astro/routes/api/admin/users/_id_/send-recovery.mjs +1 -1
- package/dist/astro/routes/api/admin/users/index.mjs +2 -2
- package/dist/astro/routes/api/auth/dev-bypass.mjs +2 -2
- package/dist/astro/routes/api/auth/invite/complete.mjs +6 -6
- package/dist/astro/routes/api/auth/invite/index.mjs +3 -3
- package/dist/astro/routes/api/auth/invite/register-options.mjs +5 -5
- package/dist/astro/routes/api/auth/logout.mjs +1 -1
- package/dist/astro/routes/api/auth/magic-link/send.mjs +4 -4
- package/dist/astro/routes/api/auth/magic-link/verify.mjs +1 -1
- package/dist/astro/routes/api/auth/me.mjs +2 -2
- package/dist/astro/routes/api/auth/mode.mjs +1 -1
- package/dist/astro/routes/api/auth/oauth/_provider_/callback.mjs +3 -3
- package/dist/astro/routes/api/auth/oauth/_provider_.mjs +2 -2
- package/dist/astro/routes/api/auth/passkey/_id_.mjs +2 -2
- package/dist/astro/routes/api/auth/passkey/options.mjs +6 -6
- package/dist/astro/routes/api/auth/passkey/register/options.mjs +5 -5
- package/dist/astro/routes/api/auth/passkey/register/verify.mjs +6 -6
- package/dist/astro/routes/api/auth/passkey/verify.mjs +6 -6
- package/dist/astro/routes/api/auth/signup/complete.mjs +6 -6
- package/dist/astro/routes/api/auth/signup/request.mjs +4 -4
- package/dist/astro/routes/api/comments/_collection_/_contentId_/index.mjs +6 -6
- package/dist/astro/routes/api/content/_collection_/_id_/compare.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/discard-draft.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/duplicate.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/permanent.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/preview-url.mjs +4 -4
- package/dist/astro/routes/api/content/_collection_/_id_/publish.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/restore.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/revisions.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/schedule.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/_id_/terms/_taxonomy_.mjs +5 -5
- package/dist/astro/routes/api/content/_collection_/_id_/translations.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_/unpublish.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/_id_.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/authors.mjs +1 -1
- package/dist/astro/routes/api/content/_collection_/index.mjs +3 -3
- package/dist/astro/routes/api/content/_collection_/trash.mjs +3 -3
- package/dist/astro/routes/api/dashboard.mjs +1 -1
- package/dist/astro/routes/api/import/probe.d.mts +3 -3
- package/dist/astro/routes/api/import/probe.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/analyze.mjs +1 -1
- package/dist/astro/routes/api/import/wordpress/execute.d.mts +9 -9
- package/dist/astro/routes/api/import/wordpress/execute.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/media.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/prepare.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress/rewrite-urls.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.d.mts +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/analyze.mjs +3 -3
- package/dist/astro/routes/api/import/wordpress-plugin/execute.d.mts +1 -1
- package/dist/astro/routes/api/import/wordpress-plugin/execute.mjs +3 -3
- package/dist/astro/routes/api/manifest.mjs +2 -2
- package/dist/astro/routes/api/mcp.mjs +18 -18
- package/dist/astro/routes/api/media/_id_/confirm.mjs +3 -3
- package/dist/astro/routes/api/media/_id_.mjs +3 -3
- package/dist/astro/routes/api/media/providers/_providerId_/_itemId_.mjs +1 -1
- package/dist/astro/routes/api/media/providers/_providerId_/index.mjs +1 -1
- package/dist/astro/routes/api/media/providers/index.mjs +1 -1
- package/dist/astro/routes/api/media/upload-url.mjs +4 -4
- package/dist/astro/routes/api/media.mjs +4 -4
- package/dist/astro/routes/api/menus/_name_/items/_id_.mjs +3 -3
- package/dist/astro/routes/api/menus/_name_/items.mjs +3 -3
- package/dist/astro/routes/api/menus/_name_/reorder.mjs +3 -3
- package/dist/astro/routes/api/menus/_name_/translations.mjs +3 -3
- package/dist/astro/routes/api/menus/_name_.mjs +3 -3
- package/dist/astro/routes/api/menus/index.mjs +3 -3
- package/dist/astro/routes/api/oauth/authorize.mjs +6 -6
- package/dist/astro/routes/api/oauth/device/authorize.mjs +3 -3
- package/dist/astro/routes/api/oauth/device/code.mjs +5 -5
- package/dist/astro/routes/api/oauth/device/token.mjs +4 -4
- package/dist/astro/routes/api/oauth/register.mjs +1 -1
- package/dist/astro/routes/api/oauth/token/refresh.mjs +3 -3
- package/dist/astro/routes/api/oauth/token/revoke.mjs +3 -3
- package/dist/astro/routes/api/oauth/token.mjs +4 -4
- package/dist/astro/routes/api/openapi.json.mjs +1 -1
- package/dist/astro/routes/api/plugins/_pluginId_/_...path_.mjs +2 -2
- package/dist/astro/routes/api/redirects/404s/index.mjs +4 -4
- package/dist/astro/routes/api/redirects/404s/summary.mjs +4 -4
- package/dist/astro/routes/api/redirects/_id_.mjs +4 -4
- package/dist/astro/routes/api/redirects/index.mjs +4 -4
- package/dist/astro/routes/api/revisions/_revisionId_/index.mjs +1 -1
- package/dist/astro/routes/api/revisions/_revisionId_/restore.mjs +1 -1
- package/dist/astro/routes/api/schema/collections/_slug_/fields/_fieldSlug_.mjs +14 -14
- package/dist/astro/routes/api/schema/collections/_slug_/fields/index.mjs +14 -14
- package/dist/astro/routes/api/schema/collections/_slug_/fields/reorder.mjs +14 -14
- package/dist/astro/routes/api/schema/collections/_slug_/index.mjs +14 -14
- package/dist/astro/routes/api/schema/collections/index.mjs +14 -14
- package/dist/astro/routes/api/schema/index.mjs +5 -10
- package/dist/astro/routes/api/schema/index.mjs.map +1 -1
- package/dist/astro/routes/api/schema/orphans/_slug_.mjs +14 -14
- package/dist/astro/routes/api/schema/orphans/index.mjs +14 -14
- package/dist/astro/routes/api/search/enable.mjs +5 -5
- package/dist/astro/routes/api/search/index.mjs +4 -4
- package/dist/astro/routes/api/search/rebuild.mjs +5 -5
- package/dist/astro/routes/api/search/stats.mjs +3 -3
- package/dist/astro/routes/api/search/suggest.mjs +4 -4
- package/dist/astro/routes/api/sections/_slug_.mjs +5 -5
- package/dist/astro/routes/api/sections/index.mjs +5 -5
- package/dist/astro/routes/api/settings/email.mjs +1 -1
- package/dist/astro/routes/api/settings.mjs +6 -6
- package/dist/astro/routes/api/setup/admin-verify.mjs +7 -7
- package/dist/astro/routes/api/setup/admin.mjs +6 -6
- package/dist/astro/routes/api/setup/dev-bypass.mjs +10 -10
- package/dist/astro/routes/api/setup/index.mjs +9 -9
- package/dist/astro/routes/api/setup/status.mjs +2 -2
- package/dist/astro/routes/api/snapshot.mjs +3 -3
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_/translations.mjs +6 -6
- package/dist/astro/routes/api/taxonomies/_name_/terms/_slug_.mjs +6 -6
- package/dist/astro/routes/api/taxonomies/_name_/terms/index.mjs +6 -6
- package/dist/astro/routes/api/taxonomies/index.mjs +6 -6
- package/dist/astro/routes/api/themes/preview.mjs +3 -3
- package/dist/astro/routes/api/well-known/auth.mjs +1 -1
- package/dist/astro/routes/api/well-known/oauth-authorization-server.mjs +2 -2
- package/dist/astro/routes/api/well-known/oauth-protected-resource.mjs +2 -2
- package/dist/astro/routes/api/widget-areas/_name_/reorder.mjs +3 -3
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs +6 -5
- package/dist/astro/routes/api/widget-areas/_name_/widgets/_id_.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs +6 -5
- package/dist/astro/routes/api/widget-areas/_name_/widgets.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/_name_.mjs +4 -3
- package/dist/astro/routes/api/widget-areas/_name_.mjs.map +1 -1
- package/dist/astro/routes/api/widget-areas/index.mjs +6 -5
- package/dist/astro/routes/api/widget-areas/index.mjs.map +1 -1
- package/dist/astro/routes/api/widget-components.mjs +1 -1
- package/dist/astro/routes/robots.txt.mjs +3 -3
- package/dist/astro/routes/sitemap-_collection_.xml.d.mts.map +1 -1
- package/dist/astro/routes/sitemap-_collection_.xml.mjs +12 -5
- package/dist/astro/routes/sitemap-_collection_.xml.mjs.map +1 -1
- package/dist/astro/routes/sitemap.xml.mjs +4 -4
- package/dist/astro/types.d.mts +12 -12
- package/dist/auth/providers/github.d.mts +1 -1
- package/dist/auth/providers/google.d.mts +1 -1
- package/dist/{authorize-C_8t2KGa.mjs → authorize-DsMSVSaY.mjs} +1 -1
- package/dist/{authorize-C_8t2KGa.mjs.map → authorize-DsMSVSaY.mjs.map} +1 -1
- package/dist/{byline-fields-C_OsR-KF.mjs → byline-fields--WxSNS79.mjs} +1 -1
- package/dist/{byline-fields-C_OsR-KF.mjs.map → byline-fields--WxSNS79.mjs.map} +1 -1
- package/dist/{byline-fields-51kg6Vuv.mjs → byline-fields-8TMtkBnH.mjs} +2 -2
- package/dist/{byline-fields-51kg6Vuv.mjs.map → byline-fields-8TMtkBnH.mjs.map} +1 -1
- package/dist/{byline-fields-DYXKDuNX.d.mts → byline-fields-DbibsvTl.d.mts} +5 -1
- package/dist/byline-fields-DbibsvTl.d.mts.map +1 -0
- package/dist/{bylines-Cx5n-WqP.mjs → bylines-BdxWCnPL.mjs} +1 -1
- package/dist/{bylines-Cx5n-WqP.mjs.map → bylines-BdxWCnPL.mjs.map} +1 -1
- package/dist/{bylines-wurS258E.mjs → bylines-s8c2DXbH.mjs} +3 -3
- package/dist/{bylines-wurS258E.mjs.map → bylines-s8c2DXbH.mjs.map} +1 -1
- package/dist/{challenge-store-DGwuCc4R.mjs → challenge-store-DXX3rfdI.mjs} +1 -1
- package/dist/{challenge-store-DGwuCc4R.mjs.map → challenge-store-DXX3rfdI.mjs.map} +1 -1
- package/dist/cli/index.mjs +11 -10
- package/dist/cli/index.mjs.map +1 -1
- package/dist/client/cf-access.d.mts +1 -1
- package/dist/client/index.d.mts +1 -1
- package/dist/client/index.mjs +1 -1
- package/dist/{comments-CJ0RZsYR.mjs → comments-Vkivawyl.mjs} +1 -1
- package/dist/{comments-CJ0RZsYR.mjs.map → comments-Vkivawyl.mjs.map} +1 -1
- package/dist/{components-CTfpu3PZ.mjs → components-CK0cuUoH.mjs} +1 -1
- package/dist/{components-CTfpu3PZ.mjs.map → components-CK0cuUoH.mjs.map} +1 -1
- package/dist/{context-GG52SPgh.mjs → context-Y7BRkWes.mjs} +2 -2
- package/dist/{context-GG52SPgh.mjs.map → context-Y7BRkWes.mjs.map} +1 -1
- package/dist/database/instrumentation.d.mts +10 -1
- package/dist/database/instrumentation.d.mts.map +1 -1
- package/dist/database/instrumentation.mjs +13 -1
- package/dist/database/instrumentation.mjs.map +1 -1
- package/dist/db/index.d.mts +3 -3
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/{default-xLFNSsZ9.mjs → default-IlBaTFxM.mjs} +1 -1
- package/dist/{default-xLFNSsZ9.mjs.map → default-IlBaTFxM.mjs.map} +1 -1
- package/dist/{device-flow-s6_q3T7A.mjs → device-flow-R23SIbQ2.mjs} +4 -4
- package/dist/{device-flow-s6_q3T7A.mjs.map → device-flow-R23SIbQ2.mjs.map} +1 -1
- package/dist/{escape-bIyGoW5W.mjs → escape-Ds07EEyu.mjs} +1 -1
- package/dist/{escape-bIyGoW5W.mjs.map → escape-Ds07EEyu.mjs.map} +1 -1
- package/dist/{index-FfiTQJq2.d.mts → index-B1keaX5Y.d.mts} +43 -12
- package/dist/{index-FfiTQJq2.d.mts.map → index-B1keaX5Y.d.mts.map} +1 -1
- package/dist/{index-BpYeJO1E.d.mts → index-DR56od45.d.mts} +3 -3
- package/dist/{index-BpYeJO1E.d.mts.map → index-DR56od45.d.mts.map} +1 -1
- package/dist/index.d.mts +16 -16
- package/dist/index.mjs +22 -22
- package/dist/{load-B84ohfBk.mjs → load-BBetCvLC.mjs} +1 -1
- package/dist/{load-B84ohfBk.mjs.map → load-BBetCvLC.mjs.map} +1 -1
- package/dist/{loader-CpZKpFz0.mjs → loader-ZN1ll-d-.mjs} +11 -14
- package/dist/loader-ZN1ll-d-.mjs.map +1 -0
- package/dist/{manifest-schema-Cj-YrzrF.mjs → manifest-schema-BtwbL_vj.mjs} +55 -2
- package/dist/manifest-schema-BtwbL_vj.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/local-runtime.d.mts +11 -11
- package/dist/media/local-runtime.mjs +2 -2
- package/dist/{media-allowlist-CMcoYIjQ.mjs → media-allowlist-Dknq-OFY.mjs} +1 -1
- package/dist/{media-allowlist-CMcoYIjQ.mjs.map → media-allowlist-Dknq-OFY.mjs.map} +1 -1
- package/dist/media-url-VClf8glU.mjs +26 -0
- package/dist/media-url-VClf8glU.mjs.map +1 -0
- package/dist/{menus-Dp9xporj.mjs → menus-DrQLusqj.mjs} +6 -33
- package/dist/menus-DrQLusqj.mjs.map +1 -0
- package/dist/{mode-BjlXswIw.mjs → mode-CO2vQHfq.mjs} +1 -1
- package/dist/{mode-BjlXswIw.mjs.map → mode-CO2vQHfq.mjs.map} +1 -1
- package/dist/{oauth-authorization-1aPAYjiC.mjs → oauth-authorization-Bw4NdF_S.mjs} +4 -4
- package/dist/{oauth-authorization-1aPAYjiC.mjs.map → oauth-authorization-Bw4NdF_S.mjs.map} +1 -1
- package/dist/{oauth-clients-8mPDStMv.mjs → oauth-clients-BGGFp57s.mjs} +1 -1
- package/dist/{oauth-clients-8mPDStMv.mjs.map → oauth-clients-BGGFp57s.mjs.map} +1 -1
- package/dist/{oauth-state-store-BJ7YtrfD.mjs → oauth-state-store-97x0xtN2.mjs} +1 -1
- package/dist/{oauth-state-store-BJ7YtrfD.mjs.map → oauth-state-store-97x0xtN2.mjs.map} +1 -1
- package/dist/{oauth-user-lookup-BdDSDvjF.mjs → oauth-user-lookup-B_vnZHKO.mjs} +1 -1
- package/dist/{oauth-user-lookup-BdDSDvjF.mjs.map → oauth-user-lookup-B_vnZHKO.mjs.map} +1 -1
- package/dist/{options-D4MnavW_.d.mts → options-DyYIYpPd.d.mts} +3 -3
- package/dist/{options-D4MnavW_.d.mts.map → options-DyYIYpPd.d.mts.map} +1 -1
- package/dist/page/index.d.mts +2 -2
- package/dist/{passkey-config-BDVM86Tj.mjs → passkey-config-C3QgnQnU.mjs} +1 -1
- package/dist/{passkey-config-BDVM86Tj.mjs.map → passkey-config-C3QgnQnU.mjs.map} +1 -1
- package/dist/{placeholder-B9lUUEmj.d.mts → placeholder-CVBv5z8k.d.mts} +1 -1
- package/dist/{placeholder-B9lUUEmj.d.mts.map → placeholder-CVBv5z8k.d.mts.map} +1 -1
- package/dist/plugin-types.d.mts +1 -1
- package/dist/plugin-utils.d.mts +9 -9
- package/dist/plugins/adapt-sandbox-entry.d.mts +9 -9
- package/dist/plugins/adapt-sandbox-entry.mjs +2 -2
- package/dist/{public-url-egRHCy1m.mjs → public-url-BFVC2OTJ.mjs} +1 -1
- package/dist/{public-url-egRHCy1m.mjs.map → public-url-BFVC2OTJ.mjs.map} +1 -1
- package/dist/{query-BFQ029Ts.mjs → query-CbUcI4Xk.mjs} +18 -8
- package/dist/query-CbUcI4Xk.mjs.map +1 -0
- package/dist/{rate-limit-ClFFUga6.mjs → rate-limit-C7hjdkS5.mjs} +1 -1
- package/dist/{rate-limit-ClFFUga6.mjs.map → rate-limit-C7hjdkS5.mjs.map} +1 -1
- package/dist/{redirect-Cw3JTlmj.mjs → redirect-B_q19j4v.mjs} +1 -1
- package/dist/{redirect-Cw3JTlmj.mjs.map → redirect-B_q19j4v.mjs.map} +1 -1
- package/dist/{redirects-DEygMrRO.mjs → redirects-CCbCqCCd.mjs} +4 -2
- package/dist/redirects-CCbCqCCd.mjs.map +1 -0
- package/dist/{redirects-OIu6vQ2i.mjs → redirects-DxVoR7PI.mjs} +1 -1
- package/dist/{redirects-OIu6vQ2i.mjs.map → redirects-DxVoR7PI.mjs.map} +1 -1
- package/dist/request-context.d.mts +7 -0
- package/dist/request-context.d.mts.map +1 -1
- package/dist/request-context.mjs +2 -1
- package/dist/request-context.mjs.map +1 -1
- package/dist/{runner-BcRuXq_h.d.mts → runner-DTdhuI9i.d.mts} +2 -2
- package/dist/{runner-BcRuXq_h.d.mts.map → runner-DTdhuI9i.d.mts.map} +1 -1
- package/dist/runtime.d.mts +10 -10
- package/dist/runtime.mjs +1 -1
- package/dist/{schema-CS7Eg5gh.mjs → schema-C1E70ug_.mjs} +2 -2
- package/dist/{schema-CS7Eg5gh.mjs.map → schema-C1E70ug_.mjs.map} +1 -1
- package/dist/{search-o-aQzHI1.mjs → search-B3SGZw91.mjs} +2 -2
- package/dist/{search-o-aQzHI1.mjs.map → search-B3SGZw91.mjs.map} +1 -1
- package/dist/{secrets-C_ZtRos3.mjs → secrets-ChPTmy9x.mjs} +1 -1
- package/dist/{secrets-C_ZtRos3.mjs.map → secrets-ChPTmy9x.mjs.map} +1 -1
- package/dist/{sections-DhsZ0ns9.mjs → sections-D_lVzwRZ.mjs} +2 -2
- package/dist/{sections-DhsZ0ns9.mjs.map → sections-D_lVzwRZ.mjs.map} +1 -1
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +6 -6
- package/dist/seo/index.d.mts +1 -1
- package/dist/seo/index.d.mts.map +1 -1
- package/dist/seo/index.mjs +3 -12
- package/dist/seo/index.mjs.map +1 -1
- package/dist/{seo-DfjLvu8i.mjs → seo-D_LPkOtu.mjs} +4 -3
- package/dist/seo-D_LPkOtu.mjs.map +1 -0
- package/dist/{service-DAxg8RPR.mjs → service-ChDcsTBs.mjs} +2 -2
- package/dist/{service-DAxg8RPR.mjs.map → service-ChDcsTBs.mjs.map} +1 -1
- package/dist/{settings-DIsbHTRE.mjs → settings-Cv47v9u8.mjs} +2 -2
- package/dist/{settings-DIsbHTRE.mjs.map → settings-Cv47v9u8.mjs.map} +1 -1
- package/dist/settings-DfxiWY_s.mjs +411 -0
- package/dist/settings-DfxiWY_s.mjs.map +1 -0
- package/dist/{setup-complete-Yuv78yua.mjs → setup-complete-yvPE4OsP.mjs} +1 -1
- package/dist/{setup-complete-Yuv78yua.mjs.map → setup-complete-yvPE4OsP.mjs.map} +1 -1
- package/dist/{setup-nonce-Bm0uKqmf.mjs → setup-nonce-C9aFzb94.mjs} +1 -1
- package/dist/{setup-nonce-Bm0uKqmf.mjs.map → setup-nonce-C9aFzb94.mjs.map} +1 -1
- package/dist/{site-url-mEVmwIFi.mjs → site-url-CnHlmAs9.mjs} +1 -1
- package/dist/{site-url-mEVmwIFi.mjs.map → site-url-CnHlmAs9.mjs.map} +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/{taxonomies-UusDXv3C.mjs → taxonomies-BILwiyGk.mjs} +2 -2
- package/dist/{taxonomies-UusDXv3C.mjs.map → taxonomies-BILwiyGk.mjs.map} +1 -1
- package/dist/{taxonomies-BEW7S5AI.mjs → taxonomies-BdAmbOwx.mjs} +46 -9
- package/dist/taxonomies-BdAmbOwx.mjs.map +1 -0
- package/dist/{transport-BwQeeY2p.d.mts → transport-B7PPP2CC.d.mts} +1 -1
- package/dist/{transport-BwQeeY2p.d.mts.map → transport-B7PPP2CC.d.mts.map} +1 -1
- package/dist/{transport--Ck3RBin.mjs → transport-CmpLD7W3.mjs} +1 -1
- package/dist/{transport--Ck3RBin.mjs.map → transport-CmpLD7W3.mjs.map} +1 -1
- package/dist/{types-DWnN7weG.d.mts → types-BFgrqwSk.d.mts} +1 -1
- package/dist/{types-DWnN7weG.d.mts.map → types-BFgrqwSk.d.mts.map} +1 -1
- package/dist/{types-Qa7-HJJC.d.mts → types-BH8-30hc.d.mts} +1 -1
- package/dist/{types-Qa7-HJJC.d.mts.map → types-BH8-30hc.d.mts.map} +1 -1
- package/dist/{types-OT_Es5mp.d.mts → types-BPzXTV9x.d.mts} +1 -1
- package/dist/{types-OT_Es5mp.d.mts.map → types-BPzXTV9x.d.mts.map} +1 -1
- package/dist/{types-DbCWhHet.d.mts → types-BUUVn1zr.d.mts} +2 -2
- package/dist/types-BUUVn1zr.d.mts.map +1 -0
- package/dist/{types-DMwSpvcw.d.mts → types-CPAPl93j.d.mts} +9 -3
- package/dist/{types-DMwSpvcw.d.mts.map → types-CPAPl93j.d.mts.map} +1 -1
- package/dist/types-CZI4E3qG.mjs +3 -0
- package/dist/{types-kwqCOUxj.d.mts → types-D4kUqbHh.d.mts} +1 -1
- package/dist/{types-kwqCOUxj.d.mts.map → types-D4kUqbHh.d.mts.map} +1 -1
- package/dist/{types-WVmpZBJV.d.mts → types-DTniiNto.d.mts} +2 -2
- package/dist/{types-WVmpZBJV.d.mts.map → types-DTniiNto.d.mts.map} +1 -1
- package/dist/types-DZk_y-MU.mjs.map +1 -1
- package/dist/{types-DX6v9KzJ.d.mts → types-S15DXXNi.d.mts} +1 -1
- package/dist/{types-DX6v9KzJ.d.mts.map → types-S15DXXNi.d.mts.map} +1 -1
- package/dist/{validate-ZP9Dvg0P.mjs → validate-Bz4vqcX1.mjs} +1 -1
- package/dist/{validate-ZP9Dvg0P.mjs.map → validate-Bz4vqcX1.mjs.map} +1 -1
- package/dist/{validate-BPAHUSge.d.mts → validate-CNwkPWzz.d.mts} +5 -5
- package/dist/{validate-BPAHUSge.d.mts.map → validate-CNwkPWzz.d.mts.map} +1 -1
- package/dist/{validation-CE5i4q0c.mjs → validation-DgGTJm3u.mjs} +1 -1
- package/dist/{validation-CE5i4q0c.mjs.map → validation-DgGTJm3u.mjs.map} +1 -1
- package/dist/version-D-5txk2m.mjs +7 -0
- package/dist/{version-Dw0JXu45.mjs.map → version-D-5txk2m.mjs.map} +1 -1
- package/dist/{widgets-ClEnYQCH.mjs → widgets-DZfmAbE4.mjs} +47 -44
- package/dist/widgets-DZfmAbE4.mjs.map +1 -0
- package/package.json +10 -10
- package/src/api/handlers/marketplace.ts +2 -5
- package/src/api/handlers/registry.ts +70 -0
- package/src/api/handlers/seo.ts +9 -1
- package/src/api/schemas/schema.ts +13 -1
- package/src/astro/middleware.ts +20 -6
- package/src/astro/routes/api/schema/index.ts +7 -15
- package/src/astro/routes/sitemap-[collection].xml.ts +13 -2
- package/src/cli/commands/bundle-utils.ts +2 -0
- package/src/cli/commands/secrets.ts +2 -2
- package/src/database/instrumentation.ts +13 -0
- package/src/emdash-runtime.ts +31 -25
- package/src/loader.ts +24 -15
- package/src/plugins/manifest-schema.ts +75 -0
- package/src/plugins/marketplace.ts +2 -5
- package/src/plugins/types.ts +12 -0
- package/src/query.ts +13 -2
- package/src/request-context.ts +8 -0
- package/src/schema/types.ts +11 -1
- package/src/seo/index.ts +2 -28
- package/src/seo/media-url.ts +32 -0
- package/src/settings/index.ts +32 -40
- package/src/taxonomies/index.ts +78 -12
- package/src/utils/isolate-cache.ts +189 -0
- package/src/widgets/index.ts +57 -54
- package/dist/api-BZ6bhjYs.mjs.map +0 -1
- package/dist/byline-fields-DYXKDuNX.d.mts.map +0 -1
- package/dist/loader-CpZKpFz0.mjs.map +0 -1
- package/dist/manifest-schema-Cj-YrzrF.mjs.map +0 -1
- package/dist/menus-Dp9xporj.mjs.map +0 -1
- package/dist/query-BFQ029Ts.mjs.map +0 -1
- package/dist/redirects-DEygMrRO.mjs.map +0 -1
- package/dist/seo-DfjLvu8i.mjs.map +0 -1
- package/dist/settings-B1p-gPUK.mjs +0 -235
- package/dist/settings-B1p-gPUK.mjs.map +0 -1
- package/dist/taxonomies-BEW7S5AI.mjs.map +0 -1
- package/dist/types-Cj2S6FuC.mjs +0 -3
- package/dist/types-DbCWhHet.d.mts.map +0 -1
- package/dist/version-Dw0JXu45.mjs +0 -7
- package/dist/widgets-ClEnYQCH.mjs.map +0 -1
- /package/dist/{api-tokens-B6VgoE6M.mjs → api-tokens-Oq39ba-Z.mjs} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redirects-OIu6vQ2i.mjs","names":[],"sources":["../src/redirects/loops.ts","../src/api/handlers/redirects.ts"],"sourcesContent":["/**\n * Redirect loop and chain detection utilities.\n *\n * Builds a directed graph from redirect rules and detects:\n * - Cycles (loops): /a → /b → /c → /a\n * - Long chains: /a → /b → /c → /d → /e (exceeding a warning threshold)\n *\n * Handles both exact and pattern redirects. When the walker encounters\n * a path with no exact source match, it tests against compiled pattern\n * sources and resolves the destination using captured parameters.\n */\n\nimport {\n\tcompilePattern,\n\tmatchPattern,\n\tinterpolateDestination,\n\ttype CompiledPattern,\n} from \"./patterns.js\";\n\nexport interface RedirectEdge {\n\tid: string;\n\tsource: string;\n\tdestination: string;\n\tenabled: boolean;\n\tisPattern: boolean;\n}\n\ninterface CompiledPatternRedirect {\n\tid: string;\n\tcompiled: CompiledPattern;\n\tdestination: string;\n}\n\n/**\n * Compile all enabled pattern redirects for matching during graph walks.\n */\nfunction compilePatterns(edges: RedirectEdge[]): CompiledPatternRedirect[] {\n\tconst result: CompiledPatternRedirect[] = [];\n\tfor (const edge of edges) {\n\t\tif (edge.enabled && edge.isPattern) {\n\t\t\tresult.push({\n\t\t\t\tid: edge.id,\n\t\t\t\tcompiled: compilePattern(edge.source),\n\t\t\t\tdestination: edge.destination,\n\t\t\t});\n\t\t}\n\t}\n\treturn result;\n}\n\n/** Single-segment dummy value for representative path generation */\nconst DUMMY_SEGMENT = \"__p__\";\n\n/** Splat pattern: [...paramName] */\nconst SPLAT_RE = /\\[\\.\\.\\.(\\w+)\\]/g;\n\n/** Param pattern: [paramName] */\nconst PARAM_RE = /\\[(\\w+)\\]/g;\n\n/**\n * Extract the literal prefix from a pattern source (everything before the\n * first placeholder), stripped of leading segments shared with a base path.\n * e.g., \"/new/docs/[slug]\" → \"docs/__p__\" (the part after \"/new/\")\n */\nfunction extractPatternSuffix(patternSource: string): string {\n\t// Replace placeholders with dummy values\n\tlet result = patternSource.replace(SPLAT_RE, DUMMY_SEGMENT);\n\tSPLAT_RE.lastIndex = 0;\n\tresult = result.replace(PARAM_RE, DUMMY_SEGMENT);\n\t// Strip leading slash and first segment (e.g., \"/new/docs/__p__\" → \"docs/__p__\")\n\tconst parts = result.split(\"/\").filter(Boolean);\n\treturn parts.slice(1).join(\"/\");\n}\n\n/**\n * Generate representative concrete paths from a template string.\n * Replaces [param] with a dummy segment and [...rest] with multiple\n * depth variants. For catch-alls, also generates representatives using\n * literal prefixes from existing pattern sources to catch cross-pattern loops.\n */\nfunction generateRepresentatives(template: string, existingEdges?: RedirectEdge[]): string[] {\n\tconst hasSplat = SPLAT_RE.test(template);\n\tSPLAT_RE.lastIndex = 0;\n\n\tif (hasSplat) {\n\t\t// Extract the static prefix before the catch-all (e.g., \"/old/\" from \"/old/[...path]\")\n\t\tconst splatIndex = template.indexOf(\"[...\");\n\t\tconst prefix = template.slice(0, splatIndex);\n\n\t\tconst reps = [\n\t\t\ttemplate.replace(SPLAT_RE, DUMMY_SEGMENT).replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t\ttemplate\n\t\t\t\t.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)\n\t\t\t\t.replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t\ttemplate\n\t\t\t\t.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)\n\t\t\t\t.replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t];\n\n\t\t// Add representatives derived from existing pattern sources' literal prefixes\n\t\tif (existingEdges) {\n\t\t\tfor (const edge of existingEdges) {\n\t\t\t\tif (edge.enabled && edge.isPattern && edge.source !== template) {\n\t\t\t\t\tconst suffix = extractPatternSuffix(edge.source);\n\t\t\t\t\tif (suffix) {\n\t\t\t\t\t\treps.push(`${prefix}${suffix}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn reps;\n\t}\n\n\treturn [template.replace(PARAM_RE, DUMMY_SEGMENT)];\n}\n\n/**\n * Resolve the next hop for a given path. Tries exact match first,\n * then pattern matching with parameter interpolation for concrete paths,\n * then representative-based matching for template strings.\n */\nfunction resolveNext(\n\tpath: string,\n\tgraph: Map<string, { destination: string; id: string }>,\n\tpatterns: CompiledPatternRedirect[],\n\tedges?: RedirectEdge[],\n): { destination: string; id: string } | null {\n\t// Exact match (fast) — works for both real paths and template strings\n\tconst exact = graph.get(path);\n\tif (exact) return exact;\n\n\tif (!path.includes(\"[\")) {\n\t\t// Concrete path — try pattern matching directly\n\t\tfor (const pr of patterns) {\n\t\t\tconst params = matchPattern(pr.compiled, path);\n\t\t\tif (params) {\n\t\t\t\tconst resolved = interpolateDestination(pr.destination, params);\n\t\t\t\treturn { destination: resolved, id: pr.id };\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Template string — generate representative paths and test against patterns\n\t\tconst representatives = generateRepresentatives(path, edges);\n\t\tfor (const pr of patterns) {\n\t\t\tfor (const rep of representatives) {\n\t\t\t\tconst params = matchPattern(pr.compiled, rep);\n\t\t\t\tif (params) {\n\t\t\t\t\tconst resolved = interpolateDestination(pr.destination, params);\n\t\t\t\t\treturn { destination: resolved, id: pr.id };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n/**\n * Build an adjacency map from redirect edges.\n * Includes both exact and pattern redirects — pattern redirects use their\n * template strings as literal graph edges, which works because EmDash\n * patterns pass parameters through without transformation.\n */\nfunction buildGraph(edges: RedirectEdge[]): Map<string, { destination: string; id: string }> {\n\tconst graph = new Map<string, { destination: string; id: string }>();\n\tfor (const edge of edges) {\n\t\tif (edge.enabled) {\n\t\t\tgraph.set(edge.source, { destination: edge.destination, id: edge.id });\n\t\t}\n\t}\n\treturn graph;\n}\n\n/**\n * Detect all redirect IDs that participate in cycles.\n * Walks every node in the graph once, collecting IDs from any cycles found.\n *\n * @returns Array of redirect IDs that are part of a loop\n */\nexport function detectLoops(edges: RedirectEdge[]): string[] {\n\tconst graph = buildGraph(edges);\n\tconst patterns = compilePatterns(edges);\n\tconst visited = new Set<string>();\n\tconst loopRedirectIds = new Set<string>();\n\n\tfor (const [startSource] of graph) {\n\t\tif (visited.has(startSource)) continue;\n\n\t\tconst path: string[] = [];\n\t\tconst pathSet = new Set<string>();\n\t\tconst pathIds: string[] = [];\n\t\tlet current: string | undefined = startSource;\n\n\t\twhile (current) {\n\t\t\tif (pathSet.has(current)) {\n\t\t\t\t// Found a cycle — collect IDs of redirects in the loop\n\t\t\t\tconst loopStart = path.indexOf(current);\n\t\t\t\tfor (const id of pathIds.slice(loopStart)) loopRedirectIds.add(id);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (visited.has(current)) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst next = resolveNext(current, graph, patterns, edges);\n\t\t\tif (!next) break;\n\n\t\t\tpath.push(current);\n\t\t\tpathSet.add(current);\n\t\t\tpathIds.push(next.id);\n\t\t\tcurrent = next.destination;\n\t\t}\n\n\t\tfor (const node of path) visited.add(node);\n\t}\n\n\treturn [...loopRedirectIds];\n}\n\n/**\n * Find a compiled pattern redirect whose source matches the given resolved path,\n * returning the source template string for display purposes.\n */\nfunction findMatchingTemplate(\n\tresolvedPath: string,\n\tpatterns: CompiledPatternRedirect[],\n): string | null {\n\tfor (const pr of patterns) {\n\t\tif (matchPattern(pr.compiled, resolvedPath) !== null) {\n\t\t\treturn pr.compiled.source;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if adding or updating a redirect would create a loop.\n *\n * Walks the chain from `destination` through existing redirects.\n * If it reaches `source`, a cycle would form.\n *\n * @returns The loop path if a cycle would be created, or null if safe\n */\nexport function wouldCreateLoop(\n\tsource: string,\n\tdestination: string,\n\texistingEdges: RedirectEdge[],\n\texcludeId?: string,\n): string[] | null {\n\tconst filtered = excludeId ? existingEdges.filter((e) => e.id !== excludeId) : existingEdges;\n\tconst graph = buildGraph(filtered);\n\tconst patterns = compilePatterns(filtered);\n\n\t// If the proposed source is a pattern, compile it so we can check\n\t// whether resolved paths would match it (not just string equality)\n\tconst sourceIsPattern = source.includes(\"[\");\n\tconst compiledSource = sourceIsPattern ? compilePattern(source) : null;\n\n\t// Determine starting points for the walk. If the destination is a\n\t// template, generate representative concrete paths AND find existing\n\t// exact sources in the graph that match the template.\n\tlet startingPoints: string[];\n\tif (destination.includes(\"[\")) {\n\t\tconst reps = generateRepresentatives(destination, filtered);\n\t\t// Also find existing exact graph keys that match this template\n\t\tconst compiled = compilePattern(destination);\n\t\tfor (const [key] of graph) {\n\t\t\tif (!key.includes(\"[\") && matchPattern(compiled, key) !== null) {\n\t\t\t\treps.push(key);\n\t\t\t}\n\t\t}\n\t\t// Always include the destination itself — it may be an exact graph key\n\t\t// (e.g., /a/sub/[...path] exists as a literal source in the graph)\n\t\treps.push(destination);\n\t\tstartingPoints = reps;\n\t} else {\n\t\tstartingPoints = [destination];\n\t}\n\n\tfor (const start of startingPoints) {\n\t\tconst path = [source, destination];\n\t\tlet current = start;\n\t\tconst seen = new Set<string>([source, destination, start]);\n\n\t\t// Walk the chain until it ends or we revisit a node\n\t\t// eslint-disable-next-line no-constant-condition -- terminates via return/break when chain ends or cycle found\n\t\twhile (true) {\n\t\t\tconst next = resolveNext(current, graph, patterns, filtered);\n\t\t\tif (!next) break; // chain ends, try next starting point\n\n\t\t\t// Check if we've looped back — either exact match or pattern match\n\t\t\tconst loopsBack =\n\t\t\t\tseen.has(next.destination) ||\n\t\t\t\t(compiledSource !== null && matchPattern(compiledSource, next.destination) !== null);\n\n\t\t\tif (loopsBack) {\n\t\t\t\t// Show the source template instead of dummy resolved path\n\t\t\t\tconst displayPath =\n\t\t\t\t\t!seen.has(next.destination) && compiledSource !== null ? source : next.destination;\n\t\t\t\tpath.push(displayPath);\n\t\t\t\treturn path; // cycle found\n\t\t\t}\n\n\t\t\t// If the resolved path contains dummy segments, try to find the\n\t\t\t// original pattern template that produced it for cleaner display\n\t\t\tconst cleanDest = next.destination.includes(DUMMY_SEGMENT)\n\t\t\t\t? (findMatchingTemplate(next.destination, patterns) ?? next.destination)\n\t\t\t\t: next.destination;\n\t\t\tpath.push(cleanDest);\n\t\t\tseen.add(next.destination);\n\t\t\tcurrent = next.destination;\n\t\t}\n\t}\n\n\treturn null;\n}\n","/**\n * Redirect CRUD and 404 log handlers\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"../../database/repositories/options.js\";\nimport {\n\tRedirectRepository,\n\ttype Redirect,\n\ttype NotFoundEntry,\n\ttype NotFoundSummary,\n} from \"../../database/repositories/redirect.js\";\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { FindManyResult } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { wouldCreateLoop, detectLoops, type RedirectEdge } from \"../../redirects/loops.js\";\nimport { validatePattern, validateDestinationParams, isPattern } from \"../../redirects/patterns.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Redirects\n// ---------------------------------------------------------------------------\n\n/**\n * List redirects with cursor pagination and optional filters\n */\nexport async function handleRedirectList(\n\tdb: Kysely<Database>,\n\tparams: {\n\t\tcursor?: string;\n\t\tlimit?: number;\n\t\tsearch?: string;\n\t\tgroup?: string;\n\t\tenabled?: boolean;\n\t\tauto?: boolean;\n\t},\n): Promise<ApiResult<FindManyResult<Redirect> & { loopRedirectIds?: string[] }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst result = await repo.findMany(params);\n\n\t\tconst loopRedirectIds = await getLoopRedirectIds(db);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\t...result,\n\t\t\t\t...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_LIST_ERROR\", message: \"Failed to fetch redirects\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a redirect rule\n */\nexport async function handleRedirectCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tsource: string;\n\t\tdestination: string;\n\t\ttype?: number;\n\t\tenabled?: boolean;\n\t\tgroupName?: string | null;\n\t},\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\n\t\t// Source and destination must differ\n\t\tif (input.source === input.destination) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Source and destination must be different\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If source looks like a pattern, validate it\n\t\tconst sourceIsPattern = isPattern(input.source);\n\t\tif (sourceIsPattern) {\n\t\t\tconst patternError = validatePattern(input.source);\n\t\t\tif (patternError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: `Invalid source pattern: ${patternError}` },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Validate destination params reference valid source params\n\t\t\tconst destError = validateDestinationParams(input.source, input.destination);\n\t\t\tif (destError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: destError },\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for duplicate source (exact match only for non-patterns)\n\t\tconst existing = await repo.findBySource(input.source);\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `A redirect from \"${input.source}\" already exists`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Check for redirect loops (skip if creating as disabled)\n\t\tif (input.enabled !== false) {\n\t\t\tconst edges = toEdges(await repo.findAllEnabled());\n\t\t\tconst loopPath = wouldCreateLoop(input.source, input.destination, edges);\n\t\t\tif (loopPath) return loopError(loopPath);\n\t\t}\n\n\t\tconst redirect = await repo.create({\n\t\t\tsource: input.source,\n\t\t\tdestination: input.destination,\n\t\t\ttype: input.type ?? 301,\n\t\t\tisPattern: sourceIsPattern,\n\t\t\tenabled: input.enabled ?? true,\n\t\t\tgroupName: input.groupName ?? null,\n\t\t});\n\n\t\treturn { success: true, data: redirect };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_CREATE_ERROR\", message: \"Failed to create redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a redirect by ID\n */\nexport async function handleRedirectGet(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst redirect = await repo.findById(id);\n\n\t\tif (!redirect) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: redirect };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_GET_ERROR\", message: \"Failed to fetch redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a redirect by ID\n */\nexport async function handleRedirectUpdate(\n\tdb: Kysely<Database>,\n\tid: string,\n\tinput: {\n\t\tsource?: string;\n\t\tdestination?: string;\n\t\ttype?: number;\n\t\tenabled?: boolean;\n\t\tgroupName?: string | null;\n\t},\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\n\t\tconst existing = await repo.findById(id);\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\tconst newSource = input.source ?? existing.source;\n\t\tconst newDest = input.destination ?? existing.destination;\n\n\t\t// Source and destination must differ\n\t\tif (newSource === newDest) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Source and destination must be different\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If source is changing, validate patterns\n\t\tif (input.source !== undefined) {\n\t\t\tconst sourceIsPattern = isPattern(input.source);\n\t\t\tif (sourceIsPattern) {\n\t\t\t\tconst patternError = validatePattern(input.source);\n\t\t\t\tif (patternError) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: {\n\t\t\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\t\t\tmessage: `Invalid source pattern: ${patternError}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for duplicate source (exclude self)\n\t\t\tconst dup = await repo.findBySource(input.source);\n\t\t\tif (dup && dup.id !== id) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `A redirect from \"${input.source}\" already exists`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate destination params against the (possibly updated) source\n\t\tconst newSourceIsPattern = isPattern(newSource);\n\t\tif (newSourceIsPattern) {\n\t\t\tconst destError = validateDestinationParams(newSource, newDest);\n\t\t\tif (destError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: destError },\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for redirect loops if source or destination changed\n\t\tif (input.source !== undefined || input.destination !== undefined) {\n\t\t\tconst edges = toEdges(await repo.findAllEnabled());\n\t\t\tconst loopPath = wouldCreateLoop(newSource, newDest, edges, id);\n\t\t\tif (loopPath) return loopError(loopPath);\n\t\t}\n\n\t\tconst updated = await repo.update(id, {\n\t\t\tsource: input.source,\n\t\t\tdestination: input.destination,\n\t\t\ttype: input.type,\n\t\t\tenabled: input.enabled,\n\t\t\tgroupName: input.groupName,\n\t\t});\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"REDIRECT_UPDATE_ERROR\", message: \"Failed to update redirect\" },\n\t\t\t};\n\t\t}\n\n\t\t// Recompute cache — redirect was modified, so re-fetch\n\t\tawait updateLoopCache(db);\n\n\t\treturn { success: true, data: updated };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_UPDATE_ERROR\", message: \"Failed to update redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a redirect by ID\n */\nexport async function handleRedirectDelete(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.delete(id);\n\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\tawait updateLoopCache(db);\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_DELETE_ERROR\", message: \"Failed to delete redirect\" },\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Loop analysis cache\n// ---------------------------------------------------------------------------\n\nfunction loopError(loopPath: string[]): ApiResult<never> {\n\tconst hops = loopPath\n\t\t.slice(0, -1)\n\t\t.map((p, i) => `${p} \\u2192 ${loopPath[i + 1]}`)\n\t\t.join(\"\\n\");\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `This redirect would create a loop:\\n${hops}`,\n\t\t},\n\t};\n}\n\nfunction toEdges(redirects: Redirect[]): RedirectEdge[] {\n\treturn redirects.map((r) => ({\n\t\tid: r.id,\n\t\tsource: r.source,\n\t\tdestination: r.destination,\n\t\tenabled: r.enabled,\n\t\tisPattern: r.isPattern,\n\t}));\n}\n\nconst LOOP_CACHE_KEY = \"_redirect_loop_ids\";\n\n/**\n * Recompute loop redirect IDs and store in the options table.\n */\nasync function updateLoopCache(db: Kysely<Database>): Promise<void> {\n\ttry {\n\t\tconst options = new OptionsRepository(db);\n\t\tconst edges = toEdges(await new RedirectRepository(db).findAllEnabled());\n\t\tconst loopRedirectIds = detectLoops(edges);\n\t\tawait options.set(LOOP_CACHE_KEY, loopRedirectIds);\n\t} catch (error) {\n\t\tconsole.error(\"Failed to update redirect loop cache:\", error);\n\t}\n}\n\n/**\n * Get loop redirect IDs from cache, computing lazily on first access.\n */\nasync function getLoopRedirectIds(db: Kysely<Database>): Promise<string[]> {\n\ttry {\n\t\tconst options = new OptionsRepository(db);\n\t\tconst cached = await options.get<string[]>(LOOP_CACHE_KEY);\n\t\tif (cached !== null) return cached;\n\n\t\t// First access after upgrade — compute and cache\n\t\tawait updateLoopCache(db);\n\t\treturn (await options.get<string[]>(LOOP_CACHE_KEY)) ?? [];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 404 Log\n// ---------------------------------------------------------------------------\n\n/**\n * List 404 log entries with cursor pagination\n */\nexport async function handleNotFoundList(\n\tdb: Kysely<Database>,\n\tparams: { cursor?: string; limit?: number; search?: string },\n): Promise<ApiResult<FindManyResult<NotFoundEntry>>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst result = await repo.find404s(params);\n\t\treturn { success: true, data: result };\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_LIST_ERROR\", message: \"Failed to fetch 404 log\" },\n\t\t};\n\t}\n}\n\n/**\n * Get 404 summary (grouped by path, sorted by count)\n */\nexport async function handleNotFoundSummary(\n\tdb: Kysely<Database>,\n\tlimit?: number,\n): Promise<ApiResult<{ items: NotFoundSummary[] }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst items = await repo.get404Summary(limit);\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_SUMMARY_ERROR\", message: \"Failed to fetch 404 summary\" },\n\t\t};\n\t}\n}\n\n/**\n * Clear all 404 log entries\n */\nexport async function handleNotFoundClear(\n\tdb: Kysely<Database>,\n): Promise<ApiResult<{ deleted: number }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.clear404s();\n\t\treturn { success: true, data: { deleted } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_CLEAR_ERROR\", message: \"Failed to clear 404 log\" },\n\t\t};\n\t}\n}\n\n/**\n * Prune 404 log entries older than a given date\n */\nexport async function handleNotFoundPrune(\n\tdb: Kysely<Database>,\n\tolderThan: string,\n): Promise<ApiResult<{ deleted: number }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.prune404s(olderThan);\n\t\treturn { success: true, data: { deleted } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_PRUNE_ERROR\", message: \"Failed to prune 404 log\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"redirects-DxVoR7PI.mjs","names":[],"sources":["../src/redirects/loops.ts","../src/api/handlers/redirects.ts"],"sourcesContent":["/**\n * Redirect loop and chain detection utilities.\n *\n * Builds a directed graph from redirect rules and detects:\n * - Cycles (loops): /a → /b → /c → /a\n * - Long chains: /a → /b → /c → /d → /e (exceeding a warning threshold)\n *\n * Handles both exact and pattern redirects. When the walker encounters\n * a path with no exact source match, it tests against compiled pattern\n * sources and resolves the destination using captured parameters.\n */\n\nimport {\n\tcompilePattern,\n\tmatchPattern,\n\tinterpolateDestination,\n\ttype CompiledPattern,\n} from \"./patterns.js\";\n\nexport interface RedirectEdge {\n\tid: string;\n\tsource: string;\n\tdestination: string;\n\tenabled: boolean;\n\tisPattern: boolean;\n}\n\ninterface CompiledPatternRedirect {\n\tid: string;\n\tcompiled: CompiledPattern;\n\tdestination: string;\n}\n\n/**\n * Compile all enabled pattern redirects for matching during graph walks.\n */\nfunction compilePatterns(edges: RedirectEdge[]): CompiledPatternRedirect[] {\n\tconst result: CompiledPatternRedirect[] = [];\n\tfor (const edge of edges) {\n\t\tif (edge.enabled && edge.isPattern) {\n\t\t\tresult.push({\n\t\t\t\tid: edge.id,\n\t\t\t\tcompiled: compilePattern(edge.source),\n\t\t\t\tdestination: edge.destination,\n\t\t\t});\n\t\t}\n\t}\n\treturn result;\n}\n\n/** Single-segment dummy value for representative path generation */\nconst DUMMY_SEGMENT = \"__p__\";\n\n/** Splat pattern: [...paramName] */\nconst SPLAT_RE = /\\[\\.\\.\\.(\\w+)\\]/g;\n\n/** Param pattern: [paramName] */\nconst PARAM_RE = /\\[(\\w+)\\]/g;\n\n/**\n * Extract the literal prefix from a pattern source (everything before the\n * first placeholder), stripped of leading segments shared with a base path.\n * e.g., \"/new/docs/[slug]\" → \"docs/__p__\" (the part after \"/new/\")\n */\nfunction extractPatternSuffix(patternSource: string): string {\n\t// Replace placeholders with dummy values\n\tlet result = patternSource.replace(SPLAT_RE, DUMMY_SEGMENT);\n\tSPLAT_RE.lastIndex = 0;\n\tresult = result.replace(PARAM_RE, DUMMY_SEGMENT);\n\t// Strip leading slash and first segment (e.g., \"/new/docs/__p__\" → \"docs/__p__\")\n\tconst parts = result.split(\"/\").filter(Boolean);\n\treturn parts.slice(1).join(\"/\");\n}\n\n/**\n * Generate representative concrete paths from a template string.\n * Replaces [param] with a dummy segment and [...rest] with multiple\n * depth variants. For catch-alls, also generates representatives using\n * literal prefixes from existing pattern sources to catch cross-pattern loops.\n */\nfunction generateRepresentatives(template: string, existingEdges?: RedirectEdge[]): string[] {\n\tconst hasSplat = SPLAT_RE.test(template);\n\tSPLAT_RE.lastIndex = 0;\n\n\tif (hasSplat) {\n\t\t// Extract the static prefix before the catch-all (e.g., \"/old/\" from \"/old/[...path]\")\n\t\tconst splatIndex = template.indexOf(\"[...\");\n\t\tconst prefix = template.slice(0, splatIndex);\n\n\t\tconst reps = [\n\t\t\ttemplate.replace(SPLAT_RE, DUMMY_SEGMENT).replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t\ttemplate\n\t\t\t\t.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)\n\t\t\t\t.replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t\ttemplate\n\t\t\t\t.replace(SPLAT_RE, `${DUMMY_SEGMENT}/${DUMMY_SEGMENT}/${DUMMY_SEGMENT}`)\n\t\t\t\t.replace(PARAM_RE, DUMMY_SEGMENT),\n\t\t];\n\n\t\t// Add representatives derived from existing pattern sources' literal prefixes\n\t\tif (existingEdges) {\n\t\t\tfor (const edge of existingEdges) {\n\t\t\t\tif (edge.enabled && edge.isPattern && edge.source !== template) {\n\t\t\t\t\tconst suffix = extractPatternSuffix(edge.source);\n\t\t\t\t\tif (suffix) {\n\t\t\t\t\t\treps.push(`${prefix}${suffix}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn reps;\n\t}\n\n\treturn [template.replace(PARAM_RE, DUMMY_SEGMENT)];\n}\n\n/**\n * Resolve the next hop for a given path. Tries exact match first,\n * then pattern matching with parameter interpolation for concrete paths,\n * then representative-based matching for template strings.\n */\nfunction resolveNext(\n\tpath: string,\n\tgraph: Map<string, { destination: string; id: string }>,\n\tpatterns: CompiledPatternRedirect[],\n\tedges?: RedirectEdge[],\n): { destination: string; id: string } | null {\n\t// Exact match (fast) — works for both real paths and template strings\n\tconst exact = graph.get(path);\n\tif (exact) return exact;\n\n\tif (!path.includes(\"[\")) {\n\t\t// Concrete path — try pattern matching directly\n\t\tfor (const pr of patterns) {\n\t\t\tconst params = matchPattern(pr.compiled, path);\n\t\t\tif (params) {\n\t\t\t\tconst resolved = interpolateDestination(pr.destination, params);\n\t\t\t\treturn { destination: resolved, id: pr.id };\n\t\t\t}\n\t\t}\n\t} else {\n\t\t// Template string — generate representative paths and test against patterns\n\t\tconst representatives = generateRepresentatives(path, edges);\n\t\tfor (const pr of patterns) {\n\t\t\tfor (const rep of representatives) {\n\t\t\t\tconst params = matchPattern(pr.compiled, rep);\n\t\t\t\tif (params) {\n\t\t\t\t\tconst resolved = interpolateDestination(pr.destination, params);\n\t\t\t\t\treturn { destination: resolved, id: pr.id };\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn null;\n}\n\n/**\n * Build an adjacency map from redirect edges.\n * Includes both exact and pattern redirects — pattern redirects use their\n * template strings as literal graph edges, which works because EmDash\n * patterns pass parameters through without transformation.\n */\nfunction buildGraph(edges: RedirectEdge[]): Map<string, { destination: string; id: string }> {\n\tconst graph = new Map<string, { destination: string; id: string }>();\n\tfor (const edge of edges) {\n\t\tif (edge.enabled) {\n\t\t\tgraph.set(edge.source, { destination: edge.destination, id: edge.id });\n\t\t}\n\t}\n\treturn graph;\n}\n\n/**\n * Detect all redirect IDs that participate in cycles.\n * Walks every node in the graph once, collecting IDs from any cycles found.\n *\n * @returns Array of redirect IDs that are part of a loop\n */\nexport function detectLoops(edges: RedirectEdge[]): string[] {\n\tconst graph = buildGraph(edges);\n\tconst patterns = compilePatterns(edges);\n\tconst visited = new Set<string>();\n\tconst loopRedirectIds = new Set<string>();\n\n\tfor (const [startSource] of graph) {\n\t\tif (visited.has(startSource)) continue;\n\n\t\tconst path: string[] = [];\n\t\tconst pathSet = new Set<string>();\n\t\tconst pathIds: string[] = [];\n\t\tlet current: string | undefined = startSource;\n\n\t\twhile (current) {\n\t\t\tif (pathSet.has(current)) {\n\t\t\t\t// Found a cycle — collect IDs of redirects in the loop\n\t\t\t\tconst loopStart = path.indexOf(current);\n\t\t\t\tfor (const id of pathIds.slice(loopStart)) loopRedirectIds.add(id);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (visited.has(current)) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst next = resolveNext(current, graph, patterns, edges);\n\t\t\tif (!next) break;\n\n\t\t\tpath.push(current);\n\t\t\tpathSet.add(current);\n\t\t\tpathIds.push(next.id);\n\t\t\tcurrent = next.destination;\n\t\t}\n\n\t\tfor (const node of path) visited.add(node);\n\t}\n\n\treturn [...loopRedirectIds];\n}\n\n/**\n * Find a compiled pattern redirect whose source matches the given resolved path,\n * returning the source template string for display purposes.\n */\nfunction findMatchingTemplate(\n\tresolvedPath: string,\n\tpatterns: CompiledPatternRedirect[],\n): string | null {\n\tfor (const pr of patterns) {\n\t\tif (matchPattern(pr.compiled, resolvedPath) !== null) {\n\t\t\treturn pr.compiled.source;\n\t\t}\n\t}\n\treturn null;\n}\n\n/**\n * Check if adding or updating a redirect would create a loop.\n *\n * Walks the chain from `destination` through existing redirects.\n * If it reaches `source`, a cycle would form.\n *\n * @returns The loop path if a cycle would be created, or null if safe\n */\nexport function wouldCreateLoop(\n\tsource: string,\n\tdestination: string,\n\texistingEdges: RedirectEdge[],\n\texcludeId?: string,\n): string[] | null {\n\tconst filtered = excludeId ? existingEdges.filter((e) => e.id !== excludeId) : existingEdges;\n\tconst graph = buildGraph(filtered);\n\tconst patterns = compilePatterns(filtered);\n\n\t// If the proposed source is a pattern, compile it so we can check\n\t// whether resolved paths would match it (not just string equality)\n\tconst sourceIsPattern = source.includes(\"[\");\n\tconst compiledSource = sourceIsPattern ? compilePattern(source) : null;\n\n\t// Determine starting points for the walk. If the destination is a\n\t// template, generate representative concrete paths AND find existing\n\t// exact sources in the graph that match the template.\n\tlet startingPoints: string[];\n\tif (destination.includes(\"[\")) {\n\t\tconst reps = generateRepresentatives(destination, filtered);\n\t\t// Also find existing exact graph keys that match this template\n\t\tconst compiled = compilePattern(destination);\n\t\tfor (const [key] of graph) {\n\t\t\tif (!key.includes(\"[\") && matchPattern(compiled, key) !== null) {\n\t\t\t\treps.push(key);\n\t\t\t}\n\t\t}\n\t\t// Always include the destination itself — it may be an exact graph key\n\t\t// (e.g., /a/sub/[...path] exists as a literal source in the graph)\n\t\treps.push(destination);\n\t\tstartingPoints = reps;\n\t} else {\n\t\tstartingPoints = [destination];\n\t}\n\n\tfor (const start of startingPoints) {\n\t\tconst path = [source, destination];\n\t\tlet current = start;\n\t\tconst seen = new Set<string>([source, destination, start]);\n\n\t\t// Walk the chain until it ends or we revisit a node\n\t\t// eslint-disable-next-line no-constant-condition -- terminates via return/break when chain ends or cycle found\n\t\twhile (true) {\n\t\t\tconst next = resolveNext(current, graph, patterns, filtered);\n\t\t\tif (!next) break; // chain ends, try next starting point\n\n\t\t\t// Check if we've looped back — either exact match or pattern match\n\t\t\tconst loopsBack =\n\t\t\t\tseen.has(next.destination) ||\n\t\t\t\t(compiledSource !== null && matchPattern(compiledSource, next.destination) !== null);\n\n\t\t\tif (loopsBack) {\n\t\t\t\t// Show the source template instead of dummy resolved path\n\t\t\t\tconst displayPath =\n\t\t\t\t\t!seen.has(next.destination) && compiledSource !== null ? source : next.destination;\n\t\t\t\tpath.push(displayPath);\n\t\t\t\treturn path; // cycle found\n\t\t\t}\n\n\t\t\t// If the resolved path contains dummy segments, try to find the\n\t\t\t// original pattern template that produced it for cleaner display\n\t\t\tconst cleanDest = next.destination.includes(DUMMY_SEGMENT)\n\t\t\t\t? (findMatchingTemplate(next.destination, patterns) ?? next.destination)\n\t\t\t\t: next.destination;\n\t\t\tpath.push(cleanDest);\n\t\t\tseen.add(next.destination);\n\t\t\tcurrent = next.destination;\n\t\t}\n\t}\n\n\treturn null;\n}\n","/**\n * Redirect CRUD and 404 log handlers\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"../../database/repositories/options.js\";\nimport {\n\tRedirectRepository,\n\ttype Redirect,\n\ttype NotFoundEntry,\n\ttype NotFoundSummary,\n} from \"../../database/repositories/redirect.js\";\nimport { InvalidCursorError } from \"../../database/repositories/types.js\";\nimport type { FindManyResult } from \"../../database/repositories/types.js\";\nimport type { Database } from \"../../database/types.js\";\nimport { wouldCreateLoop, detectLoops, type RedirectEdge } from \"../../redirects/loops.js\";\nimport { validatePattern, validateDestinationParams, isPattern } from \"../../redirects/patterns.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Redirects\n// ---------------------------------------------------------------------------\n\n/**\n * List redirects with cursor pagination and optional filters\n */\nexport async function handleRedirectList(\n\tdb: Kysely<Database>,\n\tparams: {\n\t\tcursor?: string;\n\t\tlimit?: number;\n\t\tsearch?: string;\n\t\tgroup?: string;\n\t\tenabled?: boolean;\n\t\tauto?: boolean;\n\t},\n): Promise<ApiResult<FindManyResult<Redirect> & { loopRedirectIds?: string[] }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst result = await repo.findMany(params);\n\n\t\tconst loopRedirectIds = await getLoopRedirectIds(db);\n\n\t\treturn {\n\t\t\tsuccess: true,\n\t\t\tdata: {\n\t\t\t\t...result,\n\t\t\t\t...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),\n\t\t\t},\n\t\t};\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_LIST_ERROR\", message: \"Failed to fetch redirects\" },\n\t\t};\n\t}\n}\n\n/**\n * Create a redirect rule\n */\nexport async function handleRedirectCreate(\n\tdb: Kysely<Database>,\n\tinput: {\n\t\tsource: string;\n\t\tdestination: string;\n\t\ttype?: number;\n\t\tenabled?: boolean;\n\t\tgroupName?: string | null;\n\t},\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\n\t\t// Source and destination must differ\n\t\tif (input.source === input.destination) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Source and destination must be different\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If source looks like a pattern, validate it\n\t\tconst sourceIsPattern = isPattern(input.source);\n\t\tif (sourceIsPattern) {\n\t\t\tconst patternError = validatePattern(input.source);\n\t\t\tif (patternError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: `Invalid source pattern: ${patternError}` },\n\t\t\t\t};\n\t\t\t}\n\n\t\t\t// Validate destination params reference valid source params\n\t\t\tconst destError = validateDestinationParams(input.source, input.destination);\n\t\t\tif (destError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: destError },\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for duplicate source (exact match only for non-patterns)\n\t\tconst existing = await repo.findBySource(input.source);\n\t\tif (existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\tmessage: `A redirect from \"${input.source}\" already exists`,\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// Check for redirect loops (skip if creating as disabled)\n\t\tif (input.enabled !== false) {\n\t\t\tconst edges = toEdges(await repo.findAllEnabled());\n\t\t\tconst loopPath = wouldCreateLoop(input.source, input.destination, edges);\n\t\t\tif (loopPath) return loopError(loopPath);\n\t\t}\n\n\t\tconst redirect = await repo.create({\n\t\t\tsource: input.source,\n\t\t\tdestination: input.destination,\n\t\t\ttype: input.type ?? 301,\n\t\t\tisPattern: sourceIsPattern,\n\t\t\tenabled: input.enabled ?? true,\n\t\t\tgroupName: input.groupName ?? null,\n\t\t});\n\n\t\treturn { success: true, data: redirect };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_CREATE_ERROR\", message: \"Failed to create redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Get a redirect by ID\n */\nexport async function handleRedirectGet(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst redirect = await repo.findById(id);\n\n\t\tif (!redirect) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: redirect };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_GET_ERROR\", message: \"Failed to fetch redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Update a redirect by ID\n */\nexport async function handleRedirectUpdate(\n\tdb: Kysely<Database>,\n\tid: string,\n\tinput: {\n\t\tsource?: string;\n\t\tdestination?: string;\n\t\ttype?: number;\n\t\tenabled?: boolean;\n\t\tgroupName?: string | null;\n\t},\n): Promise<ApiResult<Redirect>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\n\t\tconst existing = await repo.findById(id);\n\t\tif (!existing) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\tconst newSource = input.source ?? existing.source;\n\t\tconst newDest = input.destination ?? existing.destination;\n\n\t\t// Source and destination must differ\n\t\tif (newSource === newDest) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: {\n\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\tmessage: \"Source and destination must be different\",\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\n\t\t// If source is changing, validate patterns\n\t\tif (input.source !== undefined) {\n\t\t\tconst sourceIsPattern = isPattern(input.source);\n\t\t\tif (sourceIsPattern) {\n\t\t\t\tconst patternError = validatePattern(input.source);\n\t\t\t\tif (patternError) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: {\n\t\t\t\t\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\t\t\t\t\tmessage: `Invalid source pattern: ${patternError}`,\n\t\t\t\t\t\t},\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Check for duplicate source (exclude self)\n\t\t\tconst dup = await repo.findBySource(input.source);\n\t\t\tif (dup && dup.id !== id) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"CONFLICT\",\n\t\t\t\t\t\tmessage: `A redirect from \"${input.source}\" already exists`,\n\t\t\t\t\t},\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Validate destination params against the (possibly updated) source\n\t\tconst newSourceIsPattern = isPattern(newSource);\n\t\tif (newSourceIsPattern) {\n\t\t\tconst destError = validateDestinationParams(newSource, newDest);\n\t\t\tif (destError) {\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: { code: \"VALIDATION_ERROR\", message: destError },\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for redirect loops if source or destination changed\n\t\tif (input.source !== undefined || input.destination !== undefined) {\n\t\t\tconst edges = toEdges(await repo.findAllEnabled());\n\t\t\tconst loopPath = wouldCreateLoop(newSource, newDest, edges, id);\n\t\t\tif (loopPath) return loopError(loopPath);\n\t\t}\n\n\t\tconst updated = await repo.update(id, {\n\t\t\tsource: input.source,\n\t\t\tdestination: input.destination,\n\t\t\ttype: input.type,\n\t\t\tenabled: input.enabled,\n\t\t\tgroupName: input.groupName,\n\t\t});\n\n\t\tif (!updated) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"REDIRECT_UPDATE_ERROR\", message: \"Failed to update redirect\" },\n\t\t\t};\n\t\t}\n\n\t\t// Recompute cache — redirect was modified, so re-fetch\n\t\tawait updateLoopCache(db);\n\n\t\treturn { success: true, data: updated };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_UPDATE_ERROR\", message: \"Failed to update redirect\" },\n\t\t};\n\t}\n}\n\n/**\n * Delete a redirect by ID\n */\nexport async function handleRedirectDelete(\n\tdb: Kysely<Database>,\n\tid: string,\n): Promise<ApiResult<{ deleted: true }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.delete(id);\n\n\t\tif (!deleted) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: `Redirect \"${id}\" not found` },\n\t\t\t};\n\t\t}\n\n\t\tawait updateLoopCache(db);\n\n\t\treturn { success: true, data: { deleted: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"REDIRECT_DELETE_ERROR\", message: \"Failed to delete redirect\" },\n\t\t};\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Loop analysis cache\n// ---------------------------------------------------------------------------\n\nfunction loopError(loopPath: string[]): ApiResult<never> {\n\tconst hops = loopPath\n\t\t.slice(0, -1)\n\t\t.map((p, i) => `${p} \\u2192 ${loopPath[i + 1]}`)\n\t\t.join(\"\\n\");\n\treturn {\n\t\tsuccess: false,\n\t\terror: {\n\t\t\tcode: \"VALIDATION_ERROR\",\n\t\t\tmessage: `This redirect would create a loop:\\n${hops}`,\n\t\t},\n\t};\n}\n\nfunction toEdges(redirects: Redirect[]): RedirectEdge[] {\n\treturn redirects.map((r) => ({\n\t\tid: r.id,\n\t\tsource: r.source,\n\t\tdestination: r.destination,\n\t\tenabled: r.enabled,\n\t\tisPattern: r.isPattern,\n\t}));\n}\n\nconst LOOP_CACHE_KEY = \"_redirect_loop_ids\";\n\n/**\n * Recompute loop redirect IDs and store in the options table.\n */\nasync function updateLoopCache(db: Kysely<Database>): Promise<void> {\n\ttry {\n\t\tconst options = new OptionsRepository(db);\n\t\tconst edges = toEdges(await new RedirectRepository(db).findAllEnabled());\n\t\tconst loopRedirectIds = detectLoops(edges);\n\t\tawait options.set(LOOP_CACHE_KEY, loopRedirectIds);\n\t} catch (error) {\n\t\tconsole.error(\"Failed to update redirect loop cache:\", error);\n\t}\n}\n\n/**\n * Get loop redirect IDs from cache, computing lazily on first access.\n */\nasync function getLoopRedirectIds(db: Kysely<Database>): Promise<string[]> {\n\ttry {\n\t\tconst options = new OptionsRepository(db);\n\t\tconst cached = await options.get<string[]>(LOOP_CACHE_KEY);\n\t\tif (cached !== null) return cached;\n\n\t\t// First access after upgrade — compute and cache\n\t\tawait updateLoopCache(db);\n\t\treturn (await options.get<string[]>(LOOP_CACHE_KEY)) ?? [];\n\t} catch {\n\t\treturn [];\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// 404 Log\n// ---------------------------------------------------------------------------\n\n/**\n * List 404 log entries with cursor pagination\n */\nexport async function handleNotFoundList(\n\tdb: Kysely<Database>,\n\tparams: { cursor?: string; limit?: number; search?: string },\n): Promise<ApiResult<FindManyResult<NotFoundEntry>>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst result = await repo.find404s(params);\n\t\treturn { success: true, data: result };\n\t} catch (error) {\n\t\tif (error instanceof InvalidCursorError) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"INVALID_CURSOR\", message: error.message },\n\t\t\t};\n\t\t}\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_LIST_ERROR\", message: \"Failed to fetch 404 log\" },\n\t\t};\n\t}\n}\n\n/**\n * Get 404 summary (grouped by path, sorted by count)\n */\nexport async function handleNotFoundSummary(\n\tdb: Kysely<Database>,\n\tlimit?: number,\n): Promise<ApiResult<{ items: NotFoundSummary[] }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst items = await repo.get404Summary(limit);\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_SUMMARY_ERROR\", message: \"Failed to fetch 404 summary\" },\n\t\t};\n\t}\n}\n\n/**\n * Clear all 404 log entries\n */\nexport async function handleNotFoundClear(\n\tdb: Kysely<Database>,\n): Promise<ApiResult<{ deleted: number }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.clear404s();\n\t\treturn { success: true, data: { deleted } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_CLEAR_ERROR\", message: \"Failed to clear 404 log\" },\n\t\t};\n\t}\n}\n\n/**\n * Prune 404 log entries older than a given date\n */\nexport async function handleNotFoundPrune(\n\tdb: Kysely<Database>,\n\tolderThan: string,\n): Promise<ApiResult<{ deleted: number }>> {\n\ttry {\n\t\tconst repo = new RedirectRepository(db);\n\t\tconst deleted = await repo.prune404s(olderThan);\n\t\treturn { success: true, data: { deleted } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: { code: \"NOT_FOUND_PRUNE_ERROR\", message: \"Failed to prune 404 log\" },\n\t\t};\n\t}\n}\n"],"mappings":"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"}
|
|
@@ -19,6 +19,13 @@ interface RequestMetrics {
|
|
|
19
19
|
dbLastOffset: number | null;
|
|
20
20
|
cacheHits: number;
|
|
21
21
|
cacheMisses: number;
|
|
22
|
+
/**
|
|
23
|
+
* Physical database round trips. Differs from `dbCount` (logical queries)
|
|
24
|
+
* when a backend batches: the DO SQL driver coalesces same-turn SELECTs into
|
|
25
|
+
* one RPC, so `rpcCount` can be far lower than `dbCount`. Bumped by the
|
|
26
|
+
* adapter, not the Kysely log hook.
|
|
27
|
+
*/
|
|
28
|
+
rpcCount: number;
|
|
22
29
|
}
|
|
23
30
|
declare function createRequestMetrics(start: number): RequestMetrics;
|
|
24
31
|
interface EmDashRequestContext {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-context.d.mts","names":[],"sources":["../src/request-context.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"request-context.d.mts","names":[],"sources":["../src/request-context.ts"],"mappings":";;;;;;AA+DA;;;;;;;UA9BiB,cAAA;EAChB,KAAA;EACA,OAAA;EACA,SAAA;EACA,aAAA;EACA,YAAA;EACA,SAAA;EACA,WAAA;EA+DwB;;AAoBzB;;;;EA5EC,QAAA;AAAA;AAAA,iBAGe,oBAAA,CAAqB,KAAA,WAAgB,cAAA;AAAA,UAapC,oBAAA;EA4Dc;EA1D9B,QAAA;EA0DiC;EAxDjC,OAAA;IACC,UAAA;IACA,EAAA;EAAA;EAsD0E;EAnD3E,MAAA;EA2DgC;;;;;;;EAnDhC,EAAA;;;;;;;;;;;EAWA,YAAA;;;;;;EAMA,aAAA,GAAgB,aAAA;;;;;;EAMhB,OAAA,GAAU,cAAA;AAAA;;;;;iBAoBK,cAAA,GAAA,CAAkB,GAAA,EAAK,oBAAA,EAAsB,EAAA,QAAU,CAAA,GAAI,CAAA;;;;;iBAQ3D,iBAAA,CAAA,GAAqB,oBAAA"}
|
package/dist/request-context.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"request-context.mjs","names":[],"sources":["../src/request-context.ts"],"sourcesContent":["/**\n * EmDash Request Context\n *\n * Uses AsyncLocalStorage to provide request-scoped state to query functions\n * without requiring explicit parameter passing. The middleware wraps next()\n * in als.run(), making the context available to all code during rendering.\n *\n * Middleware always wraps each request in a context so per-request\n * metrics (db.*, cache.*) can be surfaced via Server-Timing. The cost is\n * one ALS frame per request — sub-microsecond, negligible compared to\n * any real work.\n *\n * The AsyncLocalStorage instance is stored on globalThis with a Symbol key\n * to guarantee a singleton even when bundlers duplicate this module across\n * code-split chunks. Without this, Rollup/Vite may inline the module into\n * multiple chunks (e.g. middleware and page components), each with its own\n * ALS instance — breaking request-scoped state propagation.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport type { QueryRecorder } from \"./database/instrumentation.js\";\n\n/**\n * Lightweight always-on counters surfaced in Server-Timing.\n *\n * Bumped by the Kysely log hook (db queries) and by `requestCached`\n * (cache hits/misses). Read by middleware after the response is\n * generated to emit `db.*` and `cache.*` Server-Timing fields.\n *\n * Offsets are milliseconds from `start` (the request's entry into\n * middleware), captured via `performance.now()`.\n */\nexport interface RequestMetrics {\n\tstart: number;\n\tdbCount: number;\n\tdbTotalMs: number;\n\tdbFirstOffset: number | null;\n\tdbLastOffset: number | null;\n\tcacheHits: number;\n\tcacheMisses: number;\n}\n\nexport function createRequestMetrics(start: number): RequestMetrics {\n\treturn {\n\t\tstart,\n\t\tdbCount: 0,\n\t\tdbTotalMs: 0,\n\t\tdbFirstOffset: null,\n\t\tdbLastOffset: null,\n\t\tcacheHits: 0,\n\t\tcacheMisses: 0,\n\t};\n}\n\nexport interface EmDashRequestContext {\n\t/** Whether the current request is in visual editing mode */\n\teditMode: boolean;\n\t/** Preview token info, if this is a preview request */\n\tpreview?: {\n\t\tcollection: string;\n\t\tid: string;\n\t};\n\t/** Current locale from Astro's i18n routing (when configured) */\n\tlocale?: string;\n\t/**\n\t * Per-request database override.\n\t *\n\t * Set by middleware when D1 read replica sessions are enabled.\n\t * The runtime's `db` getter checks this first, falling back to\n\t * the singleton instance. Also used by the DO preview pattern.\n\t */\n\tdb?: unknown;\n\t/**\n\t * Indicates the per-request `db` points at an isolated database\n\t * instance whose schema may diverge from the configured one\n\t * (playground, DO preview sessions). When true, schema-derived caches\n\t * (manifest, taxonomy defs, etc.) must not be reused across requests.\n\t *\n\t * Plain D1 Sessions API routing does NOT set this — sessions are just\n\t * a routing hint over the same schema, so the module-scoped manifest\n\t * cache remains valid.\n\t */\n\tdbIsIsolated?: boolean;\n\t/**\n\t * Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.\n\t * The Kysely `log` hook appends an event per query; middleware flushes\n\t * to NDJSON after the response.\n\t */\n\tqueryRecorder?: QueryRecorder;\n\t/**\n\t * Per-request metrics for Server-Timing. Always attached by middleware\n\t * for requests that emit timing headers; bumped by the Kysely log hook\n\t * and `requestCached`.\n\t */\n\tmetrics?: RequestMetrics;\n}\n\nconst ALS_KEY = Symbol.for(\"emdash:request-context\");\n\nconst storage: AsyncLocalStorage<EmDashRequestContext> =\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\t((globalThis as Record<symbol, unknown>)[ALS_KEY] as\n\t\t| AsyncLocalStorage<EmDashRequestContext>\n\t\t| undefined) ??\n\t(() => {\n\t\tconst als = new AsyncLocalStorage<EmDashRequestContext>();\n\t\t(globalThis as Record<symbol, unknown>)[ALS_KEY] = als;\n\t\treturn als;\n\t})();\n\n/**\n * Run a function within an EmDash request context.\n * Called by middleware to wrap next().\n */\nexport function runWithContext<T>(ctx: EmDashRequestContext, fn: () => T): T {\n\treturn storage.run(ctx, fn);\n}\n\n/**\n * Get the current request context.\n * Returns undefined if no context is set (logged-out fast path).\n */\nexport function getRequestContext(): EmDashRequestContext | undefined {\n\treturn storage.getStore();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"request-context.mjs","names":[],"sources":["../src/request-context.ts"],"sourcesContent":["/**\n * EmDash Request Context\n *\n * Uses AsyncLocalStorage to provide request-scoped state to query functions\n * without requiring explicit parameter passing. The middleware wraps next()\n * in als.run(), making the context available to all code during rendering.\n *\n * Middleware always wraps each request in a context so per-request\n * metrics (db.*, cache.*) can be surfaced via Server-Timing. The cost is\n * one ALS frame per request — sub-microsecond, negligible compared to\n * any real work.\n *\n * The AsyncLocalStorage instance is stored on globalThis with a Symbol key\n * to guarantee a singleton even when bundlers duplicate this module across\n * code-split chunks. Without this, Rollup/Vite may inline the module into\n * multiple chunks (e.g. middleware and page components), each with its own\n * ALS instance — breaking request-scoped state propagation.\n */\n\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nimport type { QueryRecorder } from \"./database/instrumentation.js\";\n\n/**\n * Lightweight always-on counters surfaced in Server-Timing.\n *\n * Bumped by the Kysely log hook (db queries) and by `requestCached`\n * (cache hits/misses). Read by middleware after the response is\n * generated to emit `db.*` and `cache.*` Server-Timing fields.\n *\n * Offsets are milliseconds from `start` (the request's entry into\n * middleware), captured via `performance.now()`.\n */\nexport interface RequestMetrics {\n\tstart: number;\n\tdbCount: number;\n\tdbTotalMs: number;\n\tdbFirstOffset: number | null;\n\tdbLastOffset: number | null;\n\tcacheHits: number;\n\tcacheMisses: number;\n\t/**\n\t * Physical database round trips. Differs from `dbCount` (logical queries)\n\t * when a backend batches: the DO SQL driver coalesces same-turn SELECTs into\n\t * one RPC, so `rpcCount` can be far lower than `dbCount`. Bumped by the\n\t * adapter, not the Kysely log hook.\n\t */\n\trpcCount: number;\n}\n\nexport function createRequestMetrics(start: number): RequestMetrics {\n\treturn {\n\t\tstart,\n\t\tdbCount: 0,\n\t\tdbTotalMs: 0,\n\t\tdbFirstOffset: null,\n\t\tdbLastOffset: null,\n\t\tcacheHits: 0,\n\t\tcacheMisses: 0,\n\t\trpcCount: 0,\n\t};\n}\n\nexport interface EmDashRequestContext {\n\t/** Whether the current request is in visual editing mode */\n\teditMode: boolean;\n\t/** Preview token info, if this is a preview request */\n\tpreview?: {\n\t\tcollection: string;\n\t\tid: string;\n\t};\n\t/** Current locale from Astro's i18n routing (when configured) */\n\tlocale?: string;\n\t/**\n\t * Per-request database override.\n\t *\n\t * Set by middleware when D1 read replica sessions are enabled.\n\t * The runtime's `db` getter checks this first, falling back to\n\t * the singleton instance. Also used by the DO preview pattern.\n\t */\n\tdb?: unknown;\n\t/**\n\t * Indicates the per-request `db` points at an isolated database\n\t * instance whose schema may diverge from the configured one\n\t * (playground, DO preview sessions). When true, schema-derived caches\n\t * (manifest, taxonomy defs, etc.) must not be reused across requests.\n\t *\n\t * Plain D1 Sessions API routing does NOT set this — sessions are just\n\t * a routing hint over the same schema, so the module-scoped manifest\n\t * cache remains valid.\n\t */\n\tdbIsIsolated?: boolean;\n\t/**\n\t * Query recorder attached by middleware when EMDASH_QUERY_LOG_FILE is set.\n\t * The Kysely `log` hook appends an event per query; middleware flushes\n\t * to NDJSON after the response.\n\t */\n\tqueryRecorder?: QueryRecorder;\n\t/**\n\t * Per-request metrics for Server-Timing. Always attached by middleware\n\t * for requests that emit timing headers; bumped by the Kysely log hook\n\t * and `requestCached`.\n\t */\n\tmetrics?: RequestMetrics;\n}\n\nconst ALS_KEY = Symbol.for(\"emdash:request-context\");\n\nconst storage: AsyncLocalStorage<EmDashRequestContext> =\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\t((globalThis as Record<symbol, unknown>)[ALS_KEY] as\n\t\t| AsyncLocalStorage<EmDashRequestContext>\n\t\t| undefined) ??\n\t(() => {\n\t\tconst als = new AsyncLocalStorage<EmDashRequestContext>();\n\t\t(globalThis as Record<symbol, unknown>)[ALS_KEY] = als;\n\t\treturn als;\n\t})();\n\n/**\n * Run a function within an EmDash request context.\n * Called by middleware to wrap next().\n */\nexport function runWithContext<T>(ctx: EmDashRequestContext, fn: () => T): T {\n\treturn storage.run(ctx, fn);\n}\n\n/**\n * Get the current request context.\n * Returns undefined if no context is set (logged-out fast path).\n */\nexport function getRequestContext(): EmDashRequestContext | undefined {\n\treturn storage.getStore();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAkDA,SAAgB,qBAAqB,OAA+B;AACnE,QAAO;EACN;EACA,SAAS;EACT,WAAW;EACX,eAAe;EACf,cAAc;EACd,WAAW;EACX,aAAa;EACb,UAAU;EACV;;AA8CF,MAAM,UAAU,OAAO,IAAI,yBAAyB;AAEpD,MAAM,UAEH,WAAuC,mBAGlC;CACN,MAAM,MAAM,IAAI,mBAAyC;AACzD,CAAC,WAAuC,WAAW;AACnD,QAAO;IACJ;;;;;AAML,SAAgB,eAAkB,KAA2B,IAAgB;AAC5E,QAAO,QAAQ,IAAI,KAAK,GAAG;;;;;;AAO5B,SAAgB,oBAAsD;AACrE,QAAO,QAAQ,UAAU"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as Database } from "./types-
|
|
1
|
+
import { t as Database } from "./types-BPzXTV9x.mjs";
|
|
2
2
|
import { Kysely } from "kysely";
|
|
3
3
|
|
|
4
4
|
//#region src/database/migrations/runner.d.ts
|
|
@@ -44,4 +44,4 @@ declare function rollbackMigration(db: Kysely<Database>, options?: MigrationOpti
|
|
|
44
44
|
}>;
|
|
45
45
|
//#endregion
|
|
46
46
|
export { runMigrations as i, getMigrationStatus as n, rollbackMigration as r, MigrationStatus as t };
|
|
47
|
-
//# sourceMappingURL=runner-
|
|
47
|
+
//# sourceMappingURL=runner-DTdhuI9i.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runner-
|
|
1
|
+
{"version":3,"file":"runner-DTdhuI9i.d.mts","names":[],"sources":["../src/database/migrations/runner.ts"],"mappings":";;;;UA0GiB,eAAA;EAChB,OAAA;EACA,OAAA;AAAA;AAAA,UAOgB,gBAAA;EAChB,oBAAA;AAAA;;;;iBAgBqB,kBAAA,CACrB,EAAA,EAAI,MAAA,CAAO,QAAA,GACX,OAAA,GAAU,gBAAA,GACR,OAAA,CAAQ,eAAA;;;;;;;;;;;;AAoKX;;;;;;;;;iBAAsB,aAAA,CACrB,EAAA,EAAI,MAAA,CAAO,QAAA,GACX,OAAA,GAAU,gBAAA,GACR,OAAA;EAAU,OAAA;AAAA;;;;iBA8CS,iBAAA,CACrB,EAAA,EAAI,MAAA,CAAO,QAAA,GACX,OAAA,GAAU,gBAAA,GACR,OAAA;EAAU,UAAA;AAAA"}
|
package/dist/runtime.d.mts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import "./options-
|
|
2
|
-
import "./types-
|
|
3
|
-
import "./types-
|
|
4
|
-
import "./byline-fields-
|
|
5
|
-
import { f as MediaProvider } from "./placeholder-
|
|
6
|
-
import { En as getDb, Sn as EntryFilter, Tn as emdashLoader, bn as CollectionFilter, xn as EntryData } from "./index-
|
|
7
|
-
import "./runner-
|
|
8
|
-
import "./index-
|
|
9
|
-
import "./types-
|
|
10
|
-
import "./validate-
|
|
1
|
+
import "./options-DyYIYpPd.mjs";
|
|
2
|
+
import "./types-BPzXTV9x.mjs";
|
|
3
|
+
import "./types-CPAPl93j.mjs";
|
|
4
|
+
import "./byline-fields-DbibsvTl.mjs";
|
|
5
|
+
import { f as MediaProvider } from "./placeholder-CVBv5z8k.mjs";
|
|
6
|
+
import { En as getDb, Sn as EntryFilter, Tn as emdashLoader, bn as CollectionFilter, xn as EntryData } from "./index-B1keaX5Y.mjs";
|
|
7
|
+
import "./runner-DTdhuI9i.mjs";
|
|
8
|
+
import "./index-DR56od45.mjs";
|
|
9
|
+
import "./types-BFgrqwSk.mjs";
|
|
10
|
+
import "./validate-CNwkPWzz.mjs";
|
|
11
11
|
|
|
12
12
|
//#region src/media/provider-loader.d.ts
|
|
13
13
|
/**
|
package/dist/runtime.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "./dialect-helpers-DRI5pyY3.mjs";
|
|
2
2
|
import "./base64-CqR-7kqF.mjs";
|
|
3
3
|
import "./types-BXSUSAjt.mjs";
|
|
4
|
-
import { n as emdashLoader, r as getDb } from "./loader-
|
|
4
|
+
import { n as emdashLoader, r as getDb } from "./loader-ZN1ll-d-.mjs";
|
|
5
5
|
|
|
6
6
|
//#region src/media/provider-loader.ts
|
|
7
7
|
let virtualMediaProviders;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as __exportAll } from "./runner--4wMWwKM.mjs";
|
|
2
2
|
import { r as requestCached } from "./request-cache-D32LpnmI.mjs";
|
|
3
3
|
import { n as SchemaRegistry } from "./registry-brYh-rAT.mjs";
|
|
4
|
-
import { r as getDb } from "./loader-
|
|
4
|
+
import { r as getDb } from "./loader-ZN1ll-d-.mjs";
|
|
5
5
|
|
|
6
6
|
//#region src/schema/query.ts
|
|
7
7
|
/**
|
|
@@ -38,4 +38,4 @@ var schema_exports = /* @__PURE__ */ __exportAll({ SchemaRegistry: () => SchemaR
|
|
|
38
38
|
|
|
39
39
|
//#endregion
|
|
40
40
|
export { getCollectionInfo as n, schema_exports as t };
|
|
41
|
-
//# sourceMappingURL=schema-
|
|
41
|
+
//# sourceMappingURL=schema-C1E70ug_.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema-
|
|
1
|
+
{"version":3,"file":"schema-C1E70ug_.mjs","names":[],"sources":["../src/schema/query.ts","../src/schema/index.ts"],"sourcesContent":["/**\n * Collection info query for Astro templates.\n *\n * Same pattern as getMenu() / getComments() — uses getDb() for ambient DB access.\n */\n\nimport type { Kysely } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { getDb } from \"../loader.js\";\nimport { requestCached } from \"../request-cache.js\";\nimport { SchemaRegistry } from \"./registry.js\";\nimport type { Collection } from \"./types.js\";\n\n/**\n * Get collection metadata by slug.\n *\n * @example\n * ```ts\n * import { getCollectionInfo } from \"emdash\";\n *\n * const info = await getCollectionInfo(\"posts\");\n * if (info?.commentsEnabled) {\n * // render comment UI\n * }\n * ```\n */\nexport async function getCollectionInfo(slug: string): Promise<Collection | null> {\n\treturn requestCached(`collection-info:${slug}`, async () => {\n\t\tconst db = await getDb();\n\t\treturn getCollectionInfoWithDb(db, slug);\n\t});\n}\n\n/**\n * Get collection metadata with an explicit db handle.\n *\n * @internal Use `getCollectionInfo()` in templates. This variant is for\n * routes that already have a database handle.\n */\nexport async function getCollectionInfoWithDb(\n\tdb: Kysely<Database>,\n\tslug: string,\n): Promise<Collection | null> {\n\tconst registry = new SchemaRegistry(db);\n\treturn registry.getCollection(slug);\n}\n","export { SchemaRegistry, SchemaError } from \"./registry.js\";\nexport type {\n\tFieldType,\n\tColumnType,\n\tCollectionSupport,\n\tCollectionSource,\n\tFieldValidation,\n\tFieldWidgetOptions,\n\tCollection,\n\tField,\n\tCreateCollectionInput,\n\tUpdateCollectionInput,\n\tCreateFieldInput,\n\tUpdateFieldInput,\n\tCollectionWithFields,\n} from \"./types.js\";\nexport { FIELD_TYPE_TO_COLUMN, RESERVED_FIELD_SLUGS, RESERVED_COLLECTION_SLUGS } from \"./types.js\";\n\nexport { getCollectionInfo, getCollectionInfoWithDb } from \"./query.js\";\n\nexport {\n\tgenerateZodSchema,\n\tgenerateFieldSchema,\n\tgetCachedSchema,\n\tinvalidateSchemaCache,\n\tclearSchemaCache,\n\tvalidateContent,\n\tgenerateTypeScript,\n} from \"./zod-generator.js\";\n"],"mappings":";;;;;;;;;;;;;;;;;;;AA2BA,eAAsB,kBAAkB,MAA0C;AACjF,QAAO,cAAc,mBAAmB,QAAQ,YAAY;AAE3D,SAAO,wBADI,MAAM,OAAO,EACW,KAAK;GACvC;;;;;;;;AASH,eAAsB,wBACrB,IACA,MAC6B;AAE7B,QADiB,IAAI,eAAe,GAAG,CACvB,cAAc,KAAK"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { a as __exportAll } from "./runner--4wMWwKM.mjs";
|
|
2
2
|
import { t as validateIdentifier } from "./validate-VPnKoIzW.mjs";
|
|
3
3
|
import { t as FTSManager } from "./fts-manager-1RgHmopc.mjs";
|
|
4
|
-
import { r as getDb } from "./loader-
|
|
4
|
+
import { r as getDb } from "./loader-ZN1ll-d-.mjs";
|
|
5
5
|
import { sql } from "kysely";
|
|
6
6
|
import { toPlainText } from "@portabletext/toolkit";
|
|
7
7
|
|
|
@@ -373,4 +373,4 @@ var search_exports = /* @__PURE__ */ __exportAll({ searchWithDb: () => searchWit
|
|
|
373
373
|
|
|
374
374
|
//#endregion
|
|
375
375
|
export { getSuggestions as a, searchWithDb as c, getSearchStats as i, extractPlainText as n, search as o, extractSearchableFields as r, searchCollection as s, search_exports as t };
|
|
376
|
-
//# sourceMappingURL=search-
|
|
376
|
+
//# sourceMappingURL=search-B3SGZw91.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search-o-aQzHI1.mjs","names":[],"sources":["../src/search/query.ts","../src/search/text-extraction.ts","../src/search/index.ts"],"sourcesContent":["/**\n * Search Query Functions\n *\n * Programmatic API for searching content using FTS5.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { getDb } from \"../loader.js\";\nimport { FTSManager } from \"./fts-manager.js\";\nimport type {\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n/** Pattern to split on whitespace for query term extraction */\nconst WHITESPACE_SPLIT_PATTERN = /\\s+/;\nconst FTS_OPERATORS_PATTERN = /\\b(AND|OR|NOT|NEAR)\\b/i;\nconst DOUBLE_QUOTE_PATTERN = /\"/g;\n\n/**\n * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5\n * error fingerprints rather than a broad \"fts5\" / \"syntax error\" filter\n * (which would also swallow internal table-corruption errors). The two\n * fingerprints we care about are:\n *\n * - \"fts5: syntax error near …\" — unbalanced quotes, stray operators,\n * other malformed user input\n * - \"unknown special query: …\" — bare special tokens like `^*` that\n * parse but don't resolve to a real FTS5 directive\n */\nfunction isFts5SyntaxError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst message = error.message.toLowerCase();\n\treturn message.includes(\"fts5: syntax error\") || message.includes(\"unknown special query\");\n}\n\n/**\n * Search across multiple collections\n *\n * Public API that auto-injects the database.\n *\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * import { search } from \"emdash\";\n *\n * const results = await search(\"hello world\", {\n * collections: [\"posts\", \"pages\"],\n * limit: 20\n * });\n * ```\n */\nexport async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {\n\tconst db = await getDb();\n\treturn searchWithDb(db, query, options);\n}\n\n/**\n * Search across multiple collections (with explicit db)\n *\n * @internal Use `search()` in templates. This variant is for admin routes\n * that already have a database handle.\n *\n * @param db - Kysely database instance\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n */\nexport async function searchWithDb(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn { items: [] };\n\t}\n\n\t// Search each collection and merge results\n\tconst allResults: SearchResult[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst collectionResults = await searchSingleCollection(\n\t\t\tdb,\n\t\t\tcollection,\n\t\t\tquery,\n\t\t\t{\n\t\t\t\tstatus,\n\t\t\t\tlocale: options.locale,\n\t\t\t\tlimit: limit * 2, // Get extra for merging\n\t\t\t},\n\t\t\tconfig.weights,\n\t\t);\n\n\t\tallResults.push(...collectionResults);\n\t}\n\n\t// Sort by score descending\n\tallResults.sort((a, b) => b.score - a.score);\n\n\t// Apply limit\n\tconst items = allResults.slice(0, limit);\n\n\treturn { items };\n}\n\n/**\n * Search within a single collection\n *\n * @param db - Kysely database instance\n * @param collection - Collection slug\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * const results = await searchCollection(db, \"posts\", \"hello world\", {\n * limit: 10\n * });\n * ```\n */\nexport async function searchCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst config = await ftsManager.getSearchConfig(collection);\n\n\tif (!config?.enabled) {\n\t\treturn { items: [] };\n\t}\n\n\tconst items = await searchSingleCollection(db, collection, query, options, config.weights);\n\n\treturn { items };\n}\n\n/**\n * Internal function to search a single collection\n */\nasync function searchSingleCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions,\n\tweights?: Record<string, number>,\n): Promise<SearchResult[]> {\n\t// Validate before any raw SQL interpolation\n\tvalidateIdentifier(collection, \"collection slug\");\n\n\tconst ftsManager = new FTSManager(db);\n\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\tconst contentTable = ftsManager.getContentTableName(collection);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst locale = options.locale;\n\n\t// Check if FTS table exists\n\tif (!(await ftsManager.ftsTableExists(collection))) {\n\t\treturn [];\n\t}\n\n\t// Escape the query for FTS5\n\tconst escapedQuery = escapeQuery(query);\n\tif (!escapedQuery) {\n\t\treturn [];\n\t}\n\n\t// Get searchable fields for snippet generation\n\tconst searchableFields = await ftsManager.getSearchableFields(collection);\n\n\t// Build weight string for bm25 if weights provided\n\t// Format: bm25(table, weight1, weight2, ...)\n\t// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)\n\tlet bm25Args = \"\";\n\tif (weights && searchableFields.length > 0) {\n\t\tconst weightValues = [\"0\", \"0\"]; // id column, locale column\n\t\tfor (const field of searchableFields) {\n\t\t\tweightValues.push(String(weights[field] ?? 1));\n\t\t}\n\t\tbm25Args = weightValues.join(\", \");\n\t}\n\n\t// Build and execute the search query\n\t// Using raw SQL because Kysely doesn't have FTS5 support\n\tconst bm25Expr = bm25Args ? `bm25(\"${ftsTable}\", ${bm25Args})` : `bm25(\"${ftsTable}\")`;\n\n\t// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)\n\tlet results;\n\ttry {\n\t\tresults = await sql<{\n\t\t\tid: string;\n\t\t\tslug: string | null;\n\t\t\tlocale: string;\n\t\t\ttitle: string | null;\n\t\t\tsnippet: string | null;\n\t\t\tscore: number;\n\t\t}>`\n\t\tSELECT \n\t\t\tc.id,\n\t\t\tc.slug,\n\t\t\tc.locale,\n\t\t\tc.title,\n\t\t\tsnippet(\"${sql.raw(ftsTable)}\", 2, '<mark>', '</mark>', '...', 32) as snippet,\n\t\t\t${sql.raw(bm25Expr)} as score\n\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${escapedQuery}\n\t\tAND c.status = ${status}\n\t\tAND c.deleted_at IS NULL\n\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\tORDER BY score\n\t\tLIMIT ${limit}\n\t`.execute(db);\n\t} catch (error) {\n\t\t// FTS5 returns syntax errors for queries with unbalanced quotes,\n\t\t// stray operators, or other malformed input. Treat these as\n\t\t// \"no matches\" so the user gets an empty result rather than an\n\t\t// internals-leaking error. Other errors (table missing, IO) still\n\t\t// propagate. Intentionally not logged: any anonymous client can\n\t\t// trigger this path, and the underlying error message embeds the\n\t\t// raw query, so logging would be both noisy and a log-injection\n\t\t// vector.\n\t\tif (isFts5SyntaxError(error)) {\n\t\t\treturn [];\n\t\t}\n\t\tthrow error;\n\t}\n\n\treturn results.rows.map((row) => ({\n\t\tcollection,\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\tlocale: row.locale,\n\t\ttitle: row.title ?? undefined,\n\t\t// SQLite's snippet() returns NULL when the targeted column is\n\t\t// NULL for that row — even if the row matched via a different\n\t\t// searchable column. Skip sanitization in that case so we don't\n\t\t// throw on `null.replace`. The SearchResult.snippet field is\n\t\t// already optional, so omitting it is the documented contract.\n\t\tsnippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),\n\t\tscore: Math.abs(row.score), // bm25 returns negative scores\n\t}));\n}\n\n// Module-scope regexes so the engine doesn't recompile per call —\n// snippet sanitization runs on every search result.\nconst SNIPPET_AMP_RE = /&/g;\nconst SNIPPET_LT_RE = /</g;\nconst SNIPPET_GT_RE = />/g;\nconst SNIPPET_QUOT_RE = /\"/g;\nconst SNIPPET_APOS_RE = /'/g;\n\n/**\n * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.\n *\n * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`\n * markers around matched terms but does not escape the surrounding\n * source text. Posts that legitimately contain `<`, `>`, `&`, `\"` or\n * `'` would render as broken markup, and a `<script>` literal in a\n * title (or any other indexed field) would execute when displayed.\n *\n * The fix: HTML-escape the whole string, which turns the markers into\n * `<mark>` / `</mark>`. Then restore those two patterns to\n * their original tag form. The result is \"the indexed text with all\n * HTML metacharacters escaped, plus a small set of literal `<mark>`\n * highlight tags around matched terms\" — which matches the API's\n * documented contract.\n */\nfunction sanitizeSnippet(snippet: string): string {\n\treturn snippet\n\t\t.replace(SNIPPET_AMP_RE, \"&\")\n\t\t.replace(SNIPPET_LT_RE, \"<\")\n\t\t.replace(SNIPPET_GT_RE, \">\")\n\t\t.replace(SNIPPET_QUOT_RE, \""\")\n\t\t.replace(SNIPPET_APOS_RE, \"'\")\n\t\t.replaceAll(\"<mark>\", \"<mark>\")\n\t\t.replaceAll(\"</mark>\", \"</mark>\");\n}\n\n/**\n * Get search suggestions for autocomplete\n *\n * @param db - Kysely database instance\n * @param query - Partial search query\n * @param options - Suggestion options\n * @returns Array of suggestions\n */\nexport async function getSuggestions(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SuggestOptions = {},\n): Promise<Suggestion[]> {\n\tconst limit = options.limit ?? 5;\n\tconst locale = options.locale;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst suggestions: Suggestion[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst ftsManager = new FTSManager(db);\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Validate before raw SQL interpolation\n\t\tvalidateIdentifier(collection, \"collection slug\");\n\n\t\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\t\tconst contentTable = ftsManager.getContentTableName(collection);\n\n\t\t// Use prefix search for autocomplete. `escapeQuery` already appends `*`\n\t\t// to each term for prefix matching, so we must not append another one.\n\t\tconst prefixQuery = escapeQuery(query);\n\t\tif (!prefixQuery) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet results;\n\t\ttry {\n\t\t\tresults = await sql<{\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t}>`\n\t\t\t\tSELECT \n\t\t\t\t\tc.id,\n\t\t\t\t\tc.title\n\t\t\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\t\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\t\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${prefixQuery}\n\t\t\t\tAND c.status = 'published'\n\t\t\t\tAND c.deleted_at IS NULL\n\t\t\t\tAND c.title IS NOT NULL\n\t\t\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\t\t\tORDER BY bm25(\"${sql.raw(ftsTable)}\")\n\t\t\t\tLIMIT ${limit}\n\t\t\t`.execute(db);\n\t\t} catch (error) {\n\t\t\t// Same swallow as searchSingleCollection: malformed prefix\n\t\t\t// queries should yield no suggestions, not surface DB errors.\n\t\t\t// Intentionally not logged (anonymous-triggerable, echoes\n\t\t\t// user input -- see searchSingleCollection for rationale).\n\t\t\tif (isFts5SyntaxError(error)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of results.rows) {\n\t\t\tsuggestions.push({\n\t\t\t\tcollection,\n\t\t\t\tid: row.id,\n\t\t\t\ttitle: row.title,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn suggestions.slice(0, limit);\n}\n\n/**\n * Get search statistics for all collections\n */\nexport async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {\n\tconst ftsManager = new FTSManager(db);\n\tconst collections = await getSearchableCollections(db);\n\tconst stats: SearchStats = { collections: {} };\n\n\tfor (const collection of collections) {\n\t\tconst collectionStats = await ftsManager.getIndexStats(collection);\n\t\tif (collectionStats) {\n\t\t\tstats.collections[collection] = collectionStats;\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Get list of collections with search enabled\n */\nasync function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {\n\tconst results = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select([\"slug\", \"search_config\"])\n\t\t.execute();\n\n\treturn results\n\t\t.filter((r) => {\n\t\t\tif (!r.search_config) return false;\n\t\t\ttry {\n\t\t\t\tconst config = JSON.parse(r.search_config);\n\t\t\t\treturn config.enabled === true;\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t})\n\t\t.map((r) => r.slug);\n}\n\n/**\n * Escape a query string for FTS5\n *\n * Handles special characters and prevents injection.\n */\nfunction escapeQuery(query: string): string {\n\tif (!query || typeof query !== \"string\") {\n\t\treturn \"\";\n\t}\n\n\t// Trim whitespace\n\tquery = query.trim();\n\n\tif (query.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// If already a quoted phrase, escape only interior quotes and preserve phrase syntax\n\tif (query.startsWith('\"') && query.endsWith('\"') && query.length >= 2) {\n\t\tconst inner = query.slice(1, -1);\n\t\treturn `\"${inner.replace(DOUBLE_QUOTE_PATTERN, '\"\"')}\"`;\n\t}\n\n\t// Escape any existing quotes\n\tconst escaped = query.replace(DOUBLE_QUOTE_PATTERN, '\"\"');\n\n\t// If the query contains FTS5 operators (AND, OR, NOT, NEAR),\n\t// pass through with quotes escaped but operators preserved\n\tif (FTS_OPERATORS_PATTERN.test(query)) {\n\t\treturn escaped;\n\t}\n\n\t// For simple queries, wrap each word to handle special chars\n\tconst terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);\n\tif (terms.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// Join with implicit AND, add prefix matching (*) to all terms\n\t// This allows \"hel wor\" to match \"hello world\"\n\treturn terms.map((t) => `\"${t}\"*`).join(\" \");\n}\n","/**\n * Text Extraction\n *\n * Extracts plain text from Portable Text blocks for FTS indexing.\n * Uses @portabletext/toolkit as base with extensions for custom block types.\n */\n\nimport { toPlainText } from \"@portabletext/toolkit\";\n\nimport type { PortableTextBlock } from \"../content/converters/types.js\";\n\n/**\n * Validate that a value looks like a Portable Text block array.\n * Each element must have at least a `_type` string property.\n */\nfunction isPortableTextArray(value: unknown[]): value is PortableTextBlock[] {\n\treturn value.every(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" &&\n\t\t\titem !== null &&\n\t\t\t\"_type\" in item &&\n\t\t\ttypeof item._type === \"string\",\n\t);\n}\n\n/**\n * Extract additional text from custom block types that toPlainText doesn't handle\n */\nfunction extractCustomBlockText(block: PortableTextBlock): string {\n\t// Code blocks - include the code content\n\tif (block._type === \"code\" && \"code\" in block && typeof block.code === \"string\") {\n\t\treturn block.code;\n\t}\n\n\t// Image blocks - include alt text and caption\n\tif (block._type === \"image\") {\n\t\tconst parts: string[] = [];\n\t\tif (\"alt\" in block && typeof block.alt === \"string\" && block.alt) {\n\t\t\tparts.push(block.alt);\n\t\t}\n\t\tif (\"caption\" in block && typeof block.caption === \"string\" && block.caption) {\n\t\t\tparts.push(block.caption);\n\t\t}\n\t\treturn parts.join(\" \");\n\t}\n\n\treturn \"\";\n}\n\n/**\n * Extract plain text from Portable Text blocks\n *\n * Uses @portabletext/toolkit's toPlainText for standard blocks,\n * plus extracts text from custom block types (code, images with alt/caption).\n *\n * @param blocks - Array of Portable Text blocks (or a JSON string)\n * @returns Plain text content\n *\n * @example\n * ```typescript\n * const text = extractPlainText([\n * {\n * _type: \"block\",\n * _key: \"abc\",\n * children: [{ _type: \"span\", _key: \"s1\", text: \"Hello World\" }]\n * }\n * ]);\n * // Returns: \"Hello World\"\n * ```\n */\nexport function extractPlainText(blocks: PortableTextBlock[] | string | null | undefined): string {\n\tif (!blocks) {\n\t\treturn \"\";\n\t}\n\n\t// Handle JSON string input\n\tlet parsedBlocks: PortableTextBlock[];\n\tif (typeof blocks === \"string\") {\n\t\ttry {\n\t\t\tparsedBlocks = JSON.parse(blocks);\n\t\t} catch {\n\t\t\t// If it's not valid JSON, treat as plain text\n\t\t\treturn blocks;\n\t\t}\n\t} else {\n\t\tparsedBlocks = blocks;\n\t}\n\n\tif (!Array.isArray(parsedBlocks)) {\n\t\treturn \"\";\n\t}\n\n\t// Use official toPlainText for standard blocks.\n\t// toPlainText expects `{ _type: string; [key: string]: any }[]` but our blocks use\n\t// `unknown` index sigs. They're structurally compatible at runtime — spread each block\n\t// to satisfy the wider index signature without an unsafe cast.\n\tconst toolkitBlocks = parsedBlocks.map((b) => {\n\t\tconst obj: Record<string, unknown> & { _type: string } = { _type: b._type };\n\t\tfor (const [key, val] of Object.entries(b)) {\n\t\t\tobj[key] = val;\n\t\t}\n\t\treturn obj;\n\t});\n\tconst standardText = toPlainText(toolkitBlocks);\n\n\t// Extract text from custom block types that toPlainText doesn't handle\n\tconst customTexts = parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0);\n\n\t// Combine both\n\tconst allTexts = [standardText, ...customTexts].filter((t) => t.length > 0);\n\treturn allTexts.join(\"\\n\");\n}\n\n/**\n * Extract searchable text from a content entry\n *\n * Extracts text from specified fields, handling both plain text and Portable Text.\n *\n * @param entry - Content entry data\n * @param fields - Field names to extract text from\n * @returns Object mapping field names to extracted text\n */\nexport function extractSearchableFields(\n\tentry: Record<string, unknown>,\n\tfields: string[],\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\n\tfor (const field of fields) {\n\t\tconst value = entry[field];\n\n\t\tif (value === null || value === undefined) {\n\t\t\tresult[field] = \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\t// Could be plain text or JSON Portable Text\n\t\t\tif (value.startsWith(\"[\")) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = value;\n\t\t\t}\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Validate the array looks like Portable Text before treating it as such\n\t\t\tif (isPortableTextArray(value)) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = JSON.stringify(value);\n\t\t\t}\n\t\t} else if (typeof value === \"object\") {\n\t\t\t// Object — serialize to JSON for searchable text\n\t\t\tresult[field] = JSON.stringify(value);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tresult[field] = `${value}`;\n\t\t} else {\n\t\t\tresult[field] = \"\";\n\t\t}\n\t}\n\n\treturn result;\n}\n","/**\n * Search Module\n *\n * Full-text search for EmDash using SQLite FTS5.\n */\n\n// Types\nexport type {\n\tSearchConfig,\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n// FTS Manager\nexport { FTSManager } from \"./fts-manager.js\";\n\n// Query functions (public API uses getDb() internally)\nexport { search, searchWithDb, searchCollection, getSuggestions, getSearchStats } from \"./query.js\";\n\n// Text extraction\nexport { extractPlainText, extractSearchableFields } from \"./text-extraction.js\";\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"search-B3SGZw91.mjs","names":[],"sources":["../src/search/query.ts","../src/search/text-extraction.ts","../src/search/index.ts"],"sourcesContent":["/**\n * Search Query Functions\n *\n * Programmatic API for searching content using FTS5.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\n\nimport type { Database } from \"../database/types.js\";\nimport { validateIdentifier } from \"../database/validate.js\";\nimport { getDb } from \"../loader.js\";\nimport { FTSManager } from \"./fts-manager.js\";\nimport type {\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n/** Pattern to split on whitespace for query term extraction */\nconst WHITESPACE_SPLIT_PATTERN = /\\s+/;\nconst FTS_OPERATORS_PATTERN = /\\b(AND|OR|NOT|NEAR)\\b/i;\nconst DOUBLE_QUOTE_PATTERN = /\"/g;\n\n/**\n * Detect FTS5 query syntax errors. Match specifically on the SQLite FTS5\n * error fingerprints rather than a broad \"fts5\" / \"syntax error\" filter\n * (which would also swallow internal table-corruption errors). The two\n * fingerprints we care about are:\n *\n * - \"fts5: syntax error near …\" — unbalanced quotes, stray operators,\n * other malformed user input\n * - \"unknown special query: …\" — bare special tokens like `^*` that\n * parse but don't resolve to a real FTS5 directive\n */\nfunction isFts5SyntaxError(error: unknown): boolean {\n\tif (!(error instanceof Error)) return false;\n\tconst message = error.message.toLowerCase();\n\treturn message.includes(\"fts5: syntax error\") || message.includes(\"unknown special query\");\n}\n\n/**\n * Search across multiple collections\n *\n * Public API that auto-injects the database.\n *\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * import { search } from \"emdash\";\n *\n * const results = await search(\"hello world\", {\n * collections: [\"posts\", \"pages\"],\n * limit: 20\n * });\n * ```\n */\nexport async function search(query: string, options: SearchOptions = {}): Promise<SearchResponse> {\n\tconst db = await getDb();\n\treturn searchWithDb(db, query, options);\n}\n\n/**\n * Search across multiple collections (with explicit db)\n *\n * @internal Use `search()` in templates. This variant is for admin routes\n * that already have a database handle.\n *\n * @param db - Kysely database instance\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n */\nexport async function searchWithDb(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn { items: [] };\n\t}\n\n\t// Search each collection and merge results\n\tconst allResults: SearchResult[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst collectionResults = await searchSingleCollection(\n\t\t\tdb,\n\t\t\tcollection,\n\t\t\tquery,\n\t\t\t{\n\t\t\t\tstatus,\n\t\t\t\tlocale: options.locale,\n\t\t\t\tlimit: limit * 2, // Get extra for merging\n\t\t\t},\n\t\t\tconfig.weights,\n\t\t);\n\n\t\tallResults.push(...collectionResults);\n\t}\n\n\t// Sort by score descending\n\tallResults.sort((a, b) => b.score - a.score);\n\n\t// Apply limit\n\tconst items = allResults.slice(0, limit);\n\n\treturn { items };\n}\n\n/**\n * Search within a single collection\n *\n * @param db - Kysely database instance\n * @param collection - Collection slug\n * @param query - Search query (FTS5 syntax supported)\n * @param options - Search options\n * @returns Search results with pagination\n *\n * @example\n * ```typescript\n * const results = await searchCollection(db, \"posts\", \"hello world\", {\n * limit: 10\n * });\n * ```\n */\nexport async function searchCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions = {},\n): Promise<SearchResponse> {\n\tconst ftsManager = new FTSManager(db);\n\tconst config = await ftsManager.getSearchConfig(collection);\n\n\tif (!config?.enabled) {\n\t\treturn { items: [] };\n\t}\n\n\tconst items = await searchSingleCollection(db, collection, query, options, config.weights);\n\n\treturn { items };\n}\n\n/**\n * Internal function to search a single collection\n */\nasync function searchSingleCollection(\n\tdb: Kysely<Database>,\n\tcollection: string,\n\tquery: string,\n\toptions: CollectionSearchOptions,\n\tweights?: Record<string, number>,\n): Promise<SearchResult[]> {\n\t// Validate before any raw SQL interpolation\n\tvalidateIdentifier(collection, \"collection slug\");\n\n\tconst ftsManager = new FTSManager(db);\n\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\tconst contentTable = ftsManager.getContentTableName(collection);\n\tconst limit = options.limit ?? 20;\n\tconst status = options.status ?? \"published\";\n\tconst locale = options.locale;\n\n\t// Check if FTS table exists\n\tif (!(await ftsManager.ftsTableExists(collection))) {\n\t\treturn [];\n\t}\n\n\t// Escape the query for FTS5\n\tconst escapedQuery = escapeQuery(query);\n\tif (!escapedQuery) {\n\t\treturn [];\n\t}\n\n\t// Get searchable fields for snippet generation\n\tconst searchableFields = await ftsManager.getSearchableFields(collection);\n\n\t// Build weight string for bm25 if weights provided\n\t// Format: bm25(table, weight1, weight2, ...)\n\t// First two weights are for 'id' and 'locale' columns (UNINDEXED, so 0)\n\tlet bm25Args = \"\";\n\tif (weights && searchableFields.length > 0) {\n\t\tconst weightValues = [\"0\", \"0\"]; // id column, locale column\n\t\tfor (const field of searchableFields) {\n\t\t\tweightValues.push(String(weights[field] ?? 1));\n\t\t}\n\t\tbm25Args = weightValues.join(\", \");\n\t}\n\n\t// Build and execute the search query\n\t// Using raw SQL because Kysely doesn't have FTS5 support\n\tconst bm25Expr = bm25Args ? `bm25(\"${ftsTable}\", ${bm25Args})` : `bm25(\"${ftsTable}\")`;\n\n\t// Snippet column index is 2 (after id=0, locale=1, first searchable field=2)\n\tlet results;\n\ttry {\n\t\tresults = await sql<{\n\t\t\tid: string;\n\t\t\tslug: string | null;\n\t\t\tlocale: string;\n\t\t\ttitle: string | null;\n\t\t\tsnippet: string | null;\n\t\t\tscore: number;\n\t\t}>`\n\t\tSELECT \n\t\t\tc.id,\n\t\t\tc.slug,\n\t\t\tc.locale,\n\t\t\tc.title,\n\t\t\tsnippet(\"${sql.raw(ftsTable)}\", 2, '<mark>', '</mark>', '...', 32) as snippet,\n\t\t\t${sql.raw(bm25Expr)} as score\n\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${escapedQuery}\n\t\tAND c.status = ${status}\n\t\tAND c.deleted_at IS NULL\n\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\tORDER BY score\n\t\tLIMIT ${limit}\n\t`.execute(db);\n\t} catch (error) {\n\t\t// FTS5 returns syntax errors for queries with unbalanced quotes,\n\t\t// stray operators, or other malformed input. Treat these as\n\t\t// \"no matches\" so the user gets an empty result rather than an\n\t\t// internals-leaking error. Other errors (table missing, IO) still\n\t\t// propagate. Intentionally not logged: any anonymous client can\n\t\t// trigger this path, and the underlying error message embeds the\n\t\t// raw query, so logging would be both noisy and a log-injection\n\t\t// vector.\n\t\tif (isFts5SyntaxError(error)) {\n\t\t\treturn [];\n\t\t}\n\t\tthrow error;\n\t}\n\n\treturn results.rows.map((row) => ({\n\t\tcollection,\n\t\tid: row.id,\n\t\tslug: row.slug,\n\t\tlocale: row.locale,\n\t\ttitle: row.title ?? undefined,\n\t\t// SQLite's snippet() returns NULL when the targeted column is\n\t\t// NULL for that row — even if the row matched via a different\n\t\t// searchable column. Skip sanitization in that case so we don't\n\t\t// throw on `null.replace`. The SearchResult.snippet field is\n\t\t// already optional, so omitting it is the documented contract.\n\t\tsnippet: row.snippet === null ? undefined : sanitizeSnippet(row.snippet),\n\t\tscore: Math.abs(row.score), // bm25 returns negative scores\n\t}));\n}\n\n// Module-scope regexes so the engine doesn't recompile per call —\n// snippet sanitization runs on every search result.\nconst SNIPPET_AMP_RE = /&/g;\nconst SNIPPET_LT_RE = /</g;\nconst SNIPPET_GT_RE = />/g;\nconst SNIPPET_QUOT_RE = /\"/g;\nconst SNIPPET_APOS_RE = /'/g;\n\n/**\n * Make an FTS5 snippet safe to render with `set:html` / `innerHTML`.\n *\n * SQLite's `snippet()` function splices literal `<mark>` and `</mark>`\n * markers around matched terms but does not escape the surrounding\n * source text. Posts that legitimately contain `<`, `>`, `&`, `\"` or\n * `'` would render as broken markup, and a `<script>` literal in a\n * title (or any other indexed field) would execute when displayed.\n *\n * The fix: HTML-escape the whole string, which turns the markers into\n * `<mark>` / `</mark>`. Then restore those two patterns to\n * their original tag form. The result is \"the indexed text with all\n * HTML metacharacters escaped, plus a small set of literal `<mark>`\n * highlight tags around matched terms\" — which matches the API's\n * documented contract.\n */\nfunction sanitizeSnippet(snippet: string): string {\n\treturn snippet\n\t\t.replace(SNIPPET_AMP_RE, \"&\")\n\t\t.replace(SNIPPET_LT_RE, \"<\")\n\t\t.replace(SNIPPET_GT_RE, \">\")\n\t\t.replace(SNIPPET_QUOT_RE, \""\")\n\t\t.replace(SNIPPET_APOS_RE, \"'\")\n\t\t.replaceAll(\"<mark>\", \"<mark>\")\n\t\t.replaceAll(\"</mark>\", \"</mark>\");\n}\n\n/**\n * Get search suggestions for autocomplete\n *\n * @param db - Kysely database instance\n * @param query - Partial search query\n * @param options - Suggestion options\n * @returns Array of suggestions\n */\nexport async function getSuggestions(\n\tdb: Kysely<Database>,\n\tquery: string,\n\toptions: SuggestOptions = {},\n): Promise<Suggestion[]> {\n\tconst limit = options.limit ?? 5;\n\tconst locale = options.locale;\n\n\t// Get searchable collections\n\tlet collections = options.collections;\n\tif (!collections || collections.length === 0) {\n\t\tcollections = await getSearchableCollections(db);\n\t}\n\n\tif (collections.length === 0) {\n\t\treturn [];\n\t}\n\n\tconst suggestions: Suggestion[] = [];\n\n\tfor (const collection of collections) {\n\t\tconst ftsManager = new FTSManager(db);\n\t\tconst config = await ftsManager.getSearchConfig(collection);\n\t\tif (!config?.enabled) {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Validate before raw SQL interpolation\n\t\tvalidateIdentifier(collection, \"collection slug\");\n\n\t\tconst ftsTable = ftsManager.getFtsTableName(collection);\n\t\tconst contentTable = ftsManager.getContentTableName(collection);\n\n\t\t// Use prefix search for autocomplete. `escapeQuery` already appends `*`\n\t\t// to each term for prefix matching, so we must not append another one.\n\t\tconst prefixQuery = escapeQuery(query);\n\t\tif (!prefixQuery) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tlet results;\n\t\ttry {\n\t\t\tresults = await sql<{\n\t\t\t\tid: string;\n\t\t\t\ttitle: string;\n\t\t\t}>`\n\t\t\t\tSELECT \n\t\t\t\t\tc.id,\n\t\t\t\t\tc.title\n\t\t\t\tFROM \"${sql.raw(ftsTable)}\" f\n\t\t\t\tJOIN \"${sql.raw(contentTable)}\" c ON f.id = c.id\n\t\t\t\tWHERE \"${sql.raw(ftsTable)}\" MATCH ${prefixQuery}\n\t\t\t\tAND c.status = 'published'\n\t\t\t\tAND c.deleted_at IS NULL\n\t\t\t\tAND c.title IS NOT NULL\n\t\t\t\t${locale ? sql`AND c.locale = ${locale}` : sql``}\n\t\t\t\tORDER BY bm25(\"${sql.raw(ftsTable)}\")\n\t\t\t\tLIMIT ${limit}\n\t\t\t`.execute(db);\n\t\t} catch (error) {\n\t\t\t// Same swallow as searchSingleCollection: malformed prefix\n\t\t\t// queries should yield no suggestions, not surface DB errors.\n\t\t\t// Intentionally not logged (anonymous-triggerable, echoes\n\t\t\t// user input -- see searchSingleCollection for rationale).\n\t\t\tif (isFts5SyntaxError(error)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthrow error;\n\t\t}\n\n\t\tfor (const row of results.rows) {\n\t\t\tsuggestions.push({\n\t\t\t\tcollection,\n\t\t\t\tid: row.id,\n\t\t\t\ttitle: row.title,\n\t\t\t});\n\t\t}\n\t}\n\n\treturn suggestions.slice(0, limit);\n}\n\n/**\n * Get search statistics for all collections\n */\nexport async function getSearchStats(db: Kysely<Database>): Promise<SearchStats> {\n\tconst ftsManager = new FTSManager(db);\n\tconst collections = await getSearchableCollections(db);\n\tconst stats: SearchStats = { collections: {} };\n\n\tfor (const collection of collections) {\n\t\tconst collectionStats = await ftsManager.getIndexStats(collection);\n\t\tif (collectionStats) {\n\t\t\tstats.collections[collection] = collectionStats;\n\t\t}\n\t}\n\n\treturn stats;\n}\n\n/**\n * Get list of collections with search enabled\n */\nasync function getSearchableCollections(db: Kysely<Database>): Promise<string[]> {\n\tconst results = await db\n\t\t.selectFrom(\"_emdash_collections\")\n\t\t.select([\"slug\", \"search_config\"])\n\t\t.execute();\n\n\treturn results\n\t\t.filter((r) => {\n\t\t\tif (!r.search_config) return false;\n\t\t\ttry {\n\t\t\t\tconst config = JSON.parse(r.search_config);\n\t\t\t\treturn config.enabled === true;\n\t\t\t} catch {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t})\n\t\t.map((r) => r.slug);\n}\n\n/**\n * Escape a query string for FTS5\n *\n * Handles special characters and prevents injection.\n */\nfunction escapeQuery(query: string): string {\n\tif (!query || typeof query !== \"string\") {\n\t\treturn \"\";\n\t}\n\n\t// Trim whitespace\n\tquery = query.trim();\n\n\tif (query.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// If already a quoted phrase, escape only interior quotes and preserve phrase syntax\n\tif (query.startsWith('\"') && query.endsWith('\"') && query.length >= 2) {\n\t\tconst inner = query.slice(1, -1);\n\t\treturn `\"${inner.replace(DOUBLE_QUOTE_PATTERN, '\"\"')}\"`;\n\t}\n\n\t// Escape any existing quotes\n\tconst escaped = query.replace(DOUBLE_QUOTE_PATTERN, '\"\"');\n\n\t// If the query contains FTS5 operators (AND, OR, NOT, NEAR),\n\t// pass through with quotes escaped but operators preserved\n\tif (FTS_OPERATORS_PATTERN.test(query)) {\n\t\treturn escaped;\n\t}\n\n\t// For simple queries, wrap each word to handle special chars\n\tconst terms = escaped.split(WHITESPACE_SPLIT_PATTERN).filter((t) => t.length > 0);\n\tif (terms.length === 0) {\n\t\treturn \"\";\n\t}\n\n\t// Join with implicit AND, add prefix matching (*) to all terms\n\t// This allows \"hel wor\" to match \"hello world\"\n\treturn terms.map((t) => `\"${t}\"*`).join(\" \");\n}\n","/**\n * Text Extraction\n *\n * Extracts plain text from Portable Text blocks for FTS indexing.\n * Uses @portabletext/toolkit as base with extensions for custom block types.\n */\n\nimport { toPlainText } from \"@portabletext/toolkit\";\n\nimport type { PortableTextBlock } from \"../content/converters/types.js\";\n\n/**\n * Validate that a value looks like a Portable Text block array.\n * Each element must have at least a `_type` string property.\n */\nfunction isPortableTextArray(value: unknown[]): value is PortableTextBlock[] {\n\treturn value.every(\n\t\t(item) =>\n\t\t\ttypeof item === \"object\" &&\n\t\t\titem !== null &&\n\t\t\t\"_type\" in item &&\n\t\t\ttypeof item._type === \"string\",\n\t);\n}\n\n/**\n * Extract additional text from custom block types that toPlainText doesn't handle\n */\nfunction extractCustomBlockText(block: PortableTextBlock): string {\n\t// Code blocks - include the code content\n\tif (block._type === \"code\" && \"code\" in block && typeof block.code === \"string\") {\n\t\treturn block.code;\n\t}\n\n\t// Image blocks - include alt text and caption\n\tif (block._type === \"image\") {\n\t\tconst parts: string[] = [];\n\t\tif (\"alt\" in block && typeof block.alt === \"string\" && block.alt) {\n\t\t\tparts.push(block.alt);\n\t\t}\n\t\tif (\"caption\" in block && typeof block.caption === \"string\" && block.caption) {\n\t\t\tparts.push(block.caption);\n\t\t}\n\t\treturn parts.join(\" \");\n\t}\n\n\treturn \"\";\n}\n\n/**\n * Extract plain text from Portable Text blocks\n *\n * Uses @portabletext/toolkit's toPlainText for standard blocks,\n * plus extracts text from custom block types (code, images with alt/caption).\n *\n * @param blocks - Array of Portable Text blocks (or a JSON string)\n * @returns Plain text content\n *\n * @example\n * ```typescript\n * const text = extractPlainText([\n * {\n * _type: \"block\",\n * _key: \"abc\",\n * children: [{ _type: \"span\", _key: \"s1\", text: \"Hello World\" }]\n * }\n * ]);\n * // Returns: \"Hello World\"\n * ```\n */\nexport function extractPlainText(blocks: PortableTextBlock[] | string | null | undefined): string {\n\tif (!blocks) {\n\t\treturn \"\";\n\t}\n\n\t// Handle JSON string input\n\tlet parsedBlocks: PortableTextBlock[];\n\tif (typeof blocks === \"string\") {\n\t\ttry {\n\t\t\tparsedBlocks = JSON.parse(blocks);\n\t\t} catch {\n\t\t\t// If it's not valid JSON, treat as plain text\n\t\t\treturn blocks;\n\t\t}\n\t} else {\n\t\tparsedBlocks = blocks;\n\t}\n\n\tif (!Array.isArray(parsedBlocks)) {\n\t\treturn \"\";\n\t}\n\n\t// Use official toPlainText for standard blocks.\n\t// toPlainText expects `{ _type: string; [key: string]: any }[]` but our blocks use\n\t// `unknown` index sigs. They're structurally compatible at runtime — spread each block\n\t// to satisfy the wider index signature without an unsafe cast.\n\tconst toolkitBlocks = parsedBlocks.map((b) => {\n\t\tconst obj: Record<string, unknown> & { _type: string } = { _type: b._type };\n\t\tfor (const [key, val] of Object.entries(b)) {\n\t\t\tobj[key] = val;\n\t\t}\n\t\treturn obj;\n\t});\n\tconst standardText = toPlainText(toolkitBlocks);\n\n\t// Extract text from custom block types that toPlainText doesn't handle\n\tconst customTexts = parsedBlocks.map(extractCustomBlockText).filter((text) => text.length > 0);\n\n\t// Combine both\n\tconst allTexts = [standardText, ...customTexts].filter((t) => t.length > 0);\n\treturn allTexts.join(\"\\n\");\n}\n\n/**\n * Extract searchable text from a content entry\n *\n * Extracts text from specified fields, handling both plain text and Portable Text.\n *\n * @param entry - Content entry data\n * @param fields - Field names to extract text from\n * @returns Object mapping field names to extracted text\n */\nexport function extractSearchableFields(\n\tentry: Record<string, unknown>,\n\tfields: string[],\n): Record<string, string> {\n\tconst result: Record<string, string> = {};\n\n\tfor (const field of fields) {\n\t\tconst value = entry[field];\n\n\t\tif (value === null || value === undefined) {\n\t\t\tresult[field] = \"\";\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (typeof value === \"string\") {\n\t\t\t// Could be plain text or JSON Portable Text\n\t\t\tif (value.startsWith(\"[\")) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = value;\n\t\t\t}\n\t\t} else if (Array.isArray(value)) {\n\t\t\t// Validate the array looks like Portable Text before treating it as such\n\t\t\tif (isPortableTextArray(value)) {\n\t\t\t\tresult[field] = extractPlainText(value);\n\t\t\t} else {\n\t\t\t\tresult[field] = JSON.stringify(value);\n\t\t\t}\n\t\t} else if (typeof value === \"object\") {\n\t\t\t// Object — serialize to JSON for searchable text\n\t\t\tresult[field] = JSON.stringify(value);\n\t\t} else if (typeof value === \"number\" || typeof value === \"boolean\") {\n\t\t\tresult[field] = `${value}`;\n\t\t} else {\n\t\t\tresult[field] = \"\";\n\t\t}\n\t}\n\n\treturn result;\n}\n","/**\n * Search Module\n *\n * Full-text search for EmDash using SQLite FTS5.\n */\n\n// Types\nexport type {\n\tSearchConfig,\n\tSearchOptions,\n\tCollectionSearchOptions,\n\tSearchResult,\n\tSearchResponse,\n\tSuggestOptions,\n\tSuggestion,\n\tSearchStats,\n} from \"./types.js\";\n\n// FTS Manager\nexport { FTSManager } from \"./fts-manager.js\";\n\n// Query functions (public API uses getDb() internally)\nexport { search, searchWithDb, searchCollection, getSuggestions, getSearchStats } from \"./query.js\";\n\n// Text extraction\nexport { extractPlainText, extractSearchableFields } from \"./text-extraction.js\";\n"],"mappings":"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"}
|
|
@@ -311,4 +311,4 @@ function readDefaultEnv() {
|
|
|
311
311
|
|
|
312
312
|
//#endregion
|
|
313
313
|
export { validateEncryptionKeyAtStartup as a, resolveSecretsCached as i, fingerprintKey as n, generateEncryptionKey as r, EmDashSecretsError as t };
|
|
314
|
-
//# sourceMappingURL=secrets-
|
|
314
|
+
//# sourceMappingURL=secrets-ChPTmy9x.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"secrets-C_ZtRos3.mjs","names":[],"sources":["../src/config/secrets.ts"],"sourcesContent":["/**\n * Centralized secrets module\n *\n * Single source of truth for site-level cryptographic secrets:\n *\n * - `EMDASH_ENCRYPTION_KEY` — primary key for encrypting plugin secrets at\n * rest. Multi-key (comma-separated) for rotation forward-compat. v1 ships\n * single-key. Format: `emdash_enc_v1_<43 base64url chars>` representing\n * 32 random bytes. **Operator-provided; never stored in the database.**\n * Losing the key means losing every secret encrypted with it. Validated\n * at runtime startup via `validateEncryptionKeyAtStartup` — request-time\n * resolution does not depend on it, so a malformed key can't 500 the\n * preview/comment hot paths for unrelated visitors.\n * - `EMDASH_IP_SALT` (optional) / DB-stored `emdash:ip_salt` — site-specific\n * salt for hashing commenter IPs. Generated and persisted on first need\n * if no env override is set. Replaces the previous hardcoded\n * `\"emdash-ip-salt\"` constant which was correlatable across installs.\n * - `EMDASH_PREVIEW_SECRET` (optional) / DB-stored `emdash:preview_secret` —\n * HMAC secret for signing preview URLs. Generated and persisted on first\n * need if no env override is set. Replaces the previous empty-string\n * fallback which silently disabled preview-token verification.\n *\n * The `EMDASH_AUTH_SECRET` env var is consulted only as a legacy fallback\n * source for the IP salt — that's the only path the prior code actually\n * read it from. New deployments don't need to set it.\n *\n * Modeled on `resolveS3Config` in `../storage/s3.ts`.\n */\n\nimport { sha256 } from \"@oslojs/crypto/sha2\";\nimport { encodeHexLowerCase } from \"@oslojs/encoding\";\nimport type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"../database/repositories/options.js\";\nimport type { Database } from \"../database/types.js\";\nimport { decodeBase64url, encodeBase64url } from \"../utils/base64.js\";\n\n/** v1 encryption key prefix. Bumping requires a separate KDF version. */\nexport const ENCRYPTION_KEY_PREFIX = \"emdash_enc_v1_\";\n\n/** 32 random bytes encoded as unpadded base64url = 43 chars. */\nconst ENCRYPTION_KEY_BODY_LENGTH = 43;\n\nconst REGEX_META_PATTERN = /[.*+?^${}()|[\\]\\\\]/g;\n\n/**\n * Built from the prefix constant via interpolation. The prefix has no regex\n * metacharacters today (`emdash_enc_v1_`), but escaping is cheap defense\n * against anyone changing the prefix in a future bump without remembering.\n */\nconst ENCRYPTION_KEY_PATTERN = new RegExp(\n\t`^${ENCRYPTION_KEY_PREFIX.replace(REGEX_META_PATTERN, \"\\\\$&\")}[A-Za-z0-9_-]{${ENCRYPTION_KEY_BODY_LENGTH}}$`,\n);\n\n/** Options-table key for the persisted commenter-IP salt. */\nexport const IP_SALT_OPTION_KEY = \"emdash:ip_salt\";\n\n/** Options-table key for the persisted preview HMAC secret. */\nexport const PREVIEW_SECRET_OPTION_KEY = \"emdash:preview_secret\";\n\n/** Length in bytes of generated values. 32 bytes = 256 bits. */\nconst GENERATED_SECRET_BYTES = 32;\n\n/**\n * A parsed encryption key with its kid (key id) fingerprint.\n *\n * `kid` is the first 8 chars of the SHA-256 hash of the decoded key bytes\n * (lowercase hex), used to tag envelopes so the decryptor can pick the right\n * key during rotation.\n */\nexport interface ParsedEncryptionKey {\n\t/** 8-char lowercase hex fingerprint derived from the decoded key bytes. */\n\tkid: string;\n\t/** The 32 raw key bytes, ready for `crypto.subtle.importKey`. */\n\tkey: Uint8Array;\n\t/** The original env-var-formatted string (kept for re-emit; never log). */\n\traw: string;\n}\n\n/** Resolved site secrets. */\nexport interface ResolvedSecrets {\n\t/** HMAC secret for preview URLs. Always non-empty after resolution. */\n\tpreviewSecret: string;\n\t/**\n\t * Source of `previewSecret`. Useful for diagnostics; never expose the\n\t * value itself, only the source.\n\t */\n\tpreviewSecretSource: \"env\" | \"db\";\n\t/** Salt for hashing commenter IPs. Always non-empty after resolution. */\n\tipSalt: string;\n\t/** Source of `ipSalt`. */\n\tipSaltSource: \"env\" | \"db\";\n}\n\n/** Inputs for `resolveSecrets`. */\nexport interface ResolveSecretsOptions {\n\t/**\n\t * The Kysely DB used to persist (and read back) generated salt/preview\n\t * secret values. Required — these values must be stable across requests\n\t * within a deployment.\n\t */\n\tdb: Kysely<Database>;\n\t/**\n\t * Optional explicit env override map. When omitted, falls back to\n\t * `import.meta.env` via the global accessor below. Tests pass an\n\t * explicit map to avoid leaking process state.\n\t */\n\tenv?: SecretsEnv;\n\t/**\n\t * @internal Test seam: inject a custom OptionsRepository to exercise\n\t * the lost-race re-read branch. Production callers never set this.\n\t */\n\t_repo?: OptionsRepository;\n}\n\n/** Environment-variable shape consulted by the resolver. */\nexport interface SecretsEnv {\n\t/**\n\t * Read by `validateEncryptionKeyAtStartup` and (in a follow-up PR) by the\n\t * plugin-secret encryption layer. **Not** consulted by `resolveSecrets`,\n\t * so a malformed value can't 500 the preview/comment hot paths.\n\t */\n\tEMDASH_ENCRYPTION_KEY?: string;\n\tEMDASH_PREVIEW_SECRET?: string;\n\t/** Legacy alias; new docs point at EMDASH_PREVIEW_SECRET. */\n\tPREVIEW_SECRET?: string;\n\tEMDASH_IP_SALT?: string;\n\t/**\n\t * Legacy fallback. Prior code derived the IP salt from\n\t * `EMDASH_AUTH_SECRET || AUTH_SECRET || \"emdash-ip-salt\"`. We preserve\n\t * the env-var fallback (so existing installs keep their stable salt)\n\t * but no longer read it from `import.meta.env` in route handlers.\n\t */\n\tEMDASH_AUTH_SECRET?: string;\n\t/** Legacy alias. */\n\tAUTH_SECRET?: string;\n}\n\n/**\n * Class of validation failures raised by this module.\n *\n * Errors here are operator-facing config problems (malformed key, etc.).\n * They are thrown rather than soft-skipped so misconfiguration fails loudly\n * at startup instead of silently degrading at request time.\n */\nexport class EmDashSecretsError extends Error {\n\toverride readonly name = \"EmDashSecretsError\";\n\treadonly code: string;\n\n\tconstructor(message: string, code: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Encryption key parsing\n// ---------------------------------------------------------------------------\n\n/**\n * Parse the `EMDASH_ENCRYPTION_KEY` env var.\n *\n * Accepts a single key or a comma-separated list. The first entry is the\n * primary (used for new writes); all entries are tried for decryption,\n * matched by `kid`. Whitespace around commas is tolerated. Empty entries\n * (e.g. trailing comma) are ignored.\n *\n * Returns `null` for an unset/empty input. Throws `EmDashSecretsError` on\n * any malformed entry — silent skipping would mask deployment mistakes.\n */\nexport async function parseEncryptionKeys(\n\traw: string | undefined,\n): Promise<ParsedEncryptionKey[] | null> {\n\tif (!raw) return null;\n\n\tconst entries = raw\n\t\t.split(\",\")\n\t\t.map((entry) => entry.trim())\n\t\t.filter((entry) => entry.length > 0);\n\n\tif (entries.length === 0) return null;\n\n\tconst parsed: ParsedEncryptionKey[] = [];\n\tconst seenKids = new Set<string>();\n\n\tfor (const entry of entries) {\n\t\tif (!ENCRYPTION_KEY_PATTERN.test(entry)) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t`EMDASH_ENCRYPTION_KEY entry is malformed (expected \"${ENCRYPTION_KEY_PREFIX}\" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars). Generate one with \\`emdash secrets generate\\`.`,\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\tconst body = entry.slice(ENCRYPTION_KEY_PREFIX.length);\n\t\tconst key = decodeBase64urlStrict(body);\n\t\tif (!key) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t\"EMDASH_ENCRYPTION_KEY body is not valid base64url\",\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\t\tif (key.length !== GENERATED_SECRET_BYTES) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t`EMDASH_ENCRYPTION_KEY must decode to ${GENERATED_SECRET_BYTES} bytes, got ${key.length}`,\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\t// Reject non-canonical base64url. 43 chars decode to 32 bytes but\n\t\t// the last char only carries 2 information bits — multiple raw\n\t\t// strings can decode to the same bytes. Forcing canonical form\n\t\t// guarantees `kid` (derived from bytes) is stable per key\n\t\t// material, regardless of how the operator pasted it.\n\t\tconst canonical = encodeBase64url(key);\n\t\tif (canonical !== body) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t\"EMDASH_ENCRYPTION_KEY body is not canonical base64url. Generate one with `emdash secrets generate`.\",\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\tconst kid = fingerprintKeyBytes(key);\n\t\tif (seenKids.has(kid)) {\n\t\t\t// Duplicate keys are user error (paste mistake during rotation).\n\t\t\t// We dedupe rather than throw — the rotation flow is forgiving.\n\t\t\tcontinue;\n\t\t}\n\t\tseenKids.add(kid);\n\t\tparsed.push({ kid, key, raw: entry });\n\t}\n\n\t// `parsed` always has at least one entry here: `entries` was non-empty\n\t// after filtering, the loop runs at least once, the first iteration\n\t// always passes the empty-`seenKids` check.\n\treturn parsed;\n}\n\n/**\n * Compute the kid for a raw key string (the env-var form including the\n * `emdash_enc_v1_` prefix). Public so the CLI's `fingerprint` subcommand\n * and admin endpoints can show kids without exposing raw keys.\n *\n * The kid is derived from the decoded key **bytes**, not the raw string,\n * so admin endpoints / future rotation flows can match envelope kids\n * against bytes regardless of how the env var was originally spelled.\n *\n * Validates the same shape as `parseEncryptionKeys` — including canonical\n * base64url — so the CLI can't print a kid for a key the runtime would\n * later refuse to load.\n *\n * Throws `EmDashSecretsError` for malformed or non-canonical input.\n */\nexport async function fingerprintKey(raw: string): Promise<string> {\n\tif (!ENCRYPTION_KEY_PATTERN.test(raw)) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Key must match \"${ENCRYPTION_KEY_PREFIX}\" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars`,\n\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t);\n\t}\n\tconst body = raw.slice(ENCRYPTION_KEY_PREFIX.length);\n\tconst bytes = decodeBase64urlStrict(body);\n\tif (!bytes || bytes.length !== GENERATED_SECRET_BYTES || encodeBase64url(bytes) !== body) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Key body must decode to ${GENERATED_SECRET_BYTES} canonical base64url bytes`,\n\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t);\n\t}\n\treturn fingerprintKeyBytes(bytes);\n}\n\n/**\n * Internal: kid derivation from raw key bytes. The single source of truth\n * for what makes two keys \"the same key\" — used by both `parseEncryptionKeys`\n * and `fingerprintKey`.\n */\nfunction fingerprintKeyBytes(key: Uint8Array): string {\n\treturn encodeHexLowerCase(sha256(key)).slice(0, 8);\n}\n\n/**\n * Generate a fresh `EMDASH_ENCRYPTION_KEY` value. Used by the CLI's\n * `secrets generate` subcommand and by `create-emdash` scaffolding.\n */\nexport function generateEncryptionKey(): string {\n\tconst bytes = new Uint8Array(GENERATED_SECRET_BYTES);\n\tcrypto.getRandomValues(bytes);\n\treturn `${ENCRYPTION_KEY_PREFIX}${encodeBase64url(bytes)}`;\n}\n\n// ---------------------------------------------------------------------------\n// Site-secret resolution (DB-backed with env override)\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve site secrets. Reads env vars; for IP salt and preview secret,\n * falls back to a DB-stored value, generating one atomically on first need.\n *\n * Idempotent. Concurrent callers race on the atomic `setIfAbsent`; whichever\n * wins, all callers converge on the same stored value.\n *\n * Note: `EMDASH_ENCRYPTION_KEY` is **not** consumed here. It's validated\n * separately at runtime startup (see `validateEncryptionKeyAtStartup`) so a\n * malformed key can't take down preview-token verification or comment\n * submission for unrelated visitors. Future plugin-secret encryption code\n * will read it via its own dedicated helper.\n */\nexport async function resolveSecrets(options: ResolveSecretsOptions): Promise<ResolvedSecrets> {\n\tconst env = options.env ?? readDefaultEnv();\n\tconst repo = options._repo ?? new OptionsRepository(options.db);\n\n\tconst previewEnvOverride = pickFirstNonEmpty(env.EMDASH_PREVIEW_SECRET, env.PREVIEW_SECRET);\n\tconst ipSaltEnvOverride = pickFirstNonEmpty(\n\t\tenv.EMDASH_IP_SALT,\n\t\tenv.EMDASH_AUTH_SECRET,\n\t\tenv.AUTH_SECRET,\n\t);\n\n\tconst [previewSecret, ipSalt] = await Promise.all([\n\t\tpreviewEnvOverride !== null\n\t\t\t? Promise.resolve({ value: previewEnvOverride, source: \"env\" as const })\n\t\t\t: ensureGeneratedOption(repo, PREVIEW_SECRET_OPTION_KEY),\n\t\tipSaltEnvOverride !== null\n\t\t\t? Promise.resolve({ value: ipSaltEnvOverride, source: \"env\" as const })\n\t\t\t: ensureGeneratedOption(repo, IP_SALT_OPTION_KEY),\n\t]);\n\n\treturn {\n\t\tpreviewSecret: previewSecret.value,\n\t\tpreviewSecretSource: previewSecret.source,\n\t\tipSalt: ipSalt.value,\n\t\tipSaltSource: ipSalt.source,\n\t};\n}\n\n/**\n * Validate `EMDASH_ENCRYPTION_KEY` once at runtime startup. Logs an\n * operator-facing error if the value is malformed but does **not** throw —\n * the key is currently inert (no consumers), and the follow-up PR that\n * actually uses it will throw at point of use. This way, deployment\n * mistakes surface immediately in startup logs without wedging unrelated\n * request paths in the meantime.\n *\n * Returns `true` if the key is unset or valid, `false` if it was malformed.\n */\nexport async function validateEncryptionKeyAtStartup(env?: SecretsEnv): Promise<boolean> {\n\tconst resolved = env ?? readDefaultEnv();\n\ttry {\n\t\tawait parseEncryptionKeys(resolved.EMDASH_ENCRYPTION_KEY);\n\t\treturn true;\n\t} catch (error) {\n\t\tif (error instanceof EmDashSecretsError) {\n\t\t\tconsole.error(\n\t\t\t\t`[emdash] EMDASH_ENCRYPTION_KEY is invalid: ${error.message} ` +\n\t\t\t\t\t\"Plugin-secret encryption will fail once it ships. \" +\n\t\t\t\t\t\"Generate a fresh key with `emdash secrets generate`.\",\n\t\t\t);\n\t\t\treturn false;\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Per-DB cache of resolved secrets, keyed by Kysely instance identity.\n *\n * The resolved values are stable for the lifetime of the deployment (env\n * vars don't change without a restart, and DB-stored values are written\n * once via `setIfAbsent`). Caching avoids one options-table read per\n * request on the hot paths (preview verification, comment hashing).\n *\n * Lives on `globalThis` so module-duplication during SSR bundling can't\n * fragment the cache. See `request-context.ts` for the same pattern.\n */\n// Versioned to prevent cache fragmentation if `ResolvedSecrets`'s shape\n// ever changes. Bump the suffix on incompatible changes so a co-resident\n// older build doesn't read a newer-shape value.\nconst SECRETS_CACHE_KEY = Symbol.for(\"@emdash-cms/core/secrets-cache@1\");\n\ninterface SecretsCacheHolder {\n\tcache: WeakMap<Kysely<Database>, Promise<ResolvedSecrets>>;\n}\n\nfunction getSecretsCache(): WeakMap<Kysely<Database>, Promise<ResolvedSecrets>> {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\tconst holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;\n\tlet entry = holder[SECRETS_CACHE_KEY];\n\tif (!entry) {\n\t\tentry = { cache: new WeakMap() };\n\t\tholder[SECRETS_CACHE_KEY] = entry;\n\t}\n\treturn entry.cache;\n}\n\n/**\n * Memoized wrapper around `resolveSecrets`. Use this from request-time hot\n * paths (preview verification, comment IP hashing) so they don't reread\n * env / re-query options on every request.\n *\n * The cache is keyed by `Kysely` instance, so playground / per-DO / per-test\n * databases each get their own resolution.\n */\nexport function resolveSecretsCached(db: Kysely<Database>): Promise<ResolvedSecrets> {\n\tconst cache = getSecretsCache();\n\tconst cached = cache.get(db);\n\tif (cached) return cached;\n\tconst promise = resolveSecrets({ db }).catch((error) => {\n\t\t// Don't poison the cache on transient failure; next caller retries.\n\t\tcache.delete(db);\n\t\tthrow error;\n\t});\n\tcache.set(db, promise);\n\treturn promise;\n}\n\n/**\n * Test-only helper: clear the secrets cache. Tests that mutate env between\n * cases need this so a stale resolution doesn't leak across cases.\n *\n * @internal\n */\nexport function _clearSecretsCacheForTesting(): void {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\tconst holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;\n\tholder[SECRETS_CACHE_KEY] = undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Internals\n// ---------------------------------------------------------------------------\n\n/**\n * Read or generate-and-persist a random base64url secret stored in the\n * options table.\n *\n * Concurrency: `setIfAbsent` is an atomic INSERT...ON CONFLICT DO NOTHING.\n * On race, the loser re-reads to converge on the winner's value.\n */\nasync function ensureGeneratedOption(\n\trepo: OptionsRepository,\n\toptionKey: string,\n): Promise<{ value: string; source: \"db\" }> {\n\tconst existing = await repo.get<string>(optionKey);\n\tif (typeof existing === \"string\" && existing.length > 0) {\n\t\treturn { value: existing, source: \"db\" };\n\t}\n\n\tconst generated = generateRandomSecret();\n\tconst inserted = await repo.setIfAbsent(optionKey, generated);\n\tif (inserted) {\n\t\treturn { value: generated, source: \"db\" };\n\t}\n\n\t// Lost the race — another process inserted first. Re-read to pick up\n\t// the winner. If the row is somehow still missing or empty, treat that\n\t// as a real error rather than looping.\n\tconst winner = await repo.get<string>(optionKey);\n\tif (typeof winner !== \"string\" || winner.length === 0) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Failed to persist generated secret for \"${optionKey}\"`,\n\t\t\t\"SECRET_PERSIST_FAILED\",\n\t\t);\n\t}\n\treturn { value: winner, source: \"db\" };\n}\n\n/** Generate 32 random bytes encoded as unpadded base64url. */\nfunction generateRandomSecret(): string {\n\tconst bytes = new Uint8Array(GENERATED_SECRET_BYTES);\n\tcrypto.getRandomValues(bytes);\n\treturn encodeBase64url(bytes);\n}\n\n/** Return the first non-empty string from `values`, or `null` if all are empty. */\nfunction pickFirstNonEmpty(...values: (string | undefined)[]): string | null {\n\tfor (const value of values) {\n\t\tif (typeof value === \"string\" && value.length > 0) {\n\t\t\treturn value;\n\t\t}\n\t}\n\treturn null;\n}\n\nconst BASE64URL_CHARSET_PATTERN = /^[A-Za-z0-9_-]+$/;\n\n/**\n * Validate base64url shape and decode. Returns `null` on malformed input\n * (rather than throwing) so the caller can produce a config-specific error.\n */\nfunction decodeBase64urlStrict(input: string): Uint8Array | null {\n\t// `decodeBase64url` accepts padded input too; the env-var format is\n\t// strictly unpadded base64url, so we do a charset check first.\n\tif (!BASE64URL_CHARSET_PATTERN.test(input)) return null;\n\ttry {\n\t\treturn decodeBase64url(input);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Default env reader.\n *\n * Note: this is the **only** code path in core that reads both\n * `import.meta.env` and `process.env`. Route handlers should not — they\n * always run inside the Astro/Vite bundle where `import.meta.env` is\n * the correct source. This resolver is shared with the CLI surface (via\n * `cli/commands/secrets.ts`) which runs outside the bundle, so we\n * deliberately consult both. `import.meta.env` wins so build-time\n * substitutions are honored when present.\n *\n * The convention documented in AGENTS.md (\"import.meta.env.EMDASH_X ||\n * import.meta.env.X\") is the route-handler convention; this is the\n * shared-with-CLI exception.\n */\nfunction readDefaultEnv(): SecretsEnv {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is loose by design\n\tconst meta = (import.meta.env ?? {}) as Record<string, string | undefined>;\n\tconst proc = typeof process !== \"undefined\" && process.env ? process.env : {};\n\n\treturn {\n\t\tEMDASH_ENCRYPTION_KEY: meta.EMDASH_ENCRYPTION_KEY ?? proc.EMDASH_ENCRYPTION_KEY,\n\t\tEMDASH_PREVIEW_SECRET: meta.EMDASH_PREVIEW_SECRET ?? proc.EMDASH_PREVIEW_SECRET,\n\t\tPREVIEW_SECRET: meta.PREVIEW_SECRET ?? proc.PREVIEW_SECRET,\n\t\tEMDASH_IP_SALT: meta.EMDASH_IP_SALT ?? proc.EMDASH_IP_SALT,\n\t\tEMDASH_AUTH_SECRET: meta.EMDASH_AUTH_SECRET ?? proc.EMDASH_AUTH_SECRET,\n\t\tAUTH_SECRET: meta.AUTH_SECRET ?? proc.AUTH_SECRET,\n\t};\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"secrets-ChPTmy9x.mjs","names":[],"sources":["../src/config/secrets.ts"],"sourcesContent":["/**\n * Centralized secrets module\n *\n * Single source of truth for site-level cryptographic secrets:\n *\n * - `EMDASH_ENCRYPTION_KEY` — primary key for encrypting plugin secrets at\n * rest. Multi-key (comma-separated) for rotation forward-compat. v1 ships\n * single-key. Format: `emdash_enc_v1_<43 base64url chars>` representing\n * 32 random bytes. **Operator-provided; never stored in the database.**\n * Losing the key means losing every secret encrypted with it. Validated\n * at runtime startup via `validateEncryptionKeyAtStartup` — request-time\n * resolution does not depend on it, so a malformed key can't 500 the\n * preview/comment hot paths for unrelated visitors.\n * - `EMDASH_IP_SALT` (optional) / DB-stored `emdash:ip_salt` — site-specific\n * salt for hashing commenter IPs. Generated and persisted on first need\n * if no env override is set. Replaces the previous hardcoded\n * `\"emdash-ip-salt\"` constant which was correlatable across installs.\n * - `EMDASH_PREVIEW_SECRET` (optional) / DB-stored `emdash:preview_secret` —\n * HMAC secret for signing preview URLs. Generated and persisted on first\n * need if no env override is set. Replaces the previous empty-string\n * fallback which silently disabled preview-token verification.\n *\n * The `EMDASH_AUTH_SECRET` env var is consulted only as a legacy fallback\n * source for the IP salt — that's the only path the prior code actually\n * read it from. New deployments don't need to set it.\n *\n * Modeled on `resolveS3Config` in `../storage/s3.ts`.\n */\n\nimport { sha256 } from \"@oslojs/crypto/sha2\";\nimport { encodeHexLowerCase } from \"@oslojs/encoding\";\nimport type { Kysely } from \"kysely\";\n\nimport { OptionsRepository } from \"../database/repositories/options.js\";\nimport type { Database } from \"../database/types.js\";\nimport { decodeBase64url, encodeBase64url } from \"../utils/base64.js\";\n\n/** v1 encryption key prefix. Bumping requires a separate KDF version. */\nexport const ENCRYPTION_KEY_PREFIX = \"emdash_enc_v1_\";\n\n/** 32 random bytes encoded as unpadded base64url = 43 chars. */\nconst ENCRYPTION_KEY_BODY_LENGTH = 43;\n\nconst REGEX_META_PATTERN = /[.*+?^${}()|[\\]\\\\]/g;\n\n/**\n * Built from the prefix constant via interpolation. The prefix has no regex\n * metacharacters today (`emdash_enc_v1_`), but escaping is cheap defense\n * against anyone changing the prefix in a future bump without remembering.\n */\nconst ENCRYPTION_KEY_PATTERN = new RegExp(\n\t`^${ENCRYPTION_KEY_PREFIX.replace(REGEX_META_PATTERN, \"\\\\$&\")}[A-Za-z0-9_-]{${ENCRYPTION_KEY_BODY_LENGTH}}$`,\n);\n\n/** Options-table key for the persisted commenter-IP salt. */\nexport const IP_SALT_OPTION_KEY = \"emdash:ip_salt\";\n\n/** Options-table key for the persisted preview HMAC secret. */\nexport const PREVIEW_SECRET_OPTION_KEY = \"emdash:preview_secret\";\n\n/** Length in bytes of generated values. 32 bytes = 256 bits. */\nconst GENERATED_SECRET_BYTES = 32;\n\n/**\n * A parsed encryption key with its kid (key id) fingerprint.\n *\n * `kid` is the first 8 chars of the SHA-256 hash of the decoded key bytes\n * (lowercase hex), used to tag envelopes so the decryptor can pick the right\n * key during rotation.\n */\nexport interface ParsedEncryptionKey {\n\t/** 8-char lowercase hex fingerprint derived from the decoded key bytes. */\n\tkid: string;\n\t/** The 32 raw key bytes, ready for `crypto.subtle.importKey`. */\n\tkey: Uint8Array;\n\t/** The original env-var-formatted string (kept for re-emit; never log). */\n\traw: string;\n}\n\n/** Resolved site secrets. */\nexport interface ResolvedSecrets {\n\t/** HMAC secret for preview URLs. Always non-empty after resolution. */\n\tpreviewSecret: string;\n\t/**\n\t * Source of `previewSecret`. Useful for diagnostics; never expose the\n\t * value itself, only the source.\n\t */\n\tpreviewSecretSource: \"env\" | \"db\";\n\t/** Salt for hashing commenter IPs. Always non-empty after resolution. */\n\tipSalt: string;\n\t/** Source of `ipSalt`. */\n\tipSaltSource: \"env\" | \"db\";\n}\n\n/** Inputs for `resolveSecrets`. */\nexport interface ResolveSecretsOptions {\n\t/**\n\t * The Kysely DB used to persist (and read back) generated salt/preview\n\t * secret values. Required — these values must be stable across requests\n\t * within a deployment.\n\t */\n\tdb: Kysely<Database>;\n\t/**\n\t * Optional explicit env override map. When omitted, falls back to\n\t * `import.meta.env` via the global accessor below. Tests pass an\n\t * explicit map to avoid leaking process state.\n\t */\n\tenv?: SecretsEnv;\n\t/**\n\t * @internal Test seam: inject a custom OptionsRepository to exercise\n\t * the lost-race re-read branch. Production callers never set this.\n\t */\n\t_repo?: OptionsRepository;\n}\n\n/** Environment-variable shape consulted by the resolver. */\nexport interface SecretsEnv {\n\t/**\n\t * Read by `validateEncryptionKeyAtStartup` and (in a follow-up PR) by the\n\t * plugin-secret encryption layer. **Not** consulted by `resolveSecrets`,\n\t * so a malformed value can't 500 the preview/comment hot paths.\n\t */\n\tEMDASH_ENCRYPTION_KEY?: string;\n\tEMDASH_PREVIEW_SECRET?: string;\n\t/** Legacy alias; new docs point at EMDASH_PREVIEW_SECRET. */\n\tPREVIEW_SECRET?: string;\n\tEMDASH_IP_SALT?: string;\n\t/**\n\t * Legacy fallback. Prior code derived the IP salt from\n\t * `EMDASH_AUTH_SECRET || AUTH_SECRET || \"emdash-ip-salt\"`. We preserve\n\t * the env-var fallback (so existing installs keep their stable salt)\n\t * but no longer read it from `import.meta.env` in route handlers.\n\t */\n\tEMDASH_AUTH_SECRET?: string;\n\t/** Legacy alias. */\n\tAUTH_SECRET?: string;\n}\n\n/**\n * Class of validation failures raised by this module.\n *\n * Errors here are operator-facing config problems (malformed key, etc.).\n * They are thrown rather than soft-skipped so misconfiguration fails loudly\n * at startup instead of silently degrading at request time.\n */\nexport class EmDashSecretsError extends Error {\n\toverride readonly name = \"EmDashSecretsError\";\n\treadonly code: string;\n\n\tconstructor(message: string, code: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t}\n}\n\n// ---------------------------------------------------------------------------\n// Encryption key parsing\n// ---------------------------------------------------------------------------\n\n/**\n * Parse the `EMDASH_ENCRYPTION_KEY` env var.\n *\n * Accepts a single key or a comma-separated list. The first entry is the\n * primary (used for new writes); all entries are tried for decryption,\n * matched by `kid`. Whitespace around commas is tolerated. Empty entries\n * (e.g. trailing comma) are ignored.\n *\n * Returns `null` for an unset/empty input. Throws `EmDashSecretsError` on\n * any malformed entry — silent skipping would mask deployment mistakes.\n */\nexport async function parseEncryptionKeys(\n\traw: string | undefined,\n): Promise<ParsedEncryptionKey[] | null> {\n\tif (!raw) return null;\n\n\tconst entries = raw\n\t\t.split(\",\")\n\t\t.map((entry) => entry.trim())\n\t\t.filter((entry) => entry.length > 0);\n\n\tif (entries.length === 0) return null;\n\n\tconst parsed: ParsedEncryptionKey[] = [];\n\tconst seenKids = new Set<string>();\n\n\tfor (const entry of entries) {\n\t\tif (!ENCRYPTION_KEY_PATTERN.test(entry)) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t`EMDASH_ENCRYPTION_KEY entry is malformed (expected \"${ENCRYPTION_KEY_PREFIX}\" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars). Generate one with \\`emdash secrets generate\\`.`,\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\tconst body = entry.slice(ENCRYPTION_KEY_PREFIX.length);\n\t\tconst key = decodeBase64urlStrict(body);\n\t\tif (!key) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t\"EMDASH_ENCRYPTION_KEY body is not valid base64url\",\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\t\tif (key.length !== GENERATED_SECRET_BYTES) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t`EMDASH_ENCRYPTION_KEY must decode to ${GENERATED_SECRET_BYTES} bytes, got ${key.length}`,\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\t// Reject non-canonical base64url. 43 chars decode to 32 bytes but\n\t\t// the last char only carries 2 information bits — multiple raw\n\t\t// strings can decode to the same bytes. Forcing canonical form\n\t\t// guarantees `kid` (derived from bytes) is stable per key\n\t\t// material, regardless of how the operator pasted it.\n\t\tconst canonical = encodeBase64url(key);\n\t\tif (canonical !== body) {\n\t\t\tthrow new EmDashSecretsError(\n\t\t\t\t\"EMDASH_ENCRYPTION_KEY body is not canonical base64url. Generate one with `emdash secrets generate`.\",\n\t\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t\t);\n\t\t}\n\n\t\tconst kid = fingerprintKeyBytes(key);\n\t\tif (seenKids.has(kid)) {\n\t\t\t// Duplicate keys are user error (paste mistake during rotation).\n\t\t\t// We dedupe rather than throw — the rotation flow is forgiving.\n\t\t\tcontinue;\n\t\t}\n\t\tseenKids.add(kid);\n\t\tparsed.push({ kid, key, raw: entry });\n\t}\n\n\t// `parsed` always has at least one entry here: `entries` was non-empty\n\t// after filtering, the loop runs at least once, the first iteration\n\t// always passes the empty-`seenKids` check.\n\treturn parsed;\n}\n\n/**\n * Compute the kid for a raw key string (the env-var form including the\n * `emdash_enc_v1_` prefix). Public so the CLI's `fingerprint` subcommand\n * and admin endpoints can show kids without exposing raw keys.\n *\n * The kid is derived from the decoded key **bytes**, not the raw string,\n * so admin endpoints / future rotation flows can match envelope kids\n * against bytes regardless of how the env var was originally spelled.\n *\n * Validates the same shape as `parseEncryptionKeys` — including canonical\n * base64url — so the CLI can't print a kid for a key the runtime would\n * later refuse to load.\n *\n * Throws `EmDashSecretsError` for malformed or non-canonical input.\n */\nexport async function fingerprintKey(raw: string): Promise<string> {\n\tif (!ENCRYPTION_KEY_PATTERN.test(raw)) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Key must match \"${ENCRYPTION_KEY_PREFIX}\" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars`,\n\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t);\n\t}\n\tconst body = raw.slice(ENCRYPTION_KEY_PREFIX.length);\n\tconst bytes = decodeBase64urlStrict(body);\n\tif (!bytes || bytes.length !== GENERATED_SECRET_BYTES || encodeBase64url(bytes) !== body) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Key body must decode to ${GENERATED_SECRET_BYTES} canonical base64url bytes`,\n\t\t\t\"INVALID_ENCRYPTION_KEY\",\n\t\t);\n\t}\n\treturn fingerprintKeyBytes(bytes);\n}\n\n/**\n * Internal: kid derivation from raw key bytes. The single source of truth\n * for what makes two keys \"the same key\" — used by both `parseEncryptionKeys`\n * and `fingerprintKey`.\n */\nfunction fingerprintKeyBytes(key: Uint8Array): string {\n\treturn encodeHexLowerCase(sha256(key)).slice(0, 8);\n}\n\n/**\n * Generate a fresh `EMDASH_ENCRYPTION_KEY` value. Used by the CLI's\n * `secrets generate` subcommand and by `create-emdash` scaffolding.\n */\nexport function generateEncryptionKey(): string {\n\tconst bytes = new Uint8Array(GENERATED_SECRET_BYTES);\n\tcrypto.getRandomValues(bytes);\n\treturn `${ENCRYPTION_KEY_PREFIX}${encodeBase64url(bytes)}`;\n}\n\n// ---------------------------------------------------------------------------\n// Site-secret resolution (DB-backed with env override)\n// ---------------------------------------------------------------------------\n\n/**\n * Resolve site secrets. Reads env vars; for IP salt and preview secret,\n * falls back to a DB-stored value, generating one atomically on first need.\n *\n * Idempotent. Concurrent callers race on the atomic `setIfAbsent`; whichever\n * wins, all callers converge on the same stored value.\n *\n * Note: `EMDASH_ENCRYPTION_KEY` is **not** consumed here. It's validated\n * separately at runtime startup (see `validateEncryptionKeyAtStartup`) so a\n * malformed key can't take down preview-token verification or comment\n * submission for unrelated visitors. Future plugin-secret encryption code\n * will read it via its own dedicated helper.\n */\nexport async function resolveSecrets(options: ResolveSecretsOptions): Promise<ResolvedSecrets> {\n\tconst env = options.env ?? readDefaultEnv();\n\tconst repo = options._repo ?? new OptionsRepository(options.db);\n\n\tconst previewEnvOverride = pickFirstNonEmpty(env.EMDASH_PREVIEW_SECRET, env.PREVIEW_SECRET);\n\tconst ipSaltEnvOverride = pickFirstNonEmpty(\n\t\tenv.EMDASH_IP_SALT,\n\t\tenv.EMDASH_AUTH_SECRET,\n\t\tenv.AUTH_SECRET,\n\t);\n\n\tconst [previewSecret, ipSalt] = await Promise.all([\n\t\tpreviewEnvOverride !== null\n\t\t\t? Promise.resolve({ value: previewEnvOverride, source: \"env\" as const })\n\t\t\t: ensureGeneratedOption(repo, PREVIEW_SECRET_OPTION_KEY),\n\t\tipSaltEnvOverride !== null\n\t\t\t? Promise.resolve({ value: ipSaltEnvOverride, source: \"env\" as const })\n\t\t\t: ensureGeneratedOption(repo, IP_SALT_OPTION_KEY),\n\t]);\n\n\treturn {\n\t\tpreviewSecret: previewSecret.value,\n\t\tpreviewSecretSource: previewSecret.source,\n\t\tipSalt: ipSalt.value,\n\t\tipSaltSource: ipSalt.source,\n\t};\n}\n\n/**\n * Validate `EMDASH_ENCRYPTION_KEY` once at runtime startup. Logs an\n * operator-facing error if the value is malformed but does **not** throw —\n * the key is currently inert (no consumers), and the follow-up PR that\n * actually uses it will throw at point of use. This way, deployment\n * mistakes surface immediately in startup logs without wedging unrelated\n * request paths in the meantime.\n *\n * Returns `true` if the key is unset or valid, `false` if it was malformed.\n */\nexport async function validateEncryptionKeyAtStartup(env?: SecretsEnv): Promise<boolean> {\n\tconst resolved = env ?? readDefaultEnv();\n\ttry {\n\t\tawait parseEncryptionKeys(resolved.EMDASH_ENCRYPTION_KEY);\n\t\treturn true;\n\t} catch (error) {\n\t\tif (error instanceof EmDashSecretsError) {\n\t\t\tconsole.error(\n\t\t\t\t`[emdash] EMDASH_ENCRYPTION_KEY is invalid: ${error.message} ` +\n\t\t\t\t\t\"Plugin-secret encryption will fail once it ships. \" +\n\t\t\t\t\t\"Generate a fresh key with `emdash secrets generate`.\",\n\t\t\t);\n\t\t\treturn false;\n\t\t}\n\t\tthrow error;\n\t}\n}\n\n/**\n * Per-DB cache of resolved secrets, keyed by Kysely instance identity.\n *\n * The resolved values are stable for the lifetime of the deployment (env\n * vars don't change without a restart, and DB-stored values are written\n * once via `setIfAbsent`). Caching avoids one options-table read per\n * request on the hot paths (preview verification, comment hashing).\n *\n * Lives on `globalThis` so module-duplication during SSR bundling can't\n * fragment the cache. See `request-context.ts` for the same pattern.\n */\n// Versioned to prevent cache fragmentation if `ResolvedSecrets`'s shape\n// ever changes. Bump the suffix on incompatible changes so a co-resident\n// older build doesn't read a newer-shape value.\nconst SECRETS_CACHE_KEY = Symbol.for(\"@emdash-cms/core/secrets-cache@1\");\n\ninterface SecretsCacheHolder {\n\tcache: WeakMap<Kysely<Database>, Promise<ResolvedSecrets>>;\n}\n\nfunction getSecretsCache(): WeakMap<Kysely<Database>, Promise<ResolvedSecrets>> {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\tconst holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;\n\tlet entry = holder[SECRETS_CACHE_KEY];\n\tif (!entry) {\n\t\tentry = { cache: new WeakMap() };\n\t\tholder[SECRETS_CACHE_KEY] = entry;\n\t}\n\treturn entry.cache;\n}\n\n/**\n * Memoized wrapper around `resolveSecrets`. Use this from request-time hot\n * paths (preview verification, comment IP hashing) so they don't reread\n * env / re-query options on every request.\n *\n * The cache is keyed by `Kysely` instance, so playground / per-DO / per-test\n * databases each get their own resolution.\n */\nexport function resolveSecretsCached(db: Kysely<Database>): Promise<ResolvedSecrets> {\n\tconst cache = getSecretsCache();\n\tconst cached = cache.get(db);\n\tif (cached) return cached;\n\tconst promise = resolveSecrets({ db }).catch((error) => {\n\t\t// Don't poison the cache on transient failure; next caller retries.\n\t\tcache.delete(db);\n\t\tthrow error;\n\t});\n\tcache.set(db, promise);\n\treturn promise;\n}\n\n/**\n * Test-only helper: clear the secrets cache. Tests that mutate env between\n * cases need this so a stale resolution doesn't leak across cases.\n *\n * @internal\n */\nexport function _clearSecretsCacheForTesting(): void {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis singleton pattern\n\tconst holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;\n\tholder[SECRETS_CACHE_KEY] = undefined;\n}\n\n// ---------------------------------------------------------------------------\n// Internals\n// ---------------------------------------------------------------------------\n\n/**\n * Read or generate-and-persist a random base64url secret stored in the\n * options table.\n *\n * Concurrency: `setIfAbsent` is an atomic INSERT...ON CONFLICT DO NOTHING.\n * On race, the loser re-reads to converge on the winner's value.\n */\nasync function ensureGeneratedOption(\n\trepo: OptionsRepository,\n\toptionKey: string,\n): Promise<{ value: string; source: \"db\" }> {\n\tconst existing = await repo.get<string>(optionKey);\n\tif (typeof existing === \"string\" && existing.length > 0) {\n\t\treturn { value: existing, source: \"db\" };\n\t}\n\n\tconst generated = generateRandomSecret();\n\tconst inserted = await repo.setIfAbsent(optionKey, generated);\n\tif (inserted) {\n\t\treturn { value: generated, source: \"db\" };\n\t}\n\n\t// Lost the race — another process inserted first. Re-read to pick up\n\t// the winner. If the row is somehow still missing or empty, treat that\n\t// as a real error rather than looping.\n\tconst winner = await repo.get<string>(optionKey);\n\tif (typeof winner !== \"string\" || winner.length === 0) {\n\t\tthrow new EmDashSecretsError(\n\t\t\t`Failed to persist generated secret for \"${optionKey}\"`,\n\t\t\t\"SECRET_PERSIST_FAILED\",\n\t\t);\n\t}\n\treturn { value: winner, source: \"db\" };\n}\n\n/** Generate 32 random bytes encoded as unpadded base64url. */\nfunction generateRandomSecret(): string {\n\tconst bytes = new Uint8Array(GENERATED_SECRET_BYTES);\n\tcrypto.getRandomValues(bytes);\n\treturn encodeBase64url(bytes);\n}\n\n/** Return the first non-empty string from `values`, or `null` if all are empty. */\nfunction pickFirstNonEmpty(...values: (string | undefined)[]): string | null {\n\tfor (const value of values) {\n\t\tif (typeof value === \"string\" && value.length > 0) {\n\t\t\treturn value;\n\t\t}\n\t}\n\treturn null;\n}\n\nconst BASE64URL_CHARSET_PATTERN = /^[A-Za-z0-9_-]+$/;\n\n/**\n * Validate base64url shape and decode. Returns `null` on malformed input\n * (rather than throwing) so the caller can produce a config-specific error.\n */\nfunction decodeBase64urlStrict(input: string): Uint8Array | null {\n\t// `decodeBase64url` accepts padded input too; the env-var format is\n\t// strictly unpadded base64url, so we do a charset check first.\n\tif (!BASE64URL_CHARSET_PATTERN.test(input)) return null;\n\ttry {\n\t\treturn decodeBase64url(input);\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Default env reader.\n *\n * Note: this is the **only** code path in core that reads both\n * `import.meta.env` and `process.env`. Route handlers should not — they\n * always run inside the Astro/Vite bundle where `import.meta.env` is\n * the correct source. This resolver is shared with the CLI surface (via\n * `cli/commands/secrets.ts`) which runs outside the bundle, so we\n * deliberately consult both. `import.meta.env` wins so build-time\n * substitutions are honored when present.\n *\n * The convention documented in AGENTS.md (\"import.meta.env.EMDASH_X ||\n * import.meta.env.X\") is the route-handler convention; this is the\n * shared-with-CLI exception.\n */\nfunction readDefaultEnv(): SecretsEnv {\n\t// eslint-disable-next-line typescript/no-unsafe-type-assertion -- import.meta.env is loose by design\n\tconst meta = (import.meta.env ?? {}) as Record<string, string | undefined>;\n\tconst proc = typeof process !== \"undefined\" && process.env ? process.env : {};\n\n\treturn {\n\t\tEMDASH_ENCRYPTION_KEY: meta.EMDASH_ENCRYPTION_KEY ?? proc.EMDASH_ENCRYPTION_KEY,\n\t\tEMDASH_PREVIEW_SECRET: meta.EMDASH_PREVIEW_SECRET ?? proc.EMDASH_PREVIEW_SECRET,\n\t\tPREVIEW_SECRET: meta.PREVIEW_SECRET ?? proc.PREVIEW_SECRET,\n\t\tEMDASH_IP_SALT: meta.EMDASH_IP_SALT ?? proc.EMDASH_IP_SALT,\n\t\tEMDASH_AUTH_SECRET: meta.EMDASH_AUTH_SECRET ?? proc.EMDASH_AUTH_SECRET,\n\t\tAUTH_SECRET: meta.AUTH_SECRET ?? proc.AUTH_SECRET,\n\t};\n}\n"],"mappings":"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"}
|