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
@@ -15,6 +15,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
15
15
 
16
16
  import { apiError, apiSuccess, handleError } from "#api/error.js";
17
17
  import { isParseError, parseBody } from "#api/parse.js";
18
+ import { getPublicOrigin } from "#api/public-url.js";
18
19
  import { signupCompleteBody } from "#api/schemas.js";
19
20
  import { createChallengeStore } from "#auth/challenge-store.js";
20
21
  import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -22,7 +23,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
22
23
 
23
24
  export const POST: APIRoute = async ({ request, locals, session }) => {
24
25
  const { emdash } = locals;
25
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
26
26
 
27
27
  if (!emdash?.db) {
28
28
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -38,7 +38,8 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
38
38
  const url = new URL(request.url);
39
39
  const options = new OptionsRepository(emdash.db);
40
40
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
41
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
41
+ const siteUrl = getPublicOrigin(url, emdash?.config);
42
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
42
43
 
43
44
  // Verify the passkey registration response
44
45
  const challengeStore = createChallengeStore(emdash.db);
@@ -7,8 +7,8 @@
7
7
 
8
8
  import type { APIRoute } from "astro";
9
9
 
10
- import { requirePerm } from "#api/authorize.js";
11
- import { apiError, unwrapResult } from "#api/error.js";
10
+ import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
11
+ import { apiError, mapErrorStatus, unwrapResult } from "#api/error.js";
12
12
  import { parseBody, parseQuery, isParseError } from "#api/parse.js";
13
13
  import { contentListQuery, contentCreateBody } from "#api/schemas.js";
14
14
 
@@ -39,10 +39,38 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
39
39
  const body = await parseBody(request, contentCreateBody);
40
40
  if (isParseError(body)) return body;
41
41
 
42
- if (!emdash?.handleContentCreate) {
42
+ if (!emdash?.handleContentCreate || !emdash?.handleContentGet) {
43
43
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
44
44
  }
45
45
 
46
+ // Creating a translation requires edit permission on the source item
47
+ if (body.translationOf) {
48
+ const source = await emdash.handleContentGet(collection, body.translationOf);
49
+ if (!source.success) {
50
+ return apiError(
51
+ source.error?.code ?? "NOT_FOUND",
52
+ source.error?.message ?? "Translation source not found",
53
+ mapErrorStatus(source.error?.code),
54
+ );
55
+ }
56
+ const sourceData =
57
+ source.data && typeof source.data === "object"
58
+ ? (source.data as Record<string, unknown>)
59
+ : undefined;
60
+ const sourceItem =
61
+ sourceData?.item && typeof sourceData.item === "object"
62
+ ? (sourceData.item as Record<string, unknown>)
63
+ : sourceData;
64
+ const sourceAuthor = typeof sourceItem?.authorId === "string" ? sourceItem.authorId : "";
65
+ const translationDenied = requireOwnerPerm(
66
+ user,
67
+ sourceAuthor,
68
+ "content:edit_own",
69
+ "content:edit_any",
70
+ );
71
+ if (translationDenied) return translationDenied;
72
+ }
73
+
46
74
  // Auto-set authorId to current user when creating content
47
75
  const result = await emdash.handleContentCreate(collection, {
48
76
  ...body,
@@ -13,6 +13,7 @@ import {
13
13
  ContentRepository,
14
14
  importReusableBlocksAsSections,
15
15
  type WxrPost,
16
+ parseWxrDate,
16
17
  } from "emdash";
17
18
 
18
19
  import { requirePerm } from "#api/authorize.js";
@@ -236,6 +237,12 @@ async function importContent(
236
237
  bylineCache,
237
238
  );
238
239
 
240
+ // Preserve original WordPress dates using the shared WXR date parser.
241
+ // Fallback chain: postDateGmt (UTC) → pubDate (RFC 2822) → postDate (site-local).
242
+ const parsedDate = parseWxrDate(post.postDateGmt, post.pubDate, post.postDate);
243
+ const createdAt = parsedDate ? parsedDate.toISOString() : undefined;
244
+ const publishedAt = status === "published" && createdAt ? createdAt : undefined;
245
+
239
246
  // Create the content item
240
247
  const createResult = await emdash.handleContentCreate(collection, {
241
248
  data,
@@ -244,6 +251,8 @@ async function importContent(
244
251
  authorId,
245
252
  bylines: bylineId ? [{ bylineId }] : undefined,
246
253
  locale,
254
+ createdAt,
255
+ publishedAt,
247
256
  });
248
257
 
249
258
  if (createResult.success) {
@@ -286,6 +286,14 @@ async function importContent(
286
286
  }
287
287
  }
288
288
 
289
+ // Preserve original dates from the source
290
+ const itemDateTime = item.date?.getTime();
291
+ const createdAt =
292
+ itemDateTime !== undefined && !Number.isNaN(itemDateTime)
293
+ ? item.date.toISOString()
294
+ : undefined;
295
+ const publishedAt = status === "published" && createdAt ? createdAt : undefined;
296
+
289
297
  // Create the content item
290
298
  const createResult = await emdash.handleContentCreate(collection, {
291
299
  data,
@@ -295,6 +303,8 @@ async function importContent(
295
303
  bylines: bylineId ? [{ bylineId }] : undefined,
296
304
  locale: item.locale,
297
305
  translationOf,
306
+ createdAt,
307
+ publishedAt,
298
308
  });
299
309
 
300
310
  if (createResult.success) {
@@ -47,6 +47,7 @@ export const GET: APIRoute = async ({ locals }) => {
47
47
  hash: "default",
48
48
  collections: {},
49
49
  plugins: {},
50
+ taxonomies: [],
50
51
  authMode: "passkey",
51
52
  signupEnabled,
52
53
  };
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { APIRoute } from "astro";
9
9
 
10
+ import { requirePerm } from "#api/authorize.js";
10
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
11
12
 
12
13
  export const prerender = false;
@@ -15,7 +16,9 @@ export const prerender = false;
15
16
  * Get a single media item from a provider
16
17
  */
17
18
  export const GET: APIRoute = async ({ params, locals }) => {
18
- const { emdash } = locals;
19
+ const { emdash, user } = locals;
20
+ const denied = requirePerm(user, "media:read");
21
+ if (denied) return denied;
19
22
  const { providerId, itemId } = params;
20
23
 
21
24
  if (!providerId || !itemId) {
@@ -56,7 +59,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
56
59
  * Delete a media item from a provider
57
60
  */
58
61
  export const DELETE: APIRoute = async ({ params, locals }) => {
59
- const { emdash } = locals;
62
+ const { emdash, user } = locals;
63
+ const denied = requirePerm(user, "media:delete_any");
64
+ if (denied) return denied;
60
65
  const { providerId, itemId } = params;
61
66
 
62
67
  if (!providerId || !itemId) {
@@ -22,6 +22,7 @@ import {
22
22
  validateRedirectUri,
23
23
  } from "#api/handlers/oauth-authorization.js";
24
24
  import { lookupOAuthClient, validateClientRedirectUri } from "#api/handlers/oauth-clients.js";
25
+ import { getPublicOrigin } from "#api/public-url.js";
25
26
  import { VALID_SCOPES } from "#auth/api-tokens.js";
26
27
 
27
28
  export const prerender = false;
@@ -40,14 +41,18 @@ function generateCsrfToken(): string {
40
41
  }
41
42
 
42
43
  /** Build the Set-Cookie header value for the CSRF token. */
43
- function csrfCookieHeader(token: string, request: Request): string {
44
+ function csrfCookieHeader(token: string, request: Request, siteUrl?: string): string {
44
45
  // SameSite=Strict prevents cross-site form submission.
45
46
  // HttpOnly: the token value is embedded in the form hidden field server-side,
46
47
  // so JS never needs to read the cookie. HttpOnly adds defense-in-depth.
47
- // Secure is only set over HTTPS — omitting it on localhost allows the cookie
48
- // to be sent over plain HTTP during development.
49
- const secure = new URL(request.url).protocol === "https:" ? "; Secure" : "";
50
- return `${CSRF_COOKIE_NAME}=${token}; Path=/_emdash/api/oauth/authorize; HttpOnly; SameSite=Strict${secure}`;
48
+ // Secure is set when:
49
+ // - siteUrl is configured and uses https (proxy case — request may be http internally), OR
50
+ // - the actual request is over https (non-proxy case, preserve existing behaviour)
51
+ const isSecure = siteUrl
52
+ ? siteUrl.startsWith("https:")
53
+ : new URL(request.url).protocol === "https:";
54
+ const secure = isSecure ? "; Secure" : "";
55
+ return `${CSRF_COOKIE_NAME}=${token}; Path=/_emdash/oauth/authorize; HttpOnly; SameSite=Strict${secure}`;
51
56
  }
52
57
 
53
58
  /** Extract the CSRF token from the request's cookies. */
@@ -130,7 +135,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => {
130
135
 
131
136
  // If not authenticated, redirect to login with return URL
132
137
  if (!user) {
133
- const loginUrl = new URL("/_emdash/admin/login", url.origin);
138
+ const loginUrl = new URL("/_emdash/admin/login", getPublicOrigin(url, emdash?.config));
134
139
  loginUrl.searchParams.set("redirect", url.pathname + url.search);
135
140
  return Response.redirect(loginUrl.toString(), 302);
136
141
  }
@@ -169,7 +174,7 @@ export const GET: APIRoute = async ({ url, request, locals }) => {
169
174
  return new Response(html, {
170
175
  headers: {
171
176
  "Content-Type": "text/html; charset=utf-8",
172
- "Set-Cookie": csrfCookieHeader(csrfToken, request),
177
+ "Set-Cookie": csrfCookieHeader(csrfToken, request, getPublicOrigin(url, emdash?.config)),
173
178
  },
174
179
  });
175
180
  };
@@ -13,6 +13,7 @@ import { z } from "zod";
13
13
  import { apiError, handleError, unwrapResult } from "#api/error.js";
14
14
  import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
+ import { getPublicOrigin } from "#api/public-url.js";
16
17
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
17
18
 
18
19
  export const prerender = false;
@@ -41,7 +42,10 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
41
42
  }
42
43
 
43
44
  // Build the verification URI — device page lives inside the admin SPA
44
- const verificationUri = new URL("/_emdash/admin/device", url.origin).toString();
45
+ const verificationUri = new URL(
46
+ "/_emdash/admin/device",
47
+ getPublicOrigin(url, emdash?.config),
48
+ ).toString();
45
49
 
46
50
  const result = await handleDeviceCodeRequest(emdash.db, body, verificationUri);
47
51
  return unwrapResult(result);
@@ -14,6 +14,7 @@ import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/pa
14
14
 
15
15
  import { apiError, apiSuccess, handleError } from "#api/error.js";
16
16
  import { isParseError, parseBody } from "#api/parse.js";
17
+ import { getPublicOrigin } from "#api/public-url.js";
17
18
  import { setupAdminVerifyBody } from "#api/schemas.js";
18
19
  import { createChallengeStore } from "#auth/challenge-store.js";
19
20
  import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -21,7 +22,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
21
22
 
22
23
  export const POST: APIRoute = async ({ request, locals }) => {
23
24
  const { emdash } = locals;
24
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
25
25
 
26
26
  if (!emdash?.db) {
27
27
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -58,7 +58,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
58
58
  // Get passkey config
59
59
  const url = new URL(request.url);
60
60
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
61
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
61
+ const siteUrl = getPublicOrigin(url, emdash?.config);
62
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
62
63
 
63
64
  // Verify the registration response
64
65
  const challengeStore = createChallengeStore(emdash.db);
@@ -13,6 +13,7 @@ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
13
13
 
14
14
  import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
+ import { getPublicOrigin } from "#api/public-url.js";
16
17
  import { setupAdminBody } from "#api/schemas.js";
17
18
  import { createChallengeStore } from "#auth/challenge-store.js";
18
19
  import { getPasskeyConfig } from "#auth/passkey-config.js";
@@ -20,7 +21,6 @@ import { OptionsRepository } from "#db/repositories/options.js";
20
21
 
21
22
  export const POST: APIRoute = async ({ request, locals }) => {
22
23
  const { emdash } = locals;
23
- const passkeyPublicOrigin = emdash?.config.passkeyPublicOrigin;
24
24
 
25
25
  if (!emdash?.db) {
26
26
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
@@ -57,7 +57,8 @@ export const POST: APIRoute = async ({ request, locals }) => {
57
57
  // Get passkey config
58
58
  const url = new URL(request.url);
59
59
  const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
60
- const passkeyConfig = getPasskeyConfig(url, siteName, passkeyPublicOrigin);
60
+ const siteUrl = getPublicOrigin(url, emdash?.config);
61
+ const passkeyConfig = getPasskeyConfig(url, siteName, siteUrl);
61
62
 
62
63
  // Generate registration options
63
64
  const challengeStore = createChallengeStore(emdash.db);
@@ -24,6 +24,7 @@ import { ulid } from "ulidx";
24
24
  import { apiError, apiSuccess, handleError } from "#api/error.js";
25
25
  import { escapeHtml } from "#api/escape.js";
26
26
  import { handleApiTokenCreate } from "#api/handlers/api-tokens.js";
27
+ import { getPublicOrigin } from "#api/public-url.js";
27
28
  import { isSafeRedirect } from "#api/redirect.js";
28
29
  import { runMigrations } from "#db/migrations/runner.js";
29
30
  import { OptionsRepository } from "#db/repositories/options.js";
@@ -120,7 +121,7 @@ async function handleDevBypass(context: Parameters<APIRoute>[0]): Promise<Respon
120
121
  }
121
122
 
122
123
  // Store canonical site URL (used by magic-link/recovery emails)
123
- await options.set("emdash:site_url", url.origin);
124
+ await options.set("emdash:site_url", getPublicOrigin(url, emdash?.config));
124
125
 
125
126
  // Mark setup complete
126
127
  await options.set("emdash:setup_complete", true);
@@ -10,6 +10,7 @@ export const prerender = false;
10
10
 
11
11
  import { apiError, apiSuccess, handleError } from "#api/error.js";
12
12
  import { isParseError, parseBody } from "#api/parse.js";
13
+ import { getPublicOrigin } from "#api/public-url.js";
13
14
  import { setupBody } from "#api/schemas.js";
14
15
  import { getAuthMode } from "#auth/mode.js";
15
16
  import { runMigrations } from "#db/migrations/runner.js";
@@ -18,7 +19,7 @@ import { applySeed } from "#seed/apply.js";
18
19
  import { loadSeed } from "#seed/load.js";
19
20
  import { validateSeed } from "#seed/validate.js";
20
21
 
21
- export const POST: APIRoute = async ({ request, locals }) => {
22
+ export const POST: APIRoute = async ({ request, url, locals }) => {
22
23
  const { emdash } = locals;
23
24
 
24
25
  if (!emdash?.db) {
@@ -89,7 +90,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
89
90
 
90
91
  // Store the canonical site URL from the setup request.
91
92
  // This is trusted because setup runs on the real domain.
92
- const siteUrl = new URL(request.url).origin;
93
+ const siteUrl = getPublicOrigin(url, emdash.config);
93
94
  await options.set("emdash:site_url", siteUrl);
94
95
 
95
96
  if (useExternalAuth) {
@@ -16,6 +16,7 @@ import {
16
16
  parsePreviewSignatureHeader,
17
17
  verifyPreviewSignature,
18
18
  } from "#api/handlers/snapshot.js";
19
+ import { getPublicOrigin } from "#api/public-url.js";
19
20
 
20
21
  export const prerender = false;
21
22
 
@@ -65,7 +66,7 @@ export const GET: APIRoute = async ({ request, locals, url }) => {
65
66
  const includeDrafts = url.searchParams.get("drafts") === "true";
66
67
  const snapshot = await generateSnapshot(emdash.db, {
67
68
  includeDrafts,
68
- origin: url.origin,
69
+ origin: getPublicOrigin(url, emdash.config),
69
70
  });
70
71
 
71
72
  return apiSuccess(snapshot);
@@ -11,6 +11,7 @@ import type { APIRoute } from "astro";
11
11
 
12
12
  import { requirePerm } from "#api/authorize.js";
13
13
  import { apiError, apiSuccess } from "#api/error.js";
14
+ import { getPublicOrigin } from "#api/public-url.js";
14
15
 
15
16
  export const prerender = false;
16
17
 
@@ -52,7 +53,7 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
52
53
  return apiError("INVALID_REQUEST", "previewUrl must use HTTPS", 400);
53
54
  }
54
55
 
55
- const source = url.origin;
56
+ const source = getPublicOrigin(url, emdash?.config);
56
57
  const ttl = 3600; // 1 hour
57
58
  const exp = Math.floor(Date.now() / 1000) + ttl;
58
59
 
@@ -45,6 +45,7 @@ export const GET: APIRoute = async ({ locals }) => {
45
45
  methods: {
46
46
  device_flow: !isExternal
47
47
  ? {
48
+ client_id: "emdash-cli",
48
49
  device_authorization_endpoint: "/_emdash/api/oauth/device/code",
49
50
  token_endpoint: "/_emdash/api/oauth/device/token",
50
51
  }
@@ -9,12 +9,13 @@
9
9
 
10
10
  import type { APIRoute } from "astro";
11
11
 
12
+ import { getPublicOrigin } from "#api/public-url.js";
12
13
  import { VALID_SCOPES } from "#auth/api-tokens.js";
13
14
 
14
15
  export const prerender = false;
15
16
 
16
- export const GET: APIRoute = async ({ url }) => {
17
- const origin = url.origin;
17
+ export const GET: APIRoute = async ({ url, locals }) => {
18
+ const origin = getPublicOrigin(url, locals.emdash?.config);
18
19
  const issuer = `${origin}/_emdash`;
19
20
 
20
21
  return Response.json(
@@ -13,12 +13,13 @@
13
13
 
14
14
  import type { APIRoute } from "astro";
15
15
 
16
+ import { getPublicOrigin } from "#api/public-url.js";
16
17
  import { VALID_SCOPES } from "#auth/api-tokens.js";
17
18
 
18
19
  export const prerender = false;
19
20
 
20
- export const GET: APIRoute = async ({ url }) => {
21
- const origin = url.origin;
21
+ export const GET: APIRoute = async ({ url, locals }) => {
22
+ const origin = getPublicOrigin(url, locals.emdash?.config);
22
23
 
23
24
  return Response.json(
24
25
  {
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { APIRoute } from "astro";
12
12
 
13
+ import { getPublicOrigin } from "#api/public-url.js";
13
14
  import { getSiteSettingsWithDb } from "#settings/index.js";
14
15
 
15
16
  export const prerender = false;
@@ -29,7 +30,10 @@ export const GET: APIRoute = async ({ locals, url }) => {
29
30
 
30
31
  try {
31
32
  const settings = await getSiteSettingsWithDb(emdash.db);
32
- const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, "");
33
+ const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace(
34
+ TRAILING_SLASH_RE,
35
+ "",
36
+ );
33
37
  const sitemapUrl = `${siteUrl}/sitemap.xml`;
34
38
 
35
39
  // Use custom robots.txt if configured
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Per-collection sitemap endpoint
3
+ *
4
+ * GET /sitemap-{collection}.xml - Sitemap for a single content collection.
5
+ *
6
+ * Uses the collection's url_pattern to build URLs. Falls back to
7
+ * /{collection}/{slug} when no pattern is configured.
8
+ */
9
+
10
+ import type { APIRoute } from "astro";
11
+
12
+ import { handleSitemapData } from "#api/handlers/seo.js";
13
+ import { getSiteSettingsWithDb } from "#settings/index.js";
14
+
15
+ export const prerender = false;
16
+
17
+ const TRAILING_SLASH_RE = /\/$/;
18
+ const AMP_RE = /&/g;
19
+ const LT_RE = /</g;
20
+ const GT_RE = />/g;
21
+ const QUOT_RE = /"/g;
22
+ const APOS_RE = /'/g;
23
+ const SLUG_PLACEHOLDER = "{slug}";
24
+ const ID_PLACEHOLDER = "{id}";
25
+
26
+ export const GET: APIRoute = async ({ params, locals, url }) => {
27
+ const { emdash } = locals;
28
+ const collectionSlug = params.collection;
29
+
30
+ if (!emdash?.db || !collectionSlug) {
31
+ return new Response("<!-- EmDash not configured -->", {
32
+ status: 500,
33
+ headers: { "Content-Type": "application/xml" },
34
+ });
35
+ }
36
+
37
+ try {
38
+ const settings = await getSiteSettingsWithDb(emdash.db);
39
+ const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, "");
40
+
41
+ const result = await handleSitemapData(emdash.db, collectionSlug);
42
+
43
+ if (!result.success || !result.data) {
44
+ return new Response("<!-- Failed to generate sitemap -->", {
45
+ status: 500,
46
+ headers: { "Content-Type": "application/xml" },
47
+ });
48
+ }
49
+
50
+ const col = result.data.collections[0];
51
+ if (!col) {
52
+ return new Response("<!-- Collection not found or empty -->", {
53
+ status: 404,
54
+ headers: { "Content-Type": "application/xml" },
55
+ });
56
+ }
57
+
58
+ const lines: string[] = [
59
+ '<?xml version="1.0" encoding="UTF-8"?>',
60
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
61
+ ];
62
+
63
+ for (const entry of col.entries) {
64
+ const slug = entry.slug || entry.id;
65
+ const path = col.urlPattern
66
+ ? col.urlPattern
67
+ .replace(SLUG_PLACEHOLDER, encodeURIComponent(slug))
68
+ .replace(ID_PLACEHOLDER, encodeURIComponent(entry.id))
69
+ : `/${encodeURIComponent(col.collection)}/${encodeURIComponent(slug)}`;
70
+
71
+ const loc = `${siteUrl}${path}`;
72
+
73
+ lines.push(" <url>");
74
+ lines.push(` <loc>${escapeXml(loc)}</loc>`);
75
+ lines.push(` <lastmod>${escapeXml(entry.updatedAt)}</lastmod>`);
76
+ lines.push(" </url>");
77
+ }
78
+
79
+ lines.push("</urlset>");
80
+
81
+ return new Response(lines.join("\n"), {
82
+ status: 200,
83
+ headers: {
84
+ "Content-Type": "application/xml; charset=utf-8",
85
+ "Cache-Control": "public, max-age=3600",
86
+ },
87
+ });
88
+ } catch {
89
+ return new Response("<!-- Internal error generating sitemap -->", {
90
+ status: 500,
91
+ headers: { "Content-Type": "application/xml" },
92
+ });
93
+ }
94
+ };
95
+
96
+ /** Escape special XML characters in a string */
97
+ function escapeXml(str: string): string {
98
+ return str
99
+ .replace(AMP_RE, "&amp;")
100
+ .replace(LT_RE, "&lt;")
101
+ .replace(GT_RE, "&gt;")
102
+ .replace(QUOT_RE, "&quot;")
103
+ .replace(APOS_RE, "&apos;");
104
+ }
@@ -1,18 +1,17 @@
1
1
  /**
2
- * Sitemap XML endpoint
2
+ * Sitemap index endpoint
3
3
  *
4
- * GET /sitemap.xml - Auto-generated sitemap from published content
4
+ * GET /sitemap.xml - Sitemap index listing one sitemap per collection.
5
5
  *
6
- * Includes all published, non-noindex content across all collections.
7
- * The site URL is read from site settings or the request URL origin.
8
- *
9
- * Default URL pattern: /{collection}/{slug-or-id}. Users can override
10
- * by creating their own /sitemap.xml route in their Astro project.
6
+ * Each collection with published, indexable content gets its own
7
+ * child sitemap at /sitemap-{collection}.xml. The index includes
8
+ * a <lastmod> per child derived from the most recently updated entry.
11
9
  */
12
10
 
13
11
  import type { APIRoute } from "astro";
14
12
 
15
13
  import { handleSitemapData } from "#api/handlers/seo.js";
14
+ import { getPublicOrigin } from "#api/public-url.js";
16
15
  import { getSiteSettingsWithDb } from "#settings/index.js";
17
16
 
18
17
  export const prerender = false;
@@ -35,9 +34,11 @@ export const GET: APIRoute = async ({ locals, url }) => {
35
34
  }
36
35
 
37
36
  try {
38
- // Determine site URL from settings or request origin
39
37
  const settings = await getSiteSettingsWithDb(emdash.db);
40
- const siteUrl = (settings.url || url.origin).replace(TRAILING_SLASH_RE, "");
38
+ const siteUrl = (settings.url || getPublicOrigin(url, emdash?.config)).replace(
39
+ TRAILING_SLASH_RE,
40
+ "",
41
+ );
41
42
 
42
43
  const result = await handleSitemapData(emdash.db);
43
44
 
@@ -48,28 +49,22 @@ export const GET: APIRoute = async ({ locals, url }) => {
48
49
  });
49
50
  }
50
51
 
51
- const entries = result.data.entries;
52
+ const { collections } = result.data;
52
53
 
53
- // Build XML
54
54
  const lines: string[] = [
55
55
  '<?xml version="1.0" encoding="UTF-8"?>',
56
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
56
+ '<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
57
57
  ];
58
58
 
59
- for (const entry of entries) {
60
- // Default URL pattern: /{collection}/{identifier}
61
- // Encode path segments to handle slugs with spaces/unicode/reserved chars
62
- const loc = `${siteUrl}/${encodeURIComponent(entry.collection)}/${encodeURIComponent(entry.identifier)}`;
63
-
64
- lines.push(" <url>");
59
+ for (const col of collections) {
60
+ const loc = `${siteUrl}/sitemap-${encodeURIComponent(col.collection)}.xml`;
61
+ lines.push(" <sitemap>");
65
62
  lines.push(` <loc>${escapeXml(loc)}</loc>`);
66
- lines.push(` <lastmod>${escapeXml(entry.updatedAt)}</lastmod>`);
67
- lines.push(" <changefreq>weekly</changefreq>");
68
- lines.push(" <priority>0.7</priority>");
69
- lines.push(" </url>");
63
+ lines.push(` <lastmod>${escapeXml(col.lastmod)}</lastmod>`);
64
+ lines.push(" </sitemap>");
70
65
  }
71
66
 
72
- lines.push("</urlset>");
67
+ lines.push("</sitemapindex>");
73
68
 
74
69
  return new Response(lines.join("\n"), {
75
70
  status: 200,