emdash 0.6.0 → 1.0.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 (263) hide show
  1. package/dist/{adapters-Di31kZ28.d.mts → adapters-BKSf3T9R.d.mts} +1 -1
  2. package/dist/{adapters-Di31kZ28.d.mts.map → adapters-BKSf3T9R.d.mts.map} +1 -1
  3. package/dist/{apply-B4MsLM-w.mjs → apply-x0eMK1lX.mjs} +186 -28
  4. package/dist/apply-x0eMK1lX.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 +92 -17
  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 +22 -2
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.mjs +2 -2
  14. package/dist/astro/middleware/request-context.mjs +7 -2
  15. package/dist/astro/middleware/request-context.mjs.map +1 -1
  16. package/dist/astro/middleware/setup.mjs +1 -1
  17. package/dist/astro/middleware.d.mts.map +1 -1
  18. package/dist/astro/middleware.mjs +263 -74
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +25 -8
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-C4OVd8b3.mjs → byline-Chbr2GoP.mjs} +3 -3
  23. package/dist/byline-Chbr2GoP.mjs.map +1 -0
  24. package/dist/{bylines-hPTW79hw.mjs → bylines-CRNsVG88.mjs} +4 -4
  25. package/dist/{bylines-hPTW79hw.mjs.map → bylines-CRNsVG88.mjs.map} +1 -1
  26. package/dist/cli/index.mjs +17 -13
  27. package/dist/cli/index.mjs.map +1 -1
  28. package/dist/client/cf-access.d.mts +1 -1
  29. package/dist/client/index.d.mts +1 -1
  30. package/dist/client/index.mjs +1 -1
  31. package/dist/{content-BsBoyj8G.mjs → content-BcQPYxdV.mjs} +39 -15
  32. package/dist/content-BcQPYxdV.mjs.map +1 -0
  33. package/dist/db/index.d.mts +3 -3
  34. package/dist/db/index.mjs +1 -1
  35. package/dist/db/libsql.d.mts +1 -1
  36. package/dist/db/postgres.d.mts +1 -1
  37. package/dist/db/sqlite.d.mts +1 -1
  38. package/dist/{db-errors-D0UT85nC.mjs → db-errors-l1Qh2RPR.mjs} +1 -1
  39. package/dist/{db-errors-D0UT85nC.mjs.map → db-errors-l1Qh2RPR.mjs.map} +1 -1
  40. package/dist/{default-CME5YdZ3.mjs → default-DCVqE5ib.mjs} +1 -1
  41. package/dist/{default-CME5YdZ3.mjs.map → default-DCVqE5ib.mjs.map} +1 -1
  42. package/dist/{error-CiYn9yDu.mjs → error-zG5T1UGA.mjs} +1 -1
  43. package/dist/error-zG5T1UGA.mjs.map +1 -0
  44. package/dist/{index-BYv0mB9g.d.mts → index-DIb-CzNx.d.mts} +232 -15
  45. package/dist/index-DIb-CzNx.d.mts.map +1 -0
  46. package/dist/index.d.mts +11 -11
  47. package/dist/index.mjs +23 -21
  48. package/dist/{load-CBcmDIot.mjs → load-CyEoextb.mjs} +1 -1
  49. package/dist/{load-CBcmDIot.mjs.map → load-CyEoextb.mjs.map} +1 -1
  50. package/dist/{loader-DeiBJEMe.mjs → loader-CndGj8kM.mjs} +8 -6
  51. package/dist/loader-CndGj8kM.mjs.map +1 -0
  52. package/dist/{manifest-schema-V30qsMft.mjs → manifest-schema-DH9xhc6t.mjs} +13 -1
  53. package/dist/manifest-schema-DH9xhc6t.mjs.map +1 -0
  54. package/dist/media/index.d.mts +1 -1
  55. package/dist/media/local-runtime.d.mts +7 -7
  56. package/dist/media/local-runtime.mjs +2 -2
  57. package/dist/{media-DqHVh136.mjs → media-D8FbNsl0.mjs} +4 -7
  58. package/dist/media-D8FbNsl0.mjs.map +1 -0
  59. package/dist/{mode-CpNnGkPz.mjs → mode-BnAOqItE.mjs} +1 -1
  60. package/dist/mode-BnAOqItE.mjs.map +1 -0
  61. package/dist/page/index.d.mts +2 -2
  62. package/dist/placeholder-C-fk5hYI.mjs.map +1 -1
  63. package/dist/{placeholder-tzpqGWII.d.mts → placeholder-D29tWZ7o.d.mts} +1 -1
  64. package/dist/{placeholder-tzpqGWII.d.mts.map → placeholder-D29tWZ7o.d.mts.map} +1 -1
  65. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  66. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  67. package/dist/{query-Bk_3vKvU.mjs → query-fqEdLFms.mjs} +9 -9
  68. package/dist/{query-Bk_3vKvU.mjs.map → query-fqEdLFms.mjs.map} +1 -1
  69. package/dist/{redirect-7lGhLBNZ.mjs → redirect-D_pshWdf.mjs} +69 -13
  70. package/dist/redirect-D_pshWdf.mjs.map +1 -0
  71. package/dist/{registry-Ci3WxVAr.mjs → registry-C3Mr0ODu.mjs} +33 -9
  72. package/dist/registry-C3Mr0ODu.mjs.map +1 -0
  73. package/dist/{request-cache-DiR961CV.mjs → request-cache-Ci7f5pBb.mjs} +1 -1
  74. package/dist/request-cache-Ci7f5pBb.mjs.map +1 -0
  75. package/dist/{runner-Fl2NcUUz.d.mts → runner-OURCaApa.d.mts} +2 -2
  76. package/dist/{runner-Fl2NcUUz.d.mts.map → runner-OURCaApa.d.mts.map} +1 -1
  77. package/dist/{runner-Cd-_WyDo.mjs → runner-tQ7BJ4T7.mjs} +211 -134
  78. package/dist/runner-tQ7BJ4T7.mjs.map +1 -0
  79. package/dist/runtime.d.mts +6 -6
  80. package/dist/runtime.mjs +2 -2
  81. package/dist/{search-DI4bM2w9.mjs → search-BoZYFuUk.mjs} +339 -102
  82. package/dist/search-BoZYFuUk.mjs.map +1 -0
  83. package/dist/seed/index.d.mts +2 -2
  84. package/dist/seed/index.mjs +12 -12
  85. package/dist/seo/index.d.mts +1 -1
  86. package/dist/storage/local.d.mts +1 -1
  87. package/dist/storage/local.mjs +1 -1
  88. package/dist/storage/s3.d.mts +1 -1
  89. package/dist/storage/s3.d.mts.map +1 -1
  90. package/dist/storage/s3.mjs +4 -4
  91. package/dist/storage/s3.mjs.map +1 -1
  92. package/dist/{taxonomies-DbrKzDju.mjs → taxonomies-B4IAshV8.mjs} +5 -5
  93. package/dist/{taxonomies-DbrKzDju.mjs.map → taxonomies-B4IAshV8.mjs.map} +1 -1
  94. package/dist/{tokens-BFPFx3CA.mjs → tokens-D9vnZqYS.mjs} +1 -1
  95. package/dist/{tokens-BFPFx3CA.mjs.map → tokens-D9vnZqYS.mjs.map} +1 -1
  96. package/dist/{transport-BykRfpyy.mjs → transport-C9ugt2Nr.mjs} +1 -1
  97. package/dist/{transport-BykRfpyy.mjs.map → transport-C9ugt2Nr.mjs.map} +1 -1
  98. package/dist/{transport-H4Iwx7tC.d.mts → transport-CUnEL3Vs.d.mts} +1 -1
  99. package/dist/{transport-H4Iwx7tC.d.mts.map → transport-CUnEL3Vs.d.mts.map} +1 -1
  100. package/dist/types-BIgulNsW.mjs +68 -0
  101. package/dist/types-BIgulNsW.mjs.map +1 -0
  102. package/dist/{types-DDS4MxsT.mjs → types-Bm1dn-q3.mjs} +1 -1
  103. package/dist/{types-DDS4MxsT.mjs.map → types-Bm1dn-q3.mjs.map} +1 -1
  104. package/dist/{types-CnZYHyLW.d.mts → types-BmPPSUEx.d.mts} +1 -1
  105. package/dist/{types-CnZYHyLW.d.mts.map → types-BmPPSUEx.d.mts.map} +1 -1
  106. package/dist/{types-6CUZRrZP.d.mts → types-BrA0xf5I.d.mts} +24 -2
  107. package/dist/{types-6CUZRrZP.d.mts.map → types-BrA0xf5I.d.mts.map} +1 -1
  108. package/dist/{types-8xrvl_68.d.mts → types-CS8FIX7L.d.mts} +10 -1
  109. package/dist/{types-8xrvl_68.d.mts.map → types-CS8FIX7L.d.mts.map} +1 -1
  110. package/dist/{types-BH2L167P.mjs → types-CgqmmMJB.mjs} +1 -1
  111. package/dist/{types-BH2L167P.mjs.map → types-CgqmmMJB.mjs.map} +1 -1
  112. package/dist/{types-CFWjXmus.d.mts → types-DIMwPFub.d.mts} +1 -1
  113. package/dist/{types-CFWjXmus.d.mts.map → types-DIMwPFub.d.mts.map} +1 -1
  114. package/dist/{types-DgrIP0tF.d.mts → types-i36XcA_X.d.mts} +49 -6
  115. package/dist/types-i36XcA_X.d.mts.map +1 -0
  116. package/dist/{validate-CqsNItbt.mjs → validate-CxVsLehf.mjs} +2 -2
  117. package/dist/{validate-CqsNItbt.mjs.map → validate-CxVsLehf.mjs.map} +1 -1
  118. package/dist/{validate-CaLH1Ia2.d.mts → validate-DHxmpFJt.d.mts} +4 -4
  119. package/dist/{validate-CaLH1Ia2.d.mts.map → validate-DHxmpFJt.d.mts.map} +1 -1
  120. package/dist/validation-C-ZpN2GI.mjs +144 -0
  121. package/dist/validation-C-ZpN2GI.mjs.map +1 -0
  122. package/dist/version-DJrV1K0M.mjs +7 -0
  123. package/dist/{version-Uaf2ynPX.mjs.map → version-DJrV1K0M.mjs.map} +1 -1
  124. package/dist/zod-generator-CpwccCIv.mjs +132 -0
  125. package/dist/zod-generator-CpwccCIv.mjs.map +1 -0
  126. package/package.json +19 -6
  127. package/src/api/auth-storage.ts +37 -0
  128. package/src/api/error.ts +6 -0
  129. package/src/api/errors.ts +8 -0
  130. package/src/api/handlers/comments.ts +13 -0
  131. package/src/api/handlers/content.ts +124 -3
  132. package/src/api/handlers/index.ts +2 -0
  133. package/src/api/handlers/media.ts +8 -1
  134. package/src/api/handlers/menus.ts +160 -21
  135. package/src/api/handlers/redirects.ts +16 -3
  136. package/src/api/handlers/sections.ts +8 -1
  137. package/src/api/handlers/taxonomies.ts +128 -16
  138. package/src/api/handlers/validation.ts +212 -0
  139. package/src/api/openapi/document.ts +4 -1
  140. package/src/api/public-url.ts +6 -3
  141. package/src/api/route-utils.ts +14 -0
  142. package/src/api/schemas/common.ts +1 -1
  143. package/src/api/schemas/content.ts +8 -0
  144. package/src/api/schemas/setup.ts +8 -0
  145. package/src/api/schemas/widgets.ts +12 -10
  146. package/src/api/setup-complete.ts +40 -0
  147. package/src/astro/integration/font-provider.ts +3 -1
  148. package/src/astro/integration/index.ts +15 -2
  149. package/src/astro/integration/routes.ts +28 -0
  150. package/src/astro/integration/runtime.ts +74 -2
  151. package/src/astro/integration/virtual-modules.ts +41 -0
  152. package/src/astro/integration/vite-config.ts +43 -12
  153. package/src/astro/middleware/auth.ts +21 -0
  154. package/src/astro/middleware.ts +18 -1
  155. package/src/astro/routes/PluginRegistry.tsx +10 -1
  156. package/src/astro/routes/admin.astro +14 -7
  157. package/src/astro/routes/api/auth/magic-link/send.ts +2 -1
  158. package/src/astro/routes/api/auth/mode.ts +57 -0
  159. package/src/astro/routes/api/auth/oauth/[provider]/callback.ts +23 -3
  160. package/src/astro/routes/api/auth/oauth/[provider].ts +10 -4
  161. package/src/astro/routes/api/auth/passkey/options.ts +2 -1
  162. package/src/astro/routes/api/auth/signup/request.ts +26 -8
  163. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +10 -6
  164. package/src/astro/routes/api/content/[collection]/[id]/compare.ts +1 -1
  165. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +1 -1
  166. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +1 -1
  167. package/src/astro/routes/api/content/[collection]/[id]/translations.ts +26 -0
  168. package/src/astro/routes/api/content/[collection]/[id].ts +30 -2
  169. package/src/astro/routes/api/content/[collection]/index.ts +20 -10
  170. package/src/astro/routes/api/content/[collection]/trash.ts +1 -1
  171. package/src/astro/routes/api/import/wordpress/media.ts +2 -7
  172. package/src/astro/routes/api/import/wordpress/prepare.ts +10 -0
  173. package/src/astro/routes/api/import/wordpress-plugin/analyze.ts +4 -3
  174. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +4 -3
  175. package/src/astro/routes/api/manifest.ts +7 -0
  176. package/src/astro/routes/api/oauth/device/code.ts +2 -1
  177. package/src/astro/routes/api/oauth/device/token.ts +2 -1
  178. package/src/astro/routes/api/settings/email.ts +4 -9
  179. package/src/astro/routes/api/setup/admin-verify.ts +30 -5
  180. package/src/astro/routes/api/setup/admin.ts +38 -8
  181. package/src/astro/routes/api/setup/index.ts +7 -4
  182. package/src/astro/routes/api/setup/status.ts +3 -1
  183. package/src/astro/routes/api/widget-areas/[name]/widgets/[id].ts +4 -1
  184. package/src/astro/routes/api/widget-areas/[name]/widgets.ts +4 -1
  185. package/src/astro/routes/api/widget-areas/[name].ts +4 -1
  186. package/src/astro/routes/api/widget-areas/index.ts +4 -1
  187. package/src/astro/types.ts +18 -0
  188. package/src/auth/mode.ts +15 -3
  189. package/src/auth/providers/github-admin.tsx +29 -0
  190. package/src/auth/providers/github.ts +31 -0
  191. package/src/auth/providers/google-admin.tsx +44 -0
  192. package/src/auth/providers/google.ts +31 -0
  193. package/src/auth/rate-limit.ts +50 -22
  194. package/src/auth/setup-nonce.ts +22 -0
  195. package/src/auth/trusted-proxy.ts +92 -0
  196. package/src/auth/types.ts +114 -4
  197. package/src/cli/commands/bundle.ts +3 -1
  198. package/src/components/EmDashImage.astro +7 -6
  199. package/src/components/Gallery.astro +5 -3
  200. package/src/components/Image.astro +8 -3
  201. package/src/components/InlinePortableTextEditor.tsx +2 -1
  202. package/src/components/LiveSearch.astro +5 -14
  203. package/src/database/migrations/035_bounded_404_log.ts +112 -0
  204. package/src/database/migrations/runner.ts +2 -0
  205. package/src/database/repositories/audit.ts +6 -8
  206. package/src/database/repositories/byline.ts +6 -8
  207. package/src/database/repositories/comment.ts +12 -16
  208. package/src/database/repositories/content.ts +79 -40
  209. package/src/database/repositories/index.ts +1 -1
  210. package/src/database/repositories/media.ts +10 -13
  211. package/src/database/repositories/options.ts +25 -0
  212. package/src/database/repositories/plugin-storage.ts +4 -6
  213. package/src/database/repositories/redirect.ts +123 -24
  214. package/src/database/repositories/taxonomy.ts +14 -3
  215. package/src/database/repositories/types.ts +57 -8
  216. package/src/database/repositories/user.ts +6 -8
  217. package/src/database/types.ts +9 -0
  218. package/src/emdash-runtime.ts +309 -91
  219. package/src/import/registry.ts +4 -3
  220. package/src/import/ssrf.ts +253 -12
  221. package/src/index.ts +5 -1
  222. package/src/loader.ts +6 -5
  223. package/src/mcp/server.ts +753 -107
  224. package/src/media/normalize.ts +1 -1
  225. package/src/media/url.ts +78 -0
  226. package/src/plugins/context.ts +15 -3
  227. package/src/plugins/email-console.ts +10 -3
  228. package/src/plugins/hooks.ts +11 -0
  229. package/src/plugins/manager.ts +6 -0
  230. package/src/plugins/manifest-schema.ts +12 -0
  231. package/src/plugins/request-meta.ts +66 -15
  232. package/src/plugins/routes.ts +3 -1
  233. package/src/plugins/types.ts +23 -2
  234. package/src/query.ts +1 -1
  235. package/src/request-cache.ts +3 -0
  236. package/src/schema/registry.ts +41 -5
  237. package/src/search/fts-manager.ts +0 -2
  238. package/src/search/query.ts +111 -26
  239. package/src/search/types.ts +8 -1
  240. package/src/sections/index.ts +7 -9
  241. package/src/seed/apply.ts +26 -0
  242. package/src/storage/s3.ts +12 -6
  243. package/src/virtual-modules.d.ts +21 -1
  244. package/src/visual-editing/toolbar.ts +6 -1
  245. package/src/widgets/index.ts +1 -1
  246. package/dist/apply-B4MsLM-w.mjs.map +0 -1
  247. package/dist/byline-C4OVd8b3.mjs.map +0 -1
  248. package/dist/content-BsBoyj8G.mjs.map +0 -1
  249. package/dist/error-CiYn9yDu.mjs.map +0 -1
  250. package/dist/index-BYv0mB9g.d.mts.map +0 -1
  251. package/dist/loader-DeiBJEMe.mjs.map +0 -1
  252. package/dist/manifest-schema-V30qsMft.mjs.map +0 -1
  253. package/dist/media-DqHVh136.mjs.map +0 -1
  254. package/dist/mode-CpNnGkPz.mjs.map +0 -1
  255. package/dist/redirect-7lGhLBNZ.mjs.map +0 -1
  256. package/dist/registry-Ci3WxVAr.mjs.map +0 -1
  257. package/dist/request-cache-DiR961CV.mjs.map +0 -1
  258. package/dist/runner-Cd-_WyDo.mjs.map +0 -1
  259. package/dist/search-DI4bM2w9.mjs.map +0 -1
  260. package/dist/types-CMMN0pNg.mjs +0 -31
  261. package/dist/types-CMMN0pNg.mjs.map +0 -1
  262. package/dist/types-DgrIP0tF.d.mts.map +0 -1
  263. package/dist/version-Uaf2ynPX.mjs +0 -7
@@ -202,20 +202,17 @@ export class MediaRepository {
202
202
  .orderBy("id", "desc")
203
203
  .limit(limit + 1);
204
204
 
205
- // Handle cursor-based pagination
205
+ // Handle cursor-based pagination — throws on invalid cursor.
206
206
  if (options.cursor) {
207
- const decoded = decodeCursor(options.cursor);
208
- if (decoded) {
209
- const { orderValue: createdAt, id: cursorId } = decoded;
210
-
211
- // Keyset pagination: get items where (created_at, id) < cursor
212
- query = query.where((eb) =>
213
- eb.or([
214
- eb("created_at", "<", createdAt),
215
- eb.and([eb("created_at", "=", createdAt), eb("id", "<", cursorId)]),
216
- ]),
217
- );
218
- }
207
+ const { orderValue: createdAt, id: cursorId } = decodeCursor(options.cursor);
208
+
209
+ // Keyset pagination: get items where (created_at, id) < cursor
210
+ query = query.where((eb) =>
211
+ eb.or([
212
+ eb("created_at", "<", createdAt),
213
+ eb.and([eb("created_at", "=", createdAt), eb("id", "<", cursorId)]),
214
+ ]),
215
+ );
219
216
  }
220
217
 
221
218
  if (options.mimeType) {
@@ -55,6 +55,31 @@ export class OptionsRepository {
55
55
  .execute();
56
56
  }
57
57
 
58
+ /**
59
+ * Set an option value only if no row with that name exists. Atomic at the
60
+ * database level via INSERT ... ON CONFLICT DO NOTHING, so concurrent
61
+ * callers can't race past the check.
62
+ *
63
+ * Returns true when the row was inserted, false when a row already
64
+ * existed (regardless of its value — even an empty string or null).
65
+ */
66
+ async setIfAbsent<T = unknown>(name: string, value: T): Promise<boolean> {
67
+ const row: OptionTable = {
68
+ name,
69
+ value: JSON.stringify(value),
70
+ };
71
+
72
+ const result = await this.db
73
+ .insertInto("options")
74
+ .values(row)
75
+ .onConflict((oc) => oc.column("name").doNothing())
76
+ .executeTakeFirst();
77
+
78
+ // SQLite reports numInsertedOrUpdatedRows; Postgres reports the same.
79
+ // When the ON CONFLICT branch fires and does nothing, the count is 0.
80
+ return (result.numInsertedOrUpdatedRows ?? 0n) > 0n;
81
+ }
82
+
58
83
  /**
59
84
  * Delete an option
60
85
  */
@@ -226,14 +226,12 @@ export class PluginStorageRepository<T = unknown> implements StorageCollection<T
226
226
  query = query.where(({ eb }) => eb(sql.join(whereSqlParts, sql.raw("")), "=", sql.raw("1")));
227
227
  }
228
228
 
229
- // Handle cursor-based pagination
229
+ // Handle cursor-based pagination — throws on invalid cursor.
230
230
  if (cursor) {
231
231
  const decoded = decodeCursor(cursor);
232
- if (decoded) {
233
- query = query.where(({ eb }) =>
234
- eb(sql`(created_at, id)`, ">", sql`(${decoded.orderValue}, ${decoded.id})`),
235
- );
236
- }
232
+ query = query.where(({ eb }) =>
233
+ eb(sql`(created_at, id)`, ">", sql`(${decoded.orderValue}, ${decoded.id})`),
234
+ );
237
235
  }
238
236
 
239
237
  // Build ORDER BY using sql template
@@ -11,6 +11,32 @@ import { currentTimestampValue } from "../dialect-helpers.js";
11
11
  import type { Database, RedirectTable } from "../types.js";
12
12
  import { encodeCursor, decodeCursor, type FindManyResult } from "./types.js";
13
13
 
14
+ // ---------------------------------------------------------------------------
15
+ // Bounded 404 logging
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /**
19
+ * Hard cap on rows stored in `_emdash_404_log`. When exceeded, the oldest
20
+ * rows (by `last_seen_at`) are evicted on insert. Prevents an unauthenticated
21
+ * attacker from growing the table without bound by requesting unique URLs.
22
+ */
23
+ export const MAX_404_LOG_ROWS = 10_000;
24
+
25
+ /** Max stored length for the `Referer` header — truncated on insert. */
26
+ export const REFERRER_MAX_LENGTH = 512;
27
+
28
+ /** Max stored length for the `User-Agent` header — truncated on insert. */
29
+ export const USER_AGENT_MAX_LENGTH = 256;
30
+
31
+ /**
32
+ * Truncate a header-derived string to `max` chars, preserving `null`/`undefined`
33
+ * as `null`. Empty strings stay empty (the caller decides whether to coerce).
34
+ */
35
+ function truncateOrNull(value: string | null | undefined, max: number): string | null {
36
+ if (value === null || value === undefined) return null;
37
+ return value.length > max ? value.slice(0, max) : value;
38
+ }
39
+
14
40
  // ---------------------------------------------------------------------------
15
41
  // Types
16
42
  // ---------------------------------------------------------------------------
@@ -156,14 +182,12 @@ export class RedirectRepository {
156
182
 
157
183
  if (opts.cursor) {
158
184
  const decoded = decodeCursor(opts.cursor);
159
- if (decoded) {
160
- query = query.where((eb) =>
161
- eb.or([
162
- eb("created_at", "<", decoded.orderValue),
163
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
164
- ]),
165
- );
166
- }
185
+ query = query.where((eb) =>
186
+ eb.or([
187
+ eb("created_at", "<", decoded.orderValue),
188
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
189
+ ]),
190
+ );
167
191
  }
168
192
 
169
193
  const rows = await query.execute();
@@ -369,22 +393,97 @@ export class RedirectRepository {
369
393
 
370
394
  // --- 404 log ------------------------------------------------------------
371
395
 
396
+ /**
397
+ * Record a 404 hit for `entry.path`.
398
+ *
399
+ * Dedups by path: repeat hits increment `hits` and refresh `last_seen_at`
400
+ * on the existing row instead of inserting a new one. Referrer and
401
+ * user-agent are truncated to bounded lengths so a malicious client can't
402
+ * blow up storage with huge headers. When the table would exceed
403
+ * MAX_404_LOG_ROWS, the oldest entries (by `last_seen_at`) are evicted.
404
+ *
405
+ * This is called from the public redirect middleware on every 404 and
406
+ * must never throw for an unauthenticated caller — failures bubble up to
407
+ * the middleware, which swallows them.
408
+ */
372
409
  async log404(entry: {
373
410
  path: string;
374
411
  referrer?: string | null;
375
412
  userAgent?: string | null;
376
413
  ip?: string | null;
377
414
  }): Promise<void> {
415
+ const now = new Date().toISOString();
416
+ const referrer = truncateOrNull(entry.referrer, REFERRER_MAX_LENGTH);
417
+ const userAgent = truncateOrNull(entry.userAgent, USER_AGENT_MAX_LENGTH);
418
+ const ip = entry.ip ?? null;
419
+
420
+ // Atomic upsert by path. The UNIQUE index on `path` makes this safe
421
+ // under concurrency: two requests for the same new path can't both
422
+ // insert — the second one hits the conflict branch and increments
423
+ // hits instead of failing with a uniqueness error.
378
424
  await this.db
379
425
  .insertInto("_emdash_404_log")
380
426
  .values({
381
427
  id: ulid(),
382
428
  path: entry.path,
383
- referrer: entry.referrer ?? null,
384
- user_agent: entry.userAgent ?? null,
385
- ip: entry.ip ?? null,
386
- created_at: new Date().toISOString(),
429
+ referrer,
430
+ user_agent: userAgent,
431
+ ip,
432
+ hits: 1,
433
+ last_seen_at: now,
434
+ created_at: now,
387
435
  })
436
+ .onConflict((oc) =>
437
+ oc.column("path").doUpdateSet({
438
+ hits: sql`hits + 1`,
439
+ last_seen_at: now,
440
+ referrer,
441
+ user_agent: userAgent,
442
+ ip,
443
+ }),
444
+ )
445
+ .execute();
446
+
447
+ // Enforce the row cap. Cheap when the table is under cap (single
448
+ // COUNT(*) query); evicts oldest rows if we're over. Updates (dedup
449
+ // hits) don't grow the table so this is a no-op for repeat paths.
450
+ await this.enforce404Cap();
451
+ }
452
+
453
+ /**
454
+ * Delete the oldest rows from `_emdash_404_log` if the row count exceeds
455
+ * MAX_404_LOG_ROWS. "Oldest" is by `last_seen_at`, so a path that keeps
456
+ * getting hit stays in the table even if it was first seen long ago.
457
+ *
458
+ * Private — callers use `log404`, which invokes this after every upsert.
459
+ */
460
+ private async enforce404Cap(): Promise<void> {
461
+ const countRow = await this.db
462
+ .selectFrom("_emdash_404_log")
463
+ .select((eb) => eb.fn.countAll<number>().as("c"))
464
+ .executeTakeFirst();
465
+ const count = Number(countRow?.c ?? 0);
466
+ if (count <= MAX_404_LOG_ROWS) return;
467
+
468
+ const excess = count - MAX_404_LOG_ROWS;
469
+
470
+ // Evict the oldest rows in a single SQL statement. Using a subquery
471
+ // (rather than materialising the victim IDs in JS and passing them
472
+ // back as bind parameters) keeps the statement bounded regardless of
473
+ // how far over cap the table is — important for existing installs
474
+ // that crossed the threshold before this cap was introduced.
475
+ await this.db
476
+ .deleteFrom("_emdash_404_log")
477
+ .where(
478
+ "id",
479
+ "in",
480
+ this.db
481
+ .selectFrom("_emdash_404_log")
482
+ .select("id")
483
+ .orderBy("last_seen_at", "asc")
484
+ .orderBy("id", "asc")
485
+ .limit(excess),
486
+ )
388
487
  .execute();
389
488
  }
390
489
 
@@ -408,14 +507,12 @@ export class RedirectRepository {
408
507
 
409
508
  if (opts.cursor) {
410
509
  const decoded = decodeCursor(opts.cursor);
411
- if (decoded) {
412
- query = query.where((eb) =>
413
- eb.or([
414
- eb("created_at", "<", decoded.orderValue),
415
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
416
- ]),
417
- );
418
- }
510
+ query = query.where((eb) =>
511
+ eb.or([
512
+ eb("created_at", "<", decoded.orderValue),
513
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
514
+ ]),
515
+ );
419
516
  }
420
517
 
421
518
  const rows = await query.execute();
@@ -438,6 +535,10 @@ export class RedirectRepository {
438
535
  }
439
536
 
440
537
  async get404Summary(limit = 50): Promise<NotFoundSummary[]> {
538
+ // Since rows are now deduped by path, each path has exactly one row
539
+ // with `hits` as the running count and `last_seen_at` as the latest
540
+ // timestamp. The subquery for `top_referrer` collapses to a simple
541
+ // pick of the row's stored referrer (the most recent one seen).
441
542
  const rows = await sql<{
442
543
  path: string;
443
544
  count: number;
@@ -446,14 +547,12 @@ export class RedirectRepository {
446
547
  }>`
447
548
  SELECT
448
549
  path,
449
- COUNT(*) as count,
450
- MAX(created_at) as last_seen,
550
+ SUM(hits) as count,
551
+ MAX(last_seen_at) as last_seen,
451
552
  (
452
553
  SELECT referrer FROM _emdash_404_log AS inner_log
453
554
  WHERE inner_log.path = _emdash_404_log.path
454
555
  AND referrer IS NOT NULL AND referrer != ''
455
- GROUP BY referrer
456
- ORDER BY COUNT(*) DESC
457
556
  LIMIT 1
458
557
  ) as top_referrer
459
558
  FROM _emdash_404_log
@@ -41,12 +41,15 @@ export class TaxonomyRepository {
41
41
  async create(input: CreateTaxonomyInput): Promise<Taxonomy> {
42
42
  const id = ulid();
43
43
 
44
+ // Empty-string parentId is coerced to null defensively. Higher layers
45
+ // also normalize this — see handleTermCreate / handleTermUpdate.
46
+ const parentId = input.parentId === undefined || input.parentId === "" ? null : input.parentId;
44
47
  const row: TaxonomyTable = {
45
48
  id,
46
49
  name: input.name,
47
50
  slug: input.slug,
48
51
  label: input.label,
49
- parent_id: input.parentId ?? null,
52
+ parent_id: parentId,
50
53
  data: input.data ? JSON.stringify(input.data) : null,
51
54
  };
52
55
 
@@ -90,11 +93,15 @@ export class TaxonomyRepository {
90
93
  * Get all terms for a taxonomy (e.g., all categories)
91
94
  */
92
95
  async findByName(name: string, options: { parentId?: string | null } = {}): Promise<Taxonomy[]> {
96
+ // `id asc` is a stable tiebreaker for terms that share a label.
97
+ // Without it the SQL ordering is implementation-defined when labels
98
+ // match, which breaks keyset pagination over `(label, id)`.
93
99
  let query = this.db
94
100
  .selectFrom("taxonomies")
95
101
  .selectAll()
96
102
  .where("name", "=", name)
97
- .orderBy("label", "asc");
103
+ .orderBy("label", "asc")
104
+ .orderBy("id", "asc");
98
105
 
99
106
  if (options.parentId !== undefined) {
100
107
  if (options.parentId === null) {
@@ -117,6 +124,7 @@ export class TaxonomyRepository {
117
124
  .selectAll()
118
125
  .where("parent_id", "=", parentId)
119
126
  .orderBy("label", "asc")
127
+ .orderBy("id", "asc")
120
128
  .execute();
121
129
 
122
130
  return rows.map((row) => this.rowToTaxonomy(row));
@@ -132,7 +140,10 @@ export class TaxonomyRepository {
132
140
  const updates: Partial<TaxonomyTable> = {};
133
141
  if (input.slug !== undefined) updates.slug = input.slug;
134
142
  if (input.label !== undefined) updates.label = input.label;
135
- if (input.parentId !== undefined) updates.parent_id = input.parentId;
143
+ if (input.parentId !== undefined) {
144
+ // Defense in depth: empty-string parentId means null (no parent).
145
+ updates.parent_id = input.parentId === "" ? null : input.parentId;
146
+ }
136
147
  if (input.data !== undefined) updates.data = JSON.stringify(input.data);
137
148
 
138
149
  if (Object.keys(updates).length > 0) {
@@ -1,5 +1,15 @@
1
1
  import { encodeBase64, decodeBase64 } from "../../utils/base64.js";
2
2
 
3
+ /**
4
+ * Hard cap on cursor length. Cursors we issue are short JSON-in-base64
5
+ * blobs; a real cursor is well under 200 chars. This guards against
6
+ * malicious callers passing megabyte-sized strings to force the base64
7
+ * decoder to allocate (decodeBase64 is O(N) in input size). The MCP and
8
+ * REST schemas also clamp at 2048 — this 4096 cap is a defense-in-depth
9
+ * floor inside the repository helpers.
10
+ */
11
+ const MAX_CURSOR_LENGTH = 4096;
12
+
3
13
  export interface CreateContentInput {
4
14
  type: string;
5
15
  slug?: string | null;
@@ -87,17 +97,45 @@ export function encodeCursor(orderValue: string, id: string): string {
87
97
  return encodeBase64(JSON.stringify({ orderValue, id }));
88
98
  }
89
99
 
90
- /** Decode a cursor to order value + id. Returns null if invalid. */
91
- export function decodeCursor(cursor: string): { orderValue: string; id: string } | null {
100
+ /**
101
+ * Thrown when a pagination cursor cannot be decoded.
102
+ *
103
+ * Repository callers should let this propagate; handler catch blocks
104
+ * map it to a structured `INVALID_CURSOR` error so client pagination
105
+ * bugs surface immediately rather than silently re-fetching the first
106
+ * page.
107
+ */
108
+ export class InvalidCursorError extends Error {
109
+ constructor(cursor: string) {
110
+ const display = cursor.length > 50 ? `${cursor.slice(0, 47)}...` : cursor;
111
+ super(`Invalid pagination cursor: ${display}`);
112
+ this.name = "InvalidCursorError";
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Decode a cursor to order value + id.
118
+ *
119
+ * Throws `InvalidCursorError` if the cursor is empty, not valid base64,
120
+ * not valid JSON, or doesn't contain string `orderValue` and `id` fields.
121
+ */
122
+ export function decodeCursor(cursor: string): { orderValue: string; id: string } {
123
+ if (!cursor) throw new InvalidCursorError(cursor);
124
+ if (cursor.length > MAX_CURSOR_LENGTH) throw new InvalidCursorError(cursor);
125
+ let parsed: unknown;
92
126
  try {
93
- const parsed = JSON.parse(decodeBase64(cursor));
94
- if (typeof parsed.orderValue === "string" && typeof parsed.id === "string") {
95
- return parsed;
96
- }
97
- return null;
127
+ parsed = JSON.parse(decodeBase64(cursor));
98
128
  } catch {
99
- return null;
129
+ throw new InvalidCursorError(cursor);
130
+ }
131
+ if (parsed === null || typeof parsed !== "object") {
132
+ throw new InvalidCursorError(cursor);
133
+ }
134
+ const candidate = parsed as { orderValue?: unknown; id?: unknown };
135
+ if (typeof candidate.orderValue !== "string" || typeof candidate.id !== "string") {
136
+ throw new InvalidCursorError(cursor);
100
137
  }
138
+ return { orderValue: candidate.orderValue, id: candidate.id };
101
139
  }
102
140
 
103
141
  export interface ContentItem {
@@ -121,6 +159,17 @@ export interface ContentItem {
121
159
  translationGroup: string | null;
122
160
  /** SEO metadata — only populated for collections with `has_seo` enabled */
123
161
  seo?: ContentSeo;
162
+ /**
163
+ * For collections that support `revisions`: when a draft revision exists,
164
+ * `data` reflects the unsaved draft and `liveData` carries the currently-
165
+ * published values. When no draft exists, `liveData` is undefined.
166
+ *
167
+ * Hydrated by `EmDashRuntime.hydrateDraftData()` — repositories themselves
168
+ * never set this field; it's purely a runtime-overlay concept that gives
169
+ * agents a clear picture of "draft vs. live" without re-fetching the
170
+ * revision history.
171
+ */
172
+ liveData?: Record<string, unknown>;
124
173
  }
125
174
 
126
175
  export class EmDashValidationError extends Error {
@@ -123,14 +123,12 @@ export class UserRepository {
123
123
 
124
124
  if (options.cursor) {
125
125
  const decoded = decodeCursor(options.cursor);
126
- if (decoded) {
127
- query = query.where((eb) =>
128
- eb.or([
129
- eb("created_at", "<", decoded.orderValue),
130
- eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
131
- ]),
132
- );
133
- }
126
+ query = query.where((eb) =>
127
+ eb.or([
128
+ eb("created_at", "<", decoded.orderValue),
129
+ eb.and([eb("created_at", "=", decoded.orderValue), eb("id", "<", decoded.id)]),
130
+ ]),
131
+ );
134
132
  }
135
133
 
136
134
  const rows = await query.execute();
@@ -466,6 +466,15 @@ export interface NotFoundLogTable {
466
466
  referrer: string | null;
467
467
  user_agent: string | null;
468
468
  ip: string | null;
469
+ hits: number;
470
+ /**
471
+ * Migration 035 adds this as a nullable column (SQLite can't add a
472
+ * NOT NULL column with a non-constant default to an existing table).
473
+ * The `log404` upsert always writes a value, so new and updated rows
474
+ * always have one, but existing rows pre-migration were backfilled
475
+ * without a NOT NULL constraint. Typed as nullable to match the schema.
476
+ */
477
+ last_seen_at: string | null;
469
478
  created_at: string;
470
479
  }
471
480