emdash 0.8.0 → 0.10.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-BKSf3T9R.d.mts → adapters-BktHA7EO.d.mts} +1 -1
- package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-BktHA7EO.d.mts.map} +1 -1
- package/dist/{apply-x0eMK1lX.mjs → apply-UsrFuO7l.mjs} +207 -355
- package/dist/apply-UsrFuO7l.mjs.map +1 -0
- package/dist/astro/index.d.mts +6 -6
- package/dist/astro/index.d.mts.map +1 -1
- package/dist/astro/index.mjs +118 -4
- package/dist/astro/index.mjs.map +1 -1
- package/dist/astro/middleware/auth.d.mts +6 -7
- package/dist/astro/middleware/auth.d.mts.map +1 -1
- package/dist/astro/middleware/auth.mjs +14 -57
- package/dist/astro/middleware/auth.mjs.map +1 -1
- package/dist/astro/middleware/redirect.d.mts.map +1 -1
- package/dist/astro/middleware/redirect.mjs +15 -10
- package/dist/astro/middleware/redirect.mjs.map +1 -1
- package/dist/astro/middleware/request-context.d.mts.map +1 -1
- package/dist/astro/middleware/request-context.mjs +8 -5
- package/dist/astro/middleware/request-context.mjs.map +1 -1
- package/dist/astro/middleware/setup.mjs +1 -1
- package/dist/astro/middleware.d.mts.map +1 -1
- package/dist/astro/middleware.mjs +70 -121
- package/dist/astro/middleware.mjs.map +1 -1
- package/dist/astro/types.d.mts +25 -10
- package/dist/astro/types.d.mts.map +1 -1
- package/dist/{byline-Chbr2GoP.mjs → byline-C3vnhIpU.mjs} +4 -4
- package/dist/{byline-Chbr2GoP.mjs.map → byline-C3vnhIpU.mjs.map} +1 -1
- package/dist/bylines-esI7ioa9.mjs +113 -0
- package/dist/bylines-esI7ioa9.mjs.map +1 -0
- package/dist/cache-fTzxgMFJ.mjs +65 -0
- package/dist/cache-fTzxgMFJ.mjs.map +1 -0
- package/dist/{chunks-HGz06Soa.mjs → chunks-Da2-b-oA.mjs} +8 -2
- package/dist/{chunks-HGz06Soa.mjs.map → chunks-Da2-b-oA.mjs.map} +1 -1
- package/dist/cli/index.mjs +456 -90
- 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 +3 -3
- package/dist/client/index.mjs.map +1 -1
- package/dist/{config-BXwuX8Bx.mjs → config-CVssduLe.mjs} +1 -1
- package/dist/{config-BXwuX8Bx.mjs.map → config-CVssduLe.mjs.map} +1 -1
- package/dist/{content-BcQPYxdV.mjs → content-C7G4QXkK.mjs} +42 -14
- package/dist/content-C7G4QXkK.mjs.map +1 -0
- package/dist/db/index.d.mts +3 -3
- package/dist/db/index.mjs +2 -2
- package/dist/db/libsql.d.mts +1 -1
- package/dist/db/libsql.d.mts.map +1 -1
- package/dist/db/libsql.mjs +7 -2
- package/dist/db/libsql.mjs.map +1 -1
- package/dist/db/postgres.d.mts +1 -1
- package/dist/db/sqlite.d.mts +1 -1
- package/dist/db/sqlite.d.mts.map +1 -1
- package/dist/db/sqlite.mjs +8 -3
- package/dist/db/sqlite.mjs.map +1 -1
- package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-B7P2pSCn.mjs} +1 -1
- package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-B7P2pSCn.mjs.map} +1 -1
- package/dist/{default-DCVqE5ib.mjs → default-pHuz9WF6.mjs} +1 -1
- package/dist/{default-DCVqE5ib.mjs.map → default-pHuz9WF6.mjs.map} +1 -1
- package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
- package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
- package/dist/{error-zG5T1UGA.mjs → error-DqnRMM5z.mjs} +1 -1
- package/dist/{error-zG5T1UGA.mjs.map → error-DqnRMM5z.mjs.map} +1 -1
- package/dist/{index-DIb-CzNx.d.mts → index-DjPMOfO0.d.mts} +162 -87
- package/dist/index-DjPMOfO0.d.mts.map +1 -0
- package/dist/index.d.mts +11 -11
- package/dist/index.mjs +27 -24
- package/dist/{load-CyEoextb.mjs → load-sXRuM7Us.mjs} +2 -2
- package/dist/{load-CyEoextb.mjs.map → load-sXRuM7Us.mjs.map} +1 -1
- package/dist/{loader-CndGj8kM.mjs → loader-Bx2_9-5e.mjs} +53 -8
- package/dist/loader-Bx2_9-5e.mjs.map +1 -0
- package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-CXAbd1vH.mjs} +33 -3
- package/dist/manifest-schema-CXAbd1vH.mjs.map +1 -0
- package/dist/media/index.d.mts +1 -1
- package/dist/media/index.mjs +1 -1
- package/dist/media/local-runtime.d.mts +7 -7
- package/dist/{mode-BnAOqItE.mjs → mode-YhqNVef_.mjs} +1 -1
- package/dist/{mode-BnAOqItE.mjs.map → mode-YhqNVef_.mjs.map} +1 -1
- package/dist/options-nPxWnrya.mjs +117 -0
- package/dist/options-nPxWnrya.mjs.map +1 -0
- package/dist/page/index.d.mts +2 -2
- package/dist/{patterns-CrCYkMBb.mjs → patterns-DsUZ4uxI.mjs} +1 -1
- package/dist/{patterns-CrCYkMBb.mjs.map → patterns-DsUZ4uxI.mjs.map} +1 -1
- package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-CDPtkelt.d.mts} +1 -1
- package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-CDPtkelt.d.mts.map} +1 -1
- package/dist/{placeholder-C-fk5hYI.mjs → placeholder-Ci0RLeCk.mjs} +1 -1
- package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-Ci0RLeCk.mjs.map} +1 -1
- package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
- package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
- package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
- package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
- package/dist/public-url-B1AxbbbQ.mjs +51 -0
- package/dist/public-url-B1AxbbbQ.mjs.map +1 -0
- package/dist/{query-fqEdLFms.mjs → query-Bo-msrmu.mjs} +114 -16
- package/dist/query-Bo-msrmu.mjs.map +1 -0
- package/dist/{redirect-D_pshWdf.mjs → redirect-C5H7VGIX.mjs} +11 -6
- package/dist/redirect-C5H7VGIX.mjs.map +1 -0
- package/dist/{registry-C3Mr0ODu.mjs → registry-Beb7wxFc.mjs} +39 -5
- package/dist/registry-Beb7wxFc.mjs.map +1 -0
- package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-C-tIpYIw.mjs} +1 -1
- package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-C-tIpYIw.mjs.map} +1 -1
- package/dist/runner-Clwe4Mme.d.mts +44 -0
- package/dist/runner-Clwe4Mme.d.mts.map +1 -0
- package/dist/{runner-tQ7BJ4T7.mjs → runner-DMnlIkh4.mjs} +616 -191
- package/dist/runner-DMnlIkh4.mjs.map +1 -0
- package/dist/runtime.d.mts +6 -6
- package/dist/runtime.mjs +2 -2
- package/dist/{search-BoZYFuUk.mjs → search-DkN-BqsS.mjs} +270 -152
- package/dist/search-DkN-BqsS.mjs.map +1 -0
- package/dist/secrets-CZ8rxLX3.mjs +314 -0
- package/dist/secrets-CZ8rxLX3.mjs.map +1 -0
- package/dist/seed/index.d.mts +2 -2
- package/dist/seed/index.mjs +13 -11
- package/dist/seo/index.d.mts +1 -1
- package/dist/storage/local.d.mts +1 -1
- package/dist/storage/local.mjs +1 -1
- package/dist/storage/s3.d.mts +1 -1
- package/dist/storage/s3.mjs +1 -1
- package/dist/taxonomies-CTtewrSQ.mjs +407 -0
- package/dist/taxonomies-CTtewrSQ.mjs.map +1 -0
- package/dist/taxonomy-DSxx2K2L.mjs +218 -0
- package/dist/taxonomy-DSxx2K2L.mjs.map +1 -0
- package/dist/{tokens-D9vnZqYS.mjs → tokens-CyRDPVW2.mjs} +1 -1
- package/dist/{tokens-D9vnZqYS.mjs.map → tokens-CyRDPVW2.mjs.map} +1 -1
- package/dist/{transaction-Cn2rjY78.mjs → transaction-D44LBXvU.mjs} +1 -1
- package/dist/{transaction-Cn2rjY78.mjs.map → transaction-D44LBXvU.mjs.map} +1 -1
- package/dist/{transport-CUnEL3Vs.d.mts → transport-DX_5rpsq.d.mts} +1 -1
- package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DX_5rpsq.d.mts.map} +1 -1
- package/dist/{transport-C9ugt2Nr.mjs → transport-xpzIjCIB.mjs} +6 -5
- package/dist/{transport-C9ugt2Nr.mjs.map → transport-xpzIjCIB.mjs.map} +1 -1
- package/dist/{types-BrA0xf5I.d.mts → types-B_CXXnzh.d.mts} +1 -1
- package/dist/{types-BrA0xf5I.d.mts.map → types-B_CXXnzh.d.mts.map} +1 -1
- package/dist/{types-DIMwPFub.d.mts → types-C-aFbqmA.d.mts} +1 -1
- package/dist/{types-DIMwPFub.d.mts.map → types-C-aFbqmA.d.mts.map} +1 -1
- package/dist/types-CoO6mpV3.mjs +68 -0
- package/dist/types-CoO6mpV3.mjs.map +1 -0
- package/dist/{types-i36XcA_X.d.mts → types-D19uBYWn.d.mts} +83 -7
- package/dist/types-D19uBYWn.d.mts.map +1 -0
- package/dist/{types-BmPPSUEx.d.mts → types-Dl1fgFjn.d.mts} +24 -2
- package/dist/{types-BmPPSUEx.d.mts.map → types-Dl1fgFjn.d.mts.map} +1 -1
- package/dist/{types-CS8FIX7L.d.mts → types-Dtx1mSMX.d.mts} +9 -1
- package/dist/types-Dtx1mSMX.d.mts.map +1 -0
- package/dist/{types-Bm1dn-q3.mjs → types-Eg829jj9.mjs} +1 -1
- package/dist/{types-Bm1dn-q3.mjs.map → types-Eg829jj9.mjs.map} +1 -1
- package/dist/{types-CgqmmMJB.mjs → types-K-EkEQCI.mjs} +1 -1
- package/dist/{types-CgqmmMJB.mjs.map → types-K-EkEQCI.mjs.map} +1 -1
- package/dist/{validate-CxVsLehf.mjs → validate-CBIbxM3L.mjs} +14 -10
- package/dist/validate-CBIbxM3L.mjs.map +1 -0
- package/dist/{validate-DHxmpFJt.d.mts → validate-DHGwADqO.d.mts} +18 -5
- package/dist/validate-DHGwADqO.d.mts.map +1 -0
- package/dist/{validation-C-ZpN2GI.mjs → validation-B1NYiEos.mjs} +6 -6
- package/dist/{validation-C-ZpN2GI.mjs.map → validation-B1NYiEos.mjs.map} +1 -1
- package/dist/version-CMD42IRC.mjs +7 -0
- package/dist/{version-Bbq8TCrz.mjs.map → version-CMD42IRC.mjs.map} +1 -1
- package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-BNJDQBSZ.mjs} +11 -6
- package/dist/{zod-generator-CpwccCIv.mjs.map → zod-generator-BNJDQBSZ.mjs.map} +1 -1
- package/locals.d.ts +1 -6
- package/package.json +9 -8
- package/src/api/handlers/comments.ts +6 -4
- package/src/api/handlers/content.ts +40 -1
- package/src/api/handlers/dashboard.ts +29 -36
- package/src/api/handlers/device-flow.ts +5 -0
- package/src/api/handlers/marketplace.ts +11 -4
- package/src/api/handlers/menus.ts +256 -75
- package/src/api/handlers/oauth-authorization.ts +72 -33
- package/src/api/handlers/revision.ts +23 -14
- package/src/api/handlers/taxonomies.ts +273 -100
- package/src/api/public-url.ts +48 -2
- package/src/api/schemas/comments.ts +2 -2
- package/src/api/schemas/common.ts +7 -0
- package/src/api/schemas/content.ts +17 -0
- package/src/api/schemas/menus.ts +23 -0
- package/src/api/schemas/sections.ts +3 -3
- package/src/api/schemas/taxonomies.ts +39 -0
- package/src/api/schemas/users.ts +1 -1
- package/src/api/types.ts +5 -1
- package/src/astro/integration/index.ts +17 -0
- package/src/astro/integration/routes.ts +10 -0
- package/src/astro/integration/runtime.ts +30 -0
- package/src/astro/integration/virtual-modules.ts +32 -2
- package/src/astro/integration/vite-config.ts +6 -1
- package/src/astro/middleware/auth.ts +13 -6
- package/src/astro/middleware/redirect.ts +29 -16
- package/src/astro/middleware/request-context.ts +15 -5
- package/src/astro/middleware.ts +23 -9
- package/src/astro/routes/api/auth/invite/complete.ts +6 -1
- package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
- package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
- package/src/astro/routes/api/auth/signup/complete.ts +6 -1
- package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
- package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
- package/src/astro/routes/api/content/[collection]/[id]/permanent.ts +1 -1
- package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
- package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
- package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
- package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
- package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
- package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
- package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
- package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
- package/src/astro/routes/api/import/wordpress/rewrite-url-helpers.ts +196 -0
- package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +9 -177
- package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
- package/src/astro/routes/api/manifest.ts +62 -45
- package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
- package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
- package/src/astro/routes/api/menus/[name]/items.ts +16 -6
- package/src/astro/routes/api/menus/[name]/reorder.ts +8 -3
- package/src/astro/routes/api/menus/[name]/translations.ts +82 -0
- package/src/astro/routes/api/menus/[name].ts +19 -10
- package/src/astro/routes/api/menus/index.ts +9 -6
- package/src/astro/routes/api/openapi.json.ts +27 -10
- package/src/astro/routes/api/redirects/404s/index.ts +10 -4
- package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
- package/src/astro/routes/api/redirects/[id].ts +10 -4
- package/src/astro/routes/api/redirects/index.ts +7 -3
- package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
- package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
- package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
- package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
- package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
- package/src/astro/routes/api/schema/collections/index.ts +1 -1
- package/src/astro/routes/api/search/index.ts +10 -2
- package/src/astro/routes/api/sections/[slug].ts +10 -4
- package/src/astro/routes/api/sections/index.ts +7 -3
- package/src/astro/routes/api/setup/admin-verify.ts +6 -1
- package/src/astro/routes/api/snapshot.ts +44 -18
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug]/translations.ts +89 -0
- package/src/astro/routes/api/taxonomies/[name]/terms/[slug].ts +22 -22
- package/src/astro/routes/api/taxonomies/[name]/terms/index.ts +11 -14
- package/src/astro/routes/api/taxonomies/index.ts +9 -7
- package/src/astro/routes/api/themes/preview.ts +11 -5
- package/src/astro/types.ts +23 -3
- package/src/auth/allowed-origins.ts +168 -0
- package/src/auth/passkey-config.ts +35 -13
- package/src/bylines/index.ts +37 -88
- package/src/cli/commands/auth.ts +28 -6
- package/src/cli/commands/bundle-utils.ts +11 -2
- package/src/cli/commands/bundle.ts +28 -8
- package/src/cli/commands/content.ts +13 -0
- package/src/cli/commands/export-seed.ts +82 -21
- package/src/cli/commands/login.ts +8 -1
- package/src/cli/commands/plugin-init.ts +216 -90
- package/src/cli/commands/publish.ts +24 -0
- package/src/cli/commands/secrets.ts +183 -0
- package/src/cli/credentials.ts +1 -1
- package/src/cli/index.ts +5 -1
- package/src/client/index.ts +4 -4
- package/src/client/transport.ts +17 -7
- package/src/components/Break.astro +2 -2
- package/src/components/EmDashHead.astro +18 -13
- package/src/components/Embed.astro +1 -1
- package/src/components/Gallery.astro +1 -1
- package/src/components/Image.astro +1 -1
- package/src/components/InlinePortableTextEditor.tsx +104 -18
- package/src/config/secrets.ts +528 -0
- package/src/database/dialect-helpers.ts +50 -0
- package/src/database/migrations/034_published_at_index.ts +1 -1
- package/src/database/migrations/035_bounded_404_log.ts +56 -39
- package/src/database/migrations/036_i18n_menus_and_taxonomies.ts +477 -0
- package/src/database/migrations/runner.ts +158 -23
- package/src/database/repositories/content.ts +47 -12
- package/src/database/repositories/redirect.ts +14 -3
- package/src/database/repositories/taxonomy.ts +212 -82
- package/src/database/types.ts +10 -2
- package/src/db/libsql.ts +1 -3
- package/src/db/sqlite.ts +2 -5
- package/src/emdash-runtime.ts +84 -159
- package/src/i18n/resolve.ts +37 -0
- package/src/index.ts +9 -0
- package/src/loader.ts +73 -3
- package/src/mcp/server.ts +180 -54
- package/src/menus/index.ts +143 -124
- package/src/menus/types.ts +15 -1
- package/src/page/site-identity.ts +58 -0
- package/src/plugins/adapt-sandbox-entry.ts +22 -10
- package/src/plugins/context.ts +13 -10
- package/src/plugins/define-plugin.ts +40 -12
- package/src/plugins/hooks.ts +23 -19
- package/src/plugins/index.ts +9 -0
- package/src/plugins/manifest-schema.ts +37 -2
- package/src/plugins/types.ts +151 -11
- package/src/preview/urls.ts +23 -3
- package/src/query.ts +148 -5
- package/src/redirects/cache.ts +38 -18
- package/src/schema/registry.ts +56 -0
- package/src/schema/zod-generator.ts +39 -7
- package/src/seed/apply.ts +142 -54
- package/src/seed/types.ts +14 -1
- package/src/seed/validate.ts +27 -13
- package/src/settings/index.ts +80 -6
- package/src/settings/types.ts +23 -1
- package/src/taxonomies/index.ts +237 -210
- package/src/taxonomies/types.ts +10 -0
- package/dist/apply-x0eMK1lX.mjs.map +0 -1
- package/dist/bylines-CRNsVG88.mjs +0 -157
- package/dist/bylines-CRNsVG88.mjs.map +0 -1
- package/dist/cache-BkKBuIvS.mjs +0 -56
- package/dist/cache-BkKBuIvS.mjs.map +0 -1
- package/dist/chunk-ClPoSABd.mjs +0 -21
- package/dist/content-BcQPYxdV.mjs.map +0 -1
- package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
- package/dist/index-DIb-CzNx.d.mts.map +0 -1
- package/dist/loader-CndGj8kM.mjs.map +0 -1
- package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
- package/dist/query-fqEdLFms.mjs.map +0 -1
- package/dist/redirect-D_pshWdf.mjs.map +0 -1
- package/dist/registry-C3Mr0ODu.mjs.map +0 -1
- package/dist/runner-OURCaApa.d.mts +0 -34
- package/dist/runner-OURCaApa.d.mts.map +0 -1
- package/dist/runner-tQ7BJ4T7.mjs.map +0 -1
- package/dist/search-BoZYFuUk.mjs.map +0 -1
- package/dist/taxonomies-B4IAshV8.mjs +0 -308
- package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
- package/dist/types-CS8FIX7L.d.mts.map +0 -1
- package/dist/types-i36XcA_X.d.mts.map +0 -1
- package/dist/validate-CxVsLehf.mjs.map +0 -1
- package/dist/validate-DHxmpFJt.d.mts.map +0 -1
- package/dist/version-Bbq8TCrz.mjs +0 -7
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized secrets module
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for site-level cryptographic secrets:
|
|
5
|
+
*
|
|
6
|
+
* - `EMDASH_ENCRYPTION_KEY` — primary key for encrypting plugin secrets at
|
|
7
|
+
* rest. Multi-key (comma-separated) for rotation forward-compat. v1 ships
|
|
8
|
+
* single-key. Format: `emdash_enc_v1_<43 base64url chars>` representing
|
|
9
|
+
* 32 random bytes. **Operator-provided; never stored in the database.**
|
|
10
|
+
* Losing the key means losing every secret encrypted with it. Validated
|
|
11
|
+
* at runtime startup via `validateEncryptionKeyAtStartup` — request-time
|
|
12
|
+
* resolution does not depend on it, so a malformed key can't 500 the
|
|
13
|
+
* preview/comment hot paths for unrelated visitors.
|
|
14
|
+
* - `EMDASH_IP_SALT` (optional) / DB-stored `emdash:ip_salt` — site-specific
|
|
15
|
+
* salt for hashing commenter IPs. Generated and persisted on first need
|
|
16
|
+
* if no env override is set. Replaces the previous hardcoded
|
|
17
|
+
* `"emdash-ip-salt"` constant which was correlatable across installs.
|
|
18
|
+
* - `EMDASH_PREVIEW_SECRET` (optional) / DB-stored `emdash:preview_secret` —
|
|
19
|
+
* HMAC secret for signing preview URLs. Generated and persisted on first
|
|
20
|
+
* need if no env override is set. Replaces the previous empty-string
|
|
21
|
+
* fallback which silently disabled preview-token verification.
|
|
22
|
+
*
|
|
23
|
+
* The `EMDASH_AUTH_SECRET` env var is consulted only as a legacy fallback
|
|
24
|
+
* source for the IP salt — that's the only path the prior code actually
|
|
25
|
+
* read it from. New deployments don't need to set it.
|
|
26
|
+
*
|
|
27
|
+
* Modeled on `resolveS3Config` in `../storage/s3.ts`.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { sha256 } from "@oslojs/crypto/sha2";
|
|
31
|
+
import { encodeHexLowerCase } from "@oslojs/encoding";
|
|
32
|
+
import type { Kysely } from "kysely";
|
|
33
|
+
|
|
34
|
+
import { OptionsRepository } from "../database/repositories/options.js";
|
|
35
|
+
import type { Database } from "../database/types.js";
|
|
36
|
+
import { decodeBase64url, encodeBase64url } from "../utils/base64.js";
|
|
37
|
+
|
|
38
|
+
/** v1 encryption key prefix. Bumping requires a separate KDF version. */
|
|
39
|
+
export const ENCRYPTION_KEY_PREFIX = "emdash_enc_v1_";
|
|
40
|
+
|
|
41
|
+
/** 32 random bytes encoded as unpadded base64url = 43 chars. */
|
|
42
|
+
const ENCRYPTION_KEY_BODY_LENGTH = 43;
|
|
43
|
+
|
|
44
|
+
const REGEX_META_PATTERN = /[.*+?^${}()|[\]\\]/g;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Built from the prefix constant via interpolation. The prefix has no regex
|
|
48
|
+
* metacharacters today (`emdash_enc_v1_`), but escaping is cheap defense
|
|
49
|
+
* against anyone changing the prefix in a future bump without remembering.
|
|
50
|
+
*/
|
|
51
|
+
const ENCRYPTION_KEY_PATTERN = new RegExp(
|
|
52
|
+
`^${ENCRYPTION_KEY_PREFIX.replace(REGEX_META_PATTERN, "\\$&")}[A-Za-z0-9_-]{${ENCRYPTION_KEY_BODY_LENGTH}}$`,
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
/** Options-table key for the persisted commenter-IP salt. */
|
|
56
|
+
export const IP_SALT_OPTION_KEY = "emdash:ip_salt";
|
|
57
|
+
|
|
58
|
+
/** Options-table key for the persisted preview HMAC secret. */
|
|
59
|
+
export const PREVIEW_SECRET_OPTION_KEY = "emdash:preview_secret";
|
|
60
|
+
|
|
61
|
+
/** Length in bytes of generated values. 32 bytes = 256 bits. */
|
|
62
|
+
const GENERATED_SECRET_BYTES = 32;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A parsed encryption key with its kid (key id) fingerprint.
|
|
66
|
+
*
|
|
67
|
+
* `kid` is the first 8 chars of the SHA-256 hash of the decoded key bytes
|
|
68
|
+
* (lowercase hex), used to tag envelopes so the decryptor can pick the right
|
|
69
|
+
* key during rotation.
|
|
70
|
+
*/
|
|
71
|
+
export interface ParsedEncryptionKey {
|
|
72
|
+
/** 8-char lowercase hex fingerprint derived from the decoded key bytes. */
|
|
73
|
+
kid: string;
|
|
74
|
+
/** The 32 raw key bytes, ready for `crypto.subtle.importKey`. */
|
|
75
|
+
key: Uint8Array;
|
|
76
|
+
/** The original env-var-formatted string (kept for re-emit; never log). */
|
|
77
|
+
raw: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Resolved site secrets. */
|
|
81
|
+
export interface ResolvedSecrets {
|
|
82
|
+
/** HMAC secret for preview URLs. Always non-empty after resolution. */
|
|
83
|
+
previewSecret: string;
|
|
84
|
+
/**
|
|
85
|
+
* Source of `previewSecret`. Useful for diagnostics; never expose the
|
|
86
|
+
* value itself, only the source.
|
|
87
|
+
*/
|
|
88
|
+
previewSecretSource: "env" | "db";
|
|
89
|
+
/** Salt for hashing commenter IPs. Always non-empty after resolution. */
|
|
90
|
+
ipSalt: string;
|
|
91
|
+
/** Source of `ipSalt`. */
|
|
92
|
+
ipSaltSource: "env" | "db";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Inputs for `resolveSecrets`. */
|
|
96
|
+
export interface ResolveSecretsOptions {
|
|
97
|
+
/**
|
|
98
|
+
* The Kysely DB used to persist (and read back) generated salt/preview
|
|
99
|
+
* secret values. Required — these values must be stable across requests
|
|
100
|
+
* within a deployment.
|
|
101
|
+
*/
|
|
102
|
+
db: Kysely<Database>;
|
|
103
|
+
/**
|
|
104
|
+
* Optional explicit env override map. When omitted, falls back to
|
|
105
|
+
* `import.meta.env` via the global accessor below. Tests pass an
|
|
106
|
+
* explicit map to avoid leaking process state.
|
|
107
|
+
*/
|
|
108
|
+
env?: SecretsEnv;
|
|
109
|
+
/**
|
|
110
|
+
* @internal Test seam: inject a custom OptionsRepository to exercise
|
|
111
|
+
* the lost-race re-read branch. Production callers never set this.
|
|
112
|
+
*/
|
|
113
|
+
_repo?: OptionsRepository;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Environment-variable shape consulted by the resolver. */
|
|
117
|
+
export interface SecretsEnv {
|
|
118
|
+
/**
|
|
119
|
+
* Read by `validateEncryptionKeyAtStartup` and (in a follow-up PR) by the
|
|
120
|
+
* plugin-secret encryption layer. **Not** consulted by `resolveSecrets`,
|
|
121
|
+
* so a malformed value can't 500 the preview/comment hot paths.
|
|
122
|
+
*/
|
|
123
|
+
EMDASH_ENCRYPTION_KEY?: string;
|
|
124
|
+
EMDASH_PREVIEW_SECRET?: string;
|
|
125
|
+
/** Legacy alias; new docs point at EMDASH_PREVIEW_SECRET. */
|
|
126
|
+
PREVIEW_SECRET?: string;
|
|
127
|
+
EMDASH_IP_SALT?: string;
|
|
128
|
+
/**
|
|
129
|
+
* Legacy fallback. Prior code derived the IP salt from
|
|
130
|
+
* `EMDASH_AUTH_SECRET || AUTH_SECRET || "emdash-ip-salt"`. We preserve
|
|
131
|
+
* the env-var fallback (so existing installs keep their stable salt)
|
|
132
|
+
* but no longer read it from `import.meta.env` in route handlers.
|
|
133
|
+
*/
|
|
134
|
+
EMDASH_AUTH_SECRET?: string;
|
|
135
|
+
/** Legacy alias. */
|
|
136
|
+
AUTH_SECRET?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Class of validation failures raised by this module.
|
|
141
|
+
*
|
|
142
|
+
* Errors here are operator-facing config problems (malformed key, etc.).
|
|
143
|
+
* They are thrown rather than soft-skipped so misconfiguration fails loudly
|
|
144
|
+
* at startup instead of silently degrading at request time.
|
|
145
|
+
*/
|
|
146
|
+
export class EmDashSecretsError extends Error {
|
|
147
|
+
override readonly name = "EmDashSecretsError";
|
|
148
|
+
readonly code: string;
|
|
149
|
+
|
|
150
|
+
constructor(message: string, code: string) {
|
|
151
|
+
super(message);
|
|
152
|
+
this.code = code;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Encryption key parsing
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Parse the `EMDASH_ENCRYPTION_KEY` env var.
|
|
162
|
+
*
|
|
163
|
+
* Accepts a single key or a comma-separated list. The first entry is the
|
|
164
|
+
* primary (used for new writes); all entries are tried for decryption,
|
|
165
|
+
* matched by `kid`. Whitespace around commas is tolerated. Empty entries
|
|
166
|
+
* (e.g. trailing comma) are ignored.
|
|
167
|
+
*
|
|
168
|
+
* Returns `null` for an unset/empty input. Throws `EmDashSecretsError` on
|
|
169
|
+
* any malformed entry — silent skipping would mask deployment mistakes.
|
|
170
|
+
*/
|
|
171
|
+
export async function parseEncryptionKeys(
|
|
172
|
+
raw: string | undefined,
|
|
173
|
+
): Promise<ParsedEncryptionKey[] | null> {
|
|
174
|
+
if (!raw) return null;
|
|
175
|
+
|
|
176
|
+
const entries = raw
|
|
177
|
+
.split(",")
|
|
178
|
+
.map((entry) => entry.trim())
|
|
179
|
+
.filter((entry) => entry.length > 0);
|
|
180
|
+
|
|
181
|
+
if (entries.length === 0) return null;
|
|
182
|
+
|
|
183
|
+
const parsed: ParsedEncryptionKey[] = [];
|
|
184
|
+
const seenKids = new Set<string>();
|
|
185
|
+
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (!ENCRYPTION_KEY_PATTERN.test(entry)) {
|
|
188
|
+
throw new EmDashSecretsError(
|
|
189
|
+
`EMDASH_ENCRYPTION_KEY entry is malformed (expected "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars). Generate one with \`emdash secrets generate\`.`,
|
|
190
|
+
"INVALID_ENCRYPTION_KEY",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const body = entry.slice(ENCRYPTION_KEY_PREFIX.length);
|
|
195
|
+
const key = decodeBase64urlStrict(body);
|
|
196
|
+
if (!key) {
|
|
197
|
+
throw new EmDashSecretsError(
|
|
198
|
+
"EMDASH_ENCRYPTION_KEY body is not valid base64url",
|
|
199
|
+
"INVALID_ENCRYPTION_KEY",
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (key.length !== GENERATED_SECRET_BYTES) {
|
|
203
|
+
throw new EmDashSecretsError(
|
|
204
|
+
`EMDASH_ENCRYPTION_KEY must decode to ${GENERATED_SECRET_BYTES} bytes, got ${key.length}`,
|
|
205
|
+
"INVALID_ENCRYPTION_KEY",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Reject non-canonical base64url. 43 chars decode to 32 bytes but
|
|
210
|
+
// the last char only carries 2 information bits — multiple raw
|
|
211
|
+
// strings can decode to the same bytes. Forcing canonical form
|
|
212
|
+
// guarantees `kid` (derived from bytes) is stable per key
|
|
213
|
+
// material, regardless of how the operator pasted it.
|
|
214
|
+
const canonical = encodeBase64url(key);
|
|
215
|
+
if (canonical !== body) {
|
|
216
|
+
throw new EmDashSecretsError(
|
|
217
|
+
"EMDASH_ENCRYPTION_KEY body is not canonical base64url. Generate one with `emdash secrets generate`.",
|
|
218
|
+
"INVALID_ENCRYPTION_KEY",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const kid = fingerprintKeyBytes(key);
|
|
223
|
+
if (seenKids.has(kid)) {
|
|
224
|
+
// Duplicate keys are user error (paste mistake during rotation).
|
|
225
|
+
// We dedupe rather than throw — the rotation flow is forgiving.
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
seenKids.add(kid);
|
|
229
|
+
parsed.push({ kid, key, raw: entry });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// `parsed` always has at least one entry here: `entries` was non-empty
|
|
233
|
+
// after filtering, the loop runs at least once, the first iteration
|
|
234
|
+
// always passes the empty-`seenKids` check.
|
|
235
|
+
return parsed;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Compute the kid for a raw key string (the env-var form including the
|
|
240
|
+
* `emdash_enc_v1_` prefix). Public so the CLI's `fingerprint` subcommand
|
|
241
|
+
* and admin endpoints can show kids without exposing raw keys.
|
|
242
|
+
*
|
|
243
|
+
* The kid is derived from the decoded key **bytes**, not the raw string,
|
|
244
|
+
* so admin endpoints / future rotation flows can match envelope kids
|
|
245
|
+
* against bytes regardless of how the env var was originally spelled.
|
|
246
|
+
*
|
|
247
|
+
* Validates the same shape as `parseEncryptionKeys` — including canonical
|
|
248
|
+
* base64url — so the CLI can't print a kid for a key the runtime would
|
|
249
|
+
* later refuse to load.
|
|
250
|
+
*
|
|
251
|
+
* Throws `EmDashSecretsError` for malformed or non-canonical input.
|
|
252
|
+
*/
|
|
253
|
+
export async function fingerprintKey(raw: string): Promise<string> {
|
|
254
|
+
if (!ENCRYPTION_KEY_PATTERN.test(raw)) {
|
|
255
|
+
throw new EmDashSecretsError(
|
|
256
|
+
`Key must match "${ENCRYPTION_KEY_PREFIX}" followed by ${ENCRYPTION_KEY_BODY_LENGTH} base64url chars`,
|
|
257
|
+
"INVALID_ENCRYPTION_KEY",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const body = raw.slice(ENCRYPTION_KEY_PREFIX.length);
|
|
261
|
+
const bytes = decodeBase64urlStrict(body);
|
|
262
|
+
if (!bytes || bytes.length !== GENERATED_SECRET_BYTES || encodeBase64url(bytes) !== body) {
|
|
263
|
+
throw new EmDashSecretsError(
|
|
264
|
+
`Key body must decode to ${GENERATED_SECRET_BYTES} canonical base64url bytes`,
|
|
265
|
+
"INVALID_ENCRYPTION_KEY",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
return fingerprintKeyBytes(bytes);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Internal: kid derivation from raw key bytes. The single source of truth
|
|
273
|
+
* for what makes two keys "the same key" — used by both `parseEncryptionKeys`
|
|
274
|
+
* and `fingerprintKey`.
|
|
275
|
+
*/
|
|
276
|
+
function fingerprintKeyBytes(key: Uint8Array): string {
|
|
277
|
+
return encodeHexLowerCase(sha256(key)).slice(0, 8);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Generate a fresh `EMDASH_ENCRYPTION_KEY` value. Used by the CLI's
|
|
282
|
+
* `secrets generate` subcommand and by `create-emdash` scaffolding.
|
|
283
|
+
*/
|
|
284
|
+
export function generateEncryptionKey(): string {
|
|
285
|
+
const bytes = new Uint8Array(GENERATED_SECRET_BYTES);
|
|
286
|
+
crypto.getRandomValues(bytes);
|
|
287
|
+
return `${ENCRYPTION_KEY_PREFIX}${encodeBase64url(bytes)}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Site-secret resolution (DB-backed with env override)
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolve site secrets. Reads env vars; for IP salt and preview secret,
|
|
296
|
+
* falls back to a DB-stored value, generating one atomically on first need.
|
|
297
|
+
*
|
|
298
|
+
* Idempotent. Concurrent callers race on the atomic `setIfAbsent`; whichever
|
|
299
|
+
* wins, all callers converge on the same stored value.
|
|
300
|
+
*
|
|
301
|
+
* Note: `EMDASH_ENCRYPTION_KEY` is **not** consumed here. It's validated
|
|
302
|
+
* separately at runtime startup (see `validateEncryptionKeyAtStartup`) so a
|
|
303
|
+
* malformed key can't take down preview-token verification or comment
|
|
304
|
+
* submission for unrelated visitors. Future plugin-secret encryption code
|
|
305
|
+
* will read it via its own dedicated helper.
|
|
306
|
+
*/
|
|
307
|
+
export async function resolveSecrets(options: ResolveSecretsOptions): Promise<ResolvedSecrets> {
|
|
308
|
+
const env = options.env ?? readDefaultEnv();
|
|
309
|
+
const repo = options._repo ?? new OptionsRepository(options.db);
|
|
310
|
+
|
|
311
|
+
const previewEnvOverride = pickFirstNonEmpty(env.EMDASH_PREVIEW_SECRET, env.PREVIEW_SECRET);
|
|
312
|
+
const ipSaltEnvOverride = pickFirstNonEmpty(
|
|
313
|
+
env.EMDASH_IP_SALT,
|
|
314
|
+
env.EMDASH_AUTH_SECRET,
|
|
315
|
+
env.AUTH_SECRET,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const [previewSecret, ipSalt] = await Promise.all([
|
|
319
|
+
previewEnvOverride !== null
|
|
320
|
+
? Promise.resolve({ value: previewEnvOverride, source: "env" as const })
|
|
321
|
+
: ensureGeneratedOption(repo, PREVIEW_SECRET_OPTION_KEY),
|
|
322
|
+
ipSaltEnvOverride !== null
|
|
323
|
+
? Promise.resolve({ value: ipSaltEnvOverride, source: "env" as const })
|
|
324
|
+
: ensureGeneratedOption(repo, IP_SALT_OPTION_KEY),
|
|
325
|
+
]);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
previewSecret: previewSecret.value,
|
|
329
|
+
previewSecretSource: previewSecret.source,
|
|
330
|
+
ipSalt: ipSalt.value,
|
|
331
|
+
ipSaltSource: ipSalt.source,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Validate `EMDASH_ENCRYPTION_KEY` once at runtime startup. Logs an
|
|
337
|
+
* operator-facing error if the value is malformed but does **not** throw —
|
|
338
|
+
* the key is currently inert (no consumers), and the follow-up PR that
|
|
339
|
+
* actually uses it will throw at point of use. This way, deployment
|
|
340
|
+
* mistakes surface immediately in startup logs without wedging unrelated
|
|
341
|
+
* request paths in the meantime.
|
|
342
|
+
*
|
|
343
|
+
* Returns `true` if the key is unset or valid, `false` if it was malformed.
|
|
344
|
+
*/
|
|
345
|
+
export async function validateEncryptionKeyAtStartup(env?: SecretsEnv): Promise<boolean> {
|
|
346
|
+
const resolved = env ?? readDefaultEnv();
|
|
347
|
+
try {
|
|
348
|
+
await parseEncryptionKeys(resolved.EMDASH_ENCRYPTION_KEY);
|
|
349
|
+
return true;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
if (error instanceof EmDashSecretsError) {
|
|
352
|
+
console.error(
|
|
353
|
+
`[emdash] EMDASH_ENCRYPTION_KEY is invalid: ${error.message} ` +
|
|
354
|
+
"Plugin-secret encryption will fail once it ships. " +
|
|
355
|
+
"Generate a fresh key with `emdash secrets generate`.",
|
|
356
|
+
);
|
|
357
|
+
return false;
|
|
358
|
+
}
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Per-DB cache of resolved secrets, keyed by Kysely instance identity.
|
|
365
|
+
*
|
|
366
|
+
* The resolved values are stable for the lifetime of the deployment (env
|
|
367
|
+
* vars don't change without a restart, and DB-stored values are written
|
|
368
|
+
* once via `setIfAbsent`). Caching avoids one options-table read per
|
|
369
|
+
* request on the hot paths (preview verification, comment hashing).
|
|
370
|
+
*
|
|
371
|
+
* Lives on `globalThis` so module-duplication during SSR bundling can't
|
|
372
|
+
* fragment the cache. See `request-context.ts` for the same pattern.
|
|
373
|
+
*/
|
|
374
|
+
// Versioned to prevent cache fragmentation if `ResolvedSecrets`'s shape
|
|
375
|
+
// ever changes. Bump the suffix on incompatible changes so a co-resident
|
|
376
|
+
// older build doesn't read a newer-shape value.
|
|
377
|
+
const SECRETS_CACHE_KEY = Symbol.for("@emdash-cms/core/secrets-cache@1");
|
|
378
|
+
|
|
379
|
+
interface SecretsCacheHolder {
|
|
380
|
+
cache: WeakMap<Kysely<Database>, Promise<ResolvedSecrets>>;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function getSecretsCache(): WeakMap<Kysely<Database>, Promise<ResolvedSecrets>> {
|
|
384
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern
|
|
385
|
+
const holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;
|
|
386
|
+
let entry = holder[SECRETS_CACHE_KEY];
|
|
387
|
+
if (!entry) {
|
|
388
|
+
entry = { cache: new WeakMap() };
|
|
389
|
+
holder[SECRETS_CACHE_KEY] = entry;
|
|
390
|
+
}
|
|
391
|
+
return entry.cache;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Memoized wrapper around `resolveSecrets`. Use this from request-time hot
|
|
396
|
+
* paths (preview verification, comment IP hashing) so they don't reread
|
|
397
|
+
* env / re-query options on every request.
|
|
398
|
+
*
|
|
399
|
+
* The cache is keyed by `Kysely` instance, so playground / per-DO / per-test
|
|
400
|
+
* databases each get their own resolution.
|
|
401
|
+
*/
|
|
402
|
+
export function resolveSecretsCached(db: Kysely<Database>): Promise<ResolvedSecrets> {
|
|
403
|
+
const cache = getSecretsCache();
|
|
404
|
+
const cached = cache.get(db);
|
|
405
|
+
if (cached) return cached;
|
|
406
|
+
const promise = resolveSecrets({ db }).catch((error) => {
|
|
407
|
+
// Don't poison the cache on transient failure; next caller retries.
|
|
408
|
+
cache.delete(db);
|
|
409
|
+
throw error;
|
|
410
|
+
});
|
|
411
|
+
cache.set(db, promise);
|
|
412
|
+
return promise;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Test-only helper: clear the secrets cache. Tests that mutate env between
|
|
417
|
+
* cases need this so a stale resolution doesn't leak across cases.
|
|
418
|
+
*
|
|
419
|
+
* @internal
|
|
420
|
+
*/
|
|
421
|
+
export function _clearSecretsCacheForTesting(): void {
|
|
422
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- globalThis singleton pattern
|
|
423
|
+
const holder = globalThis as Record<symbol, SecretsCacheHolder | undefined>;
|
|
424
|
+
holder[SECRETS_CACHE_KEY] = undefined;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Internals
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Read or generate-and-persist a random base64url secret stored in the
|
|
433
|
+
* options table.
|
|
434
|
+
*
|
|
435
|
+
* Concurrency: `setIfAbsent` is an atomic INSERT...ON CONFLICT DO NOTHING.
|
|
436
|
+
* On race, the loser re-reads to converge on the winner's value.
|
|
437
|
+
*/
|
|
438
|
+
async function ensureGeneratedOption(
|
|
439
|
+
repo: OptionsRepository,
|
|
440
|
+
optionKey: string,
|
|
441
|
+
): Promise<{ value: string; source: "db" }> {
|
|
442
|
+
const existing = await repo.get<string>(optionKey);
|
|
443
|
+
if (typeof existing === "string" && existing.length > 0) {
|
|
444
|
+
return { value: existing, source: "db" };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const generated = generateRandomSecret();
|
|
448
|
+
const inserted = await repo.setIfAbsent(optionKey, generated);
|
|
449
|
+
if (inserted) {
|
|
450
|
+
return { value: generated, source: "db" };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Lost the race — another process inserted first. Re-read to pick up
|
|
454
|
+
// the winner. If the row is somehow still missing or empty, treat that
|
|
455
|
+
// as a real error rather than looping.
|
|
456
|
+
const winner = await repo.get<string>(optionKey);
|
|
457
|
+
if (typeof winner !== "string" || winner.length === 0) {
|
|
458
|
+
throw new EmDashSecretsError(
|
|
459
|
+
`Failed to persist generated secret for "${optionKey}"`,
|
|
460
|
+
"SECRET_PERSIST_FAILED",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
return { value: winner, source: "db" };
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Generate 32 random bytes encoded as unpadded base64url. */
|
|
467
|
+
function generateRandomSecret(): string {
|
|
468
|
+
const bytes = new Uint8Array(GENERATED_SECRET_BYTES);
|
|
469
|
+
crypto.getRandomValues(bytes);
|
|
470
|
+
return encodeBase64url(bytes);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Return the first non-empty string from `values`, or `null` if all are empty. */
|
|
474
|
+
function pickFirstNonEmpty(...values: (string | undefined)[]): string | null {
|
|
475
|
+
for (const value of values) {
|
|
476
|
+
if (typeof value === "string" && value.length > 0) {
|
|
477
|
+
return value;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const BASE64URL_CHARSET_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Validate base64url shape and decode. Returns `null` on malformed input
|
|
487
|
+
* (rather than throwing) so the caller can produce a config-specific error.
|
|
488
|
+
*/
|
|
489
|
+
function decodeBase64urlStrict(input: string): Uint8Array | null {
|
|
490
|
+
// `decodeBase64url` accepts padded input too; the env-var format is
|
|
491
|
+
// strictly unpadded base64url, so we do a charset check first.
|
|
492
|
+
if (!BASE64URL_CHARSET_PATTERN.test(input)) return null;
|
|
493
|
+
try {
|
|
494
|
+
return decodeBase64url(input);
|
|
495
|
+
} catch {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Default env reader.
|
|
502
|
+
*
|
|
503
|
+
* Note: this is the **only** code path in core that reads both
|
|
504
|
+
* `import.meta.env` and `process.env`. Route handlers should not — they
|
|
505
|
+
* always run inside the Astro/Vite bundle where `import.meta.env` is
|
|
506
|
+
* the correct source. This resolver is shared with the CLI surface (via
|
|
507
|
+
* `cli/commands/secrets.ts`) which runs outside the bundle, so we
|
|
508
|
+
* deliberately consult both. `import.meta.env` wins so build-time
|
|
509
|
+
* substitutions are honored when present.
|
|
510
|
+
*
|
|
511
|
+
* The convention documented in AGENTS.md ("import.meta.env.EMDASH_X ||
|
|
512
|
+
* import.meta.env.X") is the route-handler convention; this is the
|
|
513
|
+
* shared-with-CLI exception.
|
|
514
|
+
*/
|
|
515
|
+
function readDefaultEnv(): SecretsEnv {
|
|
516
|
+
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- import.meta.env is loose by design
|
|
517
|
+
const meta = (import.meta.env ?? {}) as Record<string, string | undefined>;
|
|
518
|
+
const proc = typeof process !== "undefined" && process.env ? process.env : {};
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
EMDASH_ENCRYPTION_KEY: meta.EMDASH_ENCRYPTION_KEY ?? proc.EMDASH_ENCRYPTION_KEY,
|
|
522
|
+
EMDASH_PREVIEW_SECRET: meta.EMDASH_PREVIEW_SECRET ?? proc.EMDASH_PREVIEW_SECRET,
|
|
523
|
+
PREVIEW_SECRET: meta.PREVIEW_SECRET ?? proc.PREVIEW_SECRET,
|
|
524
|
+
EMDASH_IP_SALT: meta.EMDASH_IP_SALT ?? proc.EMDASH_IP_SALT,
|
|
525
|
+
EMDASH_AUTH_SECRET: meta.EMDASH_AUTH_SECRET ?? proc.EMDASH_AUTH_SECRET,
|
|
526
|
+
AUTH_SECRET: meta.AUTH_SECRET ?? proc.AUTH_SECRET,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
@@ -90,6 +90,56 @@ export async function tableExists(db: Kysely<any>, tableName: string): Promise<b
|
|
|
90
90
|
return result.rows.length > 0;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Check if an index exists in the database.
|
|
95
|
+
*/
|
|
96
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
|
|
97
|
+
export async function indexExists(db: Kysely<any>, indexName: string): Promise<boolean> {
|
|
98
|
+
if (isPostgres(db)) {
|
|
99
|
+
const result = await sql<{ exists: boolean }>`
|
|
100
|
+
SELECT EXISTS(
|
|
101
|
+
SELECT 1 FROM pg_indexes
|
|
102
|
+
WHERE schemaname = current_schema() AND indexname = ${indexName}
|
|
103
|
+
) as exists
|
|
104
|
+
`.execute(db);
|
|
105
|
+
return result.rows[0]?.exists === true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const result = await sql<{ name: string }>`
|
|
109
|
+
SELECT name FROM sqlite_master
|
|
110
|
+
WHERE type = 'index' AND name = ${indexName}
|
|
111
|
+
`.execute(db);
|
|
112
|
+
return result.rows.length > 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if a column exists in the database.
|
|
117
|
+
*/
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- accepts any Kysely instance
|
|
119
|
+
export async function columnExists(
|
|
120
|
+
db: Kysely<any>,
|
|
121
|
+
tableName: string,
|
|
122
|
+
columnName: string,
|
|
123
|
+
): Promise<boolean> {
|
|
124
|
+
if (isPostgres(db)) {
|
|
125
|
+
const result = await sql<{ exists: boolean }>`
|
|
126
|
+
SELECT EXISTS(
|
|
127
|
+
SELECT 1 FROM information_schema.columns
|
|
128
|
+
WHERE table_schema = current_schema()
|
|
129
|
+
AND table_name = ${tableName}
|
|
130
|
+
AND column_name = ${columnName}
|
|
131
|
+
) as exists
|
|
132
|
+
`.execute(db);
|
|
133
|
+
return result.rows[0]?.exists === true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const result = await sql<{ name: string }>`
|
|
137
|
+
SELECT name FROM pragma_table_info(${tableName})
|
|
138
|
+
WHERE name = ${columnName}
|
|
139
|
+
`.execute(db);
|
|
140
|
+
return result.rows.length > 0;
|
|
141
|
+
}
|
|
142
|
+
|
|
93
143
|
/**
|
|
94
144
|
* List tables matching a LIKE pattern.
|
|
95
145
|
*/
|
|
@@ -10,7 +10,7 @@ export async function up(db: Kysely<unknown>): Promise<void> {
|
|
|
10
10
|
const table = { name: tableName };
|
|
11
11
|
|
|
12
12
|
await sql`
|
|
13
|
-
CREATE INDEX ${sql.ref(`idx_${table.name}_deleted_published_id`)}
|
|
13
|
+
CREATE INDEX IF NOT EXISTS ${sql.ref(`idx_${table.name}_deleted_published_id`)}
|
|
14
14
|
ON ${sql.ref(table.name)} (deleted_at, published_at DESC, id DESC)
|
|
15
15
|
`.execute(db);
|
|
16
16
|
}
|