emdash 0.5.0 → 0.6.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 (205) hide show
  1. package/dist/{adapters-C2BzVy0p.d.mts → adapters-Di31kZ28.d.mts} +16 -1
  2. package/dist/adapters-Di31kZ28.d.mts.map +1 -0
  3. package/dist/{apply-Cma_PiF6.mjs → apply-B4MsLM-w.mjs} +27 -12
  4. package/dist/apply-B4MsLM-w.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +6 -6
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +199 -33
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +5 -5
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +30 -4
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +1 -1
  14. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  15. package/dist/astro/middleware/request-context.mjs +5 -3
  16. package/dist/astro/middleware/request-context.mjs.map +1 -1
  17. package/dist/astro/middleware/setup.mjs +1 -1
  18. package/dist/astro/middleware.d.mts.map +1 -1
  19. package/dist/astro/middleware.mjs +460 -180
  20. package/dist/astro/middleware.mjs.map +1 -1
  21. package/dist/astro/types.d.mts +8 -9
  22. package/dist/astro/types.d.mts.map +1 -1
  23. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  24. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  25. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  26. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  27. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  28. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  29. package/dist/chunks-HGz06Soa.mjs +19 -0
  30. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  31. package/dist/cli/index.mjs +9 -8
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/client/cf-access.d.mts +1 -1
  34. package/dist/client/index.d.mts +1 -1
  35. package/dist/client/index.mjs +1 -1
  36. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  37. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  38. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  39. package/dist/connection-2igzM-AT.mjs.map +1 -0
  40. package/dist/database/instrumentation.d.mts +45 -0
  41. package/dist/database/instrumentation.d.mts.map +1 -0
  42. package/dist/database/instrumentation.mjs +61 -0
  43. package/dist/database/instrumentation.mjs.map +1 -0
  44. package/dist/db/index.d.mts +3 -3
  45. package/dist/db/index.mjs.map +1 -1
  46. package/dist/db/libsql.d.mts +1 -1
  47. package/dist/db/postgres.d.mts +1 -1
  48. package/dist/db/sqlite.d.mts +1 -1
  49. package/dist/db-errors-D0UT85nC.mjs +41 -0
  50. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  51. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  52. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  53. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  54. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  55. package/dist/{index-CCWzlriB.d.mts → index-BYv0mB9g.d.mts} +135 -19
  56. package/dist/index-BYv0mB9g.d.mts.map +1 -0
  57. package/dist/index.d.mts +11 -11
  58. package/dist/index.mjs +20 -18
  59. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  60. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  61. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  62. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  63. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  64. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  65. package/dist/media/index.d.mts +1 -1
  66. package/dist/media/index.mjs +1 -1
  67. package/dist/media/local-runtime.d.mts +7 -7
  68. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  69. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  70. package/dist/page/index.d.mts +11 -2
  71. package/dist/page/index.d.mts.map +1 -1
  72. package/dist/page/index.mjs +23 -1
  73. package/dist/page/index.mjs.map +1 -1
  74. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  75. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  76. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  77. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  78. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  79. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  80. package/dist/{query-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
  81. package/dist/query-Bk_3vKvU.mjs.map +1 -0
  82. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  83. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  84. package/dist/request-cache-DiR961CV.mjs +79 -0
  85. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  86. package/dist/request-context.d.mts +19 -16
  87. package/dist/request-context.d.mts.map +1 -1
  88. package/dist/request-context.mjs.map +1 -1
  89. package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
  90. package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
  91. package/dist/runtime.d.mts +6 -6
  92. package/dist/runtime.mjs +1 -1
  93. package/dist/{search-Cn1SYvYF.mjs → search-DI4bM2w9.mjs} +96 -206
  94. package/dist/search-DI4bM2w9.mjs.map +1 -0
  95. package/dist/seed/index.d.mts +2 -2
  96. package/dist/seed/index.mjs +8 -7
  97. package/dist/seo/index.d.mts +1 -1
  98. package/dist/storage/local.d.mts +1 -1
  99. package/dist/storage/local.mjs +1 -1
  100. package/dist/storage/s3.d.mts +1 -1
  101. package/dist/storage/s3.mjs +1 -1
  102. package/dist/taxonomies-DbrKzDju.mjs +308 -0
  103. package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
  104. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  105. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  106. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  107. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  108. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  109. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  110. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  111. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  112. package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
  113. package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
  114. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  115. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  116. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  117. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  118. package/dist/{types-DeG21anB.d.mts → types-CnZYHyLW.d.mts} +55 -5
  119. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  120. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  121. package/dist/types-DDS4MxsT.mjs.map +1 -0
  122. package/dist/{types-C3ronwXb.d.mts → types-DgrIP0tF.d.mts} +102 -4
  123. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  124. package/dist/{validate-Db1yNL3i.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
  125. package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
  126. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  127. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  128. package/dist/version-Uaf2ynPX.mjs +7 -0
  129. package/dist/{version-CMMjTuqu.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
  130. package/package.json +10 -5
  131. package/src/after.ts +62 -0
  132. package/src/api/handlers/oauth-authorization.ts +2 -32
  133. package/src/api/handlers/oauth-clients.ts +40 -4
  134. package/src/api/handlers/taxonomies.ts +13 -0
  135. package/src/api/oauth/redirect-uri.ts +34 -0
  136. package/src/api/openapi/document.ts +126 -118
  137. package/src/api/schemas/media.ts +26 -15
  138. package/src/api/schemas/schema.ts +1 -0
  139. package/src/astro/integration/font-provider.ts +176 -0
  140. package/src/astro/integration/index.ts +42 -0
  141. package/src/astro/integration/routes.ts +6 -0
  142. package/src/astro/integration/runtime.ts +63 -0
  143. package/src/astro/integration/virtual-modules.ts +41 -39
  144. package/src/astro/integration/vite-config.ts +16 -5
  145. package/src/astro/middleware/auth.ts +33 -1
  146. package/src/astro/middleware/request-context.ts +15 -3
  147. package/src/astro/middleware.ts +340 -263
  148. package/src/astro/routes/admin.astro +7 -3
  149. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  150. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  151. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  152. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
  153. package/src/astro/routes/api/media/upload-url.ts +10 -2
  154. package/src/astro/routes/api/media.ts +10 -7
  155. package/src/astro/routes/api/oauth/register.ts +178 -0
  156. package/src/astro/routes/api/oauth/token.ts +15 -0
  157. package/src/astro/routes/api/openapi.json.ts +15 -5
  158. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  159. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  160. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  161. package/src/astro/routes/api/search/index.ts +5 -0
  162. package/src/astro/routes/api/search/suggest.ts +3 -0
  163. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  164. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +1 -1
  165. package/src/bylines/index.ts +22 -45
  166. package/src/components/EmDashHead.astro +23 -7
  167. package/src/database/connection.ts +23 -1
  168. package/src/database/instrumentation.ts +98 -0
  169. package/src/db/adapters.ts +15 -0
  170. package/src/emdash-runtime.ts +309 -91
  171. package/src/index.ts +6 -0
  172. package/src/loader.ts +19 -24
  173. package/src/menus/index.ts +6 -3
  174. package/src/page/index.ts +1 -1
  175. package/src/page/seo-contributions.ts +36 -0
  176. package/src/query.ts +104 -7
  177. package/src/request-cache.ts +106 -0
  178. package/src/request-context.ts +19 -0
  179. package/src/schema/query.ts +5 -2
  180. package/src/schema/registry.ts +243 -166
  181. package/src/schema/types.ts +13 -2
  182. package/src/schema/zod-generator.ts +4 -0
  183. package/src/search/fts-manager.ts +19 -5
  184. package/src/search/query.ts +4 -3
  185. package/src/seed/apply.ts +15 -1
  186. package/src/settings/index.ts +24 -5
  187. package/src/taxonomies/index.ts +324 -124
  188. package/src/utils/db-errors.ts +46 -0
  189. package/src/virtual-modules.d.ts +31 -10
  190. package/src/widgets/index.ts +54 -25
  191. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  192. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  193. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  194. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  195. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  196. package/dist/index-CCWzlriB.d.mts.map +0 -1
  197. package/dist/loader-BYzwzORf.mjs.map +0 -1
  198. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  199. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  200. package/dist/search-Cn1SYvYF.mjs.map +0 -1
  201. package/dist/types-C3ronwXb.d.mts.map +0 -1
  202. package/dist/types-DeG21anB.d.mts.map +0 -1
  203. package/dist/types-xxCWI3j0.mjs.map +0 -1
  204. package/dist/validate-Db1yNL3i.d.mts.map +0 -1
  205. package/dist/version-CMMjTuqu.mjs +0 -7
@@ -9,6 +9,7 @@ import "@emdash-cms/admin/styles.css";
9
9
  // Use package-qualified import so Astro generates a proper module URL
10
10
  // (relative imports resolve to absolute paths which break client hydration)
11
11
  import AdminWrapper from "emdash/routes/PluginRegistry";
12
+ import { Font } from "astro:assets";
12
13
 
13
14
  export const prerender = false;
14
15
 
@@ -24,6 +25,7 @@ const messages = await loadMessages(resolvedLocale);
24
25
  <head>
25
26
  <meta charset="UTF-8" />
26
27
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
+ <Font cssVariable="--font-emdash" />
27
29
  <link
28
30
  rel="icon"
29
31
  href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'> <g clip-path='url(%23clip0_50_99)'> <rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23paint0_linear_50_99)' stroke-width='6'/> <rect x='18' y='34' width='39.3661' height='6.56101' fill='url(%23paint1_linear_50_99)'/> </g> <defs> <linearGradient id='paint0_linear_50_99' x1='-42.9996' y1='124' x2='92.4233' y2='-41.7456' gradientUnits='userSpaceOnUse'> <stop stop-color='%230F006B'/> <stop offset='0.0833333' stop-color='%23281A81'/> <stop offset='0.166667' stop-color='%235D0C83'/> <stop offset='0.25' stop-color='%23911475'/> <stop offset='0.333333' stop-color='%23CE2F55'/> <stop offset='0.416667' stop-color='%23FF6633'/> <stop offset='0.5' stop-color='%23F6821F'/> <stop offset='0.583333' stop-color='%23FBAD41'/> <stop offset='0.666667' stop-color='%23FFCD89'/> <stop offset='0.75' stop-color='%23FFE9CB'/> <stop offset='0.833333' stop-color='%23FFF7EC'/> <stop offset='0.916667' stop-color='%23FFF8EE'/> <stop offset='1' stop-color='white'/> </linearGradient> <linearGradient id='paint1_linear_50_99' x1='91.4992' y1='27.4982' x2='28.1217' y2='54.1775' gradientUnits='userSpaceOnUse'> <stop stop-color='white'/> <stop offset='0.129253' stop-color='%23FFF8EE'/> <stop offset='0.617058' stop-color='%23FBAD41'/> <stop offset='0.848019' stop-color='%23F6821F'/> <stop offset='1' stop-color='%23FF6633'/> </linearGradient> <clipPath id='clip0_50_99'> <rect width='75' height='75' fill='white'/> </clipPath> </defs> </svg>"
@@ -63,10 +65,12 @@ const messages = await loadMessages(resolvedLocale);
63
65
  }
64
66
  #emdash-boot-loader p {
65
67
  margin-top: 1rem;
66
- font-family:
68
+ font-family: var(
69
+ --font-emdash,
70
+ ui-sans-serif,
67
71
  system-ui,
68
- -apple-system,
69
- sans-serif;
72
+ sans-serif
73
+ );
70
74
  font-size: 0.875rem;
71
75
  color: light-dark(hsl(215.4 16.3% 46.9%), hsl(215 20.2% 65.1%));
72
76
  }
@@ -9,7 +9,7 @@ import type { APIRoute } from "astro";
9
9
  export const prerender = false;
10
10
 
11
11
  import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
12
- import { authenticateWithPasskey } from "@emdash-cms/auth/passkey";
12
+ import { authenticateWithPasskey, PasskeyAuthenticationError } 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";
@@ -63,6 +63,10 @@ export const POST: APIRoute = async ({ request, locals, session }) => {
63
63
  },
64
64
  });
65
65
  } catch (error) {
66
+ if (error instanceof PasskeyAuthenticationError) {
67
+ return apiError("UNAUTHORIZED", "Authentication failed", 401);
68
+ }
69
+
66
70
  return handleError(error, "Authentication failed", "PASSKEY_VERIFY_ERROR");
67
71
  }
68
72
  };
@@ -12,6 +12,7 @@ import { apiError, apiSuccess, handleError, requireDb } from "#api/error.js";
12
12
  import { parseBody, isParseError } from "#api/parse.js";
13
13
  import { contentTermsBody } from "#api/schemas.js";
14
14
  import { TaxonomyRepository } from "#db/repositories/taxonomy.js";
15
+ import { invalidateTermCache } from "#taxonomies/index.js";
15
16
 
16
17
  export const prerender = false;
17
18
 
@@ -122,6 +123,10 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
122
123
  // Set the terms (replaces existing)
123
124
  await repo.setTermsForEntry(collection, id, taxonomy, termIds);
124
125
 
126
+ // Term assignments changed — invalidate the hasAnyTermAssignments cache
127
+ // so hydration on subsequent reads issues a fresh query.
128
+ invalidateTermCache();
129
+
125
130
  // Get the updated terms
126
131
  const terms = await repo.getTermsForEntry(collection, id, taxonomy);
127
132
 
@@ -271,7 +271,7 @@ async function importContent(
271
271
  console.error(`Import error for "${post.title || "Untitled"}":`, error);
272
272
  result.errors.push({
273
273
  title: post.title || "Untitled",
274
- error: "Failed to import item",
274
+ error: error instanceof Error && error.message ? error.message : "Failed to import item",
275
275
  });
276
276
  }
277
277
  }
@@ -332,7 +332,7 @@ async function importContent(
332
332
  console.error(`Import error for "${item.title || "Untitled"}":`, error);
333
333
  result.errors.push({
334
334
  title: item.title || "Untitled",
335
- error: "Failed to import item",
335
+ error: error instanceof Error && error.message ? error.message : "Failed to import item",
336
336
  });
337
337
  }
338
338
  }
@@ -16,7 +16,7 @@ import { ulid } from "ulidx";
16
16
  import { requirePerm } from "#api/authorize.js";
17
17
  import { apiError, apiSuccess, handleError } from "#api/error.js";
18
18
  import { isParseError, parseBody } from "#api/parse.js";
19
- import { mediaUploadUrlBody } from "#api/schemas.js";
19
+ import { DEFAULT_MAX_UPLOAD_SIZE, mediaUploadUrlBody } from "#api/schemas.js";
20
20
 
21
21
  export const prerender = false;
22
22
 
@@ -59,7 +59,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
59
59
  }
60
60
 
61
61
  try {
62
- const body = await parseBody(request, mediaUploadUrlBody);
62
+ const maxSize = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
63
+ if (!Number.isFinite(maxSize) || maxSize <= 0) {
64
+ return apiError(
65
+ "CONFIGURATION_ERROR",
66
+ "Invalid maxUploadSize configuration. Expected a positive finite number.",
67
+ 500,
68
+ );
69
+ }
70
+ const body = await parseBody(request, mediaUploadUrlBody(maxSize));
63
71
  if (isParseError(body)) return body;
64
72
 
65
73
  // Validate content type
@@ -13,7 +13,7 @@ import { ulid } from "ulidx";
13
13
  import { requirePerm } from "#api/authorize.js";
14
14
  import { apiError, apiSuccess, handleError, unwrapResult } from "#api/error.js";
15
15
  import { isParseError, parseQuery } from "#api/parse.js";
16
- import { mediaListQuery } from "#api/schemas.js";
16
+ import { DEFAULT_MAX_UPLOAD_SIZE, formatFileSize, mediaListQuery } from "#api/schemas.js";
17
17
  import { MediaRepository } from "#db/repositories/media.js";
18
18
  import { generatePlaceholder } from "#media/placeholder.js";
19
19
  import { computeContentHash } from "#utils/hash.js";
@@ -22,9 +22,6 @@ import type { MediaItem } from "../../types.js";
22
22
 
23
23
  export const prerender = false;
24
24
 
25
- /** Maximum allowed file upload size (50 MB). */
26
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
27
-
28
25
  /**
29
26
  * Add URL to media items
30
27
  * Uses relative URLs to ensure portability across deployments
@@ -89,9 +86,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
89
86
  }
90
87
 
91
88
  try {
89
+ const rawMax = emdash.config.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
90
+ if (!Number.isFinite(rawMax) || rawMax <= 0) {
91
+ return apiError("CONFIGURATION_ERROR", "Invalid maxUploadSize configuration", 500);
92
+ }
93
+ const maxUploadSize = rawMax;
94
+
92
95
  // Best-effort size check before buffering the full multipart body
93
96
  const contentLength = request.headers.get("Content-Length");
94
- if (contentLength && parseInt(contentLength, 10) > MAX_UPLOAD_SIZE) {
97
+ if (contentLength && parseInt(contentLength, 10) > maxUploadSize) {
95
98
  return apiError("PAYLOAD_TOO_LARGE", "Upload too large", 413);
96
99
  }
97
100
 
@@ -110,10 +113,10 @@ export const POST: APIRoute = async ({ request, locals }) => {
110
113
  }
111
114
 
112
115
  // Check file size before buffering
113
- if (file.size > MAX_UPLOAD_SIZE) {
116
+ if (file.size > maxUploadSize) {
114
117
  return apiError(
115
118
  "PAYLOAD_TOO_LARGE",
116
- `File exceeds maximum size of ${MAX_UPLOAD_SIZE / 1024 / 1024}MB`,
119
+ `File exceeds maximum size of ${formatFileSize(maxUploadSize)}`,
117
120
  413,
118
121
  );
119
122
  }
@@ -0,0 +1,178 @@
1
+ /**
2
+ * POST /_emdash/api/oauth/register
3
+ *
4
+ * RFC 7591 Dynamic Client Registration. Public, unauthenticated.
5
+ * MCP clients (e.g. Claude Code) call this to register themselves
6
+ * before starting the OAuth authorization flow.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ import { apiError, handleError } from "#api/error.js";
12
+ import { handleOAuthClientCreate } from "#api/handlers/oauth-clients.js";
13
+
14
+ export const prerender = false;
15
+
16
+ const OAUTH_REGISTRATION_HEADERS: HeadersInit = {
17
+ "Cache-Control": "no-store",
18
+ Pragma: "no-cache",
19
+ // RFC 7591 dynamic client registration is called cross-origin by MCP clients,
20
+ // CLIs, and native apps. The endpoint is anonymous and carries no ambient
21
+ // credentials, so CORS `*` is safe.
22
+ "Access-Control-Allow-Origin": "*",
23
+ };
24
+
25
+ const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
26
+ "Access-Control-Allow-Origin": "*",
27
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
28
+ "Access-Control-Allow-Headers": "Content-Type",
29
+ "Access-Control-Max-Age": "86400",
30
+ };
31
+
32
+ const SUPPORTED_GRANT_TYPES = new Set([
33
+ "authorization_code",
34
+ "refresh_token",
35
+ "urn:ietf:params:oauth:grant-type:device_code",
36
+ ]);
37
+ const SUPPORTED_RESPONSE_TYPES = new Set(["code"]);
38
+
39
+ function registrationError(description: string, status = 400): Response {
40
+ return Response.json(
41
+ {
42
+ error: "invalid_client_metadata",
43
+ error_description: description,
44
+ },
45
+ { status, headers: OAUTH_REGISTRATION_HEADERS },
46
+ );
47
+ }
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function isStringArray(value: unknown): value is string[] {
54
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
55
+ }
56
+
57
+ function parseScope(value: unknown): string[] | Response | undefined {
58
+ if (value === undefined) return undefined;
59
+ if (typeof value === "string") {
60
+ const scopes = value.split(" ").filter(Boolean);
61
+ return scopes.length > 0 ? scopes : undefined;
62
+ }
63
+ if (isStringArray(value)) {
64
+ const scopes = value.filter(Boolean);
65
+ return scopes.length > 0 ? scopes : undefined;
66
+ }
67
+ return registrationError("scope must be a string or array of strings");
68
+ }
69
+
70
+ function parseSupportedStringArray(
71
+ value: unknown,
72
+ field: string,
73
+ supported: ReadonlySet<string>,
74
+ ): string[] | Response | undefined {
75
+ if (value === undefined) return undefined;
76
+ if (!isStringArray(value)) {
77
+ return registrationError(`${field} must be an array of strings`);
78
+ }
79
+ const invalidValue = value.find((item) => !supported.has(item));
80
+ if (invalidValue) {
81
+ return registrationError(`${field} contains unsupported value: ${invalidValue}`);
82
+ }
83
+ return value;
84
+ }
85
+
86
+ export const OPTIONS: APIRoute = () => {
87
+ return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
88
+ };
89
+
90
+ export const POST: APIRoute = async ({ request, locals }) => {
91
+ const { emdash } = locals;
92
+
93
+ if (!emdash?.db) {
94
+ return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
95
+ }
96
+
97
+ try {
98
+ let body: unknown;
99
+ try {
100
+ body = await request.json();
101
+ } catch {
102
+ return registrationError("Request body must be valid JSON");
103
+ }
104
+
105
+ if (!isRecord(body)) {
106
+ return registrationError("Request body must be a JSON object");
107
+ }
108
+
109
+ // redirect_uris is the only required field per RFC 7591 §2
110
+ if (!isStringArray(body.redirect_uris) || body.redirect_uris.length === 0) {
111
+ return registrationError("redirect_uris must be a non-empty array of strings");
112
+ }
113
+
114
+ if (
115
+ body.token_endpoint_auth_method !== undefined &&
116
+ body.token_endpoint_auth_method !== "none"
117
+ ) {
118
+ return registrationError("Only token_endpoint_auth_method=none is supported");
119
+ }
120
+
121
+ const grantTypes = parseSupportedStringArray(
122
+ body.grant_types,
123
+ "grant_types",
124
+ SUPPORTED_GRANT_TYPES,
125
+ );
126
+ if (grantTypes instanceof Response) {
127
+ return grantTypes;
128
+ }
129
+
130
+ const responseTypes = parseSupportedStringArray(
131
+ body.response_types,
132
+ "response_types",
133
+ SUPPORTED_RESPONSE_TYPES,
134
+ );
135
+ if (responseTypes instanceof Response) {
136
+ return responseTypes;
137
+ }
138
+
139
+ const scopes = parseScope(body.scope);
140
+ if (scopes instanceof Response) {
141
+ return scopes;
142
+ }
143
+
144
+ const clientId = crypto.randomUUID();
145
+ const clientName =
146
+ typeof body.client_name === "string" && body.client_name
147
+ ? body.client_name
148
+ : `dynamic-${clientId.slice(0, 8)}`;
149
+
150
+ const result = await handleOAuthClientCreate(emdash.db, {
151
+ id: clientId,
152
+ name: clientName,
153
+ redirectUris: body.redirect_uris,
154
+ scopes,
155
+ });
156
+
157
+ if (!result.success) {
158
+ return registrationError(result.error.message);
159
+ }
160
+
161
+ // RFC 7591 §3.2.1 response
162
+ return Response.json(
163
+ {
164
+ client_id: result.data.id,
165
+ client_id_issued_at: Math.floor(new Date(result.data.createdAt).getTime() / 1000),
166
+ redirect_uris: result.data.redirectUris,
167
+ client_name: result.data.name,
168
+ grant_types: grantTypes ?? ["authorization_code", "refresh_token"],
169
+ response_types: responseTypes ?? ["code"],
170
+ token_endpoint_auth_method: "none",
171
+ scope: result.data.scopes ? result.data.scopes.join(" ") : undefined,
172
+ },
173
+ { status: 201, headers: OAUTH_REGISTRATION_HEADERS },
174
+ );
175
+ } catch (error) {
176
+ return handleError(error, "Failed to register OAuth client", "CLIENT_REGISTER_ERROR");
177
+ }
178
+ };
@@ -87,6 +87,10 @@ const refreshSchema = z.object({
87
87
  // Handler
88
88
  // ---------------------------------------------------------------------------
89
89
 
90
+ export const OPTIONS: APIRoute = () => {
91
+ return new Response(null, { status: 204, headers: OAUTH_PREFLIGHT_HEADERS });
92
+ };
93
+
90
94
  export const POST: APIRoute = async ({ request, locals }) => {
91
95
  const { emdash } = locals;
92
96
 
@@ -166,6 +170,17 @@ const OAUTH_TOKEN_HEADERS: HeadersInit = {
166
170
  "Content-Type": "application/json",
167
171
  "Cache-Control": "no-store",
168
172
  Pragma: "no-cache",
173
+ // OAuth 2.1 token endpoint is called cross-origin by external clients. Caller
174
+ // must present PKCE code_verifier / device_code / refresh_token on each request,
175
+ // so there is no ambient credential for CSRF to exploit.
176
+ "Access-Control-Allow-Origin": "*",
177
+ };
178
+
179
+ const OAUTH_PREFLIGHT_HEADERS: HeadersInit = {
180
+ "Access-Control-Allow-Origin": "*",
181
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
182
+ "Access-Control-Allow-Headers": "Content-Type",
183
+ "Access-Control-Max-Age": "86400",
169
184
  };
170
185
 
171
186
  function oauthSuccess(data: unknown): Response {
@@ -15,13 +15,23 @@ export const prerender = false;
15
15
 
16
16
  let cachedSpec: string | null = null;
17
17
 
18
- export const GET: APIRoute = async () => {
19
- if (!cachedSpec) {
20
- const doc = generateOpenApiDocument();
21
- cachedSpec = JSON.stringify(doc);
18
+ export const GET: APIRoute = async ({ locals }) => {
19
+ const { emdash } = locals;
20
+ if (!cachedSpec && emdash) {
21
+ try {
22
+ const doc = generateOpenApiDocument({ maxUploadSize: emdash.config.maxUploadSize });
23
+ cachedSpec = JSON.stringify(doc);
24
+ } catch {
25
+ return new Response(
26
+ JSON.stringify({ error: "Failed to generate OpenAPI document: invalid configuration" }),
27
+ { status: 500, headers: { "Content-Type": "application/json" } },
28
+ );
29
+ }
22
30
  }
23
31
 
24
- return new Response(cachedSpec, {
32
+ const spec = cachedSpec ?? JSON.stringify(generateOpenApiDocument());
33
+
34
+ return new Response(spec, {
25
35
  status: 200,
26
36
  headers: {
27
37
  "Content-Type": "application/json",
@@ -57,6 +57,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
57
57
  fieldSlug,
58
58
  body as UpdateFieldInput,
59
59
  );
60
+ if (result.success) emdash!.invalidateManifest();
60
61
  return unwrapResult(result);
61
62
  };
62
63
 
@@ -72,5 +73,6 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
72
73
  if (denied) return denied;
73
74
 
74
75
  const result = await handleSchemaFieldDelete(emdash!.db, collectionSlug, fieldSlug);
76
+ if (result.success) emdash!.invalidateManifest();
75
77
  return unwrapResult(result);
76
78
  };
@@ -48,5 +48,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
48
48
  collectionSlug,
49
49
  body as CreateFieldInput,
50
50
  );
51
+ if (result.success) emdash!.invalidateManifest();
51
52
  return unwrapResult(result, 201);
52
53
  };
@@ -28,5 +28,6 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
28
28
  if (isParseError(body)) return body;
29
29
 
30
30
  const result = await handleSchemaFieldReorder(emdash!.db, collectionSlug, body.fieldSlugs);
31
+ if (result.success) emdash!.invalidateManifest();
31
32
  return unwrapResult(result);
32
33
  };
@@ -37,6 +37,11 @@ export const GET: APIRoute = async ({ url, locals }) => {
37
37
  : undefined;
38
38
 
39
39
  try {
40
+ // Verify FTS indexes are healthy on first use. At most once per worker
41
+ // lifetime; no-op after that. Moved off the cold-start hot path to
42
+ // keep anonymous public reads fast.
43
+ await emdash.ensureSearchHealthy?.();
44
+
40
45
  const result = await searchWithDb(emdash.db, query.q, {
41
46
  collections,
42
47
  status: query.status,
@@ -36,6 +36,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
36
36
  : undefined;
37
37
 
38
38
  try {
39
+ // Verify FTS indexes are healthy on first use. See search/index.ts.
40
+ await emdash.ensureSearchHealthy?.();
41
+
39
42
  const suggestions = await getSuggestions(emdash.db, query.q, {
40
43
  collections,
41
44
  locale: query.locale,
@@ -52,6 +52,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
52
52
  if (isParseError(body)) return body;
53
53
 
54
54
  const result = await handleTaxonomyCreate(emdash.db, body);
55
+ if (result.success) emdash.invalidateManifest();
55
56
  return unwrapResult(result, 201);
56
57
  } catch (error) {
57
58
  return handleError(error, "Failed to create taxonomy", "TAXONOMY_CREATE_ERROR");
@@ -33,8 +33,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
33
33
  "urn:ietf:params:oauth:grant-type:device_code",
34
34
  ],
35
35
  code_challenge_methods_supported: ["S256"],
36
+ registration_endpoint: `${origin}/_emdash/api/oauth/register`,
36
37
  token_endpoint_auth_methods_supported: ["none"],
37
- client_id_metadata_document_supported: true,
38
38
  device_authorization_endpoint: `${origin}/_emdash/api/oauth/device/code`,
39
39
  },
40
40
  {
@@ -13,47 +13,19 @@ import type { BylineSummary, ContentBylineCredit } from "../database/repositorie
13
13
  import { validateIdentifier } from "../database/validate.js";
14
14
  import { getDb } from "../loader.js";
15
15
  import { chunks, SQL_BATCH_SIZE } from "../utils/chunks.js";
16
+ import { isMissingTableError } from "../utils/db-errors.js";
16
17
 
17
18
  /**
18
- * Cached result of "does any byline exist in the database?"
19
- * null = not yet checked, true/false = cached result.
20
- * Invalidated when bylines are created or deleted.
21
- */
22
- let hasBylines: boolean | null = null;
23
-
24
- /**
25
- * Invalidate the cached "has any bylines" check.
26
- * Call this when bylines are created, updated, or deleted.
19
+ * No-op kept for API compatibility.
20
+ *
21
+ * Used to invalidate a worker-lifetime "has any byline?" probe. That
22
+ * probe added a query on every cold isolate to save one query on sites
23
+ * with zero bylines (i.e. the wrong tradeoff), so we dropped it. The
24
+ * batch byline join below returns an empty map for empty sites at the
25
+ * same cost as the probe, without the pre-check.
27
26
  */
28
27
  export function invalidateBylineCache(): void {
29
- hasBylines = null;
30
- }
31
-
32
- /**
33
- * Check if any bylines exist in the database. Result is cached
34
- * for the lifetime of the worker/process and invalidated on writes.
35
- */
36
- async function hasAnyBylines(): Promise<boolean> {
37
- if (hasBylines !== null) return hasBylines;
38
-
39
- try {
40
- const db = await getDb();
41
- const result = await sql<{ id: string }>`
42
- SELECT id FROM _emdash_bylines LIMIT 1
43
- `.execute(db);
44
- hasBylines = result.rows.length > 0;
45
- } catch (error: unknown) {
46
- // Only treat "no such table" as a safe false -- anything else should
47
- // not be cached so the next request retries.
48
- const message = error instanceof Error ? error.message : "";
49
- if (message.includes("no such table")) {
50
- hasBylines = false;
51
- } else {
52
- return false;
53
- }
54
- }
55
-
56
- return hasBylines;
28
+ // Intentionally empty.
57
29
  }
58
30
 
59
31
  /**
@@ -176,17 +148,22 @@ export async function getBylinesForEntries(
176
148
  return result;
177
149
  }
178
150
 
179
- // Skip DB queries entirely when no bylines have been created.
180
- // The cache is invalidated when bylines are created/deleted.
181
- if (!(await hasAnyBylines())) {
182
- return result;
183
- }
184
-
185
151
  const db = await getDb();
186
152
  const repo = new BylineRepository(db);
187
153
 
188
- // 1. Batch fetch all explicit byline credits
189
- const bylinesMap = await repo.getContentBylinesMany(collection, entryIds);
154
+ // 1. Batch fetch all explicit byline credits. Sites with no bylines
155
+ // get an empty map back for one query — the previous "has any bylines"
156
+ // probe traded an extra round-trip on every request to save that one
157
+ // query on empty sites, which is exactly backwards for the common case.
158
+ // Pre-migration databases (bylines table missing) fall through to the
159
+ // `isMissingTableError` catch below and return empty results.
160
+ let bylinesMap;
161
+ try {
162
+ bylinesMap = await repo.getContentBylinesMany(collection, entryIds);
163
+ } catch (error) {
164
+ if (isMissingTableError(error)) return result;
165
+ throw error;
166
+ }
190
167
 
191
168
  // 2. Collect entry IDs that need fallback lookup
192
169
  const fallbackEntryIds: string[] = [];
@@ -16,8 +16,12 @@
16
16
  import type { PublicPageContext, PageMetadataContribution } from "../plugins/types.js";
17
17
  import { resolvePageMetadata, renderPageMetadata } from "../page/metadata.js";
18
18
  import { renderFragments } from "../page/fragments.js";
19
- import { generateBaseSeoContributions } from "../page/seo-contributions.js";
19
+ import {
20
+ generateBaseSeoContributions,
21
+ generateSiteSeoContributions,
22
+ } from "../page/seo-contributions.js";
20
23
  import { getPageRuntime } from "../page/index.js";
24
+ import { getSiteSetting } from "../settings/index.js";
21
25
 
22
26
  interface Props {
23
27
  page: PublicPageContext;
@@ -33,14 +37,26 @@ let metadataHtml = "";
33
37
  let fragmentsHtml = "";
34
38
 
35
39
  if (runtime) {
36
- // Plugin contributions come BEFORE base, so resolvePageMetadata's
37
- // first-wins dedup lets plugins override base SEO defaults
38
- const pluginContributions = await runtime.collectPageMetadata(page);
39
- const allContributions = [...pluginContributions, ...baseContributions];
40
+ // Run independent async loads in parallel: site SEO settings (for
41
+ // search engine verification meta tags) and plugin page-metadata
42
+ // contributions. Plugin contributions come BEFORE site/base in the
43
+ // array, so resolvePageMetadata's first-wins dedup lets plugins
44
+ // override defaults.
45
+ //
46
+ // `getSiteSetting("seo")` is request-cached and — crucially — reads
47
+ // from `getSiteSettings()`'s cached batch when a parent template has
48
+ // already called it. So this is either a single-key query or free,
49
+ // not a second round-trip.
50
+ const [seoSettings, pluginContributions, fragments] = await Promise.all([
51
+ getSiteSetting("seo"),
52
+ runtime.collectPageMetadata(page),
53
+ runtime.collectPageFragments(page),
54
+ ]);
55
+
56
+ const siteContributions = generateSiteSeoContributions(seoSettings);
57
+ const allContributions = [...pluginContributions, ...siteContributions, ...baseContributions];
40
58
  const resolved = resolvePageMetadata(allContributions);
41
59
  metadataHtml = renderPageMetadata(resolved);
42
-
43
- const fragments = await runtime.collectPageFragments(page);
44
60
  fragmentsHtml = renderFragments(fragments, "head");
45
61
  } else {
46
62
  // No runtime (EmDash not initialized) — still render base SEO