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
@@ -1 +1 @@
1
- {"version":3,"file":"version-DlTDRdpv.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
1
+ {"version":3,"file":"version-Uaf2ynPX.mjs","names":[],"sources":["../src/version.ts"],"sourcesContent":["/**\n * Build-time version constants, replaced by tsdown/Vite `define`.\n * Falls back to \"dev\" when running uncompiled (tests, dev).\n */\n\ndeclare const __EMDASH_VERSION__: string;\ndeclare const __EMDASH_COMMIT__: string;\n\nexport const VERSION: string =\n\ttypeof __EMDASH_VERSION__ !== \"undefined\" ? __EMDASH_VERSION__ : \"dev\";\n\nexport const COMMIT: string = typeof __EMDASH_COMMIT__ !== \"undefined\" ? __EMDASH_COMMIT__ : \"dev\";\n"],"mappings":";AAQA,MAAa;AAGb,MAAa"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emdash",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Astro-native CMS with WordPress migration support",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -61,6 +61,10 @@
61
61
  "types": "./dist/db/postgres.d.mts",
62
62
  "default": "./dist/db/postgres.mjs"
63
63
  },
64
+ "./database/instrumentation": {
65
+ "types": "./dist/database/instrumentation.d.mts",
66
+ "default": "./dist/database/instrumentation.mjs"
67
+ },
64
68
  "./storage/local": {
65
69
  "types": "./dist/storage/local.d.mts",
66
70
  "default": "./dist/storage/local.mjs"
@@ -142,6 +146,7 @@
142
146
  "#mcp/*": "./src/mcp/*",
143
147
  "#comments/*": "./src/comments/*",
144
148
  "#bylines/*": "./src/bylines/*",
149
+ "#taxonomies/*": "./src/taxonomies/*",
145
150
  "#redirects/*": "./src/redirects/*",
146
151
  "#types": "./src/astro/types.js"
147
152
  },
@@ -180,9 +185,9 @@
180
185
  "ulidx": "^2.4.1",
181
186
  "upng-js": "^2.1.0",
182
187
  "zod": "^4.3.5",
183
- "@emdash-cms/admin": "0.4.0",
184
- "@emdash-cms/auth": "0.4.0",
185
- "@emdash-cms/gutenberg-to-portable-text": "0.4.0"
188
+ "@emdash-cms/auth": "0.6.0",
189
+ "@emdash-cms/admin": "0.6.0",
190
+ "@emdash-cms/gutenberg-to-portable-text": "0.6.0"
186
191
  },
187
192
  "optionalDependencies": {
188
193
  "@libsql/kysely-libsql": "^0.4.0",
@@ -210,7 +215,7 @@
210
215
  "vite": "^6.0.0",
211
216
  "vitest": "^4.0.18",
212
217
  "zod-openapi": "^5.4.6",
213
- "@emdash-cms/blocks": "0.4.0"
218
+ "@emdash-cms/blocks": "0.6.0"
214
219
  },
215
220
  "repository": {
216
221
  "type": "git",
package/src/after.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Defer work past the HTTP response.
3
+ *
4
+ * Use for bookkeeping that doesn't need to complete before the client
5
+ * gets bytes — writes that record state, maintenance queries, cache
6
+ * refreshes. `after()` hands the promise to the host's lifetime
7
+ * extender when one is available (Cloudflare's `waitUntil` under
8
+ * workerd), or fires-and-forgets on Node (the process lives for the
9
+ * next request anyway).
10
+ *
11
+ * Host binding is resolved lazily via a dynamic import of the
12
+ * `virtual:emdash/wait-until` virtual module. Lazy — rather than a
13
+ * static top-level import — so tools that walk the dist in a plain
14
+ * Node loader (`astro check`, Vitest, etc.) don't trip over the
15
+ * `virtual:` scheme: they'd only fail if they actually called
16
+ * `after()`, which they don't during type-checking.
17
+ */
18
+
19
+ export type WaitUntilFn = (promise: Promise<unknown>) => void;
20
+
21
+ // Resolves to the host's waitUntil if the adapter provided one, or
22
+ // null otherwise. Kicked off once at module load; subsequent `after()`
23
+ // calls see the cached result without re-importing.
24
+ const waitUntilReady: Promise<WaitUntilFn | null> = (async () => {
25
+ try {
26
+ // @ts-ignore - virtual module, generated by the Astro integration
27
+ const mod = (await import("virtual:emdash/wait-until")) as {
28
+ waitUntil?: WaitUntilFn;
29
+ };
30
+ return mod.waitUntil ?? null;
31
+ } catch {
32
+ // No virtual module available (Node-side tooling, tests without the
33
+ // integration in scope). Fire-and-forget is the safe fallback.
34
+ return null;
35
+ }
36
+ })();
37
+ // Surface rejections without making the module-load fail.
38
+ waitUntilReady.catch(() => {});
39
+
40
+ /**
41
+ * Schedule `fn` to run without blocking the response.
42
+ *
43
+ * Errors are caught and logged — a deferred task should never surface
44
+ * as an unhandled rejection because the response is long gone. Callers
45
+ * that care about errors should handle them inside `fn`.
46
+ */
47
+ export function after(fn: () => void | Promise<void>): void {
48
+ const promise = Promise.resolve()
49
+ .then(fn)
50
+ .catch((error) => {
51
+ console.error("[emdash] deferred task failed:", error);
52
+ });
53
+
54
+ // Defer the lifetime-extender handoff to the microtask that resolves
55
+ // waitUntilReady. On workerd this is effectively instant (the virtual
56
+ // module is already loaded in the bundle); on Node the promise
57
+ // resolves to null, so this is just one extra microtask and no-op.
58
+ void waitUntilReady.then((waitUntil) => {
59
+ if (waitUntil) waitUntil(promise);
60
+ return null;
61
+ });
62
+ }
@@ -20,6 +20,7 @@ import {
20
20
  VALID_SCOPES,
21
21
  } from "../../auth/api-tokens.js";
22
22
  import type { Database } from "../../database/types.js";
23
+ import { validateRedirectUri } from "../oauth/redirect-uri.js";
23
24
  import type { ApiResult } from "../types.js";
24
25
  import { lookupOAuthClient, validateClientRedirectUri } from "./oauth-clients.js";
25
26
 
@@ -76,38 +77,7 @@ function expiresAt(seconds: number): string {
76
77
  return new Date(Date.now() + seconds * 1000).toISOString();
77
78
  }
78
79
 
79
- /**
80
- * Validate a redirect URI per OAuth 2.1 security requirements.
81
- * Allows localhost (loopback) over HTTP, and any HTTPS URL.
82
- */
83
- export function validateRedirectUri(uri: string): string | null {
84
- try {
85
- const url = new URL(uri);
86
-
87
- // Reject protocol-relative URLs
88
- if (uri.startsWith("//")) {
89
- return "Protocol-relative redirect URIs are not allowed";
90
- }
91
-
92
- // Allow localhost/loopback over HTTP (for desktop MCP clients)
93
- if (url.protocol === "http:") {
94
- const host = url.hostname;
95
- if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") {
96
- return null; // OK
97
- }
98
- return "HTTP redirect URIs are only allowed for localhost";
99
- }
100
-
101
- // Allow HTTPS
102
- if (url.protocol === "https:") {
103
- return null; // OK
104
- }
105
-
106
- return `Unsupported redirect URI scheme: ${url.protocol}`;
107
- } catch {
108
- return "Invalid redirect URI";
109
- }
110
- }
80
+ export { validateRedirectUri };
111
81
 
112
82
  /**
113
83
  * Validate and normalize scopes. Returns validated scope list.
@@ -9,6 +9,7 @@
9
9
  import type { Kysely } from "kysely";
10
10
 
11
11
  import type { Database } from "../../database/types.js";
12
+ import { validateRedirectUri } from "../oauth/redirect-uri.js";
12
13
  import type { ApiResult } from "../types.js";
13
14
 
14
15
  // ---------------------------------------------------------------------------
@@ -21,6 +22,16 @@ function parseJsonColumn<T>(value: string): T {
21
22
  return JSON.parse(value) as T;
22
23
  }
23
24
 
25
+ function validateRegisteredRedirectUris(redirectUris: string[]): string | null {
26
+ for (const redirectUri of redirectUris) {
27
+ const error = validateRedirectUri(redirectUri);
28
+ if (error) {
29
+ return `Invalid redirect URI: ${error}`;
30
+ }
31
+ }
32
+ return null;
33
+ }
34
+
24
35
  // ---------------------------------------------------------------------------
25
36
  // Types
26
37
  // ---------------------------------------------------------------------------
@@ -61,6 +72,17 @@ export async function handleOAuthClientCreate(
61
72
  };
62
73
  }
63
74
 
75
+ const redirectUriError = validateRegisteredRedirectUris(input.redirectUris);
76
+ if (redirectUriError) {
77
+ return {
78
+ success: false,
79
+ error: {
80
+ code: "VALIDATION_ERROR",
81
+ message: redirectUriError,
82
+ },
83
+ };
84
+ }
85
+
64
86
  // Check for duplicate client ID
65
87
  const existing = await db
66
88
  .selectFrom("_emdash_oauth_clients")
@@ -83,7 +105,7 @@ export async function handleOAuthClientCreate(
83
105
  id: input.id,
84
106
  name: input.name,
85
107
  redirect_uris: JSON.stringify(input.redirectUris),
86
- scopes: input.scopes ? JSON.stringify(input.scopes) : null,
108
+ scopes: input.scopes && input.scopes.length > 0 ? JSON.stringify(input.scopes) : null,
87
109
  })
88
110
  .execute();
89
111
 
@@ -93,7 +115,7 @@ export async function handleOAuthClientCreate(
93
115
  id: input.id,
94
116
  name: input.name,
95
117
  redirectUris: input.redirectUris,
96
- scopes: input.scopes ?? null,
118
+ scopes: input.scopes && input.scopes.length > 0 ? input.scopes : null,
97
119
  createdAt: now,
98
120
  updatedAt: now,
99
121
  },
@@ -222,7 +244,20 @@ export async function handleOAuthClientUpdate(
222
244
  };
223
245
  }
224
246
 
225
- const updates: Record<string, string> = {
247
+ if (input.redirectUris !== undefined) {
248
+ const redirectUriError = validateRegisteredRedirectUris(input.redirectUris);
249
+ if (redirectUriError) {
250
+ return {
251
+ success: false,
252
+ error: {
253
+ code: "VALIDATION_ERROR",
254
+ message: redirectUriError,
255
+ },
256
+ };
257
+ }
258
+ }
259
+
260
+ const updates: Record<string, string | null> = {
226
261
  updated_at: new Date().toISOString(),
227
262
  };
228
263
 
@@ -233,7 +268,8 @@ export async function handleOAuthClientUpdate(
233
268
  updates.redirect_uris = JSON.stringify(input.redirectUris);
234
269
  }
235
270
  if (input.scopes !== undefined) {
236
- updates.scopes = input.scopes ? JSON.stringify(input.scopes) : "";
271
+ updates.scopes =
272
+ input.scopes && input.scopes.length > 0 ? JSON.stringify(input.scopes) : null;
237
273
  }
238
274
 
239
275
  await db.updateTable("_emdash_oauth_clients").set(updates).where("id", "=", clientId).execute();
@@ -7,6 +7,7 @@ import { ulid } from "ulidx";
7
7
 
8
8
  import { TaxonomyRepository } from "../../database/repositories/taxonomy.js";
9
9
  import type { Database } from "../../database/types.js";
10
+ import { invalidateTermCache } from "../../taxonomies/index.js";
10
11
  import type { ApiResult } from "../types.js";
11
12
 
12
13
  /** Taxonomy name validation pattern: lowercase alphanumeric + underscores, starts with letter */
@@ -323,6 +324,10 @@ export async function handleTermCreate(
323
324
  data: input.description ? { description: input.description } : undefined,
324
325
  });
325
326
 
327
+ // New term means `hasAnyTermAssignments` may flip from false->true next
328
+ // time an entry is tagged. Clear the cache so the next read re-probes.
329
+ invalidateTermCache();
330
+
326
331
  return {
327
332
  success: true,
328
333
  data: {
@@ -442,6 +447,10 @@ export async function handleTermUpdate(
442
447
  data: input.description !== undefined ? { description: input.description } : undefined,
443
448
  });
444
449
 
450
+ // Term label/slug changes are reflected in hydrated entry.data.terms —
451
+ // invalidate so the next read doesn't short-circuit on a stale probe.
452
+ invalidateTermCache();
453
+
445
454
  if (!updated) {
446
455
  return {
447
456
  success: false,
@@ -513,6 +522,10 @@ export async function handleTermDelete(
513
522
  };
514
523
  }
515
524
 
525
+ // Deleting a term cascades to content_taxonomies; invalidate so
526
+ // hydration no longer sees the stale assignments.
527
+ invalidateTermCache();
528
+
516
529
  return { success: true, data: { deleted: true } };
517
530
  } catch {
518
531
  return {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Validate a redirect URI per OAuth 2.1 security requirements.
3
+ *
4
+ * Allows localhost / loopback redirect URIs over HTTP for native clients,
5
+ * and any HTTPS URL for web-based flows.
6
+ */
7
+ export function validateRedirectUri(uri: string): string | null {
8
+ try {
9
+ const url = new URL(uri);
10
+
11
+ // Reject protocol-relative URLs
12
+ if (uri.startsWith("//")) {
13
+ return "Protocol-relative redirect URIs are not allowed";
14
+ }
15
+
16
+ // Allow localhost/loopback over HTTP (for desktop MCP clients)
17
+ if (url.protocol === "http:") {
18
+ const host = url.hostname;
19
+ if (host === "127.0.0.1" || host === "localhost" || host === "[::1]") {
20
+ return null;
21
+ }
22
+ return "HTTP redirect URIs are only allowed for localhost";
23
+ }
24
+
25
+ // Allow HTTPS
26
+ if (url.protocol === "https:") {
27
+ return null;
28
+ }
29
+
30
+ return `Unsupported redirect URI scheme: ${url.protocol}`;
31
+ } catch {
32
+ return "Invalid redirect URI";
33
+ }
34
+ }
@@ -37,6 +37,7 @@ import {
37
37
  trashedContentListResponseSchema,
38
38
  } from "../schemas/content.js";
39
39
  import {
40
+ DEFAULT_MAX_UPLOAD_SIZE,
40
41
  mediaConfirmBody,
41
42
  mediaConfirmResponseSchema,
42
43
  mediaExistingResponseSchema,
@@ -623,121 +624,123 @@ const contentPaths = {
623
624
  // Media routes
624
625
  // ---------------------------------------------------------------------------
625
626
 
626
- const mediaPaths = {
627
- "/_emdash/api/media": {
628
- get: {
629
- operationId: "listMedia",
630
- summary: "List media items",
631
- tags: ["Media"],
632
- requestParams: { query: mediaListQuery },
633
- responses: {
634
- "200": {
635
- description: "Media list",
636
- content: { [JSON_CONTENT]: { schema: successEnvelope(mediaListResponseSchema) } },
627
+ function buildMediaPaths(maxUploadSize: number) {
628
+ return {
629
+ "/_emdash/api/media": {
630
+ get: {
631
+ operationId: "listMedia",
632
+ summary: "List media items",
633
+ tags: ["Media"],
634
+ requestParams: { query: mediaListQuery },
635
+ responses: {
636
+ "200": {
637
+ description: "Media list",
638
+ content: { [JSON_CONTENT]: { schema: successEnvelope(mediaListResponseSchema) } },
639
+ },
640
+ ...authErrors,
641
+ ...standardErrors(500),
637
642
  },
638
- ...authErrors,
639
- ...standardErrors(500),
640
643
  },
641
644
  },
642
- },
643
- "/_emdash/api/media/{id}": {
644
- get: {
645
- operationId: "getMedia",
646
- summary: "Get a media item",
647
- tags: ["Media"],
648
- requestParams: {
649
- path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
650
- },
651
- responses: {
652
- "200": {
653
- description: "Media item",
654
- content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } },
645
+ "/_emdash/api/media/{id}": {
646
+ get: {
647
+ operationId: "getMedia",
648
+ summary: "Get a media item",
649
+ tags: ["Media"],
650
+ requestParams: {
651
+ path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
655
652
  },
656
- ...authErrors,
657
- ...standardErrors(404, 500),
658
- },
659
- },
660
- put: {
661
- operationId: "updateMedia",
662
- summary: "Update media metadata",
663
- tags: ["Media"],
664
- requestParams: {
665
- path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
666
- },
667
- requestBody: { content: { [JSON_CONTENT]: { schema: mediaUpdateBody } } },
668
- responses: {
669
- "200": {
670
- description: "Updated media item",
671
- content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } },
653
+ responses: {
654
+ "200": {
655
+ description: "Media item",
656
+ content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } },
657
+ },
658
+ ...authErrors,
659
+ ...standardErrors(404, 500),
660
+ },
661
+ },
662
+ put: {
663
+ operationId: "updateMedia",
664
+ summary: "Update media metadata",
665
+ tags: ["Media"],
666
+ requestParams: {
667
+ path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
668
+ },
669
+ requestBody: { content: { [JSON_CONTENT]: { schema: mediaUpdateBody } } },
670
+ responses: {
671
+ "200": {
672
+ description: "Updated media item",
673
+ content: { [JSON_CONTENT]: { schema: successEnvelope(mediaResponseSchema) } },
674
+ },
675
+ ...authErrors,
676
+ ...standardErrors(400, 404, 500),
672
677
  },
673
- ...authErrors,
674
- ...standardErrors(400, 404, 500),
675
678
  },
676
- },
677
- delete: {
678
- operationId: "deleteMedia",
679
- summary: "Delete a media item",
680
- tags: ["Media"],
681
- requestParams: {
682
- path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
683
- },
684
- responses: {
685
- "200": {
686
- description: "Deleted",
687
- content: { [JSON_CONTENT]: { schema: successEnvelope(deleteResponseSchema) } },
679
+ delete: {
680
+ operationId: "deleteMedia",
681
+ summary: "Delete a media item",
682
+ tags: ["Media"],
683
+ requestParams: {
684
+ path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
688
685
  },
689
- ...authErrors,
690
- ...standardErrors(404, 500),
691
- },
692
- },
693
- },
694
- "/_emdash/api/media/upload-url": {
695
- post: {
696
- operationId: "getMediaUploadUrl",
697
- summary: "Get a signed URL for direct upload",
698
- description:
699
- "Returns a signed URL for direct-to-storage upload. Creates a pending media record.",
700
- tags: ["Media"],
701
- requestBody: { content: { [JSON_CONTENT]: { schema: mediaUploadUrlBody } } },
702
- responses: {
703
- "200": {
704
- description: "Upload URL or existing media (deduplication)",
705
- content: {
706
- [JSON_CONTENT]: {
707
- schema: successEnvelope(
708
- z.union([mediaUploadUrlResponseSchema, mediaExistingResponseSchema]),
709
- ),
686
+ responses: {
687
+ "200": {
688
+ description: "Deleted",
689
+ content: { [JSON_CONTENT]: { schema: successEnvelope(deleteResponseSchema) } },
690
+ },
691
+ ...authErrors,
692
+ ...standardErrors(404, 500),
693
+ },
694
+ },
695
+ },
696
+ "/_emdash/api/media/upload-url": {
697
+ post: {
698
+ operationId: "getMediaUploadUrl",
699
+ summary: "Get a signed URL for direct upload",
700
+ description:
701
+ "Returns a signed URL for direct-to-storage upload. Creates a pending media record.",
702
+ tags: ["Media"],
703
+ requestBody: { content: { [JSON_CONTENT]: { schema: mediaUploadUrlBody(maxUploadSize) } } },
704
+ responses: {
705
+ "200": {
706
+ description: "Upload URL or existing media (deduplication)",
707
+ content: {
708
+ [JSON_CONTENT]: {
709
+ schema: successEnvelope(
710
+ z.union([mediaUploadUrlResponseSchema, mediaExistingResponseSchema]),
711
+ ),
712
+ },
710
713
  },
711
714
  },
712
- },
713
- ...authErrors,
714
- ...standardErrors(400, 500),
715
- },
716
- },
717
- },
718
- "/_emdash/api/media/{id}/confirm": {
719
- post: {
720
- operationId: "confirmMediaUpload",
721
- summary: "Confirm a media upload",
722
- description: "Marks a pending media record as ready after the file has been uploaded.",
723
- tags: ["Media"],
724
- requestParams: {
725
- path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
726
- },
727
- requestBody: { content: { [JSON_CONTENT]: { schema: mediaConfirmBody } } },
728
- responses: {
729
- "200": {
730
- description: "Confirmed media item with URL",
731
- content: {
732
- [JSON_CONTENT]: { schema: successEnvelope(mediaConfirmResponseSchema) },
715
+ ...authErrors,
716
+ ...standardErrors(400, 500),
717
+ },
718
+ },
719
+ },
720
+ "/_emdash/api/media/{id}/confirm": {
721
+ post: {
722
+ operationId: "confirmMediaUpload",
723
+ summary: "Confirm a media upload",
724
+ description: "Marks a pending media record as ready after the file has been uploaded.",
725
+ tags: ["Media"],
726
+ requestParams: {
727
+ path: z.object({ id: z.string().meta({ description: "Media ID" }) }),
728
+ },
729
+ requestBody: { content: { [JSON_CONTENT]: { schema: mediaConfirmBody } } },
730
+ responses: {
731
+ "200": {
732
+ description: "Confirmed media item with URL",
733
+ content: {
734
+ [JSON_CONTENT]: { schema: successEnvelope(mediaConfirmResponseSchema) },
735
+ },
733
736
  },
737
+ ...authErrors,
738
+ ...standardErrors(400, 404, 500),
734
739
  },
735
- ...authErrors,
736
- ...standardErrors(400, 404, 500),
737
740
  },
738
741
  },
739
- },
740
- } as const;
742
+ } as const;
743
+ }
741
744
 
742
745
  // ---------------------------------------------------------------------------
743
746
  // Schema routes
@@ -2249,20 +2252,22 @@ const userPaths = {
2249
2252
  // Merge all paths
2250
2253
  // ---------------------------------------------------------------------------
2251
2254
 
2252
- const allPaths = {
2253
- ...contentPaths,
2254
- ...mediaPaths,
2255
- ...schemaPaths,
2256
- ...commentsPaths,
2257
- ...taxonomyPaths,
2258
- ...menuPaths,
2259
- ...sectionPaths,
2260
- ...widgetPaths,
2261
- ...settingsPaths,
2262
- ...searchPaths,
2263
- ...redirectPaths,
2264
- ...userPaths,
2265
- } as const;
2255
+ function buildAllPaths(maxUploadSize: number) {
2256
+ return {
2257
+ ...contentPaths,
2258
+ ...buildMediaPaths(maxUploadSize),
2259
+ ...schemaPaths,
2260
+ ...commentsPaths,
2261
+ ...taxonomyPaths,
2262
+ ...menuPaths,
2263
+ ...sectionPaths,
2264
+ ...widgetPaths,
2265
+ ...settingsPaths,
2266
+ ...searchPaths,
2267
+ ...redirectPaths,
2268
+ ...userPaths,
2269
+ } as const;
2270
+ }
2266
2271
 
2267
2272
  // ---------------------------------------------------------------------------
2268
2273
  // Document
@@ -2274,7 +2279,10 @@ const allPaths = {
2274
2279
  * Covers: Content, Media, Schema, Comments, Taxonomies, Menus,
2275
2280
  * Sections, Widgets, Settings, Search, Redirects, Users.
2276
2281
  */
2277
- export function generateOpenApiDocument(): oas31.OpenAPIObject {
2282
+ export function generateOpenApiDocument(
2283
+ options: { maxUploadSize?: number } = {},
2284
+ ): oas31.OpenAPIObject {
2285
+ const maxUploadSize = options.maxUploadSize ?? DEFAULT_MAX_UPLOAD_SIZE;
2278
2286
  return createDocument({
2279
2287
  openapi: "3.1.0",
2280
2288
  info: {
@@ -2363,6 +2371,6 @@ export function generateOpenApiDocument(): oas31.OpenAPIObject {
2363
2371
  },
2364
2372
  security: [{ session: [] }, { bearer: [] }],
2365
2373
  // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- readonly const paths are compatible at runtime
2366
- paths: allPaths as unknown as ZodOpenApiPathsObject,
2374
+ paths: buildAllPaths(maxUploadSize) as unknown as ZodOpenApiPathsObject,
2367
2375
  });
2368
2376
  }
@@ -60,6 +60,13 @@ export const inviteCreateBody = z
60
60
  })
61
61
  .meta({ id: "InviteCreateBody" });
62
62
 
63
+ export const inviteRegisterOptionsBody = z
64
+ .object({
65
+ token: z.string().min(1),
66
+ name: z.string().optional(),
67
+ })
68
+ .meta({ id: "InviteRegisterOptionsBody" });
69
+
63
70
  export const inviteCompleteBody = z
64
71
  .object({
65
72
  token: z.string().min(1),