emdash 0.1.1 → 0.2.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 (192) hide show
  1. package/dist/{adapters-BLMa4JGD.d.mts → adapters-N6BF7RCD.d.mts} +1 -1
  2. package/dist/{adapters-BLMa4JGD.d.mts.map → adapters-N6BF7RCD.d.mts.map} +1 -1
  3. package/dist/{apply-kC39ev1Z.mjs → apply-wmVEOSbR.mjs} +56 -9
  4. package/dist/apply-wmVEOSbR.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.mjs +80 -27
  7. package/dist/astro/index.mjs.map +1 -1
  8. package/dist/astro/middleware/auth.d.mts +5 -5
  9. package/dist/astro/middleware/auth.d.mts.map +1 -1
  10. package/dist/astro/middleware/auth.mjs +127 -56
  11. package/dist/astro/middleware/auth.mjs.map +1 -1
  12. package/dist/astro/middleware/request-context.mjs +1 -1
  13. package/dist/astro/middleware/setup.mjs +1 -1
  14. package/dist/astro/middleware.d.mts.map +1 -1
  15. package/dist/astro/middleware.mjs +74 -39
  16. package/dist/astro/middleware.mjs.map +1 -1
  17. package/dist/astro/types.d.mts +30 -9
  18. package/dist/astro/types.d.mts.map +1 -1
  19. package/dist/{byline-CL847F26.mjs → byline-1WQPlISL.mjs} +51 -29
  20. package/dist/byline-1WQPlISL.mjs.map +1 -0
  21. package/dist/{bylines-C2a-2TGt.mjs → bylines-BYdTYmia.mjs} +10 -8
  22. package/dist/{bylines-C2a-2TGt.mjs.map → bylines-BYdTYmia.mjs.map} +1 -1
  23. package/dist/cli/index.mjs +15 -12
  24. package/dist/cli/index.mjs.map +1 -1
  25. package/dist/client/cf-access.d.mts +1 -1
  26. package/dist/client/index.d.mts +1 -1
  27. package/dist/client/index.mjs +1 -1
  28. package/dist/{config-CKE8p9xM.mjs → config-Cq8H0SfX.mjs} +2 -10
  29. package/dist/{config-CKE8p9xM.mjs.map → config-Cq8H0SfX.mjs.map} +1 -1
  30. package/dist/{content-D6C2WsZC.mjs → content-BmXndhdi.mjs} +16 -3
  31. package/dist/content-BmXndhdi.mjs.map +1 -0
  32. package/dist/db/index.d.mts +3 -3
  33. package/dist/db/index.mjs +1 -1
  34. package/dist/db/libsql.d.mts +1 -1
  35. package/dist/db/postgres.d.mts +1 -1
  36. package/dist/db/sqlite.d.mts +1 -1
  37. package/dist/{default-Cyi4aAxu.mjs → default-WYlzADZL.mjs} +1 -1
  38. package/dist/{default-Cyi4aAxu.mjs.map → default-WYlzADZL.mjs.map} +1 -1
  39. package/dist/{error-Cxz0tQeO.mjs → error-DrxtnGPg.mjs} +1 -1
  40. package/dist/{error-Cxz0tQeO.mjs.map → error-DrxtnGPg.mjs.map} +1 -1
  41. package/dist/{index-CLBc4gw-.d.mts → index-UHEVQMus.d.mts} +55 -17
  42. package/dist/index-UHEVQMus.d.mts.map +1 -0
  43. package/dist/index.d.mts +11 -11
  44. package/dist/index.mjs +17 -17
  45. package/dist/{load-yOOlckBj.mjs → load-Veizk2cT.mjs} +1 -1
  46. package/dist/{load-yOOlckBj.mjs.map → load-Veizk2cT.mjs.map} +1 -1
  47. package/dist/{loader-fz8Q_3EO.mjs → loader-CHb2v0jm.mjs} +1 -1
  48. package/dist/{loader-fz8Q_3EO.mjs.map → loader-CHb2v0jm.mjs.map} +1 -1
  49. package/dist/{manifest-schema-CL8DWO9b.mjs → manifest-schema-CuMio1A9.mjs} +1 -1
  50. package/dist/{manifest-schema-CL8DWO9b.mjs.map → manifest-schema-CuMio1A9.mjs.map} +1 -1
  51. package/dist/media/index.d.mts +1 -1
  52. package/dist/media/local-runtime.d.mts +7 -7
  53. package/dist/{mode-C2EzN1uE.mjs → mode-CYeM2rPt.mjs} +1 -1
  54. package/dist/{mode-C2EzN1uE.mjs.map → mode-CYeM2rPt.mjs.map} +1 -1
  55. package/dist/page/index.d.mts +10 -1
  56. package/dist/page/index.d.mts.map +1 -1
  57. package/dist/page/index.mjs +8 -4
  58. package/dist/page/index.mjs.map +1 -1
  59. package/dist/{placeholder-SvFCKbz_.d.mts → placeholder-bOx1xCTY.d.mts} +1 -1
  60. package/dist/{placeholder-SvFCKbz_.d.mts.map → placeholder-bOx1xCTY.d.mts.map} +1 -1
  61. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  62. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  63. package/dist/{query-BVYN0PJ6.mjs → query-5Hcv_5ER.mjs} +20 -8
  64. package/dist/{query-BVYN0PJ6.mjs.map → query-5Hcv_5ER.mjs.map} +1 -1
  65. package/dist/{registry-BNYQKX_d.mjs → registry-1EvbAfsC.mjs} +6 -2
  66. package/dist/{registry-BNYQKX_d.mjs.map → registry-1EvbAfsC.mjs.map} +1 -1
  67. package/dist/{runner-BraqvGYk.mjs → runner-BoN0-FPi.mjs} +155 -130
  68. package/dist/runner-BoN0-FPi.mjs.map +1 -0
  69. package/dist/{runner-EAtf0ZIe.d.mts → runner-DTqkzOzc.d.mts} +2 -2
  70. package/dist/{runner-EAtf0ZIe.d.mts.map → runner-DTqkzOzc.d.mts.map} +1 -1
  71. package/dist/runtime.d.mts +6 -6
  72. package/dist/runtime.mjs +1 -1
  73. package/dist/{search-C1gg67nN.mjs → search-BsYMed12.mjs} +235 -105
  74. package/dist/search-BsYMed12.mjs.map +1 -0
  75. package/dist/seed/index.d.mts +2 -2
  76. package/dist/seed/index.mjs +8 -8
  77. package/dist/seo/index.d.mts +1 -1
  78. package/dist/storage/local.d.mts +1 -1
  79. package/dist/storage/local.mjs +1 -1
  80. package/dist/storage/s3.d.mts +1 -1
  81. package/dist/storage/s3.mjs +1 -1
  82. package/dist/{tokens-DpgrkrXK.mjs → tokens-DrB-W6Q-.mjs} +1 -1
  83. package/dist/{tokens-DpgrkrXK.mjs.map → tokens-DrB-W6Q-.mjs.map} +1 -1
  84. package/dist/{transport-yxiQsi8I.mjs → transport-Bl8cTdYt.mjs} +1 -1
  85. package/dist/{transport-yxiQsi8I.mjs.map → transport-Bl8cTdYt.mjs.map} +1 -1
  86. package/dist/{transport-BFGblqwG.d.mts → transport-COOs9GSE.d.mts} +1 -1
  87. package/dist/{transport-BFGblqwG.d.mts.map → transport-COOs9GSE.d.mts.map} +1 -1
  88. package/dist/{types-BQo5JS0J.d.mts → types-6dqxBqsH.d.mts} +80 -106
  89. package/dist/types-6dqxBqsH.d.mts.map +1 -0
  90. package/dist/{types-DRjfYOEv.d.mts → types-7-UjSEyB.d.mts} +1 -1
  91. package/dist/{types-DRjfYOEv.d.mts.map → types-7-UjSEyB.d.mts.map} +1 -1
  92. package/dist/{types-CUBbjgmP.mjs → types-Bec-r_3_.mjs} +1 -1
  93. package/dist/{types-CUBbjgmP.mjs.map → types-Bec-r_3_.mjs.map} +1 -1
  94. package/dist/{types-DaNLHo_T.d.mts → types-BljtYPSd.d.mts} +1 -1
  95. package/dist/{types-DaNLHo_T.d.mts.map → types-BljtYPSd.d.mts.map} +1 -1
  96. package/dist/{types-BRuPJGdV.d.mts → types-CIsTnQvJ.d.mts} +3 -1
  97. package/dist/types-CIsTnQvJ.d.mts.map +1 -0
  98. package/dist/types-CMMN0pNg.mjs.map +1 -1
  99. package/dist/{types-DPfzHnjW.d.mts → types-CcreFIIH.d.mts} +1 -1
  100. package/dist/{types-DPfzHnjW.d.mts.map → types-CcreFIIH.d.mts.map} +1 -1
  101. package/dist/{types-CiA5Gac0.mjs → types-DuNbGKjF.mjs} +1 -1
  102. package/dist/{types-CiA5Gac0.mjs.map → types-DuNbGKjF.mjs.map} +1 -1
  103. package/dist/{validate-HtxZeaBi.d.mts → validate-B7KP7VLM.d.mts} +4 -4
  104. package/dist/{validate-HtxZeaBi.d.mts.map → validate-B7KP7VLM.d.mts.map} +1 -1
  105. package/dist/{validate-_rsF-Dx_.mjs → validate-CXnRKfJK.mjs} +2 -2
  106. package/dist/{validate-_rsF-Dx_.mjs.map → validate-CXnRKfJK.mjs.map} +1 -1
  107. package/package.json +6 -6
  108. package/src/api/csrf.ts +13 -2
  109. package/src/api/handlers/content.ts +7 -0
  110. package/src/api/handlers/dashboard.ts +4 -8
  111. package/src/api/handlers/device-flow.ts +55 -37
  112. package/src/api/handlers/index.ts +6 -1
  113. package/src/api/handlers/seo.ts +48 -21
  114. package/src/api/public-url.ts +84 -0
  115. package/src/api/schemas/content.ts +2 -2
  116. package/src/api/schemas/menus.ts +12 -2
  117. package/src/astro/integration/index.ts +30 -7
  118. package/src/astro/integration/routes.ts +13 -2
  119. package/src/astro/integration/runtime.ts +7 -5
  120. package/src/astro/integration/vite-config.ts +52 -9
  121. package/src/astro/middleware/auth.ts +60 -56
  122. package/src/astro/middleware/csp.ts +25 -0
  123. package/src/astro/middleware.ts +31 -3
  124. package/src/astro/routes/PluginRegistry.tsx +8 -2
  125. package/src/astro/routes/admin.astro +7 -2
  126. package/src/astro/routes/api/admin/users/[id]/disable.ts +18 -12
  127. package/src/astro/routes/api/admin/users/[id]/index.ts +26 -5
  128. package/src/astro/routes/api/auth/invite/complete.ts +3 -2
  129. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +2 -1
  130. package/src/astro/routes/api/auth/oauth/[provider].ts +2 -1
  131. package/src/astro/routes/api/auth/passkey/options.ts +3 -2
  132. package/src/astro/routes/api/auth/passkey/register/options.ts +3 -2
  133. package/src/astro/routes/api/auth/passkey/register/verify.ts +3 -2
  134. package/src/astro/routes/api/auth/passkey/verify.ts +3 -2
  135. package/src/astro/routes/api/auth/signup/complete.ts +3 -2
  136. package/src/astro/routes/api/content/[collection]/index.ts +31 -3
  137. package/src/astro/routes/api/import/wordpress/execute.ts +9 -0
  138. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +10 -0
  139. package/src/astro/routes/api/manifest.ts +1 -0
  140. package/src/astro/routes/api/media/providers/[providerId]/[itemId].ts +7 -2
  141. package/src/astro/routes/api/oauth/authorize.ts +12 -7
  142. package/src/astro/routes/api/oauth/device/code.ts +5 -1
  143. package/src/astro/routes/api/setup/admin-verify.ts +3 -2
  144. package/src/astro/routes/api/setup/admin.ts +3 -2
  145. package/src/astro/routes/api/setup/dev-bypass.ts +2 -1
  146. package/src/astro/routes/api/setup/index.ts +3 -2
  147. package/src/astro/routes/api/snapshot.ts +2 -1
  148. package/src/astro/routes/api/themes/preview.ts +2 -1
  149. package/src/astro/routes/api/well-known/auth.ts +1 -0
  150. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +3 -2
  151. package/src/astro/routes/api/well-known/oauth-protected-resource.ts +3 -2
  152. package/src/astro/routes/robots.txt.ts +5 -1
  153. package/src/astro/routes/sitemap-[collection].xml.ts +104 -0
  154. package/src/astro/routes/sitemap.xml.ts +18 -23
  155. package/src/astro/types.ts +27 -1
  156. package/src/auth/passkey-config.ts +6 -10
  157. package/src/bylines/index.ts +11 -8
  158. package/src/cli/commands/login.ts +5 -2
  159. package/src/components/InlinePortableTextEditor.tsx +5 -3
  160. package/src/content/converters/portable-text-to-prosemirror.ts +50 -2
  161. package/src/database/migrations/034_published_at_index.ts +29 -0
  162. package/src/database/migrations/runner.ts +2 -0
  163. package/src/database/repositories/byline.ts +48 -42
  164. package/src/database/repositories/content.ts +23 -1
  165. package/src/database/repositories/options.ts +9 -3
  166. package/src/database/repositories/seo.ts +34 -17
  167. package/src/database/repositories/types.ts +2 -0
  168. package/src/emdash-runtime.ts +61 -18
  169. package/src/import/index.ts +1 -1
  170. package/src/import/sources/wxr.ts +45 -2
  171. package/src/index.ts +9 -1
  172. package/src/mcp/server.ts +85 -5
  173. package/src/menus/index.ts +2 -1
  174. package/src/page/context.ts +13 -1
  175. package/src/page/jsonld.ts +10 -6
  176. package/src/page/seo-contributions.ts +1 -1
  177. package/src/plugins/context.ts +145 -35
  178. package/src/plugins/manager.ts +12 -0
  179. package/src/plugins/types.ts +80 -4
  180. package/src/query.ts +18 -0
  181. package/src/schema/registry.ts +5 -0
  182. package/src/settings/index.ts +64 -0
  183. package/src/utils/chunks.ts +17 -0
  184. package/dist/apply-kC39ev1Z.mjs.map +0 -1
  185. package/dist/byline-CL847F26.mjs.map +0 -1
  186. package/dist/content-D6C2WsZC.mjs.map +0 -1
  187. package/dist/index-CLBc4gw-.d.mts.map +0 -1
  188. package/dist/runner-BraqvGYk.mjs.map +0 -1
  189. package/dist/search-C1gg67nN.mjs.map +0 -1
  190. package/dist/types-BQo5JS0J.d.mts.map +0 -1
  191. package/dist/types-BRuPJGdV.d.mts.map +0 -1
  192. /package/src/astro/routes/api/media/file/{[key].ts → [...key].ts} +0 -0
@@ -1,5 +1,5 @@
1
- import { t as apiError } from "../../error-Cxz0tQeO.mjs";
2
- import { t as getAuthMode } from "../../mode-C2EzN1uE.mjs";
1
+ import { t as apiError } from "../../error-DrxtnGPg.mjs";
2
+ import { t as getAuthMode } from "../../mode-CYeM2rPt.mjs";
3
3
  import { ulid } from "ulidx";
4
4
  import { defineMiddleware } from "astro:middleware";
5
5
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
@@ -21,26 +21,82 @@ import { hasScope, hashPrefixedToken as hashApiToken } from "@emdash-cms/auth";
21
21
  *
22
22
  * State-changing requests (POST/PUT/DELETE) to public endpoints must either:
23
23
  * 1. Include the X-EmDash-Request: 1 header (custom header blocked cross-origin), OR
24
- * 2. Have an Origin header matching the request origin
24
+ * 2. Have an Origin header matching the request origin (or the configured public origin)
25
25
  *
26
26
  * This prevents cross-origin form submissions (which can't set custom headers)
27
27
  * and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests
28
28
  * always include a matching Origin header.
29
29
  *
30
30
  * Returns a 403 Response if the check fails, or null if allowed.
31
+ *
32
+ * @param request The incoming request
33
+ * @param url The request URL (internal origin)
34
+ * @param publicOrigin The public-facing origin from config.siteUrl. Must be
35
+ * `undefined` when absent — never `null` or `""` (security invariant H-1a).
31
36
  */
32
- function checkPublicCsrf(request, url) {
37
+ function checkPublicCsrf(request, url, publicOrigin) {
33
38
  if (request.headers.get("X-EmDash-Request") === "1") return null;
34
39
  const origin = request.headers.get("Origin");
35
40
  if (origin) {
36
41
  try {
37
- if (new URL(origin).origin === url.origin) return null;
42
+ const originUrl = new URL(origin);
43
+ if (originUrl.origin === url.origin) return null;
44
+ if (publicOrigin && originUrl.origin === publicOrigin) return null;
38
45
  } catch {}
39
46
  return apiError("CSRF_REJECTED", "Cross-origin request blocked", 403);
40
47
  }
41
48
  return null;
42
49
  }
43
50
 
51
+ //#endregion
52
+ //#region src/api/public-url.ts
53
+ /**
54
+ * Resolve siteUrl from runtime environment variables.
55
+ *
56
+ * Uses process.env (not import.meta.env) because Vite statically replaces
57
+ * import.meta.env at build time, baking out any env vars not present during
58
+ * the build. Container deployments set env vars at runtime, so we must read
59
+ * process.env which Vite leaves untouched.
60
+ *
61
+ * On Cloudflare Workers process.env is unavailable (returns undefined),
62
+ * so the fallback chain continues to url.origin.
63
+ *
64
+ * Caches after first call.
65
+ */
66
+ let _envSiteUrl = null;
67
+ function getEnvSiteUrl() {
68
+ if (_envSiteUrl !== null) return _envSiteUrl || void 0;
69
+ try {
70
+ const value = typeof process !== "undefined" && process.env?.EMDASH_SITE_URL || typeof process !== "undefined" && process.env?.SITE_URL || "";
71
+ if (value) {
72
+ const parsed = new URL(value);
73
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74
+ _envSiteUrl = "";
75
+ return;
76
+ }
77
+ _envSiteUrl = parsed.origin;
78
+ } else _envSiteUrl = "";
79
+ } catch {
80
+ _envSiteUrl = "";
81
+ }
82
+ return _envSiteUrl || void 0;
83
+ }
84
+ /**
85
+ * Return the public-facing origin for the site.
86
+ *
87
+ * Resolution order:
88
+ * 1. `config.siteUrl` (set in astro.config.mjs, origin-normalized at startup)
89
+ * 2. `EMDASH_SITE_URL` or `SITE_URL` env var (resolved at runtime for containers)
90
+ * 3. `url.origin` (internal request URL — correct when no proxy)
91
+ *
92
+ * @param url The request URL (`new URL(request.url)` or `Astro.url`)
93
+ * @param config The EmDash config (from `locals.emdash?.config`)
94
+ * @returns Origin string, e.g. `"https://mysite.example.com"`
95
+ */
96
+ function getPublicOrigin(url, config) {
97
+ return config?.siteUrl || getEnvSiteUrl() || url.origin;
98
+ }
99
+
44
100
  //#endregion
45
101
  //#region src/api/handlers/api-tokens.ts
46
102
  /**
@@ -85,26 +141,20 @@ async function resolveOAuthToken(db, rawToken) {
85
141
  }
86
142
 
87
143
  //#endregion
88
- //#region src/astro/middleware/auth.ts
89
- /** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
90
- const MW_CACHE_HEADERS = { "Cache-Control": "private, no-store" };
91
- const ROLE_ADMIN = 50;
144
+ //#region src/astro/middleware/csp.ts
92
145
  /**
93
146
  * Strict Content-Security-Policy for /_emdash routes (admin + API).
94
147
  *
95
148
  * Applied via middleware header rather than Astro's built-in CSP because
96
149
  * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'
97
150
  * when hashes are present), which would break user-facing pages.
151
+ *
152
+ * img-src allows any HTTPS origin because the admin renders user content that
153
+ * may reference external images (migrations, external hosting, embeds).
154
+ * Plugin security does not rely on img-src -- plugins run in V8 isolates with
155
+ * no DOM access, and connect-src 'self' blocks fetch-based exfiltration.
98
156
  */
99
- function buildEmDashCsp(marketplaceUrl) {
100
- const imgSources = [
101
- "'self'",
102
- "data:",
103
- "blob:"
104
- ];
105
- if (marketplaceUrl) try {
106
- imgSources.push(new URL(marketplaceUrl).origin);
107
- } catch {}
157
+ function buildEmDashCsp() {
108
158
  return [
109
159
  "default-src 'self'",
110
160
  "script-src 'self' 'unsafe-inline'",
@@ -112,11 +162,46 @@ function buildEmDashCsp(marketplaceUrl) {
112
162
  "connect-src 'self'",
113
163
  "form-action 'self'",
114
164
  "frame-ancestors 'none'",
115
- `img-src ${imgSources.join(" ")}`,
165
+ "img-src 'self' https: data: blob:",
116
166
  "object-src 'none'",
117
167
  "base-uri 'self'"
118
168
  ].join("; ");
119
169
  }
170
+
171
+ //#endregion
172
+ //#region src/astro/middleware/auth.ts
173
+ /** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */
174
+ const MW_CACHE_HEADERS = { "Cache-Control": "private, no-store" };
175
+ const ROLE_ADMIN = 50;
176
+ const MCP_ENDPOINT_PATH = "/_emdash/api/mcp";
177
+ function isUnsafeMethod(method) {
178
+ return method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
179
+ }
180
+ function csrfRejectedResponse() {
181
+ return new Response(JSON.stringify({ error: {
182
+ code: "CSRF_REJECTED",
183
+ message: "Missing required header"
184
+ } }), {
185
+ status: 403,
186
+ headers: {
187
+ "Content-Type": "application/json",
188
+ ...MW_CACHE_HEADERS
189
+ }
190
+ });
191
+ }
192
+ function mcpUnauthorizedResponse(url, config) {
193
+ const origin = getPublicOrigin(url, config);
194
+ return Response.json({ error: {
195
+ code: "NOT_AUTHENTICATED",
196
+ message: "Not authenticated"
197
+ } }, {
198
+ status: 401,
199
+ headers: {
200
+ "WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
201
+ ...MW_CACHE_HEADERS
202
+ }
203
+ });
204
+ }
120
205
  /**
121
206
  * API routes that skip auth — each handles its own access control.
122
207
  *
@@ -144,7 +229,8 @@ const PUBLIC_API_EXACT = new Set([
144
229
  "/_emdash/api/auth/passkey/options",
145
230
  "/_emdash/api/auth/passkey/verify",
146
231
  "/_emdash/api/oauth/token",
147
- "/_emdash/api/snapshot"
232
+ "/_emdash/api/snapshot",
233
+ "/_emdash/api/search"
148
234
  ]);
149
235
  function isPublicEmDashRoute(pathname) {
150
236
  if (PUBLIC_API_EXACT.has(pathname)) return true;
@@ -162,7 +248,8 @@ const onRequest = defineMiddleware(async (context, next) => {
162
248
  if (isPublicApiRoute) {
163
249
  const method = context.request.method.toUpperCase();
164
250
  if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
165
- const csrfError = checkPublicCsrf(context.request, url);
251
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
252
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
166
253
  if (csrfError) return csrfError;
167
254
  }
168
255
  return next();
@@ -170,7 +257,8 @@ const onRequest = defineMiddleware(async (context, next) => {
170
257
  if (url.pathname.startsWith("/_emdash/api/plugins/")) {
171
258
  const method = context.request.method.toUpperCase();
172
259
  if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
173
- const csrfError = checkPublicCsrf(context.request, url);
260
+ const publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);
261
+ const csrfError = checkPublicCsrf(context.request, url, publicOrigin);
174
262
  if (csrfError) return csrfError;
175
263
  }
176
264
  return handlePluginRouteAuth(context, next);
@@ -198,7 +286,7 @@ const onRequest = defineMiddleware(async (context, next) => {
198
286
  "Content-Type": "application/json",
199
287
  ...MW_CACHE_HEADERS
200
288
  };
201
- if (url.pathname === "/_emdash/api/mcp") headers["WWW-Authenticate"] = `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
289
+ if (url.pathname === "/_emdash/api/mcp") headers["WWW-Authenticate"] = `Bearer resource_metadata="${getPublicOrigin(url, context.locals.emdash?.config)}/.well-known/oauth-protected-resource"`;
202
290
  return new Response(JSON.stringify({ error: {
203
291
  code: "INVALID_TOKEN",
204
292
  message: "Invalid or expired token"
@@ -209,34 +297,20 @@ const onRequest = defineMiddleware(async (context, next) => {
209
297
  }
210
298
  const isTokenAuth = bearerResult === "authenticated";
211
299
  const method = context.request.method.toUpperCase();
300
+ if (url.pathname === MCP_ENDPOINT_PATH && !isTokenAuth) return mcpUnauthorizedResponse(url, context.locals.emdash?.config);
212
301
  const isOAuthConsent = url.pathname.startsWith("/_emdash/oauth/authorize");
213
- if (isApiRoute && !isTokenAuth && !isOAuthConsent && method !== "GET" && method !== "HEAD" && method !== "OPTIONS" && !isPublicApiRoute) {
214
- if (context.request.headers.get("X-EmDash-Request") !== "1") return new Response(JSON.stringify({ error: {
215
- code: "CSRF_REJECTED",
216
- message: "Missing required header"
217
- } }), {
218
- status: 403,
219
- headers: {
220
- "Content-Type": "application/json",
221
- ...MW_CACHE_HEADERS
222
- }
223
- });
302
+ if (isApiRoute && !isTokenAuth && !isOAuthConsent && isUnsafeMethod(method) && !isPublicApiRoute) {
303
+ if (context.request.headers.get("X-EmDash-Request") !== "1") return csrfRejectedResponse();
224
304
  }
225
305
  if (isTokenAuth) {
226
306
  const scopeError = enforceTokenScope(url.pathname, method, context.locals.tokenScopes);
227
307
  if (scopeError) return scopeError;
228
308
  const response = await next();
229
- if (!import.meta.env.DEV) {
230
- const marketplaceUrl = context.locals.emdash?.config.marketplace;
231
- response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
232
- }
309
+ if (!import.meta.env.DEV) response.headers.set("Content-Security-Policy", buildEmDashCsp());
233
310
  return response;
234
311
  }
235
312
  const response = await handleEmDashAuth(context, next);
236
- if (!import.meta.env.DEV) {
237
- const marketplaceUrl = context.locals.emdash?.config.marketplace;
238
- response.headers.set("Content-Security-Policy", buildEmDashCsp(marketplaceUrl));
239
- }
313
+ if (!import.meta.env.DEV) response.headers.set("Content-Security-Policy", buildEmDashCsp());
240
314
  return response;
241
315
  });
242
316
  /**
@@ -436,18 +510,14 @@ async function handlePasskeyAuth(context, next, isApiRoute) {
436
510
  try {
437
511
  const sessionUser = await session?.get("user");
438
512
  if (!sessionUser?.id) {
439
- if (isApiRoute) {
440
- const headers = { ...MW_CACHE_HEADERS };
441
- if (url.pathname === "/_emdash/api/mcp") headers["WWW-Authenticate"] = `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource"`;
442
- return Response.json({ error: {
443
- code: "NOT_AUTHENTICATED",
444
- message: "Not authenticated"
445
- } }, {
446
- status: 401,
447
- headers
448
- });
449
- }
450
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
513
+ if (isApiRoute) return Response.json({ error: {
514
+ code: "NOT_AUTHENTICATED",
515
+ message: "Not authenticated"
516
+ } }, {
517
+ status: 401,
518
+ headers: MW_CACHE_HEADERS
519
+ });
520
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
451
521
  loginUrl.searchParams.set("redirect", url.pathname);
452
522
  return context.redirect(loginUrl.toString());
453
523
  }
@@ -461,12 +531,13 @@ async function handlePasskeyAuth(context, next, isApiRoute) {
461
531
  status: 401,
462
532
  headers: MW_CACHE_HEADERS
463
533
  });
464
- return context.redirect("/_emdash/admin/login");
534
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
535
+ return context.redirect(loginUrl.toString());
465
536
  }
466
537
  if (user.disabled) {
467
538
  session?.destroy();
468
539
  if (isApiRoute) return apiError("ACCOUNT_DISABLED", "Account disabled", 403);
469
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
540
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
470
541
  loginUrl.searchParams.set("error", "account_disabled");
471
542
  return context.redirect(loginUrl.toString());
472
543
  }
@@ -1 +1 @@
1
- {"version":3,"file":"auth.mjs","names":["virtualAuthenticate"],"sources":["../../../src/api/csrf.ts","../../../src/api/handlers/api-tokens.ts","../../../src/astro/middleware/auth.ts"],"sourcesContent":["/**\n * CSRF protection utilities.\n *\n * Two mechanisms:\n * 1. Custom header check (X-EmDash-Request: 1) — used for authenticated API routes.\n * Browsers block cross-origin custom headers, so presence proves same-origin.\n * 2. Origin check — used for public API routes that skip auth. Compares the Origin\n * header against the request origin. Same approach as Astro's `checkOrigin`.\n */\n\nimport { apiError } from \"./error.js\";\n\n/**\n * Origin-based CSRF check for public API routes that skip auth.\n *\n * State-changing requests (POST/PUT/DELETE) to public endpoints must either:\n * 1. Include the X-EmDash-Request: 1 header (custom header blocked cross-origin), OR\n * 2. Have an Origin header matching the request origin\n *\n * This prevents cross-origin form submissions (which can't set custom headers)\n * and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests\n * always include a matching Origin header.\n *\n * Returns a 403 Response if the check fails, or null if allowed.\n */\nexport function checkPublicCsrf(request: Request, url: URL): Response | null {\n\t// Custom header present — browser blocks cross-origin custom headers\n\tconst csrfHeader = request.headers.get(\"X-EmDash-Request\");\n\tif (csrfHeader === \"1\") return null;\n\n\t// Check Origin header — present on all POST/PUT/DELETE from browsers\n\tconst origin = request.headers.get(\"Origin\");\n\tif (origin) {\n\t\ttry {\n\t\t\tconst originUrl = new URL(origin);\n\t\t\tif (originUrl.origin === url.origin) return null;\n\t\t} catch {\n\t\t\t// Malformed Origin — fall through to reject\n\t\t}\n\n\t\treturn apiError(\"CSRF_REJECTED\", \"Cross-origin request blocked\", 403);\n\t}\n\n\t// No Origin header — non-browser client (curl, server-to-server).\n\t// Allow these through since CSRF is a browser-specific attack vector.\n\t// Server-to-server requests don't carry ambient credentials (cookies).\n\treturn null;\n}\n","/**\n * API token management handlers.\n *\n * Creates, lists, and revokes Personal Access Tokens (PATs).\n * Token format: ec_pat_<base64url>\n * Only the SHA-256 hash is stored — raw token shown once at creation.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { hashApiToken, generatePrefixedToken } from \"../../auth/api-tokens.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ApiTokenInfo {\n\tid: string;\n\tname: string;\n\tprefix: string;\n\tscopes: string[];\n\tuserId: string;\n\texpiresAt: string | null;\n\tlastUsedAt: string | null;\n\tcreatedAt: string;\n}\n\nexport interface ApiTokenCreateResult {\n\t/** The raw token — shown once, never stored */\n\ttoken: string;\n\t/** Token metadata */\n\tinfo: ApiTokenInfo;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\n/**\n * Create a new API token for a user.\n */\nexport async function handleApiTokenCreate(\n\tdb: Kysely<Database>,\n\tuserId: string,\n\tinput: {\n\t\tname: string;\n\t\tscopes: string[];\n\t\texpiresAt?: string;\n\t},\n): Promise<ApiResult<ApiTokenCreateResult>> {\n\ttry {\n\t\tconst id = ulid();\n\t\tconst { raw, hash, prefix } = generatePrefixedToken(\"ec_pat_\");\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_api_tokens\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\ttoken_hash: hash,\n\t\t\t\tprefix,\n\t\t\t\tuser_id: userId,\n\t\t\t\tscopes: JSON.stringify(input.scopes),\n\t\t\t\texpires_at: input.expiresAt ?? null,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst info: ApiTokenInfo = {\n\t\t\tid,\n\t\t\tname: input.name,\n\t\t\tprefix,\n\t\t\tscopes: input.scopes,\n\t\t\tuserId,\n\t\t\texpiresAt: input.expiresAt ?? null,\n\t\t\tlastUsedAt: null,\n\t\t\tcreatedAt: new Date().toISOString(),\n\t\t};\n\n\t\treturn { success: true, data: { token: raw, info } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_CREATE_ERROR\",\n\t\t\t\tmessage: \"Failed to create API token\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * List all API tokens for a user (never returns the raw token or hash).\n */\nexport async function handleApiTokenList(\n\tdb: Kysely<Database>,\n\tuserId: string,\n): Promise<ApiResult<{ items: ApiTokenInfo[] }>> {\n\ttry {\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_api_tokens\")\n\t\t\t.select([\n\t\t\t\t\"id\",\n\t\t\t\t\"name\",\n\t\t\t\t\"prefix\",\n\t\t\t\t\"scopes\",\n\t\t\t\t\"user_id\",\n\t\t\t\t\"expires_at\",\n\t\t\t\t\"last_used_at\",\n\t\t\t\t\"created_at\",\n\t\t\t])\n\t\t\t.where(\"user_id\", \"=\", userId)\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.execute();\n\n\t\tconst items: ApiTokenInfo[] = rows.map((row) => ({\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tprefix: row.prefix,\n\t\t\tscopes: JSON.parse(row.scopes) as string[],\n\t\t\tuserId: row.user_id,\n\t\t\texpiresAt: row.expires_at,\n\t\t\tlastUsedAt: row.last_used_at,\n\t\t\tcreatedAt: row.created_at,\n\t\t}));\n\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_LIST_ERROR\",\n\t\t\t\tmessage: \"Failed to list API tokens\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Revoke (delete) an API token.\n */\nexport async function handleApiTokenRevoke(\n\tdb: Kysely<Database>,\n\ttokenId: string,\n\tuserId: string,\n): Promise<ApiResult<{ revoked: boolean }>> {\n\ttry {\n\t\tconst result = await db\n\t\t\t.deleteFrom(\"_emdash_api_tokens\")\n\t\t\t.where(\"id\", \"=\", tokenId)\n\t\t\t.where(\"user_id\", \"=\", userId)\n\t\t\t.executeTakeFirst();\n\n\t\tif (result.numDeletedRows === 0n) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Token not found\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: { revoked: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_REVOKE_ERROR\",\n\t\t\t\tmessage: \"Failed to revoke API token\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Resolve a raw API token (ec_pat_...) to a user ID and scopes.\n * Updates last_used_at on successful lookup.\n * Returns null if the token is invalid or expired.\n */\nexport async function resolveApiToken(\n\tdb: Kysely<Database>,\n\trawToken: string,\n): Promise<{ userId: string; scopes: string[] } | null> {\n\tconst hash = hashApiToken(rawToken);\n\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_api_tokens\")\n\t\t.select([\"id\", \"user_id\", \"scopes\", \"expires_at\"])\n\t\t.where(\"token_hash\", \"=\", hash)\n\t\t.executeTakeFirst();\n\n\tif (!row) return null;\n\n\t// Check expiry\n\tif (row.expires_at && new Date(row.expires_at) < new Date()) {\n\t\treturn null;\n\t}\n\n\t// Update last_used_at (fire-and-forget, don't block the request)\n\tdb.updateTable(\"_emdash_api_tokens\")\n\t\t.set({ last_used_at: new Date().toISOString() })\n\t\t.where(\"id\", \"=\", row.id)\n\t\t.execute()\n\t\t.catch(() => {}); // Non-critical, swallow errors\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tscopes: JSON.parse(row.scopes) as string[],\n\t};\n}\n\n/**\n * Resolve an OAuth access token (ec_oat_...) to a user ID and scopes.\n * Returns null if the token is invalid or expired.\n */\nexport async function resolveOAuthToken(\n\tdb: Kysely<Database>,\n\trawToken: string,\n): Promise<{ userId: string; scopes: string[] } | null> {\n\tconst hash = hashApiToken(rawToken);\n\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_oauth_tokens\")\n\t\t.select([\"user_id\", \"scopes\", \"expires_at\", \"token_type\"])\n\t\t.where(\"token_hash\", \"=\", hash)\n\t\t.where(\"token_type\", \"=\", \"access\")\n\t\t.executeTakeFirst();\n\n\tif (!row) return null;\n\n\t// Check expiry\n\tif (new Date(row.expires_at) < new Date()) {\n\t\treturn null;\n\t}\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tscopes: JSON.parse(row.scopes) as string[],\n\t};\n}\n","/**\n * Auth middleware for admin routes\n *\n * Checks if the user is authenticated and has appropriate permissions.\n * Supports two auth modes:\n * - Passkey (default): Session-based auth with passkey login\n * - External providers: JWT-based auth (Cloudflare Access, etc.)\n *\n * This middleware runs AFTER the setup middleware - so if we get here,\n * we know setup is complete and users exist.\n */\n\nimport type { User, RoleLevel } from \"@emdash-cms/auth\";\nimport { createKyselyAdapter } from \"@emdash-cms/auth/adapters/kysely\";\nimport { defineMiddleware } from \"astro:middleware\";\nimport { ulid } from \"ulidx\";\n// Import auth provider via virtual module (statically bundled)\n// This avoids dynamic import issues in Cloudflare Workers\nimport { authenticate as virtualAuthenticate } from \"virtual:emdash/auth\";\n\nimport { checkPublicCsrf } from \"../../api/csrf.js\";\nimport { apiError } from \"../../api/error.js\";\n\n/** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */\nconst MW_CACHE_HEADERS = {\n\t\"Cache-Control\": \"private, no-store\",\n} as const;\nimport { resolveApiToken, resolveOAuthToken } from \"../../api/handlers/api-tokens.js\";\nimport { hasScope } from \"../../auth/api-tokens.js\";\nimport { getAuthMode, type ExternalAuthMode } from \"../../auth/mode.js\";\nimport type { ExternalAuthConfig } from \"../../auth/types.js\";\nimport type { EmDashHandlers, EmDashManifest } from \"../types.js\";\n\ndeclare global {\n\tnamespace App {\n\t\tinterface Locals {\n\t\t\tuser?: User;\n\t\t\t/** Token scopes when authenticated via API token or OAuth token. Undefined for session auth. */\n\t\t\ttokenScopes?: string[];\n\t\t\temdash?: EmDashHandlers;\n\t\t\temdashManifest?: EmDashManifest;\n\t\t}\n\t\tinterface SessionData {\n\t\t\tuser: { id: string };\n\t\t\thasSeenWelcome: boolean;\n\t\t}\n\t}\n}\n\n// Role level constants (matching @emdash-cms/auth)\nconst ROLE_ADMIN = 50;\n\n/**\n * Strict Content-Security-Policy for /_emdash routes (admin + API).\n *\n * Applied via middleware header rather than Astro's built-in CSP because\n * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'\n * when hashes are present), which would break user-facing pages.\n */\nfunction buildEmDashCsp(marketplaceUrl?: string): string {\n\tconst imgSources = [\"'self'\", \"data:\", \"blob:\"];\n\tif (marketplaceUrl) {\n\t\ttry {\n\t\t\timgSources.push(new URL(marketplaceUrl).origin);\n\t\t} catch {\n\t\t\t// ignore invalid marketplace URL\n\t\t}\n\t}\n\treturn [\n\t\t\"default-src 'self'\",\n\t\t\"script-src 'self' 'unsafe-inline'\",\n\t\t\"style-src 'self' 'unsafe-inline'\",\n\t\t\"connect-src 'self'\",\n\t\t\"form-action 'self'\",\n\t\t\"frame-ancestors 'none'\",\n\t\t`img-src ${imgSources.join(\" \")}`,\n\t\t\"object-src 'none'\",\n\t\t\"base-uri 'self'\",\n\t].join(\"; \");\n}\n\n/**\n * API routes that skip auth — each handles its own access control.\n *\n * Prefix entries match any path starting with that prefix.\n * Exact entries (no trailing slash or wildcard) match that path only.\n */\nconst PUBLIC_API_PREFIXES = [\n\t\"/_emdash/api/setup\",\n\t\"/_emdash/api/auth/login\",\n\t\"/_emdash/api/auth/register\",\n\t\"/_emdash/api/auth/dev-bypass\",\n\t\"/_emdash/api/auth/signup/\",\n\t\"/_emdash/api/auth/magic-link/\",\n\t\"/_emdash/api/auth/invite/accept\",\n\t\"/_emdash/api/auth/invite/complete\",\n\t\"/_emdash/api/auth/oauth/\",\n\t\"/_emdash/api/oauth/device/token\",\n\t\"/_emdash/api/oauth/device/code\",\n\t\"/_emdash/api/oauth/token\",\n\t\"/_emdash/api/comments/\",\n\t\"/_emdash/api/media/file/\",\n\t\"/_emdash/.well-known/\",\n];\n\nconst PUBLIC_API_EXACT = new Set([\n\t\"/_emdash/api/auth/passkey/options\",\n\t\"/_emdash/api/auth/passkey/verify\",\n\t\"/_emdash/api/oauth/token\",\n\t\"/_emdash/api/snapshot\",\n]);\n\nfunction isPublicEmDashRoute(pathname: string): boolean {\n\tif (PUBLIC_API_EXACT.has(pathname)) return true;\n\tif (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true;\n\tif (import.meta.env.DEV && pathname === \"/_emdash/api/typegen\") return true;\n\treturn false;\n}\n\nexport const onRequest = defineMiddleware(async (context, next) => {\n\tconst { url } = context;\n\n\t// Only check auth on admin routes and API routes\n\tconst isAdminRoute = url.pathname.startsWith(\"/_emdash/admin\");\n\tconst isSetupRoute = url.pathname.startsWith(\"/_emdash/admin/setup\");\n\tconst isApiRoute = url.pathname.startsWith(\"/_emdash/api\");\n\tconst isPublicApiRoute = isPublicEmDashRoute(url.pathname);\n\n\tconst isPublicRoute = !isAdminRoute && !isApiRoute;\n\n\t// Public API routes skip auth but still need CSRF protection on state-changing methods.\n\t// We check Origin header against the request host (same approach as Astro's checkOrigin).\n\t// This prevents cross-origin form submissions and fetch requests from malicious sites.\n\tif (isPublicApiRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst csrfError = checkPublicCsrf(context.request, url);\n\t\t\tif (csrfError) return csrfError;\n\t\t}\n\t\treturn next();\n\t}\n\n\t// Plugin routes: soft auth (resolve user if credentials present, but never block).\n\t// The catch-all handler decides per-route whether auth is required (public vs private).\n\t// Public plugin routes that accept POST are vulnerable to cross-origin form submissions,\n\t// so we apply the same Origin-based CSRF check as other public routes.\n\tconst isPluginRoute = url.pathname.startsWith(\"/_emdash/api/plugins/\");\n\tif (isPluginRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst csrfError = checkPublicCsrf(context.request, url);\n\t\t\tif (csrfError) return csrfError;\n\t\t}\n\t\treturn handlePluginRouteAuth(context, next);\n\t}\n\n\t// Setup routes: skip auth but still enforce CSRF on state-changing methods\n\tif (isSetupRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst csrfHeader = context.request.headers.get(\"X-EmDash-Request\");\n\t\t\tif (csrfHeader !== \"1\") {\n\t\t\t\treturn new Response(\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\terror: { code: \"CSRF_REJECTED\", message: \"Missing required header\" },\n\t\t\t\t\t}),\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t}\n\n\t// For public routes: soft auth check (set locals.user if session exists, but never block)\n\tif (isPublicRoute) {\n\t\treturn handlePublicRouteAuth(context, next);\n\t}\n\n\t// --- Everything below is /_emdash (admin + API) ---\n\n\t// Try Bearer token auth first (API tokens and OAuth tokens).\n\t// If successful, skip CSRF (tokens aren't ambient credentials like cookies).\n\tconst bearerResult = await handleBearerAuth(context);\n\n\tif (bearerResult === \"invalid\") {\n\t\tconst headers: Record<string, string> = {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t...MW_CACHE_HEADERS,\n\t\t};\n\t\t// Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery\n\t\tif (url.pathname === \"/_emdash/api/mcp\") {\n\t\t\theaders[\"WWW-Authenticate\"] =\n\t\t\t\t`Bearer resource_metadata=\"${url.origin}/.well-known/oauth-protected-resource\"`;\n\t\t}\n\t\treturn new Response(\n\t\t\tJSON.stringify({ error: { code: \"INVALID_TOKEN\", message: \"Invalid or expired token\" } }),\n\t\t\t{ status: 401, headers },\n\t\t);\n\t}\n\n\tconst isTokenAuth = bearerResult === \"authenticated\";\n\n\t// CSRF protection: require X-EmDash-Request header on state-changing requests.\n\t// Skip for token-authenticated requests (tokens aren't ambient credentials).\n\t// Browsers block cross-origin custom headers, so this prevents CSRF without tokens.\n\t// OAuth authorize consent is exempt: it's a standard HTML form POST that can't\n\t// include custom headers. The consent flow is protected by session + single-use codes.\n\tconst method = context.request.method.toUpperCase();\n\tconst isOAuthConsent = url.pathname.startsWith(\"/_emdash/oauth/authorize\");\n\tif (\n\t\tisApiRoute &&\n\t\t!isTokenAuth &&\n\t\t!isOAuthConsent &&\n\t\tmethod !== \"GET\" &&\n\t\tmethod !== \"HEAD\" &&\n\t\tmethod !== \"OPTIONS\" &&\n\t\t!isPublicApiRoute\n\t) {\n\t\tconst csrfHeader = context.request.headers.get(\"X-EmDash-Request\");\n\t\tif (csrfHeader !== \"1\") {\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({ error: { code: \"CSRF_REJECTED\", message: \"Missing required header\" } }),\n\t\t\t\t{\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t}\n\n\t// If already authenticated via Bearer token, enforce scope then skip session/external auth\n\tif (isTokenAuth) {\n\t\t// Enforce API token scopes based on URL pattern + HTTP method\n\t\tconst scopeError = enforceTokenScope(url.pathname, method, context.locals.tokenScopes);\n\t\tif (scopeError) return scopeError;\n\n\t\tconst response = await next();\n\t\tif (!import.meta.env.DEV) {\n\t\t\tconst marketplaceUrl = context.locals.emdash?.config.marketplace;\n\t\t\tresponse.headers.set(\"Content-Security-Policy\", buildEmDashCsp(marketplaceUrl));\n\t\t}\n\t\treturn response;\n\t}\n\n\tconst response = await handleEmDashAuth(context, next);\n\n\t// Set strict CSP on all /_emdash responses (prod only)\n\tif (!import.meta.env.DEV) {\n\t\tconst marketplaceUrl = context.locals.emdash?.config.marketplace;\n\t\tresponse.headers.set(\"Content-Security-Policy\", buildEmDashCsp(marketplaceUrl));\n\t}\n\n\treturn response;\n});\n\n/**\n * Auth handling for /_emdash routes. Returns a Response from either\n * an auth error/redirect or the downstream route handler.\n */\nasync function handleEmDashAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { url, locals } = context;\n\tconst { emdash } = locals;\n\n\tconst isLoginRoute = url.pathname.startsWith(\"/_emdash/admin/login\");\n\tconst isApiRoute = url.pathname.startsWith(\"/_emdash/api\");\n\n\tif (!emdash?.db) {\n\t\t// No database - let the admin handle this error\n\t\treturn next();\n\t}\n\n\t// Determine auth mode from config\n\tconst authMode = getAuthMode(emdash.config);\n\n\tif (authMode.type === \"external\") {\n\t\t// In dev mode, fall back to passkey auth since external JWT won't be present\n\t\tif (import.meta.env.DEV) {\n\t\t\tif (isLoginRoute) {\n\t\t\t\treturn next();\n\t\t\t}\n\n\t\t\treturn handlePasskeyAuth(context, next, isApiRoute);\n\t\t}\n\n\t\t// External auth provider (Cloudflare Access, etc.)\n\t\treturn handleExternalAuth(context, next, authMode, isApiRoute);\n\t}\n\n\t// Passkey authentication (default)\n\tif (isLoginRoute) {\n\t\treturn next();\n\t}\n\n\treturn handlePasskeyAuth(context, next, isApiRoute);\n}\n\n/**\n * Soft auth for plugin routes: resolve user from Bearer token or session if present,\n * but never block unauthenticated requests. The catch-all handler checks route\n * metadata to decide whether auth is required (public vs private routes).\n */\nasync function handlePluginRouteAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { locals } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Try Bearer token auth first (API tokens and OAuth tokens)\n\t\tconst bearerResult = await handleBearerAuth(context);\n\t\tif (bearerResult === \"authenticated\") {\n\t\t\t// User and tokenScopes are set on locals by handleBearerAuth\n\t\t\treturn next();\n\t\t}\n\t\tif (bearerResult === \"invalid\") {\n\t\t\t// A token was presented but is invalid/expired — return 401 so the\n\t\t\t// caller knows their token is bad (don't silently downgrade to no-auth).\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({ error: { code: \"INVALID_TOKEN\", message: \"Invalid or expired token\" } }),\n\t\t\t\t{\n\t\t\t\t\tstatus: 401,\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t\t// \"none\" — no token presented, try session auth below.\n\t} catch (error) {\n\t\tconsole.error(\"Plugin route bearer auth error:\", error);\n\t}\n\n\ttry {\n\t\t// Try session auth (sets locals.user if session exists)\n\t\tconst { session } = context;\n\t\tconst sessionUser = await session?.get(\"user\");\n\t\tif (sessionUser?.id && emdash?.db) {\n\t\t\tconst adapter = createKyselyAdapter(emdash.db);\n\t\t\tconst user = await adapter.getUserById(sessionUser.id);\n\t\t\tif (user && !user.disabled) {\n\t\t\t\tlocals.user = user;\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\t// Log but don't block — public routes should still work without session\n\t\tconsole.error(\"Plugin route session auth error:\", error);\n\t}\n\n\treturn next();\n}\n\n/**\n * Soft auth check for public routes with edit mode cookie.\n * Checks the session and sets locals.user if valid, but never blocks the request.\n */\nasync function handlePublicRouteAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { locals, session } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\tconst sessionUser = await session?.get(\"user\");\n\t\tif (sessionUser?.id && emdash?.db) {\n\t\t\tconst adapter = createKyselyAdapter(emdash.db);\n\t\t\tconst user = await adapter.getUserById(sessionUser.id);\n\t\t\tif (user && !user.disabled) {\n\t\t\t\tlocals.user = user;\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Silently continue — public page should render normally\n\t}\n\n\treturn next();\n}\n\n/**\n * Handle external auth provider authentication (Cloudflare Access, etc.)\n */\nasync function handleExternalAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n\tauthMode: ExternalAuthMode,\n\t_isApiRoute: boolean,\n): Promise<Response> {\n\tconst { locals, request } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Use the authenticate function from the virtual module\n\t\t// (statically imported at build time to work with Cloudflare Workers)\n\t\tif (typeof virtualAuthenticate !== \"function\") {\n\t\t\tthrow new Error(\n\t\t\t\t`Auth provider ${authMode.entrypoint} does not export an authenticate function`,\n\t\t\t);\n\t\t}\n\n\t\t// Authenticate via the provider\n\t\tconst authResult = await virtualAuthenticate(request, authMode.config);\n\n\t\t// Get external auth config for auto-provision settings\n\t\t// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowing AuthModeConfig to ExternalAuthConfig after provider check\n\t\tconst externalConfig = authMode.config as ExternalAuthConfig;\n\n\t\t// Find or create user\n\t\tconst adapter = createKyselyAdapter(emdash!.db);\n\t\tlet user = await adapter.getUserByEmail(authResult.email);\n\n\t\tif (!user) {\n\t\t\t// User doesn't exist\n\t\t\tif (externalConfig.autoProvision === false) {\n\t\t\t\treturn new Response(\"User not authorized\", {\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Check if this is the first user (they become admin)\n\t\t\tconst userCount = await emdash!.db\n\t\t\t\t.selectFrom(\"users\")\n\t\t\t\t.select(emdash!.db.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.executeTakeFirst();\n\n\t\t\tconst isFirstUser = Number(userCount?.count ?? 0) === 0;\n\t\t\tconst role = isFirstUser ? ROLE_ADMIN : authResult.role;\n\n\t\t\t// Create user\n\t\t\tconst now = new Date().toISOString();\n\t\t\tconst newUser = {\n\t\t\t\tid: ulid(),\n\t\t\t\temail: authResult.email,\n\t\t\t\tname: authResult.name,\n\t\t\t\trole,\n\t\t\t\temail_verified: 1,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t};\n\n\t\t\tawait emdash!.db.insertInto(\"users\").values(newUser).execute();\n\n\t\t\tuser = await adapter.getUserByEmail(authResult.email);\n\n\t\t\tconsole.log(\n\t\t\t\t`[external-auth] Provisioned user: ${authResult.email} (role: ${role}, first: ${isFirstUser})`,\n\t\t\t);\n\t\t} else {\n\t\t\t// User exists - check if we need to sync anything\n\t\t\tconst updates: Record<string, unknown> = {};\n\t\t\tlet newName: string | undefined;\n\t\t\tlet newRole: RoleLevel | undefined;\n\n\t\t\t// Sync name from provider if provider provides one and local differs\n\t\t\tif (authResult.name && user.name !== authResult.name) {\n\t\t\t\tnewName = authResult.name;\n\t\t\t\tupdates.name = newName;\n\t\t\t}\n\n\t\t\t// Sync role if enabled\n\t\t\tif (externalConfig.syncRoles && user.role !== authResult.role) {\n\t\t\t\tnewRole = authResult.role;\n\t\t\t\tupdates.role = newRole;\n\t\t\t}\n\n\t\t\tif (Object.keys(updates).length > 0) {\n\t\t\t\tupdates.updated_at = new Date().toISOString();\n\t\t\t\tawait emdash!.db.updateTable(\"users\").set(updates).where(\"id\", \"=\", user.id).execute();\n\n\t\t\t\tuser = {\n\t\t\t\t\t...user,\n\t\t\t\t\t...(newName ? { name: newName } : {}),\n\t\t\t\t\t...(newRole ? { role: newRole } : {}),\n\t\t\t\t};\n\n\t\t\t\tconsole.log(\n\t\t\t\t\t`[external-auth] Updated user ${authResult.email}:`,\n\t\t\t\t\tObject.keys(updates).filter((k) => k !== \"updated_at\"),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tif (!user) {\n\t\t\t// This shouldn't happen, but handle it gracefully\n\t\t\treturn new Response(\"Failed to provision user\", {\n\t\t\t\tstatus: 500,\n\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t});\n\t\t}\n\n\t\t// Check if user is disabled locally\n\t\tif (user.disabled) {\n\t\t\treturn new Response(\"Account disabled\", {\n\t\t\t\tstatus: 403,\n\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t});\n\t\t}\n\n\t\t// Set user in locals\n\t\tlocals.user = user;\n\n\t\t// Persist to session so public pages can identify the user\n\t\t// (external auth headers are only verified on /_emdash routes)\n\t\tconst { session } = context;\n\t\tsession?.set(\"user\", { id: user.id });\n\n\t\treturn next();\n\t} catch (error) {\n\t\tconsole.error(\"[external-auth] Auth error:\", error);\n\n\t\treturn new Response(\"Authentication failed\", {\n\t\t\tstatus: 401,\n\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t});\n\t}\n}\n\n/**\n * Try to authenticate via Bearer token (API token or OAuth token).\n *\n * Returns:\n * - \"authenticated\" if token is valid and user is resolved\n * - \"invalid\" if a token was provided but is invalid/expired\n * - \"none\" if no Bearer token was provided\n */\nasync function handleBearerAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n): Promise<\"authenticated\" | \"invalid\" | \"none\"> {\n\tconst authHeader = context.request.headers.get(\"Authorization\");\n\tif (!authHeader?.startsWith(\"Bearer \")) return \"none\";\n\n\tconst token = authHeader.slice(7);\n\tif (!token) return \"none\";\n\n\tconst { locals } = context;\n\tconst { emdash } = locals;\n\tif (!emdash?.db) return \"none\";\n\n\t// Resolve token based on prefix\n\tlet resolved: { userId: string; scopes: string[] } | null = null;\n\n\tif (token.startsWith(\"ec_pat_\")) {\n\t\tresolved = await resolveApiToken(emdash.db, token);\n\t} else if (token.startsWith(\"ec_oat_\")) {\n\t\tresolved = await resolveOAuthToken(emdash.db, token);\n\t} else {\n\t\t// Unknown token format\n\t\treturn \"invalid\";\n\t}\n\n\tif (!resolved) return \"invalid\";\n\n\t// Look up the user\n\tconst adapter = createKyselyAdapter(emdash.db);\n\tconst user = await adapter.getUserById(resolved.userId);\n\n\tif (!user || user.disabled) return \"invalid\";\n\n\t// Set user and scopes on locals\n\tlocals.user = user;\n\tlocals.tokenScopes = resolved.scopes;\n\n\treturn \"authenticated\";\n}\n\n/**\n * Handle passkey (session-based) authentication\n */\nasync function handlePasskeyAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n\tisApiRoute: boolean,\n): Promise<Response> {\n\tconst { url, locals, session } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Check session for user (session.get returns a Promise)\n\t\tconst sessionUser = await session?.get(\"user\");\n\n\t\tif (!sessionUser?.id) {\n\t\t\t// Not authenticated\n\t\t\tif (isApiRoute) {\n\t\t\t\tconst headers: Record<string, string> = { ...MW_CACHE_HEADERS };\n\t\t\t\t// Add WWW-Authenticate on MCP endpoint 401s to trigger OAuth discovery\n\t\t\t\tif (url.pathname === \"/_emdash/api/mcp\") {\n\t\t\t\t\theaders[\"WWW-Authenticate\"] =\n\t\t\t\t\t\t`Bearer resource_metadata=\"${url.origin}/.well-known/oauth-protected-resource\"`;\n\t\t\t\t}\n\t\t\t\treturn Response.json(\n\t\t\t\t\t{ error: { code: \"NOT_AUTHENTICATED\", message: \"Not authenticated\" } },\n\t\t\t\t\t{ status: 401, headers },\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst loginUrl = new URL(\"/_emdash/admin/login\", url.origin);\n\t\t\tloginUrl.searchParams.set(\"redirect\", url.pathname);\n\t\t\treturn context.redirect(loginUrl.toString());\n\t\t}\n\n\t\t// Get full user from database\n\t\tconst adapter = createKyselyAdapter(emdash!.db);\n\t\tconst user = await adapter.getUserById(sessionUser.id);\n\n\t\tif (!user) {\n\t\t\t// User no longer exists - clear session\n\t\t\tsession?.destroy();\n\t\t\tif (isApiRoute) {\n\t\t\t\treturn Response.json(\n\t\t\t\t\t{ error: { code: \"NOT_FOUND\", message: \"User not found\" } },\n\t\t\t\t\t{ status: 401, headers: MW_CACHE_HEADERS },\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn context.redirect(\"/_emdash/admin/login\");\n\t\t}\n\n\t\t// Check if user is disabled\n\t\tif (user.disabled) {\n\t\t\tsession?.destroy();\n\t\t\tif (isApiRoute) {\n\t\t\t\treturn apiError(\"ACCOUNT_DISABLED\", \"Account disabled\", 403);\n\t\t\t}\n\t\t\tconst loginUrl = new URL(\"/_emdash/admin/login\", url.origin);\n\t\t\tloginUrl.searchParams.set(\"error\", \"account_disabled\");\n\t\t\treturn context.redirect(loginUrl.toString());\n\t\t}\n\n\t\t// Set user in locals for use by routes\n\t\tlocals.user = user;\n\t} catch (error) {\n\t\tconsole.error(\"Auth middleware error:\", error);\n\t\t// On error, redirect to login\n\t\treturn context.redirect(\"/_emdash/admin/login\");\n\t}\n\n\treturn next();\n}\n\n// =============================================================================\n// Token scope enforcement\n// =============================================================================\n\n/**\n * Scope rules: ordered list of (pathPrefix, method, requiredScope) tuples.\n * First matching rule wins. Methods: \"*\" = any, \"WRITE\" = POST/PUT/PATCH/DELETE.\n *\n * Routes not matched by any rule default to \"admin\" scope (fail-closed).\n */\nconst SCOPE_RULES: Array<[prefix: string, method: string, scope: string]> = [\n\t// Content routes\n\t[\"/_emdash/api/content\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/content\", \"WRITE\", \"content:write\"],\n\n\t// Media routes (excluding /file/ which is public)\n\t[\"/_emdash/api/media/file\", \"*\", \"media:read\"], // public anyway, but scope if token-authed\n\t[\"/_emdash/api/media\", \"GET\", \"media:read\"],\n\t[\"/_emdash/api/media\", \"WRITE\", \"media:write\"],\n\n\t// Schema routes\n\t[\"/_emdash/api/schema\", \"GET\", \"schema:read\"],\n\t[\"/_emdash/api/schema\", \"WRITE\", \"schema:write\"],\n\n\t// Taxonomy, menu, section, widget, revision — all content domain\n\t[\"/_emdash/api/taxonomies\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/taxonomies\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/menus\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/menus\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/sections\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/sections\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/widget-areas\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/widget-areas\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/revisions\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/revisions\", \"WRITE\", \"content:write\"],\n\n\t// Search\n\t[\"/_emdash/api/search\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/search\", \"WRITE\", \"admin\"],\n\n\t// Import, admin, settings, plugins — all require admin scope\n\t[\"/_emdash/api/import\", \"*\", \"admin\"],\n\t[\"/_emdash/api/admin\", \"*\", \"admin\"],\n\t[\"/_emdash/api/settings\", \"*\", \"admin\"],\n\t[\"/_emdash/api/plugins\", \"*\", \"admin\"],\n\n\t// MCP endpoint — scopes enforced per-tool inside mcp/server.ts\n\t[\"/_emdash/api/mcp\", \"*\", \"content:read\"],\n];\n\nconst WRITE_METHODS = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n\n/**\n * Enforce API token scopes based on the request URL and HTTP method.\n * Returns a 403 Response if the scope is insufficient, or null if allowed.\n *\n * Session-authenticated requests (tokenScopes === undefined) are never checked.\n */\nfunction enforceTokenScope(\n\tpathname: string,\n\tmethod: string,\n\ttokenScopes: string[] | undefined,\n): Response | null {\n\t// Session auth — implicit full access, no scope restrictions\n\tif (!tokenScopes) return null;\n\n\tconst isWrite = WRITE_METHODS.has(method);\n\n\tfor (const [prefix, ruleMethod, scope] of SCOPE_RULES) {\n\t\t// Match exact prefix or prefix followed by /\n\t\tif (pathname !== prefix && !pathname.startsWith(prefix + \"/\")) continue;\n\n\t\t// Check method match\n\t\tif (ruleMethod === \"*\" || (ruleMethod === \"WRITE\" && isWrite) || ruleMethod === method) {\n\t\t\tif (hasScope(tokenScopes, scope)) return null;\n\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"INSUFFICIENT_SCOPE\",\n\t\t\t\t\t\tmessage: `Token lacks required scope: ${scope}`,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\t{ status: 403, headers: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS } },\n\t\t\t);\n\t\t}\n\t}\n\n\t// No rule matched — default to admin scope (fail-closed)\n\tif (hasScope(tokenScopes, \"admin\")) return null;\n\n\treturn new Response(\n\t\tJSON.stringify({\n\t\t\terror: {\n\t\t\t\tcode: \"INSUFFICIENT_SCOPE\",\n\t\t\t\tmessage: \"Token lacks required scope: admin\",\n\t\t\t},\n\t\t}),\n\t\t{ status: 403, headers: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS } },\n\t);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"auth.mjs","names":["virtualAuthenticate"],"sources":["../../../src/api/csrf.ts","../../../src/api/public-url.ts","../../../src/api/handlers/api-tokens.ts","../../../src/astro/middleware/csp.ts","../../../src/astro/middleware/auth.ts"],"sourcesContent":["/**\n * CSRF protection utilities.\n *\n * Two mechanisms:\n * 1. Custom header check (X-EmDash-Request: 1) — used for authenticated API routes.\n * Browsers block cross-origin custom headers, so presence proves same-origin.\n * 2. Origin check — used for public API routes that skip auth. Compares the Origin\n * header against the request origin. Same approach as Astro's `checkOrigin`.\n */\n\nimport { apiError } from \"./error.js\";\n\n/**\n * Origin-based CSRF check for public API routes that skip auth.\n *\n * State-changing requests (POST/PUT/DELETE) to public endpoints must either:\n * 1. Include the X-EmDash-Request: 1 header (custom header blocked cross-origin), OR\n * 2. Have an Origin header matching the request origin (or the configured public origin)\n *\n * This prevents cross-origin form submissions (which can't set custom headers)\n * and cross-origin fetch (blocked by CORS unless allowed). Same-origin requests\n * always include a matching Origin header.\n *\n * Returns a 403 Response if the check fails, or null if allowed.\n *\n * @param request The incoming request\n * @param url The request URL (internal origin)\n * @param publicOrigin The public-facing origin from config.siteUrl. Must be\n * `undefined` when absent — never `null` or `\"\"` (security invariant H-1a).\n */\nexport function checkPublicCsrf(\n\trequest: Request,\n\turl: URL,\n\tpublicOrigin?: string,\n): Response | null {\n\t// Custom header present — browser blocks cross-origin custom headers\n\tconst csrfHeader = request.headers.get(\"X-EmDash-Request\");\n\tif (csrfHeader === \"1\") return null;\n\n\t// Check Origin header — present on all POST/PUT/DELETE from browsers\n\tconst origin = request.headers.get(\"Origin\");\n\tif (origin) {\n\t\ttry {\n\t\t\tconst originUrl = new URL(origin);\n\t\t\t// Accept if Origin matches either the internal or public origin\n\t\t\tif (originUrl.origin === url.origin) return null;\n\t\t\tif (publicOrigin && originUrl.origin === publicOrigin) return null;\n\t\t} catch {\n\t\t\t// Malformed Origin — fall through to reject\n\t\t}\n\n\t\treturn apiError(\"CSRF_REJECTED\", \"Cross-origin request blocked\", 403);\n\t}\n\n\t// No Origin header — non-browser client (curl, server-to-server).\n\t// Allow these through since CSRF is a browser-specific attack vector.\n\t// Server-to-server requests don't carry ambient credentials (cookies).\n\treturn null;\n}\n","/**\n * Public URL helpers for reverse-proxy deployments.\n *\n * Behind a TLS-terminating proxy the internal request URL\n * (`http://localhost:4321`) differs from the browser-facing origin\n * (`https://mysite.example.com`). These pure helpers resolve the\n * correct public origin from config, falling back to the request URL.\n *\n * Workers-safe: no Node.js imports.\n */\n\nimport type { EmDashConfig } from \"../astro/integration/runtime.js\";\n\n/**\n * Resolve siteUrl from runtime environment variables.\n *\n * Uses process.env (not import.meta.env) because Vite statically replaces\n * import.meta.env at build time, baking out any env vars not present during\n * the build. Container deployments set env vars at runtime, so we must read\n * process.env which Vite leaves untouched.\n *\n * On Cloudflare Workers process.env is unavailable (returns undefined),\n * so the fallback chain continues to url.origin.\n *\n * Caches after first call.\n */\nlet _envSiteUrl: string | undefined | null = null;\n\n/** @internal Reset cached env value — test-only. */\nexport function _resetEnvSiteUrlCache(): void {\n\t_envSiteUrl = null;\n}\n\nfunction getEnvSiteUrl(): string | undefined {\n\tif (_envSiteUrl !== null) return _envSiteUrl || undefined;\n\ttry {\n\t\t// process.env is available on Node.js; undefined on Workers\n\t\tconst value =\n\t\t\t(typeof process !== \"undefined\" && process.env?.EMDASH_SITE_URL) ||\n\t\t\t(typeof process !== \"undefined\" && process.env?.SITE_URL) ||\n\t\t\t\"\";\n\t\tif (value) {\n\t\t\tconst parsed = new URL(value);\n\t\t\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") {\n\t\t\t\t_envSiteUrl = \"\";\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\t_envSiteUrl = parsed.origin;\n\t\t} else {\n\t\t\t_envSiteUrl = \"\";\n\t\t}\n\t} catch {\n\t\t_envSiteUrl = \"\";\n\t}\n\treturn _envSiteUrl || undefined;\n}\n\n/**\n * Return the public-facing origin for the site.\n *\n * Resolution order:\n * 1. `config.siteUrl` (set in astro.config.mjs, origin-normalized at startup)\n * 2. `EMDASH_SITE_URL` or `SITE_URL` env var (resolved at runtime for containers)\n * 3. `url.origin` (internal request URL — correct when no proxy)\n *\n * @param url The request URL (`new URL(request.url)` or `Astro.url`)\n * @param config The EmDash config (from `locals.emdash?.config`)\n * @returns Origin string, e.g. `\"https://mysite.example.com\"`\n */\nexport function getPublicOrigin(url: URL, config?: EmDashConfig): string {\n\treturn config?.siteUrl || getEnvSiteUrl() || url.origin;\n}\n\n/**\n * Build a full public URL by appending a path to the public origin.\n *\n * @param url The request URL\n * @param config The EmDash config\n * @param path Path to append (must start with `/`)\n * @returns Full URL string, e.g. `\"https://mysite.example.com/_emdash/admin/login\"`\n */\nexport function getPublicUrl(url: URL, config: EmDashConfig | undefined, path: string): string {\n\treturn `${getPublicOrigin(url, config)}${path}`;\n}\n","/**\n * API token management handlers.\n *\n * Creates, lists, and revokes Personal Access Tokens (PATs).\n * Token format: ec_pat_<base64url>\n * Only the SHA-256 hash is stored — raw token shown once at creation.\n */\n\nimport type { Kysely } from \"kysely\";\nimport { ulid } from \"ulidx\";\n\nimport { hashApiToken, generatePrefixedToken } from \"../../auth/api-tokens.js\";\nimport type { Database } from \"../../database/types.js\";\nimport type { ApiResult } from \"../types.js\";\n\n// ---------------------------------------------------------------------------\n// Types\n// ---------------------------------------------------------------------------\n\nexport interface ApiTokenInfo {\n\tid: string;\n\tname: string;\n\tprefix: string;\n\tscopes: string[];\n\tuserId: string;\n\texpiresAt: string | null;\n\tlastUsedAt: string | null;\n\tcreatedAt: string;\n}\n\nexport interface ApiTokenCreateResult {\n\t/** The raw token — shown once, never stored */\n\ttoken: string;\n\t/** Token metadata */\n\tinfo: ApiTokenInfo;\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\n/**\n * Create a new API token for a user.\n */\nexport async function handleApiTokenCreate(\n\tdb: Kysely<Database>,\n\tuserId: string,\n\tinput: {\n\t\tname: string;\n\t\tscopes: string[];\n\t\texpiresAt?: string;\n\t},\n): Promise<ApiResult<ApiTokenCreateResult>> {\n\ttry {\n\t\tconst id = ulid();\n\t\tconst { raw, hash, prefix } = generatePrefixedToken(\"ec_pat_\");\n\n\t\tawait db\n\t\t\t.insertInto(\"_emdash_api_tokens\")\n\t\t\t.values({\n\t\t\t\tid,\n\t\t\t\tname: input.name,\n\t\t\t\ttoken_hash: hash,\n\t\t\t\tprefix,\n\t\t\t\tuser_id: userId,\n\t\t\t\tscopes: JSON.stringify(input.scopes),\n\t\t\t\texpires_at: input.expiresAt ?? null,\n\t\t\t})\n\t\t\t.execute();\n\n\t\tconst info: ApiTokenInfo = {\n\t\t\tid,\n\t\t\tname: input.name,\n\t\t\tprefix,\n\t\t\tscopes: input.scopes,\n\t\t\tuserId,\n\t\t\texpiresAt: input.expiresAt ?? null,\n\t\t\tlastUsedAt: null,\n\t\t\tcreatedAt: new Date().toISOString(),\n\t\t};\n\n\t\treturn { success: true, data: { token: raw, info } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_CREATE_ERROR\",\n\t\t\t\tmessage: \"Failed to create API token\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * List all API tokens for a user (never returns the raw token or hash).\n */\nexport async function handleApiTokenList(\n\tdb: Kysely<Database>,\n\tuserId: string,\n): Promise<ApiResult<{ items: ApiTokenInfo[] }>> {\n\ttry {\n\t\tconst rows = await db\n\t\t\t.selectFrom(\"_emdash_api_tokens\")\n\t\t\t.select([\n\t\t\t\t\"id\",\n\t\t\t\t\"name\",\n\t\t\t\t\"prefix\",\n\t\t\t\t\"scopes\",\n\t\t\t\t\"user_id\",\n\t\t\t\t\"expires_at\",\n\t\t\t\t\"last_used_at\",\n\t\t\t\t\"created_at\",\n\t\t\t])\n\t\t\t.where(\"user_id\", \"=\", userId)\n\t\t\t.orderBy(\"created_at\", \"desc\")\n\t\t\t.execute();\n\n\t\tconst items: ApiTokenInfo[] = rows.map((row) => ({\n\t\t\tid: row.id,\n\t\t\tname: row.name,\n\t\t\tprefix: row.prefix,\n\t\t\tscopes: JSON.parse(row.scopes) as string[],\n\t\t\tuserId: row.user_id,\n\t\t\texpiresAt: row.expires_at,\n\t\t\tlastUsedAt: row.last_used_at,\n\t\t\tcreatedAt: row.created_at,\n\t\t}));\n\n\t\treturn { success: true, data: { items } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_LIST_ERROR\",\n\t\t\t\tmessage: \"Failed to list API tokens\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Revoke (delete) an API token.\n */\nexport async function handleApiTokenRevoke(\n\tdb: Kysely<Database>,\n\ttokenId: string,\n\tuserId: string,\n): Promise<ApiResult<{ revoked: boolean }>> {\n\ttry {\n\t\tconst result = await db\n\t\t\t.deleteFrom(\"_emdash_api_tokens\")\n\t\t\t.where(\"id\", \"=\", tokenId)\n\t\t\t.where(\"user_id\", \"=\", userId)\n\t\t\t.executeTakeFirst();\n\n\t\tif (result.numDeletedRows === 0n) {\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: { code: \"NOT_FOUND\", message: \"Token not found\" },\n\t\t\t};\n\t\t}\n\n\t\treturn { success: true, data: { revoked: true } };\n\t} catch {\n\t\treturn {\n\t\t\tsuccess: false,\n\t\t\terror: {\n\t\t\t\tcode: \"TOKEN_REVOKE_ERROR\",\n\t\t\t\tmessage: \"Failed to revoke API token\",\n\t\t\t},\n\t\t};\n\t}\n}\n\n/**\n * Resolve a raw API token (ec_pat_...) to a user ID and scopes.\n * Updates last_used_at on successful lookup.\n * Returns null if the token is invalid or expired.\n */\nexport async function resolveApiToken(\n\tdb: Kysely<Database>,\n\trawToken: string,\n): Promise<{ userId: string; scopes: string[] } | null> {\n\tconst hash = hashApiToken(rawToken);\n\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_api_tokens\")\n\t\t.select([\"id\", \"user_id\", \"scopes\", \"expires_at\"])\n\t\t.where(\"token_hash\", \"=\", hash)\n\t\t.executeTakeFirst();\n\n\tif (!row) return null;\n\n\t// Check expiry\n\tif (row.expires_at && new Date(row.expires_at) < new Date()) {\n\t\treturn null;\n\t}\n\n\t// Update last_used_at (fire-and-forget, don't block the request)\n\tdb.updateTable(\"_emdash_api_tokens\")\n\t\t.set({ last_used_at: new Date().toISOString() })\n\t\t.where(\"id\", \"=\", row.id)\n\t\t.execute()\n\t\t.catch(() => {}); // Non-critical, swallow errors\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tscopes: JSON.parse(row.scopes) as string[],\n\t};\n}\n\n/**\n * Resolve an OAuth access token (ec_oat_...) to a user ID and scopes.\n * Returns null if the token is invalid or expired.\n */\nexport async function resolveOAuthToken(\n\tdb: Kysely<Database>,\n\trawToken: string,\n): Promise<{ userId: string; scopes: string[] } | null> {\n\tconst hash = hashApiToken(rawToken);\n\n\tconst row = await db\n\t\t.selectFrom(\"_emdash_oauth_tokens\")\n\t\t.select([\"user_id\", \"scopes\", \"expires_at\", \"token_type\"])\n\t\t.where(\"token_hash\", \"=\", hash)\n\t\t.where(\"token_type\", \"=\", \"access\")\n\t\t.executeTakeFirst();\n\n\tif (!row) return null;\n\n\t// Check expiry\n\tif (new Date(row.expires_at) < new Date()) {\n\t\treturn null;\n\t}\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tscopes: JSON.parse(row.scopes) as string[],\n\t};\n}\n","/**\n * Strict Content-Security-Policy for /_emdash routes (admin + API).\n *\n * Applied via middleware header rather than Astro's built-in CSP because\n * Astro's auto-hashing defeats 'unsafe-inline' (CSP3 ignores 'unsafe-inline'\n * when hashes are present), which would break user-facing pages.\n *\n * img-src allows any HTTPS origin because the admin renders user content that\n * may reference external images (migrations, external hosting, embeds).\n * Plugin security does not rely on img-src -- plugins run in V8 isolates with\n * no DOM access, and connect-src 'self' blocks fetch-based exfiltration.\n */\nexport function buildEmDashCsp(): string {\n\treturn [\n\t\t\"default-src 'self'\",\n\t\t\"script-src 'self' 'unsafe-inline'\",\n\t\t\"style-src 'self' 'unsafe-inline'\",\n\t\t\"connect-src 'self'\",\n\t\t\"form-action 'self'\",\n\t\t\"frame-ancestors 'none'\",\n\t\t\"img-src 'self' https: data: blob:\",\n\t\t\"object-src 'none'\",\n\t\t\"base-uri 'self'\",\n\t].join(\"; \");\n}\n","/**\n * Auth middleware for admin routes\n *\n * Checks if the user is authenticated and has appropriate permissions.\n * Supports two auth modes:\n * - Passkey (default): Session-based auth with passkey login\n * - External providers: JWT-based auth (Cloudflare Access, etc.)\n *\n * This middleware runs AFTER the setup middleware - so if we get here,\n * we know setup is complete and users exist.\n */\n\nimport type { User, RoleLevel } from \"@emdash-cms/auth\";\nimport { createKyselyAdapter } from \"@emdash-cms/auth/adapters/kysely\";\nimport { defineMiddleware } from \"astro:middleware\";\nimport { ulid } from \"ulidx\";\n// Import auth provider via virtual module (statically bundled)\n// This avoids dynamic import issues in Cloudflare Workers\nimport { authenticate as virtualAuthenticate } from \"virtual:emdash/auth\";\n\nimport { checkPublicCsrf } from \"../../api/csrf.js\";\nimport { apiError } from \"../../api/error.js\";\nimport { getPublicOrigin } from \"../../api/public-url.js\";\n\n/** Cache headers for middleware error responses (matches API_CACHE_HEADERS in api/error.ts) */\nconst MW_CACHE_HEADERS = {\n\t\"Cache-Control\": \"private, no-store\",\n} as const;\nimport { resolveApiToken, resolveOAuthToken } from \"../../api/handlers/api-tokens.js\";\nimport { hasScope } from \"../../auth/api-tokens.js\";\nimport { getAuthMode, type ExternalAuthMode } from \"../../auth/mode.js\";\nimport type { ExternalAuthConfig } from \"../../auth/types.js\";\nimport type { EmDashHandlers, EmDashManifest } from \"../types.js\";\nimport { buildEmDashCsp } from \"./csp.js\";\n\ndeclare global {\n\tnamespace App {\n\t\tinterface Locals {\n\t\t\tuser?: User;\n\t\t\t/** Token scopes when authenticated via API token or OAuth token. Undefined for session auth. */\n\t\t\ttokenScopes?: string[];\n\t\t\temdash?: EmDashHandlers;\n\t\t\temdashManifest?: EmDashManifest;\n\t\t}\n\t\tinterface SessionData {\n\t\t\tuser: { id: string };\n\t\t\thasSeenWelcome: boolean;\n\t\t}\n\t}\n}\n\n// Role level constants (matching @emdash-cms/auth)\nconst ROLE_ADMIN = 50;\nconst MCP_ENDPOINT_PATH = \"/_emdash/api/mcp\";\n\nfunction isUnsafeMethod(method: string): boolean {\n\treturn method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\";\n}\n\nfunction csrfRejectedResponse(): Response {\n\treturn new Response(\n\t\tJSON.stringify({ error: { code: \"CSRF_REJECTED\", message: \"Missing required header\" } }),\n\t\t{\n\t\t\tstatus: 403,\n\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t},\n\t);\n}\n\nfunction mcpUnauthorizedResponse(\n\turl: URL,\n\tconfig?: Parameters<typeof getPublicOrigin>[1],\n): Response {\n\tconst origin = getPublicOrigin(url, config);\n\treturn Response.json(\n\t\t{ error: { code: \"NOT_AUTHENTICATED\", message: \"Not authenticated\" } },\n\t\t{\n\t\t\tstatus: 401,\n\t\t\theaders: {\n\t\t\t\t\"WWW-Authenticate\": `Bearer resource_metadata=\"${origin}/.well-known/oauth-protected-resource\"`,\n\t\t\t\t...MW_CACHE_HEADERS,\n\t\t\t},\n\t\t},\n\t);\n}\n\n/**\n * API routes that skip auth — each handles its own access control.\n *\n * Prefix entries match any path starting with that prefix.\n * Exact entries (no trailing slash or wildcard) match that path only.\n */\nconst PUBLIC_API_PREFIXES = [\n\t\"/_emdash/api/setup\",\n\t\"/_emdash/api/auth/login\",\n\t\"/_emdash/api/auth/register\",\n\t\"/_emdash/api/auth/dev-bypass\",\n\t\"/_emdash/api/auth/signup/\",\n\t\"/_emdash/api/auth/magic-link/\",\n\t\"/_emdash/api/auth/invite/accept\",\n\t\"/_emdash/api/auth/invite/complete\",\n\t\"/_emdash/api/auth/oauth/\",\n\t\"/_emdash/api/oauth/device/token\",\n\t\"/_emdash/api/oauth/device/code\",\n\t\"/_emdash/api/oauth/token\",\n\t\"/_emdash/api/comments/\",\n\t\"/_emdash/api/media/file/\",\n\t\"/_emdash/.well-known/\",\n];\n\nconst PUBLIC_API_EXACT = new Set([\n\t\"/_emdash/api/auth/passkey/options\",\n\t\"/_emdash/api/auth/passkey/verify\",\n\t\"/_emdash/api/oauth/token\",\n\t\"/_emdash/api/snapshot\",\n\t// Public site search — read-only. The query layer hardcodes status='published'\n\t// so unauthenticated callers only see published content. Admin endpoints\n\t// (/enable, /rebuild, /stats) remain private because they're not in this set.\n\t\"/_emdash/api/search\",\n]);\n\nfunction isPublicEmDashRoute(pathname: string): boolean {\n\tif (PUBLIC_API_EXACT.has(pathname)) return true;\n\tif (PUBLIC_API_PREFIXES.some((p) => pathname.startsWith(p))) return true;\n\tif (import.meta.env.DEV && pathname === \"/_emdash/api/typegen\") return true;\n\treturn false;\n}\n\nexport const onRequest = defineMiddleware(async (context, next) => {\n\tconst { url } = context;\n\n\t// Only check auth on admin routes and API routes\n\tconst isAdminRoute = url.pathname.startsWith(\"/_emdash/admin\");\n\tconst isSetupRoute = url.pathname.startsWith(\"/_emdash/admin/setup\");\n\tconst isApiRoute = url.pathname.startsWith(\"/_emdash/api\");\n\tconst isPublicApiRoute = isPublicEmDashRoute(url.pathname);\n\n\tconst isPublicRoute = !isAdminRoute && !isApiRoute;\n\n\t// Public API routes skip auth but still need CSRF protection on state-changing methods.\n\t// We check Origin header against the request host (same approach as Astro's checkOrigin).\n\t// This prevents cross-origin form submissions and fetch requests from malicious sites.\n\tif (isPublicApiRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);\n\t\t\tconst csrfError = checkPublicCsrf(context.request, url, publicOrigin);\n\t\t\tif (csrfError) return csrfError;\n\t\t}\n\t\treturn next();\n\t}\n\n\t// Plugin routes: soft auth (resolve user if credentials present, but never block).\n\t// The catch-all handler decides per-route whether auth is required (public vs private).\n\t// Public plugin routes that accept POST are vulnerable to cross-origin form submissions,\n\t// so we apply the same Origin-based CSRF check as other public routes.\n\tconst isPluginRoute = url.pathname.startsWith(\"/_emdash/api/plugins/\");\n\tif (isPluginRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst publicOrigin = getPublicOrigin(url, context.locals.emdash?.config);\n\t\t\tconst csrfError = checkPublicCsrf(context.request, url, publicOrigin);\n\t\t\tif (csrfError) return csrfError;\n\t\t}\n\t\treturn handlePluginRouteAuth(context, next);\n\t}\n\n\t// Setup routes: skip auth but still enforce CSRF on state-changing methods\n\tif (isSetupRoute) {\n\t\tconst method = context.request.method.toUpperCase();\n\t\tif (method !== \"GET\" && method !== \"HEAD\" && method !== \"OPTIONS\") {\n\t\t\tconst csrfHeader = context.request.headers.get(\"X-EmDash-Request\");\n\t\t\tif (csrfHeader !== \"1\") {\n\t\t\t\treturn new Response(\n\t\t\t\t\tJSON.stringify({\n\t\t\t\t\t\terror: { code: \"CSRF_REJECTED\", message: \"Missing required header\" },\n\t\t\t\t\t}),\n\t\t\t\t\t{\n\t\t\t\t\t\tstatus: 403,\n\t\t\t\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t\treturn next();\n\t}\n\n\t// For public routes: soft auth check (set locals.user if session exists, but never block)\n\tif (isPublicRoute) {\n\t\treturn handlePublicRouteAuth(context, next);\n\t}\n\n\t// --- Everything below is /_emdash (admin + API) ---\n\n\t// Try Bearer token auth first (API tokens and OAuth tokens).\n\t// If successful, skip CSRF (tokens aren't ambient credentials like cookies).\n\tconst bearerResult = await handleBearerAuth(context);\n\n\tif (bearerResult === \"invalid\") {\n\t\tconst headers: Record<string, string> = {\n\t\t\t\"Content-Type\": \"application/json\",\n\t\t\t...MW_CACHE_HEADERS,\n\t\t};\n\t\t// Add WWW-Authenticate header on MCP endpoint 401s to trigger OAuth discovery\n\t\tif (url.pathname === \"/_emdash/api/mcp\") {\n\t\t\tconst origin = getPublicOrigin(url, context.locals.emdash?.config);\n\t\t\theaders[\"WWW-Authenticate\"] =\n\t\t\t\t`Bearer resource_metadata=\"${origin}/.well-known/oauth-protected-resource\"`;\n\t\t}\n\t\treturn new Response(\n\t\t\tJSON.stringify({ error: { code: \"INVALID_TOKEN\", message: \"Invalid or expired token\" } }),\n\t\t\t{ status: 401, headers },\n\t\t);\n\t}\n\n\tconst isTokenAuth = bearerResult === \"authenticated\";\n\n\t// MCP discovery/tooling is bearer-only. Session/external auth should never\n\t// be consulted for this endpoint, and unauthenticated requests must return\n\t// the OAuth discovery-style 401 response.\n\tconst method = context.request.method.toUpperCase();\n\tconst isMcpEndpoint = url.pathname === MCP_ENDPOINT_PATH;\n\tif (isMcpEndpoint && !isTokenAuth) {\n\t\treturn mcpUnauthorizedResponse(url, context.locals.emdash?.config);\n\t}\n\n\t// CSRF protection: require X-EmDash-Request header on state-changing requests.\n\t// Skip for token-authenticated requests (tokens aren't ambient credentials).\n\t// Browsers block cross-origin custom headers, so this prevents CSRF without tokens.\n\t// OAuth authorize consent is exempt: it's a standard HTML form POST that can't\n\t// include custom headers. The consent flow is protected by session + single-use codes.\n\tconst isOAuthConsent = url.pathname.startsWith(\"/_emdash/oauth/authorize\");\n\tif (\n\t\tisApiRoute &&\n\t\t!isTokenAuth &&\n\t\t!isOAuthConsent &&\n\t\tisUnsafeMethod(method) &&\n\t\t!isPublicApiRoute\n\t) {\n\t\tconst csrfHeader = context.request.headers.get(\"X-EmDash-Request\");\n\t\tif (csrfHeader !== \"1\") {\n\t\t\treturn csrfRejectedResponse();\n\t\t}\n\t}\n\n\t// If already authenticated via Bearer token, enforce scope then skip session/external auth\n\tif (isTokenAuth) {\n\t\t// Enforce API token scopes based on URL pattern + HTTP method\n\t\tconst scopeError = enforceTokenScope(url.pathname, method, context.locals.tokenScopes);\n\t\tif (scopeError) return scopeError;\n\n\t\tconst response = await next();\n\t\tif (!import.meta.env.DEV) {\n\t\t\tresponse.headers.set(\"Content-Security-Policy\", buildEmDashCsp());\n\t\t}\n\t\treturn response;\n\t}\n\n\tconst response = await handleEmDashAuth(context, next);\n\n\t// Set strict CSP on all /_emdash responses (prod only)\n\tif (!import.meta.env.DEV) {\n\t\tresponse.headers.set(\"Content-Security-Policy\", buildEmDashCsp());\n\t}\n\n\treturn response;\n});\n\n/**\n * Auth handling for /_emdash routes. Returns a Response from either\n * an auth error/redirect or the downstream route handler.\n */\nasync function handleEmDashAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { url, locals } = context;\n\tconst { emdash } = locals;\n\n\tconst isLoginRoute = url.pathname.startsWith(\"/_emdash/admin/login\");\n\tconst isApiRoute = url.pathname.startsWith(\"/_emdash/api\");\n\n\tif (!emdash?.db) {\n\t\t// No database - let the admin handle this error\n\t\treturn next();\n\t}\n\n\t// Determine auth mode from config\n\tconst authMode = getAuthMode(emdash.config);\n\n\tif (authMode.type === \"external\") {\n\t\t// In dev mode, fall back to passkey auth since external JWT won't be present\n\t\tif (import.meta.env.DEV) {\n\t\t\tif (isLoginRoute) {\n\t\t\t\treturn next();\n\t\t\t}\n\n\t\t\treturn handlePasskeyAuth(context, next, isApiRoute);\n\t\t}\n\n\t\t// External auth provider (Cloudflare Access, etc.)\n\t\treturn handleExternalAuth(context, next, authMode, isApiRoute);\n\t}\n\n\t// Passkey authentication (default)\n\tif (isLoginRoute) {\n\t\treturn next();\n\t}\n\n\treturn handlePasskeyAuth(context, next, isApiRoute);\n}\n\n/**\n * Soft auth for plugin routes: resolve user from Bearer token or session if present,\n * but never block unauthenticated requests. The catch-all handler checks route\n * metadata to decide whether auth is required (public vs private routes).\n */\nasync function handlePluginRouteAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { locals } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Try Bearer token auth first (API tokens and OAuth tokens)\n\t\tconst bearerResult = await handleBearerAuth(context);\n\t\tif (bearerResult === \"authenticated\") {\n\t\t\t// User and tokenScopes are set on locals by handleBearerAuth\n\t\t\treturn next();\n\t\t}\n\t\tif (bearerResult === \"invalid\") {\n\t\t\t// A token was presented but is invalid/expired — return 401 so the\n\t\t\t// caller knows their token is bad (don't silently downgrade to no-auth).\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({ error: { code: \"INVALID_TOKEN\", message: \"Invalid or expired token\" } }),\n\t\t\t\t{\n\t\t\t\t\tstatus: 401,\n\t\t\t\t\theaders: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS },\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\t\t// \"none\" — no token presented, try session auth below.\n\t} catch (error) {\n\t\tconsole.error(\"Plugin route bearer auth error:\", error);\n\t}\n\n\ttry {\n\t\t// Try session auth (sets locals.user if session exists)\n\t\tconst { session } = context;\n\t\tconst sessionUser = await session?.get(\"user\");\n\t\tif (sessionUser?.id && emdash?.db) {\n\t\t\tconst adapter = createKyselyAdapter(emdash.db);\n\t\t\tconst user = await adapter.getUserById(sessionUser.id);\n\t\t\tif (user && !user.disabled) {\n\t\t\t\tlocals.user = user;\n\t\t\t}\n\t\t}\n\t} catch (error) {\n\t\t// Log but don't block — public routes should still work without session\n\t\tconsole.error(\"Plugin route session auth error:\", error);\n\t}\n\n\treturn next();\n}\n\n/**\n * Soft auth check for public routes with edit mode cookie.\n * Checks the session and sets locals.user if valid, but never blocks the request.\n */\nasync function handlePublicRouteAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n): Promise<Response> {\n\tconst { locals, session } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\tconst sessionUser = await session?.get(\"user\");\n\t\tif (sessionUser?.id && emdash?.db) {\n\t\t\tconst adapter = createKyselyAdapter(emdash.db);\n\t\t\tconst user = await adapter.getUserById(sessionUser.id);\n\t\t\tif (user && !user.disabled) {\n\t\t\t\tlocals.user = user;\n\t\t\t}\n\t\t}\n\t} catch {\n\t\t// Silently continue — public page should render normally\n\t}\n\n\treturn next();\n}\n\n/**\n * Handle external auth provider authentication (Cloudflare Access, etc.)\n */\nasync function handleExternalAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n\tauthMode: ExternalAuthMode,\n\t_isApiRoute: boolean,\n): Promise<Response> {\n\tconst { locals, request } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Use the authenticate function from the virtual module\n\t\t// (statically imported at build time to work with Cloudflare Workers)\n\t\tif (typeof virtualAuthenticate !== \"function\") {\n\t\t\tthrow new Error(\n\t\t\t\t`Auth provider ${authMode.entrypoint} does not export an authenticate function`,\n\t\t\t);\n\t\t}\n\n\t\t// Authenticate via the provider\n\t\tconst authResult = await virtualAuthenticate(request, authMode.config);\n\n\t\t// Get external auth config for auto-provision settings\n\t\t// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowing AuthModeConfig to ExternalAuthConfig after provider check\n\t\tconst externalConfig = authMode.config as ExternalAuthConfig;\n\n\t\t// Find or create user\n\t\tconst adapter = createKyselyAdapter(emdash!.db);\n\t\tlet user = await adapter.getUserByEmail(authResult.email);\n\n\t\tif (!user) {\n\t\t\t// User doesn't exist\n\t\t\tif (externalConfig.autoProvision === false) {\n\t\t\t\treturn new Response(\"User not authorized\", {\n\t\t\t\t\tstatus: 403,\n\t\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Check if this is the first user (they become admin)\n\t\t\tconst userCount = await emdash!.db\n\t\t\t\t.selectFrom(\"users\")\n\t\t\t\t.select(emdash!.db.fn.count(\"id\").as(\"count\"))\n\t\t\t\t.executeTakeFirst();\n\n\t\t\tconst isFirstUser = Number(userCount?.count ?? 0) === 0;\n\t\t\tconst role = isFirstUser ? ROLE_ADMIN : authResult.role;\n\n\t\t\t// Create user\n\t\t\tconst now = new Date().toISOString();\n\t\t\tconst newUser = {\n\t\t\t\tid: ulid(),\n\t\t\t\temail: authResult.email,\n\t\t\t\tname: authResult.name,\n\t\t\t\trole,\n\t\t\t\temail_verified: 1,\n\t\t\t\tcreated_at: now,\n\t\t\t\tupdated_at: now,\n\t\t\t};\n\n\t\t\tawait emdash!.db.insertInto(\"users\").values(newUser).execute();\n\n\t\t\tuser = await adapter.getUserByEmail(authResult.email);\n\n\t\t\tconsole.log(\n\t\t\t\t`[external-auth] Provisioned user: ${authResult.email} (role: ${role}, first: ${isFirstUser})`,\n\t\t\t);\n\t\t} else {\n\t\t\t// User exists - check if we need to sync anything\n\t\t\tconst updates: Record<string, unknown> = {};\n\t\t\tlet newName: string | undefined;\n\t\t\tlet newRole: RoleLevel | undefined;\n\n\t\t\t// Sync name from provider if provider provides one and local differs\n\t\t\tif (authResult.name && user.name !== authResult.name) {\n\t\t\t\tnewName = authResult.name;\n\t\t\t\tupdates.name = newName;\n\t\t\t}\n\n\t\t\t// Sync role if enabled\n\t\t\tif (externalConfig.syncRoles && user.role !== authResult.role) {\n\t\t\t\tnewRole = authResult.role;\n\t\t\t\tupdates.role = newRole;\n\t\t\t}\n\n\t\t\tif (Object.keys(updates).length > 0) {\n\t\t\t\tupdates.updated_at = new Date().toISOString();\n\t\t\t\tawait emdash!.db.updateTable(\"users\").set(updates).where(\"id\", \"=\", user.id).execute();\n\n\t\t\t\tuser = {\n\t\t\t\t\t...user,\n\t\t\t\t\t...(newName ? { name: newName } : {}),\n\t\t\t\t\t...(newRole ? { role: newRole } : {}),\n\t\t\t\t};\n\n\t\t\t\tconsole.log(\n\t\t\t\t\t`[external-auth] Updated user ${authResult.email}:`,\n\t\t\t\t\tObject.keys(updates).filter((k) => k !== \"updated_at\"),\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tif (!user) {\n\t\t\t// This shouldn't happen, but handle it gracefully\n\t\t\treturn new Response(\"Failed to provision user\", {\n\t\t\t\tstatus: 500,\n\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t});\n\t\t}\n\n\t\t// Check if user is disabled locally\n\t\tif (user.disabled) {\n\t\t\treturn new Response(\"Account disabled\", {\n\t\t\t\tstatus: 403,\n\t\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t\t});\n\t\t}\n\n\t\t// Set user in locals\n\t\tlocals.user = user;\n\n\t\t// Persist to session so public pages can identify the user\n\t\t// (external auth headers are only verified on /_emdash routes)\n\t\tconst { session } = context;\n\t\tsession?.set(\"user\", { id: user.id });\n\n\t\treturn next();\n\t} catch (error) {\n\t\tconsole.error(\"[external-auth] Auth error:\", error);\n\n\t\treturn new Response(\"Authentication failed\", {\n\t\t\tstatus: 401,\n\t\t\theaders: { \"Content-Type\": \"text/plain\", ...MW_CACHE_HEADERS },\n\t\t});\n\t}\n}\n\n/**\n * Try to authenticate via Bearer token (API token or OAuth token).\n *\n * Returns:\n * - \"authenticated\" if token is valid and user is resolved\n * - \"invalid\" if a token was provided but is invalid/expired\n * - \"none\" if no Bearer token was provided\n */\nasync function handleBearerAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n): Promise<\"authenticated\" | \"invalid\" | \"none\"> {\n\tconst authHeader = context.request.headers.get(\"Authorization\");\n\tif (!authHeader?.startsWith(\"Bearer \")) return \"none\";\n\n\tconst token = authHeader.slice(7);\n\tif (!token) return \"none\";\n\n\tconst { locals } = context;\n\tconst { emdash } = locals;\n\tif (!emdash?.db) return \"none\";\n\n\t// Resolve token based on prefix\n\tlet resolved: { userId: string; scopes: string[] } | null = null;\n\n\tif (token.startsWith(\"ec_pat_\")) {\n\t\tresolved = await resolveApiToken(emdash.db, token);\n\t} else if (token.startsWith(\"ec_oat_\")) {\n\t\tresolved = await resolveOAuthToken(emdash.db, token);\n\t} else {\n\t\t// Unknown token format\n\t\treturn \"invalid\";\n\t}\n\n\tif (!resolved) return \"invalid\";\n\n\t// Look up the user\n\tconst adapter = createKyselyAdapter(emdash.db);\n\tconst user = await adapter.getUserById(resolved.userId);\n\n\tif (!user || user.disabled) return \"invalid\";\n\n\t// Set user and scopes on locals\n\tlocals.user = user;\n\tlocals.tokenScopes = resolved.scopes;\n\n\treturn \"authenticated\";\n}\n\n/**\n * Handle passkey (session-based) authentication\n */\nasync function handlePasskeyAuth(\n\tcontext: Parameters<Parameters<typeof defineMiddleware>[0]>[0],\n\tnext: Parameters<Parameters<typeof defineMiddleware>[0]>[1],\n\tisApiRoute: boolean,\n): Promise<Response> {\n\tconst { url, locals, session } = context;\n\tconst { emdash } = locals;\n\n\ttry {\n\t\t// Check session for user (session.get returns a Promise)\n\t\tconst sessionUser = await session?.get(\"user\");\n\n\t\tif (!sessionUser?.id) {\n\t\t\tif (isApiRoute) {\n\t\t\t\treturn Response.json(\n\t\t\t\t\t{ error: { code: \"NOT_AUTHENTICATED\", message: \"Not authenticated\" } },\n\t\t\t\t\t{ status: 401, headers: MW_CACHE_HEADERS },\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst loginUrl = new URL(\"/_emdash/admin/login\", getPublicOrigin(url, emdash?.config));\n\t\t\tloginUrl.searchParams.set(\"redirect\", url.pathname);\n\t\t\treturn context.redirect(loginUrl.toString());\n\t\t}\n\n\t\t// Get full user from database\n\t\tconst adapter = createKyselyAdapter(emdash!.db);\n\t\tconst user = await adapter.getUserById(sessionUser.id);\n\n\t\tif (!user) {\n\t\t\t// User no longer exists - clear session\n\t\t\tsession?.destroy();\n\t\t\tif (isApiRoute) {\n\t\t\t\treturn Response.json(\n\t\t\t\t\t{ error: { code: \"NOT_FOUND\", message: \"User not found\" } },\n\t\t\t\t\t{ status: 401, headers: MW_CACHE_HEADERS },\n\t\t\t\t);\n\t\t\t}\n\t\t\tconst loginUrl = new URL(\"/_emdash/admin/login\", getPublicOrigin(url, emdash?.config));\n\t\t\treturn context.redirect(loginUrl.toString());\n\t\t}\n\n\t\t// Check if user is disabled\n\t\tif (user.disabled) {\n\t\t\tsession?.destroy();\n\t\t\tif (isApiRoute) {\n\t\t\t\treturn apiError(\"ACCOUNT_DISABLED\", \"Account disabled\", 403);\n\t\t\t}\n\t\t\tconst loginUrl = new URL(\"/_emdash/admin/login\", getPublicOrigin(url, emdash?.config));\n\t\t\tloginUrl.searchParams.set(\"error\", \"account_disabled\");\n\t\t\treturn context.redirect(loginUrl.toString());\n\t\t}\n\n\t\t// Set user in locals for use by routes\n\t\tlocals.user = user;\n\t} catch (error) {\n\t\tconsole.error(\"Auth middleware error:\", error);\n\t\t// On error, redirect to login\n\t\treturn context.redirect(\"/_emdash/admin/login\");\n\t}\n\n\treturn next();\n}\n\n// =============================================================================\n// Token scope enforcement\n// =============================================================================\n\n/**\n * Scope rules: ordered list of (pathPrefix, method, requiredScope) tuples.\n * First matching rule wins. Methods: \"*\" = any, \"WRITE\" = POST/PUT/PATCH/DELETE.\n *\n * Routes not matched by any rule default to \"admin\" scope (fail-closed).\n */\nconst SCOPE_RULES: Array<[prefix: string, method: string, scope: string]> = [\n\t// Content routes\n\t[\"/_emdash/api/content\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/content\", \"WRITE\", \"content:write\"],\n\n\t// Media routes (excluding /file/ which is public)\n\t[\"/_emdash/api/media/file\", \"*\", \"media:read\"], // public anyway, but scope if token-authed\n\t[\"/_emdash/api/media\", \"GET\", \"media:read\"],\n\t[\"/_emdash/api/media\", \"WRITE\", \"media:write\"],\n\n\t// Schema routes\n\t[\"/_emdash/api/schema\", \"GET\", \"schema:read\"],\n\t[\"/_emdash/api/schema\", \"WRITE\", \"schema:write\"],\n\n\t// Taxonomy, menu, section, widget, revision — all content domain\n\t[\"/_emdash/api/taxonomies\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/taxonomies\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/menus\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/menus\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/sections\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/sections\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/widget-areas\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/widget-areas\", \"WRITE\", \"content:write\"],\n\t[\"/_emdash/api/revisions\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/revisions\", \"WRITE\", \"content:write\"],\n\n\t// Search\n\t[\"/_emdash/api/search\", \"GET\", \"content:read\"],\n\t[\"/_emdash/api/search\", \"WRITE\", \"admin\"],\n\n\t// Import, admin, settings, plugins — all require admin scope\n\t[\"/_emdash/api/import\", \"*\", \"admin\"],\n\t[\"/_emdash/api/admin\", \"*\", \"admin\"],\n\t[\"/_emdash/api/settings\", \"*\", \"admin\"],\n\t[\"/_emdash/api/plugins\", \"*\", \"admin\"],\n\n\t// MCP endpoint — scopes enforced per-tool inside mcp/server.ts\n\t[\"/_emdash/api/mcp\", \"*\", \"content:read\"],\n];\n\nconst WRITE_METHODS = new Set([\"POST\", \"PUT\", \"PATCH\", \"DELETE\"]);\n\n/**\n * Enforce API token scopes based on the request URL and HTTP method.\n * Returns a 403 Response if the scope is insufficient, or null if allowed.\n *\n * Session-authenticated requests (tokenScopes === undefined) are never checked.\n */\nfunction enforceTokenScope(\n\tpathname: string,\n\tmethod: string,\n\ttokenScopes: string[] | undefined,\n): Response | null {\n\t// Session auth — implicit full access, no scope restrictions\n\tif (!tokenScopes) return null;\n\n\tconst isWrite = WRITE_METHODS.has(method);\n\n\tfor (const [prefix, ruleMethod, scope] of SCOPE_RULES) {\n\t\t// Match exact prefix or prefix followed by /\n\t\tif (pathname !== prefix && !pathname.startsWith(prefix + \"/\")) continue;\n\n\t\t// Check method match\n\t\tif (ruleMethod === \"*\" || (ruleMethod === \"WRITE\" && isWrite) || ruleMethod === method) {\n\t\t\tif (hasScope(tokenScopes, scope)) return null;\n\n\t\t\treturn new Response(\n\t\t\t\tJSON.stringify({\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcode: \"INSUFFICIENT_SCOPE\",\n\t\t\t\t\t\tmessage: `Token lacks required scope: ${scope}`,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t\t{ status: 403, headers: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS } },\n\t\t\t);\n\t\t}\n\t}\n\n\t// No rule matched — default to admin scope (fail-closed)\n\tif (hasScope(tokenScopes, \"admin\")) return null;\n\n\treturn new Response(\n\t\tJSON.stringify({\n\t\t\terror: {\n\t\t\t\tcode: \"INSUFFICIENT_SCOPE\",\n\t\t\t\tmessage: \"Token lacks required scope: admin\",\n\t\t\t},\n\t\t}),\n\t\t{ status: 403, headers: { \"Content-Type\": \"application/json\", ...MW_CACHE_HEADERS } },\n\t);\n}\n"],"mappings":"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"}
@@ -1,6 +1,6 @@
1
1
  import "../../base64-MBPo9ozB.mjs";
2
2
  import { runWithContext } from "../../request-context.mjs";
3
- import { n as parseContentId, r as verifyPreviewToken } from "../../tokens-DpgrkrXK.mjs";
3
+ import { n as parseContentId, r as verifyPreviewToken } from "../../tokens-DrB-W6Q-.mjs";
4
4
  import { defineMiddleware } from "astro:middleware";
5
5
 
6
6
  //#region src/visual-editing/toolbar.ts
@@ -1,4 +1,4 @@
1
- import { t as getAuthMode } from "../../mode-C2EzN1uE.mjs";
1
+ import { t as getAuthMode } from "../../mode-CYeM2rPt.mjs";
2
2
  import { defineMiddleware } from "astro:middleware";
3
3
 
4
4
  //#region src/astro/middleware/setup.ts
@@ -1 +1 @@
1
- {"version":3,"file":"middleware.d.mts","names":[],"sources":["../../src/astro/middleware.ts"],"mappings":";;;;;;AAoLA;;;cAAa,SAAA,EA0PX,KAAA,CA1PoB,iBAAA"}
1
+ {"version":3,"file":"middleware.d.mts","names":[],"sources":["../../src/astro/middleware.ts"],"mappings":";;;;;;AAsLA;;;cAAa,SAAA,EAoRX,KAAA,CApRoB,iBAAA"}