emdash 0.1.1 → 0.3.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.
Files changed (202) hide show
  1. package/dist/{apply-kC39ev1Z.mjs → apply-Bqoekfbe.mjs} +57 -10
  2. package/dist/apply-Bqoekfbe.mjs.map +1 -0
  3. package/dist/astro/index.d.mts +23 -9
  4. package/dist/astro/index.d.mts.map +1 -1
  5. package/dist/astro/index.mjs +90 -25
  6. package/dist/astro/index.mjs.map +1 -1
  7. package/dist/astro/middleware/auth.d.mts +3 -3
  8. package/dist/astro/middleware/auth.d.mts.map +1 -1
  9. package/dist/astro/middleware/auth.mjs +126 -55
  10. package/dist/astro/middleware/auth.mjs.map +1 -1
  11. package/dist/astro/middleware/redirect.mjs +2 -2
  12. package/dist/astro/middleware/request-context.mjs +1 -1
  13. package/dist/astro/middleware.d.mts.map +1 -1
  14. package/dist/astro/middleware.mjs +80 -41
  15. package/dist/astro/middleware.mjs.map +1 -1
  16. package/dist/astro/types.d.mts +27 -6
  17. package/dist/astro/types.d.mts.map +1 -1
  18. package/dist/{byline-CL847F26.mjs → byline-BGj9p9Ht.mjs} +53 -31
  19. package/dist/byline-BGj9p9Ht.mjs.map +1 -0
  20. package/dist/{bylines-C2a-2TGt.mjs → bylines-BihaoIDY.mjs} +12 -10
  21. package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BihaoIDY.mjs.map} +1 -1
  22. package/dist/cli/index.mjs +17 -14
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
  25. package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
  26. package/dist/{content-D6C2WsZC.mjs → content-BsBoyj8G.mjs} +35 -5
  27. package/dist/content-BsBoyj8G.mjs.map +1 -0
  28. package/dist/db/index.mjs +2 -2
  29. package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
  30. package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
  31. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  32. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  33. package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
  34. package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
  35. package/dist/{index-CLBc4gw-.d.mts → index-Cff7AimE.d.mts} +77 -15
  36. package/dist/index-Cff7AimE.d.mts.map +1 -0
  37. package/dist/index.d.mts +6 -6
  38. package/dist/index.mjs +19 -19
  39. package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
  40. package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
  41. package/dist/{loader-fz8Q_3EO.mjs → loader-BmYdf3Dr.mjs} +4 -2
  42. package/dist/loader-BmYdf3Dr.mjs.map +1 -0
  43. package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
  44. package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
  45. package/dist/media/local-runtime.d.mts +4 -4
  46. package/dist/page/index.d.mts +10 -1
  47. package/dist/page/index.d.mts.map +1 -1
  48. package/dist/page/index.mjs +8 -4
  49. package/dist/page/index.mjs.map +1 -1
  50. package/dist/plugins/adapt-sandbox-entry.d.mts +3 -3
  51. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  52. package/dist/{query-BVYN0PJ6.mjs → query-sesiOndV.mjs} +20 -8
  53. package/dist/{query-BVYN0PJ6.mjs.map → query-sesiOndV.mjs.map} +1 -1
  54. package/dist/{redirect-DIfIni3r.mjs → redirect-DUAk-Yl_.mjs} +9 -2
  55. package/dist/redirect-DUAk-Yl_.mjs.map +1 -0
  56. package/dist/{registry-BNYQKX_d.mjs → registry-DU18yVo0.mjs} +14 -4
  57. package/dist/registry-DU18yVo0.mjs.map +1 -0
  58. package/dist/{runner-BraqvGYk.mjs → runner-Biufrii2.mjs} +157 -132
  59. package/dist/runner-Biufrii2.mjs.map +1 -0
  60. package/dist/runner-EAtf0ZIe.d.mts.map +1 -1
  61. package/dist/runtime.d.mts +3 -3
  62. package/dist/runtime.mjs +2 -2
  63. package/dist/{search-C1gg67nN.mjs → search-BXB-jfu2.mjs} +241 -109
  64. package/dist/search-BXB-jfu2.mjs.map +1 -0
  65. package/dist/seed/index.d.mts +1 -1
  66. package/dist/seed/index.mjs +10 -10
  67. package/dist/seo/index.d.mts +1 -1
  68. package/dist/storage/local.d.mts +1 -1
  69. package/dist/storage/local.mjs +1 -1
  70. package/dist/storage/s3.d.mts +11 -3
  71. package/dist/storage/s3.d.mts.map +1 -1
  72. package/dist/storage/s3.mjs +76 -15
  73. package/dist/storage/s3.mjs.map +1 -1
  74. package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
  75. package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
  76. package/dist/{types-BRuPJGdV.d.mts → types-BbsYgi_R.d.mts} +3 -1
  77. package/dist/types-BbsYgi_R.d.mts.map +1 -0
  78. package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
  79. package/dist/types-Bec-r_3_.mjs.map +1 -0
  80. package/dist/{types-DaNLHo_T.d.mts → types-C1-PVaS_.d.mts} +14 -6
  81. package/dist/types-C1-PVaS_.d.mts.map +1 -0
  82. package/dist/types-CMMN0pNg.mjs.map +1 -1
  83. package/dist/{types-BQo5JS0J.d.mts → types-CaKte3hR.d.mts} +78 -6
  84. package/dist/types-CaKte3hR.d.mts.map +1 -0
  85. package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
  86. package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
  87. package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
  88. package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
  89. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  90. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  91. package/dist/{validate-HtxZeaBi.d.mts → validate-bfg9OR6N.d.mts} +2 -2
  92. package/dist/{validate-HtxZeaBi.d.mts.map → validate-bfg9OR6N.d.mts.map} +1 -1
  93. package/dist/version-REAapfsU.mjs +7 -0
  94. package/dist/version-REAapfsU.mjs.map +1 -0
  95. package/package.json +6 -6
  96. package/src/api/csrf.ts +13 -2
  97. package/src/api/handlers/content.ts +7 -0
  98. package/src/api/handlers/dashboard.ts +4 -8
  99. package/src/api/handlers/device-flow.ts +55 -37
  100. package/src/api/handlers/index.ts +6 -1
  101. package/src/api/handlers/redirects.ts +95 -3
  102. package/src/api/handlers/seo.ts +48 -21
  103. package/src/api/public-url.ts +84 -0
  104. package/src/api/schemas/content.ts +2 -2
  105. package/src/api/schemas/menus.ts +12 -2
  106. package/src/api/schemas/redirects.ts +1 -0
  107. package/src/astro/integration/index.ts +30 -7
  108. package/src/astro/integration/routes.ts +13 -2
  109. package/src/astro/integration/runtime.ts +7 -5
  110. package/src/astro/integration/vite-config.ts +55 -9
  111. package/src/astro/middleware/auth.ts +60 -56
  112. package/src/astro/middleware/csp.ts +25 -0
  113. package/src/astro/middleware.ts +31 -3
  114. package/src/astro/routes/PluginRegistry.tsx +8 -2
  115. package/src/astro/routes/admin.astro +7 -2
  116. package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
  117. package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
  118. package/src/astro/routes/api/auth/invite/complete.ts +3 -2
  119. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
  120. package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
  121. package/src/astro/routes/api/auth/passkey/options.ts +3 -2
  122. package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
  123. package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
  124. package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
  125. package/src/astro/routes/api/auth/signup/complete.ts +3 -2
  126. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  127. package/src/astro/routes/api/content/[collection]/index.ts +31 -3
  128. package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
  129. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  130. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
  131. package/src/astro/routes/api/manifest.ts +4 -1
  132. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
  133. package/src/astro/routes/api/oauth/authorize.ts +12 -7
  134. package/src/astro/routes/api/oauth/device/code.ts +5 -1
  135. package/src/astro/routes/api/setup/admin-verify.ts +3 -2
  136. package/src/astro/routes/api/setup/admin.ts +3 -2
  137. package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
  138. package/src/astro/routes/api/setup/index.ts +3 -2
  139. package/src/astro/routes/api/snapshot.ts +2 -1
  140. package/src/astro/routes/api/themes/preview.ts +2 -1
  141. package/src/astro/routes/api/well-known/auth.ts +1 -0
  142. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
  143. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
  144. package/src/astro/routes/robots.txt.ts +5 -1
  145. package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
  146. package/src/astro/routes/sitemap.xml.ts +18 -23
  147. package/src/astro/storage/adapters.ts +19 -5
  148. package/src/astro/storage/types.ts +12 -4
  149. package/src/astro/types.ts +28 -1
  150. package/src/auth/passkey-config.ts +6 -10
  151. package/src/bylines/index.ts +13 -10
  152. package/src/cli/commands/login.ts +5 -2
  153. package/src/components/InlinePortableTextEditor.tsx +5 -3
  154. package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
  155. package/src/database/dialect-helpers.ts +3 -0
  156. package/src/database/migrations/034_published_at_index.ts +29 -0
  157. package/src/database/migrations/runner.ts +2 -0
  158. package/src/database/repositories/byline.ts +48 -42
  159. package/src/database/repositories/content.ts +28 -1
  160. package/src/database/repositories/options.ts +9 -3
  161. package/src/database/repositories/redirect.ts +13 -0
  162. package/src/database/repositories/seo.ts +34 -17
  163. package/src/database/repositories/types.ts +2 -0
  164. package/src/database/validate.ts +10 -10
  165. package/src/emdash-runtime.ts +66 -19
  166. package/src/import/index.ts +1 -1
  167. package/src/import/sources/wxr.ts +45 -2
  168. package/src/index.ts +10 -1
  169. package/src/loader.ts +2 -0
  170. package/src/mcp/server.ts +85 -5
  171. package/src/menus/index.ts +6 -1
  172. package/src/page/context.ts +13 -1
  173. package/src/page/jsonld.ts +10 -6
  174. package/src/page/seo-contributions.ts +1 -1
  175. package/src/plugins/context.ts +145 -35
  176. package/src/plugins/manager.ts +12 -0
  177. package/src/plugins/types.ts +80 -4
  178. package/src/query.ts +18 -0
  179. package/src/redirects/loops.ts +318 -0
  180. package/src/schema/registry.ts +8 -0
  181. package/src/search/fts-manager.ts +4 -0
  182. package/src/settings/index.ts +64 -0
  183. package/src/storage/s3.ts +94 -25
  184. package/src/storage/types.ts +13 -5
  185. package/src/utils/chunks.ts +17 -0
  186. package/src/utils/slugify.ts +11 -0
  187. package/src/version.ts +12 -0
  188. package/dist/apply-kC39ev1Z.mjs.map +0 -1
  189. package/dist/byline-CL847F26.mjs.map +0 -1
  190. package/dist/content-D6C2WsZC.mjs.map +0 -1
  191. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  192. package/dist/index-CLBc4gw-.d.mts.map +0 -1
  193. package/dist/loader-fz8Q_3EO.mjs.map +0 -1
  194. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  195. package/dist/registry-BNYQKX_d.mjs.map +0 -1
  196. package/dist/runner-BraqvGYk.mjs.map +0 -1
  197. package/dist/search-C1gg67nN.mjs.map +0 -1
  198. package/dist/types-BQo5JS0J.d.mts.map +0 -1
  199. package/dist/types-BRuPJGdV.d.mts.map +0 -1
  200. package/dist/types-CUBbjgmP.mjs.map +0 -1
  201. package/dist/types-DaNLHo_T.d.mts.map +0 -1
  202. /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
@@ -263,17 +263,19 @@ export interface EmDashConfig {
263
263
  marketplace?: string;
264
264
 
265
265
  /**
266
- * Public browser origin for passkey verification (rpId + expected WebAuthn origin).
266
+ * Public browser-facing origin for the site.
267
267
  *
268
268
  * Use when `Astro.url` / `request.url` do not match what users open — common with a
269
269
  * **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop
270
- * while the browser uses `https://`, which breaks WebAuthn origin checks.
270
+ * while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs.
271
271
  *
272
272
  * Set to the full origin users type in the address bar (no path), e.g.
273
- * `https://emdash.local:8443`. Prefer fixing `security.allowedDomains` for the proxy first;
274
- * use this when the reconstructed URL still diverges from the browser.
273
+ * `https://mysite.example.com`. When not set, falls back to environment variables
274
+ * `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin.
275
+ *
276
+ * Replaces `passkeyPublicOrigin` (which only fixed passkeys).
275
277
  */
276
- passkeyPublicOrigin?: string;
278
+ siteUrl?: string;
277
279
 
278
280
  /**
279
281
  * Enable playground mode for ephemeral "try EmDash" sites.
@@ -5,6 +5,7 @@
5
5
  * Vite-specific configuration for EmDash.
6
6
  */
7
7
 
8
+ import { existsSync } from "node:fs";
8
9
  import { createRequire } from "node:module";
9
10
  import { dirname, resolve } from "node:path";
10
11
  import { fileURLToPath } from "node:url";
@@ -12,6 +13,7 @@ import { fileURLToPath } from "node:url";
12
13
  import type { AstroConfig } from "astro";
13
14
  import type { Plugin } from "vite";
14
15
 
16
+ import { COMMIT, VERSION } from "../../version.js";
15
17
  import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
16
18
  import {
17
19
  VIRTUAL_CONFIG_ID,
@@ -49,6 +51,45 @@ import {
49
51
  generateBlockComponentsModule,
50
52
  } from "./virtual-modules.js";
51
53
 
54
+ const LOCALE_MESSAGES_RE = /[/\\]([a-z]{2}(?:-[A-Z]{2})?)[/\\]messages\.mjs$/;
55
+ /**
56
+ * Vite plugin that compiles Lingui macros in admin source files.
57
+ * Only active in dev mode when the admin package is aliased to source for HMR.
58
+ * @babel/core is dynamically imported from admin's devDependencies —
59
+ * not declared by core, never ships to end users.
60
+ */
61
+ function linguiMacroPlugin(adminSourcePath: string, adminDistPath: string): Plugin {
62
+ // Resolve @babel/core from admin's devDependencies, not core's.
63
+ const adminRequire = createRequire(resolve(adminDistPath, "index.js"));
64
+ const babelCorePath = adminRequire.resolve("@babel/core");
65
+
66
+ return {
67
+ name: "emdash-lingui-macro",
68
+ enforce: "pre",
69
+ resolveId(id, importer) {
70
+ // Redirect relative locale catalog imports (e.g. ./de/messages.mjs) from
71
+ // within admin source to the compiled dist/locales/ directory, since
72
+ // lingui compile only runs during build — not in dev watch mode.
73
+ if (!importer?.startsWith(adminSourcePath)) return;
74
+ const match = id.match(LOCALE_MESSAGES_RE);
75
+ if (match?.[1]) {
76
+ return resolve(adminDistPath, "locales", match[1], "messages.mjs");
77
+ }
78
+ },
79
+ async transform(code, id) {
80
+ if (!id.startsWith(adminSourcePath) || !code.includes("@lingui")) return;
81
+ const { transformAsync } = (await import(babelCorePath)) as typeof import("@babel/core");
82
+ const result = await transformAsync(code, {
83
+ filename: id,
84
+ plugins: ["@lingui/babel-plugin-lingui-macro"],
85
+ parserOpts: { plugins: ["jsx", "typescript"] },
86
+ });
87
+ if (!result?.code) return;
88
+ return { code: result.code, map: result.map ?? undefined };
89
+ },
90
+ };
91
+ }
92
+
52
93
  /**
53
94
  * Resolve path to the admin package dist directory.
54
95
  * Used for Vite alias to ensure the package is found in pnpm's isolated node_modules.
@@ -72,13 +113,8 @@ function resolveAdminSource(): string | undefined {
72
113
  const packageRoot = resolve(dirname(adminPath), "..");
73
114
  const srcEntry = resolve(packageRoot, "src", "index.ts");
74
115
 
75
- // Only use source alias if the source directory actually exists
76
- // (won't exist in published packages, only in the monorepo)
77
116
  try {
78
- // Use require.resolve mechanics — if the file exists, return the source dir
79
- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CJS require returns any
80
- const fs = require("node:fs") as typeof import("node:fs");
81
- if (fs.existsSync(srcEntry)) {
117
+ if (existsSync(srcEntry)) {
82
118
  return resolve(packageRoot, "src");
83
119
  }
84
120
  } catch {
@@ -243,6 +279,12 @@ export function createViteConfig(
243
279
  const useSource = adminSourcePath !== undefined;
244
280
 
245
281
  return {
282
+ // Astro SSR routes resolve version.ts from source (not tsdown dist),
283
+ // so Vite needs its own define pass for the __EMDASH_*__ placeholders.
284
+ define: {
285
+ __EMDASH_VERSION__: JSON.stringify(VERSION),
286
+ __EMDASH_COMMIT__: JSON.stringify(COMMIT),
287
+ },
246
288
  resolve: {
247
289
  dedupe: ["@emdash-cms/admin", "react", "react-dom"],
248
290
  // Array form so more-specific entries are checked first.
@@ -250,14 +292,18 @@ export function createViteConfig(
250
292
  // Vite's prefix matching on "@emdash-cms/admin" would resolve
251
293
  // "@emdash-cms/admin/styles.css" through the source directory.
252
294
  alias: [
253
- // CSS: always dist (pre-compiled by @tailwindcss/cli)
254
295
  { find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
255
- // JS: source in dev (HMR), dist in build
256
296
  { find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
257
297
  ],
258
298
  },
259
299
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Monorepo has both vite 6 (docs) and vite 7 (core). tsgo resolves correctly.
260
- plugins: [createVirtualModulesPlugin(options)] as NonNullable<AstroConfig["vite"]>["plugins"],
300
+ plugins: [
301
+ createVirtualModulesPlugin(options),
302
+ // In dev mode with source alias, compile Lingui macros on the fly
303
+ // and redirect locale .mjs imports to dist/.
304
+ // In production, macros are pre-compiled by tsdown in the admin package.
305
+ ...(useSource ? [linguiMacroPlugin(adminSourcePath!, adminDistPath)] : []),
306
+ ] as NonNullable<AstroConfig["vite"]>["plugins"],
261
307
  // Handle native modules for SSR.
262
308
  // On Node: external keeps native addons out of the SSR bundle.
263
309
  // On Cloudflare: skip — the adapter handles externalization, and setting
@@ -20,6 +20,7 @@ import { authenticate as virtualAuthenticate } from "virtual:emdash/auth";
20
20
 
21
21
  import { checkPublicCsrf } from "../../api/csrf.js";
22
22
  import { apiError } from "../../api/error.js";
23
+ import { getPublicOrigin } from "../../api/public-url.js";
23
24
 
24
25
  /** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
25
26
  const MW_CACHE_HEADERS = {
@@ -30,6 +31,7 @@ import { hasScope } from "../../auth/api-tokens.js";
30
31
  import { getAuthMode, type ExternalAuthMode } from "../../auth/mode.js";
31
32
  import type { ExternalAuthConfig } from "../../auth/types.js";
32
33
  import type { EmDashHandlers, EmDashManifest } from "../types.js";
34
+ import { buildEmDashCsp } from "./csp.js";
33
35
 
34
36
  declare global {
35
37
  namespace App {
@@ -49,34 +51,37 @@ declare global {
49
51
 
50
52
  // Role level constants (matching @emdash-cms/auth)
51
53
  const ROLE_ADMIN = 50;
54
+ const MCP_ENDPOINT_PATH = "/_emdash/api/mcp";
52
55
 
53
- /**
54
- * Strict Content-Security-Policy for /_emdash routes (admin + API).
55
- *
56
- * Applied via middleware header rather than Astro's built-in CSP because
57
- * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'
58
- * when hashes are present), which would break user-facing pages.
59
- */
60
- function buildEmDashCsp(marketplaceUrl?: string): string {
61
- const imgSources = ["'self'", "data:", "blob:"];
62
- if (marketplaceUrl) {
63
- try {
64
- imgSources.push(new URL(marketplaceUrl).origin);
65
- } catch {
66
- // ignore invalid marketplace URL
67
- }
68
- }
69
- return [
70
- "default-src 'self'",
71
- "script-src 'self' 'unsafe-inline'",
72
- "style-src 'self' 'unsafe-inline'",
73
- "connect-src 'self'",
74
- "form-action 'self'",
75
- "frame-ancestors 'none'",
76
- `img-src ${imgSources.join(" ")}`,
77
- "object-src 'none'",
78
- "base-uri 'self'",
79
- ].join("; ");
56
+ function isUnsafeMethod(method: string): boolean {
57
+ return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
58
+ }
59
+
60
+ function csrfRejectedResponse(): Response {
61
+ return new Response(
62
+ JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
63
+ {
64
+ status: 403,
65
+ headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
66
+ },
67
+ );
68
+ }
69
+
70
+ function mcpUnauthorizedResponse(
71
+ url: URL,
72
+ config?: Parameters<typeof getPublicOrigin>[1],
73
+ ): Response {
74
+ const origin = getPublicOrigin(url, config);
75
+ return Response.json(
76
+ { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
77
+ {
78
+ status: 401,
79
+ headers: {
80
+ "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
81
+ ...MW_CACHE_HEADERS,
82
+ },
83
+ },
84
+ );
80
85
  }
81
86
 
82
87
  /**
@@ -108,6 +113,10 @@ const PUBLIC_API_EXACT = new Set([
108
113
  "/_emdash/api/auth/passkey/verify",
109
114
  "/_emdash/api/oauth/token",
110
115
  "/_emdash/api/snapshot",
116
+ // Public site search — read-only. The query layer hardcodes status='published'
117
+ // so unauthenticated callers only see published content. Admin endpoints
118
+ // (/enable, /rebuild, /stats) remain private because they're not in this set.
119
+ "/_emdash/api/search",
111
120
  ]);
112
121
 
113
122
  function isPublicEmDashRoute(pathname: string): boolean {
@@ -134,7 +143,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
134
143
  if (isPublicApiRoute) {
135
144
  const method = context.request.method.toUpperCase();
136
145
  if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
137
- const csrfError = checkPublicCsrf(context.request, url);
146
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
147
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
138
148
  if (csrfError) return csrfError;
139
149
  }
140
150
  return next();
@@ -148,7 +158,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
148
158
  if (isPluginRoute) {
149
159
  const method = context.request.method.toUpperCase();
150
160
  if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
151
- const csrfError = checkPublicCsrf(context.request, url);
161
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
162
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
152
163
  if (csrfError) return csrfError;
153
164
  }
154
165
  return handlePluginRouteAuth(context, next);
@@ -192,8 +203,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
192
203
  };
193
204
  // Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery
194
205
  if (url.pathname === "/_emdash/api/mcp") {
206
+ const origin = getPublicOrigin(url, context.locals.emdash?.config);
195
207
  headers["WWW-Authenticate"] =
196
- `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
208
+ `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`;
197
209
  }
198
210
  return new Response(
199
211
  JSON.stringify({ error: { code: "INVALID_TOKEN", message: "Invalid or expired token" } }),
@@ -203,31 +215,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
203
215
 
204
216
  const isTokenAuth = bearerResult === "authenticated";
205
217
 
218
+ // MCP discovery/tooling is bearer-only. Session/external auth should never
219
+ // be consulted for this endpoint, and unauthenticated requests must return
220
+ // the OAuth discovery-style 401 response.
221
+ const method = context.request.method.toUpperCase();
222
+ const isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH;
223
+ if (isMcpEndpoint && !isTokenAuth) {
224
+ return mcpUnauthorizedResponse(url, context.locals.emdash?.config);
225
+ }
226
+
206
227
  // CSRF protection: require X-EmDash-Request header on state-changing requests.
207
228
  // Skip for token-authenticated requests (tokens aren't ambient credentials).
208
229
  // Browsers block cross-origin custom headers, so this prevents CSRF without tokens.
209
230
  // OAuth authorize consent is exempt: it's a standard HTML form POST that can't
210
231
  // include custom headers. The consent flow is protected by session + single-use codes.
211
- const method = context.request.method.toUpperCase();
212
232
  const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
213
233
  if (
214
234
  isApiRoute &&
215
235
  !isTokenAuth &&
216
236
  !isOAuthConsent &&
217
- method !== "GET" &&
218
- method !== "HEAD" &&
219
- method !== "OPTIONS" &&
237
+ isUnsafeMethod(method) &&
220
238
  !isPublicApiRoute
221
239
  ) {
222
240
  const csrfHeader = context.request.headers.get("X-EmDash-Request");
223
241
  if (csrfHeader !== "1") {
224
- return new Response(
225
- JSON.stringify({ error: { code: "CSRF_REJECTED", message: "Missing required header" } }),
226
- {
227
- status: 403,
228
- headers: { "Content-Type": "application/json", ...MW_CACHE_HEADERS },
229
- },
230
- );
242
+ return csrfRejectedResponse();
231
243
  }
232
244
  }
233
245
 
@@ -239,8 +251,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
239
251
 
240
252
  const response = await next();
241
253
  if (!import.meta.env.DEV) {
242
- const marketplaceUrl = context.locals.emdash?.config.marketplace;
243
- response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
254
+ response.headers.set("Content-Security-Policy", buildEmDashCsp());
244
255
  }
245
256
  return response;
246
257
  }
@@ -249,8 +260,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
249
260
 
250
261
  // Set strict CSP on all /_emdash responses (prod only)
251
262
  if (!import.meta.env.DEV) {
252
- const marketplaceUrl = context.locals.emdash?.config.marketplace;
253
- response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
263
+ response.headers.set("Content-Security-Policy", buildEmDashCsp());
254
264
  }
255
265
 
256
266
  return response;
@@ -584,20 +594,13 @@ async function handlePasskeyAuth(
584
594
  const sessionUser = await session?.get("user");
585
595
 
586
596
  if (!sessionUser?.id) {
587
- // Not authenticated
588
597
  if (isApiRoute) {
589
- const headers: Record<string, string> = { ...MW_CACHE_HEADERS };
590
- // Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery
591
- if (url.pathname === "/_emdash/api/mcp") {
592
- headers["WWW-Authenticate"] =
593
- `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
594
- }
595
598
  return Response.json(
596
599
  { error: { code: "NOT_AUTHENTICATED", message: "Not authenticated" } },
597
- { status: 401, headers },
600
+ { status: 401, headers: MW_CACHE_HEADERS },
598
601
  );
599
602
  }
600
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
603
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
601
604
  loginUrl.searchParams.set("redirect", url.pathname);
602
605
  return context.redirect(loginUrl.toString());
603
606
  }
@@ -615,7 +618,8 @@ async function handlePasskeyAuth(
615
618
  { status: 401, headers: MW_CACHE_HEADERS },
616
619
  );
617
620
  }
618
- return context.redirect("/_emdash/admin/login");
621
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
622
+ return context.redirect(loginUrl.toString());
619
623
  }
620
624
 
621
625
  // Check if user is disabled
@@ -624,7 +628,7 @@ async function handlePasskeyAuth(
624
628
  if (isApiRoute) {
625
629
  return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
626
630
  }
627
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
631
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
628
632
  loginUrl.searchParams.set("error", "account_disabled");
629
633
  return context.redirect(loginUrl.toString());
630
634
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Strict Content-Security-Policy for /_emdash routes (admin + API).
3
+ *
4
+ * Applied via middleware header rather than Astro's built-in CSP because
5
+ * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'
6
+ * when hashes are present), which would break user-facing pages.
7
+ *
8
+ * img-src allows any HTTPS origin because the admin renders user content that
9
+ * may reference external images (migrations, external hosting, embeds).
10
+ * Plugin security does not rely on img-src -- plugins run in V8 isolates with
11
+ * no DOM access, and connect-src 'self' blocks fetch-based exfiltration.
12
+ */
13
+ export function buildEmDashCsp(): string {
14
+ return [
15
+ "default-src 'self'",
16
+ "script-src 'self' 'unsafe-inline'",
17
+ "style-src 'self' 'unsafe-inline'",
18
+ "connect-src 'self'",
19
+ "form-action 'self'",
20
+ "frame-ancestors 'none'",
21
+ "img-src 'self' https: data: blob:",
22
+ "object-src 'none'",
23
+ "base-uri 'self'",
24
+ ].join("; ");
25
+ }
@@ -45,6 +45,7 @@ import type { SandboxRunner } from "../plugins/sandbox/types.js";
45
45
  import type { ResolvedPlugin } from "../plugins/types.js";
46
46
  import { runWithContext } from "../request-context.js";
47
47
  import type { EmDashConfig } from "./integration/runtime.js";
48
+ import type { EmDashHandlers } from "./types.js";
48
49
 
49
50
  // Cached runtime instance (persists across requests within worker)
50
51
  let runtimeInstance: EmDashRuntime | null = null;
@@ -177,6 +178,7 @@ function setBaselineSecurityHeaders(response: Response): void {
177
178
 
178
179
  /** Public routes that require the runtime (sitemap, robots.txt, etc.) */
179
180
  const PUBLIC_RUNTIME_ROUTES = new Set(["/sitemap.xml", "/robots.txt"]);
181
+ const SITEMAP_COLLECTION_RE = /^\/sitemap-[a-z][a-z0-9_]*\.xml$/;
180
182
 
181
183
  export const onRequest = defineMiddleware(async (context, next) => {
182
184
  const { request, locals, cookies } = context;
@@ -185,7 +187,8 @@ export const onRequest = defineMiddleware(async (context, next) => {
185
187
  // Process /_emdash routes and public routes with an active session
186
188
  // (logged-in editors need the runtime for toolbar/visual editing on public pages)
187
189
  const isEmDashRoute = url.pathname.startsWith("/_emdash");
188
- const isPublicRuntimeRoute = PUBLIC_RUNTIME_ROUTES.has(url.pathname);
190
+ const isPublicRuntimeRoute =
191
+ PUBLIC_RUNTIME_ROUTES.has(url.pathname) || SITEMAP_COLLECTION_RE.test(url.pathname);
189
192
 
190
193
  // Check for edit mode cookie - editors viewing public pages need the runtime
191
194
  // so auth middleware can verify their session for visual editing
@@ -198,7 +201,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
198
201
  const playgroundDb = locals.__playgroundDb;
199
202
 
200
203
  if (!isEmDashRoute && !isPublicRuntimeRoute && !hasEditCookie && !hasPreviewToken) {
201
- const sessionUser = await context.session?.get("user");
204
+ const sessionUser = context.isPrerendered ? null : await context.session?.get("user");
202
205
  if (!sessionUser && !playgroundDb) {
203
206
  // On a fresh deployment the database may be completely empty.
204
207
  // Public pages call getSiteSettings() / getMenu() via getDb(), which
@@ -222,6 +225,25 @@ export const onRequest = defineMiddleware(async (context, next) => {
222
225
  }
223
226
  }
224
227
 
228
+ // Initialize the runtime for page:metadata and page:fragments hooks.
229
+ // The runtime is a cached singleton — after the first request,
230
+ // getRuntime() is just a null-check. This enables SEO plugins to
231
+ // contribute meta tags for all visitors, not just logged-in editors.
232
+ const config = getConfig();
233
+ if (config) {
234
+ try {
235
+ const runtime = await getRuntime(config);
236
+ setupVerified = true;
237
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- partial object; getPageRuntime() only checks for these two methods
238
+ locals.emdash = {
239
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
240
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
241
+ } as EmDashHandlers;
242
+ } catch {
243
+ // Non-fatal — EmDashHead will fall back to base SEO contributions
244
+ }
245
+ }
246
+
225
247
  const response = await next();
226
248
  setBaselineSecurityHeaders(response);
227
249
  return response;
@@ -298,6 +320,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
298
320
  getMediaProvider: runtime.getMediaProvider.bind(runtime),
299
321
  getMediaProviderList: runtime.getMediaProviderList.bind(runtime),
300
322
 
323
+ // Page contribution methods (for EmDashHead/EmDashBodyStart/EmDashBodyEnd)
324
+ collectPageMetadata: runtime.collectPageMetadata.bind(runtime),
325
+ collectPageFragments: runtime.collectPageFragments.bind(runtime),
326
+
301
327
  // Direct access (for advanced use cases)
302
328
  storage: runtime.storage,
303
329
  db: runtime.db,
@@ -350,7 +376,9 @@ export const onRequest = defineMiddleware(async (context, next) => {
350
376
  const d1Binding = (virtualGetD1Binding as (config: unknown) => unknown)(dbConfig);
351
377
 
352
378
  if (d1Binding && typeof d1Binding === "object" && "withSession" in d1Binding) {
353
- const isAuthenticated = !!(await context.session?.get("user"));
379
+ const isAuthenticated = context.isPrerendered
380
+ ? false
381
+ : !!(await context.session?.get("user"));
354
382
  const isWrite = request.method !== "GET" && request.method !== "HEAD";
355
383
 
356
384
  // Determine session constraint:
@@ -7,9 +7,15 @@
7
7
  */
8
8
 
9
9
  import { AdminApp } from "@emdash-cms/admin";
10
+ import type { Messages } from "@lingui/core";
10
11
  // @ts-ignore - virtual module generated by integration
11
12
  import { pluginAdmins } from "virtual:emdash/admin-registry";
12
13
 
13
- export default function AdminWrapper() {
14
- return <AdminApp pluginAdmins={pluginAdmins} />;
14
+ interface AdminWrapperProps {
15
+ locale: string;
16
+ messages: Messages;
17
+ }
18
+
19
+ export default function AdminWrapper({ locale, messages }: AdminWrapperProps) {
20
+ return <AdminApp pluginAdmins={pluginAdmins} locale={locale} messages={messages} />;
15
21
  }
@@ -11,10 +11,15 @@ import "@emdash-cms/admin/styles.css";
11
11
  import AdminWrapper from "emdash/routes/PluginRegistry";
12
12
 
13
13
  export const prerender = false;
14
+
15
+ import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
16
+
17
+ const resolvedLocale = resolveLocale(Astro.request);
18
+ const messages = await loadMessages(resolvedLocale);
14
19
  ---
15
20
 
16
21
  <!doctype html>
17
- <html lang="en">
22
+ <html lang={resolvedLocale}>
18
23
  <head>
19
24
  <meta charset="UTF-8" />
20
25
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -75,7 +80,7 @@ export const prerender = false;
75
80
  <p>Loading EmDash...</p>
76
81
  </div>
77
82
  </div>
78
- <AdminWrapper client:only="react" />
83
+ <AdminWrapper client:only="react" locale={resolvedLocale} messages={messages} />
79
84
  </div>
80
85
  </body>
81
86
  </html>
@@ -9,6 +9,7 @@ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
9
9
  import type { APIRoute } from "astro";
10
10
 
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
+ import { withTransaction } from "#db/transaction.js";
12
13
 
13
14
  export const prerender = false;
14
15
 
@@ -43,20 +44,25 @@ export const POST: APIRoute = async ({ params, locals }) => {
43
44
  return apiError("NOT_FOUND", "User not found", 404);
44
45
  }
45
46
 
46
- // Check if this would leave no active admins
47
- if (targetUser.role === Role.ADMIN) {
48
- const adminCount = await adapter.countAdmins();
49
- if (adminCount <= 1) {
50
- return apiError(
51
- "VALIDATION_ERROR",
52
- "Cannot disable the last admin. Promote another user first.",
53
- 400,
54
- );
47
+ // Wrap admin check + disable in a transaction to prevent two
48
+ // concurrent requests from both passing the count check.
49
+ const lastAdminBlocked = await withTransaction(emdash.db, async (trx) => {
50
+ const trxAdapter = createKyselyAdapter(trx);
51
+ if (targetUser.role === Role.ADMIN) {
52
+ const adminCount = await trxAdapter.countAdmins();
53
+ if (adminCount <= 1) return true;
55
54
  }
56
- }
55
+ await trxAdapter.updateUser(id, { disabled: true });
56
+ return false;
57
+ });
57
58
 
58
- // Disable user
59
- await adapter.updateUser(id, { disabled: true });
59
+ if (lastAdminBlocked) {
60
+ return apiError(
61
+ "VALIDATION_ERROR",
62
+ "Cannot disable the last admin. Promote another user first.",
63
+ 400,
64
+ );
65
+ }
60
66
 
61
67
  // SEC-43: Revoke all OAuth tokens for the disabled user.
62
68
  // Without this, existing refresh tokens remain valid for up to 90 days.
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { apiError, apiSuccess, handleError } from "#api/error.js";
13
13
  import { isParseError, parseBody } from "#api/parse.js";
14
14
  import { userUpdateBody } from "#api/schemas.js";
15
+ import { withTransaction } from "#db/transaction.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -117,13 +118,33 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
117
118
  }
118
119
  }
119
120
 
120
- // Update user
121
- await adapter.updateUser(id, {
122
- name: body.name,
123
- email: body.email,
124
- role,
121
+ // Wrap admin demotion guard + update in a transaction to prevent
122
+ // two concurrent demotions from both passing the count check.
123
+ const isDemotingAdmin =
124
+ role !== undefined && role < Role.ADMIN && targetUser.role === Role.ADMIN;
125
+
126
+ const lastAdminBlocked = await withTransaction(emdash.db, async (trx) => {
127
+ const trxAdapter = createKyselyAdapter(trx);
128
+ if (isDemotingAdmin) {
129
+ const adminCount = await trxAdapter.countAdmins();
130
+ if (adminCount <= 1) return true;
131
+ }
132
+ await trxAdapter.updateUser(id, {
133
+ name: body.name,
134
+ email: body.email,
135
+ role,
136
+ });
137
+ return false;
125
138
  });
126
139
 
140
+ if (lastAdminBlocked) {
141
+ return apiError(
142
+ "LAST_ADMIN",
143
+ "Cannot demote the last admin. Promote another user first.",
144
+ 400,
145
+ );
146
+ }
147
+
127
148
  // Fetch updated user
128
149
  const updated = await adapter.getUserById(id);
129
150
 
@@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
15
15
 
16
16
  import { apiError, apiSuccess, handleError } from "#api/error.js";
17
17
  import { isParseError, parseBody } from "#api/parse.js";
18
+ import { getPublicOrigin } from "#api/public-url.js";
18
19
  import { inviteCompleteBody } from "#api/schemas.js";
19
20
  import { createChallengeStore } from "#auth/challenge-store.js";
20
21
  import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
22
23
 
23
24
  export const POST: APIRoute = async ({ request, locals, session }) => {
24
25
  const { emdash } = locals;
25
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
26
26
 
27
27
  if (!emdash?.db) {
28
28
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
38
38
  const url = new URL(request.url);
39
39
  const options = new OptionsRepository(emdash.db);
40
40
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
41
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
41
+ const siteUrl = getPublicOrigin(url, emdash?.config);
42
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
42
43
 
43
44
  // Verify the passkey registration response
44
45
  const challengeStore = createChallengeStore(emdash.db);
@@ -17,6 +17,7 @@ import {
17
17
  } from "@emdash-cms/auth";
18
18
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
19
19
 
20
+ import { getPublicOrigin } from "#api/public-url.js";
20
21
  import { createOAuthStateStore } from "#auth/oauth-state-store.js";
21
22
 
22
23
  type ProviderName = "github" | "google";
@@ -126,7 +127,7 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
126
127
  }
127
128
 
128
129
  const config: OAuthConsumerConfig = {
129
- baseUrl: `${url.origin}/_emdash`,
130
+ baseUrl: `${getPublicOrigin(url, emdash?.config)}/_emdash`,
130
131
  providers,
131
132
  canSelfSignup: async (email: string) => {
132
133
  // Extract domain from email