emdash 0.6.0 → 0.7.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 (97) hide show
  1. package/dist/{apply-B4MsLM-w.mjs → apply-5uslYdUu.mjs} +174 -17
  2. package/dist/apply-5uslYdUu.mjs.map +1 -0
  3. package/dist/astro/index.d.mts +4 -4
  4. package/dist/astro/index.mjs +7 -3
  5. package/dist/astro/index.mjs.map +1 -1
  6. package/dist/astro/middleware/auth.d.mts +4 -4
  7. package/dist/astro/middleware/redirect.mjs +1 -1
  8. package/dist/astro/middleware/request-context.mjs +6 -1
  9. package/dist/astro/middleware/request-context.mjs.map +1 -1
  10. package/dist/astro/middleware.mjs +13 -12
  11. package/dist/astro/middleware.mjs.map +1 -1
  12. package/dist/astro/types.d.mts +13 -4
  13. package/dist/astro/types.d.mts.map +1 -1
  14. package/dist/cli/index.mjs +4 -4
  15. package/dist/{content-BsBoyj8G.mjs → content-D7J5y73J.mjs} +27 -1
  16. package/dist/{content-BsBoyj8G.mjs.map → content-D7J5y73J.mjs.map} +1 -1
  17. package/dist/db/index.d.mts +2 -2
  18. package/dist/db/index.mjs +1 -1
  19. package/dist/{index-BYv0mB9g.d.mts → index-De6_Xv3v.d.mts} +77 -3
  20. package/dist/index-De6_Xv3v.d.mts.map +1 -0
  21. package/dist/index.d.mts +4 -4
  22. package/dist/index.mjs +7 -7
  23. package/dist/media/local-runtime.d.mts +4 -4
  24. package/dist/plugins/adapt-sandbox-entry.d.mts +4 -4
  25. package/dist/{query-Bk_3vKvU.mjs → query-g4Ug-9j9.mjs} +3 -3
  26. package/dist/{query-Bk_3vKvU.mjs.map → query-g4Ug-9j9.mjs.map} +1 -1
  27. package/dist/{redirect-7lGhLBNZ.mjs → redirect-CN0Rt9Ob.mjs} +66 -10
  28. package/dist/redirect-CN0Rt9Ob.mjs.map +1 -0
  29. package/dist/{runner-Fl2NcUUz.d.mts → runner-BR2xKwhn.d.mts} +2 -2
  30. package/dist/{runner-Fl2NcUUz.d.mts.map → runner-BR2xKwhn.d.mts.map} +1 -1
  31. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  32. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  33. package/dist/runtime.d.mts +4 -4
  34. package/dist/{search-DI4bM2w9.mjs → search-B0effn3j.mjs} +117 -23
  35. package/dist/search-B0effn3j.mjs.map +1 -0
  36. package/dist/seed/index.d.mts +2 -2
  37. package/dist/seed/index.mjs +3 -3
  38. package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-K2z0Uhnj.mjs} +2 -2
  39. package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-K2z0Uhnj.mjs.map} +1 -1
  40. package/dist/{types-8xrvl_68.d.mts → types-C2v0c34j.d.mts} +10 -1
  41. package/dist/{types-8xrvl_68.d.mts.map → types-C2v0c34j.d.mts.map} +1 -1
  42. package/dist/{validate-CaLH1Ia2.d.mts → validate-kM8Pjuf7.d.mts} +2 -2
  43. package/dist/{validate-CaLH1Ia2.d.mts.map → validate-kM8Pjuf7.d.mts.map} +1 -1
  44. package/dist/version-BnTKdfam.mjs +7 -0
  45. package/dist/{version-Uaf2ynPX.mjs.map → version-BnTKdfam.mjs.map} +1 -1
  46. package/package.json +5 -5
  47. package/src/api/handlers/content.ts +2 -0
  48. package/src/api/schemas/content.ts +8 -0
  49. package/src/astro/integration/font-provider.ts +3 -1
  50. package/src/astro/integration/index.ts +2 -0
  51. package/src/astro/integration/runtime.ts +55 -1
  52. package/src/astro/routes/admin.astro +14 -7
  53. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  54. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  55. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  56. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  57. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  58. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  59. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  60. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  61. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  62. package/src/astro/routes/api/content/[collection]/index.ts +19 -1
  63. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  64. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  65. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
  66. package/src/astro/routes/api/manifest.ts +7 -0
  67. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  68. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  69. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  70. package/src/astro/routes/api/setup/admin.ts +32 -8
  71. package/src/astro/routes/api/setup/index.ts +5 -2
  72. package/src/astro/types.ts +9 -0
  73. package/src/auth/rate-limit.ts +50 -22
  74. package/src/auth/setup-nonce.ts +22 -0
  75. package/src/auth/trusted-proxy.ts +92 -0
  76. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  77. package/src/database/migrations/runner.ts +2 -0
  78. package/src/database/repositories/content.ts +39 -0
  79. package/src/database/repositories/options.ts +25 -0
  80. package/src/database/repositories/redirect.ts +111 -8
  81. package/src/database/types.ts +9 -0
  82. package/src/emdash-runtime.ts +3 -1
  83. package/src/import/registry.ts +4 -3
  84. package/src/import/ssrf.ts +253 -12
  85. package/src/mcp/server.ts +76 -3
  86. package/src/plugins/context.ts +15 -3
  87. package/src/plugins/manager.ts +6 -0
  88. package/src/plugins/request-meta.ts +66 -15
  89. package/src/plugins/routes.ts +3 -1
  90. package/src/seed/apply.ts +26 -0
  91. package/src/visual-editing/toolbar.ts +6 -1
  92. package/dist/apply-B4MsLM-w.mjs.map +0 -1
  93. package/dist/index-BYv0mB9g.d.mts.map +0 -1
  94. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  95. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  96. package/dist/search-DI4bM2w9.mjs.map +0 -1
  97. package/dist/version-Uaf2ynPX.mjs +0 -7
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -30,7 +30,7 @@ const DURATION_PATTERN = /^(\d+)([smhdw])$/;
30
30
 
31
31
  export const POST: APIRoute = async ({ params, request, locals }) => {
32
32
  const { emdash, user } = locals;
33
- const denied = requirePerm(user, "content:read");
33
+ const denied = requirePerm(user, "content:read_drafts");
34
34
  if (denied) return denied;
35
35
  const collection = params.collection!;
36
36
  const id = params.id!;
@@ -13,7 +13,7 @@ export const prerender = false;
13
13
 
14
14
  export const GET: APIRoute = async ({ params, url, locals }) => {
15
15
  const { emdash, user } = locals;
16
- const denied = requirePerm(user, "content:read");
16
+ const denied = requirePerm(user, "content:read_drafts");
17
17
  if (denied) return denied;
18
18
  const collection = params.collection!;
19
19
  const id = params.id!;
@@ -6,6 +6,7 @@
6
6
  * Returns all locale variants linked to the same translation group.
7
7
  */
8
8
 
9
+ import { hasPermission, type Permission } from "@emdash-cms/auth";
9
10
  import type { APIRoute } from "astro";
10
11
 
11
12
  import { requirePerm } from "#api/authorize.js";
@@ -13,6 +14,15 @@ import { apiError, unwrapResult } from "#api/error.js";
13
14
 
14
15
  export const prerender = false;
15
16
 
17
+ function isPublished(t: unknown): boolean {
18
+ return (
19
+ typeof t === "object" &&
20
+ t !== null &&
21
+ "status" in t &&
22
+ (t as Record<string, unknown>).status === "published"
23
+ );
24
+ }
25
+
16
26
  export const GET: APIRoute = async ({ params, locals }) => {
17
27
  const { emdash, user } = locals;
18
28
  const denied = requirePerm(user, "content:read");
@@ -26,5 +36,21 @@ export const GET: APIRoute = async ({ params, locals }) => {
26
36
 
27
37
  const result = await emdash.handleContentTranslations(collection, id);
28
38
 
39
+ // Filter out non-published translations for users without read_drafts so a
40
+ // subscriber can't enumerate locales that aren't yet live.
41
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
42
+ const data =
43
+ result.data && typeof result.data === "object"
44
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
45
+ (result.data as Record<string, unknown>)
46
+ : undefined;
47
+ const translations = Array.isArray(data?.translations) ? data.translations : [];
48
+ const filtered = translations.filter(isPublished);
49
+ return unwrapResult({
50
+ success: true,
51
+ data: { ...data, translations: filtered },
52
+ });
53
+ }
54
+
29
55
  return unwrapResult(result);
30
56
  };
@@ -6,7 +6,7 @@
6
6
  * DELETE /_emdash/api/content/{collection}/{id} - Delete content
7
7
  */
8
8
 
9
- import { hasPermission, type Permission } from "@emdash-cms/auth";
9
+ import { hasPermission } from "@emdash-cms/auth";
10
10
  import type { APIRoute } from "astro";
11
11
 
12
12
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
@@ -30,6 +30,25 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
30
30
 
31
31
  const result = await emdash.handleContentGet(collection, id, locale);
32
32
 
33
+ // Hide non-published items from users without content:read_drafts. Return
34
+ // 404 (not 403) so subscribers can't enumerate draft IDs by status code.
35
+ if (result.success && !hasPermission(user, "content:read_drafts")) {
36
+ const data =
37
+ result.data && typeof result.data === "object"
38
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- handler returns unknown data; narrowed by typeof check
39
+ (result.data as Record<string, unknown>)
40
+ : undefined;
41
+ const item =
42
+ data?.item && typeof data.item === "object"
43
+ ? // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- narrowed by typeof check
44
+ (data.item as Record<string, unknown>)
45
+ : undefined;
46
+ const status = typeof item?.status === "string" ? item.status : null;
47
+ if (status !== "published") {
48
+ return apiError("NOT_FOUND", `Content item not found: ${id}`, 404);
49
+ }
50
+ }
51
+
33
52
  return unwrapResult(result);
34
53
  };
35
54
 
@@ -69,12 +88,21 @@ export const PUT: APIRoute = async ({ params, request, locals, cache }) => {
69
88
  const editDenied = requireOwnerPerm(user, authorId, "content:edit_own", "content:edit_any");
70
89
  if (editDenied) return editDenied;
71
90
 
91
+ // Only EDITOR+ can write publishedAt directly — incl. clearing to null.
92
+ if (body.publishedAt !== undefined && !hasPermission(user, "content:publish_any")) {
93
+ return apiError(
94
+ "FORBIDDEN",
95
+ "Writing publishedAt requires content:publish_any permission",
96
+ 403,
97
+ );
98
+ }
99
+
72
100
  // Use the resolved ID (handles slug → ID resolution)
73
101
  const resolvedId = typeof existingItem?.id === "string" ? existingItem.id : id;
74
102
 
75
103
  // Only allow authorId changes if user has content:edit_any permission (editor+)
76
104
  const canChangeAuthor =
77
- body.authorId !== undefined && user && hasPermission(user, "content:edit_any" as Permission);
105
+ body.authorId !== undefined && user && hasPermission(user, "content:edit_any");
78
106
  const updateBody = canChangeAuthor ? body : { ...body, authorId: undefined };
79
107
 
80
108
  // Pass _rev through for optimistic concurrency validation
@@ -5,6 +5,7 @@
5
5
  * POST /_emdash/api/content/{collection} - Create content
6
6
  */
7
7
 
8
+ import { hasPermission } from "@emdash-cms/auth";
8
9
  import type { APIRoute } from "astro";
9
10
 
10
11
  import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
@@ -26,7 +27,14 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
26
27
  return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
27
28
  }
28
29
 
29
- const result = await emdash.handleContentList(collection, query);
30
+ // Subscribers must only see published content; force the status filter
31
+ // regardless of caller-supplied value. Any user with content:read_drafts
32
+ // (CONTRIBUTOR+) keeps the requested filter.
33
+ const params_ = hasPermission(user, "content:read_drafts")
34
+ ? query
35
+ : { ...query, status: "published" };
36
+
37
+ const result = await emdash.handleContentList(collection, params_);
30
38
 
31
39
  return unwrapResult(result);
32
40
  };
@@ -71,6 +79,16 @@ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
71
79
  if (translationDenied) return translationDenied;
72
80
  }
73
81
 
82
+ // Only EDITOR+ can write publishedAt / createdAt directly — incl. clearing to null.
83
+ const hasDateOverride = body.publishedAt !== undefined || body.createdAt !== undefined;
84
+ if (hasDateOverride && !hasPermission(user, "content:publish_any")) {
85
+ return apiError(
86
+ "FORBIDDEN",
87
+ "Writing publishedAt or createdAt requires content:publish_any permission",
88
+ 403,
89
+ );
90
+ }
91
+
74
92
  // Auto-set authorId to current user when creating content
75
93
  const result = await emdash.handleContentCreate(collection, {
76
94
  ...body,
@@ -17,7 +17,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
17
17
  const { emdash, user } = locals;
18
18
  const collection = params.collection!;
19
19
 
20
- const denied = requirePerm(user, "content:read");
20
+ const denied = requirePerm(user, "content:read_drafts");
21
21
  if (denied) return denied;
22
22
 
23
23
  if (!emdash?.handleContentListTrashed) {
@@ -15,7 +15,7 @@ import { apiError, apiSuccess, handleError } from "#api/error.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { wpPluginAnalyzeBody } from "#api/schemas.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportAnalysis } from "#import/types.js";
20
20
  import type { EmDashHandlers } from "#types";
21
21
 
@@ -37,9 +37,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
37
  const body = await parseBody(request, wpPluginAnalyzeBody);
38
38
  if (isParseError(body)) return body;
39
39
 
40
- // SSRF: reject internal/private network targets
40
+ // SSRF: reject internal/private network targets. Uses DNS resolution
41
+ // to catch hostnames that resolve to private addresses.
41
42
  try {
42
- validateExternalUrl(body.url);
43
+ await resolveAndValidateExternalUrl(body.url);
43
44
  } catch (e) {
44
45
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
45
46
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -15,7 +15,7 @@ import { isParseError, parseBody } from "#api/parse.js";
15
15
  import { wpPluginExecuteBody } from "#api/schemas.js";
16
16
  import { BylineRepository } from "#db/repositories/byline.js";
17
17
  import { getSource } from "#import/index.js";
18
- import { validateExternalUrl, SsrfError } from "#import/ssrf.js";
18
+ import { resolveAndValidateExternalUrl, SsrfError } from "#import/ssrf.js";
19
19
  import type { ImportConfig, ImportResult, NormalizedItem } from "#import/types.js";
20
20
  import { resolveImportByline } from "#import/utils.js";
21
21
  import type { FieldType } from "#schema/types.js";
@@ -49,9 +49,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
49
49
  const body = await parseBody(request, wpPluginExecuteBody);
50
50
  if (isParseError(body)) return body;
51
51
 
52
- // SSRF: reject internal/private network targets
52
+ // SSRF: reject internal/private network targets. Uses DNS resolution
53
+ // to catch hostnames that resolve to private addresses.
53
54
  try {
54
- validateExternalUrl(body.url);
55
+ await resolveAndValidateExternalUrl(body.url);
55
56
  } catch (e) {
56
57
  const msg = e instanceof SsrfError ? e.message : "Invalid URL";
57
58
  return apiError("SSRF_BLOCKED", msg, 400);
@@ -12,6 +12,7 @@ import type { APIRoute } from "astro";
12
12
  import { getAuthMode } from "#auth/mode.js";
13
13
 
14
14
  import { COMMIT, VERSION } from "../../../version.js";
15
+ import { getStoredConfig } from "../../integration/runtime.js";
15
16
  import type { EmDashManifest } from "../../types.js";
16
17
 
17
18
  export const prerender = false;
@@ -22,6 +23,10 @@ export const GET: APIRoute = async ({ locals }) => {
22
23
  // Determine auth mode from config
23
24
  const authMode = getAuthMode(emdash?.config);
24
25
 
26
+ // Read admin branding from build-time config
27
+ const storedConfig = getStoredConfig();
28
+ const adminBranding = storedConfig?.admin;
29
+
25
30
  // Check if self-signup is enabled (any allowed domain with enabled = 1)
26
31
  // Only relevant for passkey auth — external auth providers handle their own signup
27
32
  let signupEnabled = false;
@@ -42,6 +47,7 @@ export const GET: APIRoute = async ({ locals }) => {
42
47
  ...emdashManifest,
43
48
  authMode: authMode.type === "external" ? authMode.providerType : "passkey",
44
49
  signupEnabled,
50
+ admin: adminBranding,
45
51
  }
46
52
  : {
47
53
  version: VERSION,
@@ -52,6 +58,7 @@ export const GET: APIRoute = async ({ locals }) => {
52
58
  taxonomies: [],
53
59
  authMode: "passkey",
54
60
  signupEnabled,
61
+ admin: adminBranding,
55
62
  };
56
63
 
57
64
  return Response.json(
@@ -15,6 +15,7 @@ import { handleDeviceCodeRequest } from "#api/handlers/device-flow.js";
15
15
  import { isParseError, parseBody } from "#api/parse.js";
16
16
  import { getPublicOrigin } from "#api/public-url.js";
17
17
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
18
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
18
19
 
19
20
  export const prerender = false;
20
21
 
@@ -35,7 +36,7 @@ export const POST: APIRoute = async ({ request, locals, url }) => {
35
36
  if (isParseError(body)) return body;
36
37
 
37
38
  // Rate limit: 10 requests per 60 seconds per IP
38
- const ip = getClientIp(request);
39
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
39
40
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/code", 10, 60);
40
41
  if (!rateLimit.allowed) {
41
42
  return rateLimitResponse(60);
@@ -17,6 +17,7 @@ import { apiError, handleError, unwrapResult } from "#api/error.js";
17
17
  import { handleDeviceTokenExchange } from "#api/handlers/device-flow.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
19
  import { checkRateLimit, getClientIp, rateLimitResponse } from "#auth/rate-limit.js";
20
+ import { getTrustedProxyHeaders } from "#auth/trusted-proxy.js";
20
21
 
21
22
  export const prerender = false;
22
23
 
@@ -37,7 +38,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
37
38
  if (isParseError(body)) return body;
38
39
 
39
40
  // Rate limit: 12 requests per 60 seconds per IP
40
- const ip = getClientIp(request);
41
+ const ip = getClientIp(request, getTrustedProxyHeaders(emdash.config));
41
42
  const rateLimit = await checkRateLimit(emdash.db, ip, "device/token", 12, 60);
42
43
  if (!rateLimit.allowed) {
43
44
  return rateLimitResponse(60);
@@ -8,7 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  export const prerender = false;
10
10
 
11
- import { Role } from "@emdash-cms/auth";
11
+ import { Role, secureCompare } from "@emdash-cms/auth";
12
12
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
13
13
  import { verifyRegistrationResponse, registerPasskey } from "@emdash-cms/auth/passkey";
14
14
 
@@ -18,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
18
18
  import { setupAdminVerifyBody } from "#api/schemas.js";
19
19
  import { createChallengeStore } from "#auth/challenge-store.js";
20
20
  import { getPasskeyConfig } from "#auth/passkey-config.js";
21
+ import { SETUP_NONCE_COOKIE } from "#auth/setup-nonce.js";
21
22
  import { OptionsRepository } from "#db/repositories/options.js";
22
23
 
23
- export const POST: APIRoute = async ({ request, locals }) => {
24
+ export const POST: APIRoute = async ({ cookies, request, locals }) => {
24
25
  const { emdash } = locals;
25
26
 
26
27
  if (!emdash?.db) {
@@ -45,12 +46,35 @@ export const POST: APIRoute = async ({ request, locals }) => {
45
46
  }
46
47
 
47
48
  // Get setup state
48
- const setupState = await options.get("emdash:setup_state");
49
+ const setupState = await options.get<{
50
+ step?: string;
51
+ email?: string;
52
+ name?: string | null;
53
+ nonce?: string;
54
+ }>("emdash:setup_state");
49
55
 
50
56
  if (!setupState || setupState.step !== "admin") {
51
57
  return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
52
58
  }
53
59
 
60
+ // Verify the session nonce. The cookie was minted by POST /setup/admin
61
+ // and stored alongside setup_state; presenting a matching cookie is
62
+ // proof that this verify call comes from the same browser that
63
+ // started the admin step. Constant-time compare to avoid leaking the
64
+ // stored value through timing.
65
+ const cookieNonce = cookies.get(SETUP_NONCE_COOKIE)?.value;
66
+ if (!setupState.nonce || !cookieNonce || !secureCompare(cookieNonce, setupState.nonce)) {
67
+ return apiError(
68
+ "INVALID_STATE",
69
+ "Setup session expired or tampered with. Please restart the admin step.",
70
+ 400,
71
+ );
72
+ }
73
+
74
+ if (!setupState.email) {
75
+ return apiError("INVALID_STATE", "Invalid setup state. Please restart setup.", 400);
76
+ }
77
+
54
78
  // Parse request body
55
79
  const body = await parseBody(request, setupAdminVerifyBody);
56
80
  if (isParseError(body)) return body;
@@ -73,7 +97,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
73
97
  // Create the admin user
74
98
  const user = await adapter.createUser({
75
99
  email: setupState.email,
76
- name: setupState.name,
100
+ name: setupState.name ?? null,
77
101
  role: Role.ADMIN,
78
102
  emailVerified: false, // No email verification for first user
79
103
  });
@@ -84,8 +108,9 @@ export const POST: APIRoute = async ({ request, locals }) => {
84
108
  // Mark setup as complete
85
109
  await options.set("emdash:setup_complete", true);
86
110
 
87
- // Clean up setup state
111
+ // Clean up setup state and the session nonce cookie
88
112
  await options.delete("emdash:setup_state");
113
+ cookies.delete(SETUP_NONCE_COOKIE, { path: "/_emdash/" });
89
114
 
90
115
  return apiSuccess({
91
116
  success: true,
@@ -8,6 +8,7 @@ import type { APIRoute } from "astro";
8
8
 
9
9
  export const prerender = false;
10
10
 
11
+ import { generateToken } from "@emdash-cms/auth";
11
12
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
12
13
  import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
13
14
 
@@ -17,9 +18,10 @@ import { getPublicOrigin } from "#api/public-url.js";
17
18
  import { setupAdminBody } from "#api/schemas.js";
18
19
  import { createChallengeStore } from "#auth/challenge-store.js";
19
20
  import { getPasskeyConfig } from "#auth/passkey-config.js";
21
+ import { SETUP_NONCE_COOKIE, SETUP_NONCE_MAX_AGE_SECONDS } from "#auth/setup-nonce.js";
20
22
  import { OptionsRepository } from "#db/repositories/options.js";
21
23
 
22
- export const POST: APIRoute = async ({ request, locals }) => {
24
+ export const POST: APIRoute = async ({ cookies, request, locals }) => {
23
25
  const { emdash } = locals;
24
26
 
25
27
  if (!emdash?.db) {
@@ -47,12 +49,13 @@ export const POST: APIRoute = async ({ request, locals }) => {
47
49
  const body = await parseBody(request, setupAdminBody);
48
50
  if (isParseError(body)) return body;
49
51
 
50
- // Store admin info in setup state for later
51
- await options.set("emdash:setup_state", {
52
- step: "admin",
53
- email: body.email.toLowerCase(),
54
- name: body.name || null,
55
- });
52
+ // Mint a fresh session nonce. This binds the follow-up
53
+ // /setup/admin/verify call to the same browser that made this
54
+ // request, so an unauthenticated attacker on another host cannot
55
+ // substitute their own email into the setup state during the
56
+ // setup window. Rotates on every call so a legitimate retry
57
+ // always gets a working session.
58
+ const nonce = generateToken();
56
59
 
57
60
  // Get passkey config
58
61
  const url = new URL(request.url);
@@ -78,12 +81,33 @@ export const POST: APIRoute = async ({ request, locals }) => {
78
81
  challengeStore,
79
82
  );
80
83
 
81
- // Store the temp user ID with the setup state
84
+ // Store the nonce alongside the rest of the setup state. The verify
85
+ // endpoint will constant-time compare this with the incoming cookie.
82
86
  await options.set("emdash:setup_state", {
83
87
  step: "admin",
84
88
  email: body.email.toLowerCase(),
85
89
  name: body.name || null,
86
90
  tempUserId: tempUser.id,
91
+ nonce,
92
+ });
93
+
94
+ // HttpOnly + SameSite=Strict + path-scoped. The cookie must not be
95
+ // accessible to JS (nothing in the admin UI needs to read it) and
96
+ // must not be sent on cross-site navigations. The /_emdash/ path
97
+ // scope keeps it away from user-authored frontend code.
98
+ //
99
+ // Derive `secure` from the public origin, not the internal request
100
+ // URL. Behind a TLS-terminating reverse proxy the internal hop is
101
+ // often `http:` while the browser-facing origin is `https:` —
102
+ // using `url.protocol` there would drop the Secure flag on a
103
+ // sensitive cookie over the public HTTPS connection.
104
+ const publicOrigin = new URL(siteUrl);
105
+ cookies.set(SETUP_NONCE_COOKIE, nonce, {
106
+ path: "/_emdash/",
107
+ httpOnly: true,
108
+ sameSite: "strict",
109
+ secure: publicOrigin.protocol === "https:",
110
+ maxAge: SETUP_NONCE_MAX_AGE_SECONDS,
87
111
  });
88
112
 
89
113
  return apiSuccess({
@@ -89,9 +89,12 @@ export const POST: APIRoute = async ({ request, url, locals }) => {
89
89
  const options = new OptionsRepository(emdash.db);
90
90
 
91
91
  // Store the canonical site URL from the setup request.
92
- // This is trusted because setup runs on the real domain.
92
+ // Write-once at the DB level so concurrent setup POSTs can't both
93
+ // observe an empty value and race to write. A spoofed Host header
94
+ // on a later call during the wizard window must not be able to
95
+ // replace the first value.
93
96
  const siteUrl = getPublicOrigin(url, emdash.config);
94
- await options.set("emdash:site_url", siteUrl);
97
+ await options.setIfAbsent("emdash:site_url", siteUrl);
95
98
 
96
99
  if (useExternalAuth) {
97
100
  // External auth mode: mark setup complete now
@@ -142,6 +142,15 @@ export interface EmDashManifest {
142
142
  * When true, the admin UI can show marketplace browse/install features.
143
143
  */
144
144
  marketplace?: boolean;
145
+ /**
146
+ * Admin branding overrides for white-labeling.
147
+ * Set via the `admin` config in `astro.config.mjs`.
148
+ */
149
+ admin?: {
150
+ logo?: string;
151
+ siteName?: string;
152
+ favicon?: string;
153
+ };
145
154
  }
146
155
 
147
156
  /**
@@ -100,44 +100,72 @@ export function rateLimitResponse(retryAfterSeconds: number): Response {
100
100
  *
101
101
  * Resolution order:
102
102
  * 1. `CF-Connecting-IP` — trusted only when the Cloudflare `cf` object is
103
- * present (proving the request traversed Cloudflare's edge, which
104
- * strips/overwrites client-supplied values).
105
- * 2. `X-Forwarded-For` (first entry) — also trusted only on Cloudflare.
106
- * Without a trusted reverse proxy the header is trivially spoofable,
107
- * so we don't use it for standalone deployments.
108
- * 3. `null` no trusted IP available. Callers must handle this gracefully
103
+ * present. CF edge overwrites any client-supplied value, so this is the
104
+ * cryptographically trustworthy path on Workers. Operator-declared
105
+ * trusted headers cannot override it.
106
+ * 2. `X-Forwarded-For` (first entry) — trusted only when the `cf` object
107
+ * is present (CF sets this reliably).
108
+ * 3. Operator-declared trusted proxy headers (ordered list) used as a
109
+ * fallback for non-CF deployments behind a reverse proxy the operator
110
+ * controls. Also applies as a fill-in on CF when the CF headers are
111
+ * absent (e.g. internal cron handlers).
112
+ * 4. `null` — no trusted IP available. Callers must handle this gracefully
109
113
  * (e.g. skip rate limiting).
110
114
  *
115
+ * Pass `trustedHeaders` from `getTrustedProxyHeaders(emdash.config)` so
116
+ * self-hosted non-CF deployments can opt into reading a specific header.
117
+ *
111
118
  * Aligned with `extractRequestMeta` in `plugins/request-meta.ts`.
112
119
  */
113
- export function getClientIp(request: Request): string | null {
120
+ export function getClientIp(request: Request, trustedHeaders: string[] = []): string | null {
114
121
  const headers = request.headers;
115
122
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CF Workers runtime shape
116
123
  const cf = (request as unknown as { cf?: Record<string, unknown> }).cf;
117
124
 
118
- if (!cf) {
119
- // Not on Cloudflareno trusted source of client IP
120
- return null;
121
- }
125
+ // On Cloudflare, prefer the cryptographically trustworthy headers. An
126
+ // attacker can't spoof these the CF edge strips/overwrites them.
127
+ if (cf) {
128
+ const cfIp = headers.get("cf-connecting-ip")?.trim();
129
+ if (cfIp && IP_PATTERN.test(cfIp)) {
130
+ return cfIp;
131
+ }
122
132
 
123
- // Trust CF-Connecting-IP when the cf object confirms Cloudflare
124
- const cfIp = headers.get("cf-connecting-ip")?.trim();
125
- if (cfIp && IP_PATTERN.test(cfIp)) {
126
- return cfIp;
133
+ const xff = headers.get("x-forwarded-for");
134
+ if (xff) {
135
+ const first = xff.split(",")[0]?.trim();
136
+ if (first && IP_PATTERN.test(first)) {
137
+ return first;
138
+ }
139
+ }
127
140
  }
128
141
 
129
- // Fallback to XFF on Cloudflare (CF sets this reliably)
130
- const xff = headers.get("x-forwarded-for");
131
- if (xff) {
132
- const first = xff.split(",")[0]?.trim();
133
- if (first && IP_PATTERN.test(first)) {
134
- return first;
135
- }
142
+ // Fall through to operator-declared trusted headers. On CF this fills
143
+ // in when the CF headers are absent; off-CF it's the primary source.
144
+ for (const name of trustedHeaders) {
145
+ const value = readIpFromHeader(headers, name);
146
+ if (value) return value;
136
147
  }
137
148
 
138
149
  return null;
139
150
  }
140
151
 
152
+ /**
153
+ * Read an IP from an operator-declared trusted header. XFF-style headers
154
+ * are parsed as comma-separated lists and the first entry is used.
155
+ */
156
+ function readIpFromHeader(headers: Headers, name: string): string | null {
157
+ const value = headers.get(name);
158
+ if (!value) return null;
159
+ if (name.toLowerCase().endsWith("forwarded-for")) {
160
+ const first = value.split(",")[0]?.trim();
161
+ if (!first) return null;
162
+ return IP_PATTERN.test(first) ? first : null;
163
+ }
164
+ const trimmed = value.trim();
165
+ if (!trimmed) return null;
166
+ return IP_PATTERN.test(trimmed) ? trimmed : null;
167
+ }
168
+
141
169
  /**
142
170
  * Delete expired rate limit entries.
143
171
  *
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Session binding for the first-setup admin-creation flow.
3
+ *
4
+ * Shared constants for the nonce cookie that ties /_emdash/api/setup/admin
5
+ * and /_emdash/api/setup/admin/verify to the same browser. Without this
6
+ * binding, any unauthenticated caller could POST /setup/admin during the
7
+ * setup window and substitute their own email into the stored setup state
8
+ * before the legitimate admin completes passkey verification.
9
+ *
10
+ * Implementation lives in the two route handlers; this module is just
11
+ * the name / lifetime so both ends agree.
12
+ */
13
+
14
+ /** Cookie name carrying the setup-admin session nonce. */
15
+ export const SETUP_NONCE_COOKIE = "emdash_setup_nonce";
16
+
17
+ /**
18
+ * Cookie max-age in seconds. One hour is plenty of time to complete
19
+ * a passkey registration; if the user lingers longer the admin step
20
+ * can simply be retried.
21
+ */
22
+ export const SETUP_NONCE_MAX_AGE_SECONDS = 60 * 60;