emdash 0.4.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 (212) 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 +208 -34
  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 +34 -9
  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 -8
  22. package/dist/{byline-WuOq9MFJ.mjs → byline-C4OVd8b3.mjs} +3 -19
  23. package/dist/byline-C4OVd8b3.mjs.map +1 -0
  24. package/dist/{bylines-C_Wsnz4L.mjs → bylines-hPTW79hw.mjs} +20 -33
  25. package/dist/bylines-hPTW79hw.mjs.map +1 -0
  26. package/dist/{cache-E3Dts-yT.mjs → cache-BkKBuIvS.mjs} +1 -1
  27. package/dist/{cache-E3Dts-yT.mjs.map → cache-BkKBuIvS.mjs.map} +1 -1
  28. package/dist/chunks-HGz06Soa.mjs +19 -0
  29. package/dist/chunks-HGz06Soa.mjs.map +1 -0
  30. package/dist/cli/index.mjs +9 -8
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/client/cf-access.d.mts +1 -1
  33. package/dist/client/index.d.mts +1 -1
  34. package/dist/client/index.mjs +1 -1
  35. package/dist/{config-DkxPrM9l.mjs → config-BXwuX8Bx.mjs} +1 -1
  36. package/dist/{config-DkxPrM9l.mjs.map → config-BXwuX8Bx.mjs.map} +1 -1
  37. package/dist/{connection-B4zVnQIa.mjs → connection-2igzM-AT.mjs} +19 -2
  38. package/dist/connection-2igzM-AT.mjs.map +1 -0
  39. package/dist/database/instrumentation.d.mts +45 -0
  40. package/dist/database/instrumentation.d.mts.map +1 -0
  41. package/dist/database/instrumentation.mjs +61 -0
  42. package/dist/database/instrumentation.mjs.map +1 -0
  43. package/dist/db/index.d.mts +3 -3
  44. package/dist/db/index.mjs.map +1 -1
  45. package/dist/db/libsql.d.mts +1 -1
  46. package/dist/db/postgres.d.mts +1 -1
  47. package/dist/db/sqlite.d.mts +1 -1
  48. package/dist/db-errors-D0UT85nC.mjs +41 -0
  49. package/dist/db-errors-D0UT85nC.mjs.map +1 -0
  50. package/dist/{default-PUx9RK6u.mjs → default-CME5YdZ3.mjs} +1 -1
  51. package/dist/{default-PUx9RK6u.mjs.map → default-CME5YdZ3.mjs.map} +1 -1
  52. package/dist/{error-HBeQbVhV.mjs → error-CiYn9yDu.mjs} +1 -1
  53. package/dist/{error-HBeQbVhV.mjs.map → error-CiYn9yDu.mjs.map} +1 -1
  54. package/dist/{index-CRg3PWfZ.d.mts → index-BYv0mB9g.d.mts} +135 -19
  55. package/dist/index-BYv0mB9g.d.mts.map +1 -0
  56. package/dist/index.d.mts +11 -11
  57. package/dist/index.mjs +20 -18
  58. package/dist/{load-BhSSm-TS.mjs → load-CBcmDIot.mjs} +1 -1
  59. package/dist/{load-BhSSm-TS.mjs.map → load-CBcmDIot.mjs.map} +1 -1
  60. package/dist/{loader-BYzwzORf.mjs → loader-DeiBJEMe.mjs} +18 -12
  61. package/dist/loader-DeiBJEMe.mjs.map +1 -0
  62. package/dist/{manifest-schema-BsXINkQD.mjs → manifest-schema-V30qsMft.mjs} +1 -1
  63. package/dist/{manifest-schema-BsXINkQD.mjs.map → manifest-schema-V30qsMft.mjs.map} +1 -1
  64. package/dist/media/index.d.mts +1 -1
  65. package/dist/media/index.mjs +1 -1
  66. package/dist/media/local-runtime.d.mts +7 -7
  67. package/dist/{mode-CyPLdO3C.mjs → mode-CpNnGkPz.mjs} +1 -1
  68. package/dist/{mode-CyPLdO3C.mjs.map → mode-CpNnGkPz.mjs.map} +1 -1
  69. package/dist/page/index.d.mts +11 -2
  70. package/dist/page/index.d.mts.map +1 -1
  71. package/dist/page/index.mjs +23 -1
  72. package/dist/page/index.mjs.map +1 -1
  73. package/dist/{placeholder-DntBEQo7.mjs → placeholder-C-fk5hYI.mjs} +1 -1
  74. package/dist/{placeholder-DntBEQo7.mjs.map → placeholder-C-fk5hYI.mjs.map} +1 -1
  75. package/dist/{placeholder-BBCtpTES.d.mts → placeholder-tzpqGWII.d.mts} +1 -1
  76. package/dist/{placeholder-BBCtpTES.d.mts.map → placeholder-tzpqGWII.d.mts.map} +1 -1
  77. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  78. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  79. package/dist/{query-B6Vu0d2i.mjs → query-Bk_3vKvU.mjs} +78 -11
  80. package/dist/query-Bk_3vKvU.mjs.map +1 -0
  81. package/dist/{registry-BgnP3ysR.mjs → registry-Ci3WxVAr.mjs} +133 -97
  82. package/dist/registry-Ci3WxVAr.mjs.map +1 -0
  83. package/dist/request-cache-DiR961CV.mjs +79 -0
  84. package/dist/request-cache-DiR961CV.mjs.map +1 -0
  85. package/dist/request-context.d.mts +19 -16
  86. package/dist/request-context.d.mts.map +1 -1
  87. package/dist/request-context.mjs.map +1 -1
  88. package/dist/{runner-DYv3rX8P.d.mts → runner-Fl2NcUUz.d.mts} +2 -2
  89. package/dist/{runner-DYv3rX8P.d.mts.map → runner-Fl2NcUUz.d.mts.map} +1 -1
  90. package/dist/runtime.d.mts +6 -6
  91. package/dist/runtime.mjs +1 -1
  92. package/dist/{search-B5p9D36n.mjs → search-DI4bM2w9.mjs} +110 -209
  93. package/dist/search-DI4bM2w9.mjs.map +1 -0
  94. package/dist/seed/index.d.mts +2 -2
  95. package/dist/seed/index.mjs +8 -7
  96. package/dist/seo/index.d.mts +1 -1
  97. package/dist/storage/local.d.mts +1 -1
  98. package/dist/storage/local.mjs +1 -1
  99. package/dist/storage/s3.d.mts +1 -1
  100. package/dist/storage/s3.mjs +1 -1
  101. package/dist/taxonomies-DbrKzDju.mjs +308 -0
  102. package/dist/taxonomies-DbrKzDju.mjs.map +1 -0
  103. package/dist/{tokens-DKHiCYCB.mjs → tokens-BFPFx3CA.mjs} +1 -1
  104. package/dist/{tokens-DKHiCYCB.mjs.map → tokens-BFPFx3CA.mjs.map} +1 -1
  105. package/dist/{transport-BtcQ-Z7T.mjs → transport-BykRfpyy.mjs} +1 -1
  106. package/dist/{transport-BtcQ-Z7T.mjs.map → transport-BykRfpyy.mjs.map} +1 -1
  107. package/dist/{transport-CKQA_G44.d.mts → transport-H4Iwx7tC.d.mts} +1 -1
  108. package/dist/{transport-CKQA_G44.d.mts.map → transport-H4Iwx7tC.d.mts.map} +1 -1
  109. package/dist/{types-BmkQR1En.d.mts → types-6CUZRrZP.d.mts} +1 -1
  110. package/dist/{types-BmkQR1En.d.mts.map → types-6CUZRrZP.d.mts.map} +1 -1
  111. package/dist/{types-B6BzlZxx.d.mts → types-8xrvl_68.d.mts} +1 -1
  112. package/dist/{types-B6BzlZxx.d.mts.map → types-8xrvl_68.d.mts.map} +1 -1
  113. package/dist/{types-Dz9_WMS6.mjs → types-BH2L167P.mjs} +1 -1
  114. package/dist/{types-Dz9_WMS6.mjs.map → types-BH2L167P.mjs.map} +1 -1
  115. package/dist/{types-DNZpaCBk.d.mts → types-CFWjXmus.d.mts} +1 -1
  116. package/dist/{types-DNZpaCBk.d.mts.map → types-CFWjXmus.d.mts.map} +1 -1
  117. package/dist/{types-gLYVCXCQ.d.mts → types-CnZYHyLW.d.mts} +55 -5
  118. package/dist/types-CnZYHyLW.d.mts.map +1 -0
  119. package/dist/{types-xxCWI3j0.mjs → types-DDS4MxsT.mjs} +11 -3
  120. package/dist/types-DDS4MxsT.mjs.map +1 -0
  121. package/dist/{types-BYWYxLcp.d.mts → types-DgrIP0tF.d.mts} +9 -2
  122. package/dist/types-DgrIP0tF.d.mts.map +1 -0
  123. package/dist/{validate-CcNRWH6I.d.mts → validate-CaLH1Ia2.d.mts} +5 -52
  124. package/dist/validate-CaLH1Ia2.d.mts.map +1 -0
  125. package/dist/{validate-DuZDIxfy.mjs → validate-CqsNItbt.mjs} +2 -2
  126. package/dist/{validate-DuZDIxfy.mjs.map → validate-CqsNItbt.mjs.map} +1 -1
  127. package/dist/version-Uaf2ynPX.mjs +7 -0
  128. package/dist/{version-DlTDRdpv.mjs.map → version-Uaf2ynPX.mjs.map} +1 -1
  129. package/package.json +10 -5
  130. package/src/after.ts +62 -0
  131. package/src/api/handlers/oauth-authorization.ts +2 -32
  132. package/src/api/handlers/oauth-clients.ts +40 -4
  133. package/src/api/handlers/taxonomies.ts +13 -0
  134. package/src/api/oauth/redirect-uri.ts +34 -0
  135. package/src/api/openapi/document.ts +126 -118
  136. package/src/api/schemas/auth.ts +7 -0
  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 +17 -1
  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 +39 -6
  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 +10 -5
  149. package/src/astro/routes/api/auth/invite/register-options.ts +78 -0
  150. package/src/astro/routes/api/auth/passkey/verify.ts +5 -1
  151. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +5 -0
  152. package/src/astro/routes/api/import/wordpress/execute.ts +1 -1
  153. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +1 -1
  154. package/src/astro/routes/api/media/upload-url.ts +10 -2
  155. package/src/astro/routes/api/media.ts +10 -7
  156. package/src/astro/routes/api/oauth/register.ts +178 -0
  157. package/src/astro/routes/api/oauth/token.ts +15 -0
  158. package/src/astro/routes/api/openapi.json.ts +15 -5
  159. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +2 -0
  160. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +1 -0
  161. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +1 -0
  162. package/src/astro/routes/api/search/index.ts +5 -0
  163. package/src/astro/routes/api/search/suggest.ts +3 -0
  164. package/src/astro/routes/api/taxonomies/index.ts +1 -0
  165. package/src/astro/routes/api/well-known/oauth-authorization-server.ts +6 -4
  166. package/src/bylines/index.ts +22 -45
  167. package/src/components/EmDashHead.astro +23 -7
  168. package/src/components/Table.astro +73 -41
  169. package/src/components/index.ts +2 -12
  170. package/src/components/marks.ts +20 -0
  171. package/src/database/connection.ts +23 -1
  172. package/src/database/instrumentation.ts +98 -0
  173. package/src/db/adapters.ts +15 -0
  174. package/src/emdash-runtime.ts +309 -91
  175. package/src/index.ts +6 -0
  176. package/src/loader.ts +19 -24
  177. package/src/menus/index.ts +6 -3
  178. package/src/page/index.ts +1 -1
  179. package/src/page/seo-contributions.ts +36 -0
  180. package/src/plugins/context.ts +1 -0
  181. package/src/plugins/email-console.ts +9 -2
  182. package/src/plugins/types.ts +8 -0
  183. package/src/query.ts +104 -7
  184. package/src/request-cache.ts +106 -0
  185. package/src/request-context.ts +19 -0
  186. package/src/schema/query.ts +5 -2
  187. package/src/schema/registry.ts +243 -166
  188. package/src/schema/types.ts +13 -2
  189. package/src/schema/zod-generator.ts +4 -0
  190. package/src/search/fts-manager.ts +19 -5
  191. package/src/search/query.ts +4 -3
  192. package/src/seed/apply.ts +15 -1
  193. package/src/settings/index.ts +24 -5
  194. package/src/taxonomies/index.ts +324 -124
  195. package/src/utils/db-errors.ts +46 -0
  196. package/src/virtual-modules.d.ts +31 -10
  197. package/src/widgets/index.ts +54 -25
  198. package/dist/adapters-C2BzVy0p.d.mts.map +0 -1
  199. package/dist/apply-Cma_PiF6.mjs.map +0 -1
  200. package/dist/byline-WuOq9MFJ.mjs.map +0 -1
  201. package/dist/bylines-C_Wsnz4L.mjs.map +0 -1
  202. package/dist/connection-B4zVnQIa.mjs.map +0 -1
  203. package/dist/index-CRg3PWfZ.d.mts.map +0 -1
  204. package/dist/loader-BYzwzORf.mjs.map +0 -1
  205. package/dist/query-B6Vu0d2i.mjs.map +0 -1
  206. package/dist/registry-BgnP3ysR.mjs.map +0 -1
  207. package/dist/search-B5p9D36n.mjs.map +0 -1
  208. package/dist/types-BYWYxLcp.d.mts.map +0 -1
  209. package/dist/types-gLYVCXCQ.d.mts.map +0 -1
  210. package/dist/types-xxCWI3j0.mjs.map +0 -1
  211. package/dist/validate-CcNRWH6I.d.mts.map +0 -1
  212. package/dist/version-DlTDRdpv.mjs +0 -7
@@ -9,20 +9,23 @@ 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
 
15
- import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
16
+ import { resolveLocale, loadMessages, getLocaleDir } from "@emdash-cms/admin/locales";
16
17
 
17
18
  const resolvedLocale = resolveLocale(Astro.request);
19
+ const resolvedDir = getLocaleDir(resolvedLocale);
18
20
  const messages = await loadMessages(resolvedLocale);
19
21
  ---
20
22
 
21
23
  <!doctype html>
22
- <html lang={resolvedLocale}>
24
+ <html lang={resolvedLocale} dir={resolvedDir}>
23
25
  <head>
24
26
  <meta charset="UTF-8" />
25
27
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
28
+ <Font cssVariable="--font-emdash" />
26
29
  <link
27
30
  rel="icon"
28
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>"
@@ -62,10 +65,12 @@ const messages = await loadMessages(resolvedLocale);
62
65
  }
63
66
  #emdash-boot-loader p {
64
67
  margin-top: 1rem;
65
- font-family:
68
+ font-family: var(
69
+ --font-emdash,
70
+ ui-sans-serif,
66
71
  system-ui,
67
- -apple-system,
68
- sans-serif;
72
+ sans-serif
73
+ );
69
74
  font-size: 0.875rem;
70
75
  color: light-dark(hsl(215.4 16.3% 46.9%), hsl(215 20.2% 65.1%));
71
76
  }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * POST /_emdash/api/auth/invite/register-options
3
+ *
4
+ * Generate WebAuthn registration options for an invited user.
5
+ * Validates the invite token and creates a temporary user identity
6
+ * for the passkey registration flow.
7
+ */
8
+
9
+ import type { APIRoute } from "astro";
10
+
11
+ export const prerender = false;
12
+
13
+ import { validateInvite, InviteError } from "@emdash-cms/auth";
14
+ import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
15
+ import { generateRegistrationOptions } from "@emdash-cms/auth/passkey";
16
+ import { ulid } from "ulidx";
17
+
18
+ import { apiError, apiSuccess, handleError } from "#api/error.js";
19
+ import { isParseError, parseBody } from "#api/parse.js";
20
+ import { inviteRegisterOptionsBody } from "#api/schemas.js";
21
+ import { createChallengeStore } from "#auth/challenge-store.js";
22
+ import { getPasskeyConfig } from "#auth/passkey-config.js";
23
+ import { OptionsRepository } from "#db/repositories/options.js";
24
+
25
+ export const POST: APIRoute = async ({ request, locals }) => {
26
+ const { emdash } = locals;
27
+
28
+ if (!emdash?.db) {
29
+ return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
30
+ }
31
+
32
+ try {
33
+ const body = await parseBody(request, inviteRegisterOptionsBody);
34
+ if (isParseError(body)) return body;
35
+
36
+ // Validate the invite token to get the email
37
+ const adapter = createKyselyAdapter(emdash.db);
38
+ const invite = await validateInvite(adapter, body.token);
39
+
40
+ // Get passkey config
41
+ const url = new URL(request.url);
42
+ const options = new OptionsRepository(emdash.db);
43
+ const siteName = (await options.get<string>("emdash:site_title")) ?? undefined;
44
+ const passkeyConfig = getPasskeyConfig(url, siteName);
45
+
46
+ // Generate registration options with a temporary user identity
47
+ const challengeStore = createChallengeStore(emdash.db);
48
+ const tempUser = {
49
+ id: ulid(),
50
+ email: invite.email,
51
+ name: body.name || null,
52
+ };
53
+
54
+ const registrationOptions = await generateRegistrationOptions(
55
+ passkeyConfig,
56
+ tempUser,
57
+ [],
58
+ challengeStore,
59
+ );
60
+
61
+ return apiSuccess({ options: registrationOptions });
62
+ } catch (error) {
63
+ if (error instanceof InviteError) {
64
+ const statusMap: Record<string, number> = {
65
+ invalid_token: 404,
66
+ token_expired: 410,
67
+ user_exists: 409,
68
+ };
69
+ return apiError(error.code.toUpperCase(), error.message, statusMap[error.code] ?? 400);
70
+ }
71
+
72
+ return handleError(
73
+ error,
74
+ "Failed to generate registration options",
75
+ "INVITE_REGISTER_OPTIONS_ERROR",
76
+ );
77
+ }
78
+ };
@@ -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");
@@ -1,8 +1,10 @@
1
1
  /**
2
- * GET /_emdash/.well-known/oauth-authorization-server
2
+ * GET /.well-known/oauth-authorization-server/_emdash
3
3
  *
4
- * RFC 8414 Authorization Server Metadata. Tells MCP clients which
5
- * endpoints to use for OAuth authorization, token exchange, etc.
4
+ * RFC 8414 Authorization Server Metadata. The path follows the RFC 8414
5
+ * convention: the issuer's pathname (/_emdash) is appended after
6
+ * /.well-known/oauth-authorization-server, so MCP clients can discover
7
+ * it automatically from the authorization_servers URL.
6
8
  *
7
9
  * Public, unauthenticated.
8
10
  */
@@ -31,8 +33,8 @@ export const GET: APIRoute = async ({ url, locals }) => {
31
33
  "urn:ietf:params:oauth:grant-type:device_code",
32
34
  ],
33
35
  code_challenge_methods_supported: ["S256"],
36
+ registration_endpoint: `${origin}/_emdash/api/oauth/register`,
34
37
  token_endpoint_auth_methods_supported: ["none"],
35
- client_id_metadata_document_supported: true,
36
38
  device_authorization_endpoint: `${origin}/_emdash/api/oauth/device/code`,
37
39
  },
38
40
  {