emdash 0.2.0 → 0.4.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 (197) hide show
  1. package/dist/{adapters-N6BF7RCD.d.mts → adapters-C2BzVy0p.d.mts} +1 -1
  2. package/dist/{adapters-N6BF7RCD.d.mts.map → adapters-C2BzVy0p.d.mts.map} +1 -1
  3. package/dist/{apply-wmVEOSbR.mjs → apply-Cma_PiF6.mjs} +38 -23
  4. package/dist/apply-Cma_PiF6.mjs.map +1 -0
  5. package/dist/astro/index.d.mts +25 -11
  6. package/dist/astro/index.d.mts.map +1 -1
  7. package/dist/astro/index.mjs +38 -25
  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.mjs +2 -2
  11. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  12. package/dist/astro/middleware/redirect.mjs +20 -8
  13. package/dist/astro/middleware/redirect.mjs.map +1 -1
  14. package/dist/astro/middleware/request-context.mjs +12 -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 +52 -45
  19. package/dist/astro/middleware.mjs.map +1 -1
  20. package/dist/astro/types.d.mts +9 -9
  21. package/dist/astro/types.d.mts.map +1 -1
  22. package/dist/{byline-1WQPlISL.mjs → byline-WuOq9MFJ.mjs} +5 -4
  23. package/dist/byline-WuOq9MFJ.mjs.map +1 -0
  24. package/dist/{bylines-BYdTYmia.mjs → bylines-C_Wsnz4L.mjs} +38 -6
  25. package/dist/bylines-C_Wsnz4L.mjs.map +1 -0
  26. package/dist/cache-E3Dts-yT.mjs +56 -0
  27. package/dist/cache-E3Dts-yT.mjs.map +1 -0
  28. package/dist/cli/index.mjs +13 -13
  29. package/dist/cli/index.mjs.map +1 -1
  30. package/dist/client/cf-access.d.mts +1 -1
  31. package/dist/client/index.d.mts +1 -1
  32. package/dist/client/index.mjs +1 -1
  33. package/dist/{config-Cq8H0SfX.mjs → config-DkxPrM9l.mjs} +1 -1
  34. package/dist/{config-Cq8H0SfX.mjs.map → config-DkxPrM9l.mjs.map} +1 -1
  35. package/dist/{content-BmXndhdi.mjs → content-BsBoyj8G.mjs} +20 -3
  36. package/dist/content-BsBoyj8G.mjs.map +1 -0
  37. package/dist/db/index.d.mts +3 -3
  38. package/dist/db/index.mjs +2 -2
  39. package/dist/db/libsql.d.mts +1 -1
  40. package/dist/db/postgres.d.mts +1 -1
  41. package/dist/db/sqlite.d.mts +1 -1
  42. package/dist/{default-WYlzADZL.mjs → default-PUx9RK6u.mjs} +1 -1
  43. package/dist/{default-WYlzADZL.mjs.map → default-PUx9RK6u.mjs.map} +1 -1
  44. package/dist/{dialect-helpers-B9uSp2GJ.mjs → dialect-helpers-DhTzaUxP.mjs} +4 -1
  45. package/dist/dialect-helpers-DhTzaUxP.mjs.map +1 -0
  46. package/dist/{error-DrxtnGPg.mjs → error-HBeQbVhV.mjs} +1 -1
  47. package/dist/{error-DrxtnGPg.mjs.map → error-HBeQbVhV.mjs.map} +1 -1
  48. package/dist/{index-UHEVQMus.d.mts → index-CRg3PWfZ.d.mts} +59 -33
  49. package/dist/index-CRg3PWfZ.d.mts.map +1 -0
  50. package/dist/index.d.mts +11 -11
  51. package/dist/index.mjs +20 -20
  52. package/dist/{load-Veizk2cT.mjs → load-BhSSm-TS.mjs} +1 -1
  53. package/dist/{load-Veizk2cT.mjs.map → load-BhSSm-TS.mjs.map} +1 -1
  54. package/dist/{loader-CHb2v0jm.mjs → loader-BYzwzORf.mjs} +4 -2
  55. package/dist/loader-BYzwzORf.mjs.map +1 -0
  56. package/dist/{manifest-schema-CuMio1A9.mjs → manifest-schema-BsXINkQD.mjs} +1 -1
  57. package/dist/{manifest-schema-CuMio1A9.mjs.map → manifest-schema-BsXINkQD.mjs.map} +1 -1
  58. package/dist/media/index.d.mts +1 -1
  59. package/dist/media/index.mjs +1 -1
  60. package/dist/media/local-runtime.d.mts +7 -7
  61. package/dist/{mode-CYeM2rPt.mjs → mode-CyPLdO3C.mjs} +1 -1
  62. package/dist/{mode-CYeM2rPt.mjs.map → mode-CyPLdO3C.mjs.map} +1 -1
  63. package/dist/page/index.d.mts +1 -1
  64. package/dist/patterns-CrCYkMBb.mjs +93 -0
  65. package/dist/patterns-CrCYkMBb.mjs.map +1 -0
  66. package/dist/{placeholder-bOx1xCTY.d.mts → placeholder-BBCtpTES.d.mts} +1 -1
  67. package/dist/{placeholder-bOx1xCTY.d.mts.map → placeholder-BBCtpTES.d.mts.map} +1 -1
  68. package/dist/{placeholder-aiCD8aSZ.mjs → placeholder-DntBEQo7.mjs} +1 -1
  69. package/dist/{placeholder-aiCD8aSZ.mjs.map → placeholder-DntBEQo7.mjs.map} +1 -1
  70. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  71. package/dist/plugins/adapt-sandbox-entry.mjs +1 -1
  72. package/dist/{query-5Hcv_5ER.mjs → query-B6Vu0d2i.mjs} +35 -16
  73. package/dist/{query-5Hcv_5ER.mjs.map → query-B6Vu0d2i.mjs.map} +1 -1
  74. package/dist/{redirect-DIfIni3r.mjs → redirect-7lGhLBNZ.mjs} +10 -93
  75. package/dist/redirect-7lGhLBNZ.mjs.map +1 -0
  76. package/dist/{registry-1EvbAfsC.mjs → registry-BgnP3ysR.mjs} +27 -37
  77. package/dist/registry-BgnP3ysR.mjs.map +1 -0
  78. package/dist/{runner-BoN0-FPi.mjs → runner-Cd-_WyDo.mjs} +18 -6
  79. package/dist/runner-Cd-_WyDo.mjs.map +1 -0
  80. package/dist/{runner-DTqkzOzc.d.mts → runner-DYv3rX8P.d.mts} +10 -3
  81. package/dist/runner-DYv3rX8P.d.mts.map +1 -0
  82. package/dist/runtime.d.mts +6 -6
  83. package/dist/runtime.mjs +2 -2
  84. package/dist/{search-BsYMed12.mjs → search-B5p9D36n.mjs} +108 -57
  85. package/dist/search-B5p9D36n.mjs.map +1 -0
  86. package/dist/seed/index.d.mts +2 -2
  87. package/dist/seed/index.mjs +10 -10
  88. package/dist/seo/index.d.mts +1 -1
  89. package/dist/storage/local.d.mts +1 -1
  90. package/dist/storage/local.mjs +1 -1
  91. package/dist/storage/s3.d.mts +11 -3
  92. package/dist/storage/s3.d.mts.map +1 -1
  93. package/dist/storage/s3.mjs +76 -15
  94. package/dist/storage/s3.mjs.map +1 -1
  95. package/dist/{tokens-DrB-W6Q-.mjs → tokens-DKHiCYCB.mjs} +1 -1
  96. package/dist/{tokens-DrB-W6Q-.mjs.map → tokens-DKHiCYCB.mjs.map} +1 -1
  97. package/dist/transaction-Cn2rjY78.mjs +28 -0
  98. package/dist/transaction-Cn2rjY78.mjs.map +1 -0
  99. package/dist/{transport-Bl8cTdYt.mjs → transport-BtcQ-Z7T.mjs} +1 -1
  100. package/dist/{transport-Bl8cTdYt.mjs.map → transport-BtcQ-Z7T.mjs.map} +1 -1
  101. package/dist/{transport-COOs9GSE.d.mts → transport-CKQA_G44.d.mts} +1 -1
  102. package/dist/{transport-COOs9GSE.d.mts.map → transport-CKQA_G44.d.mts.map} +1 -1
  103. package/dist/{types-7-UjSEyB.d.mts → types-B6BzlZxx.d.mts} +1 -1
  104. package/dist/{types-7-UjSEyB.d.mts.map → types-B6BzlZxx.d.mts.map} +1 -1
  105. package/dist/{types-6dqxBqsH.d.mts → types-BYWYxLcp.d.mts} +109 -5
  106. package/dist/types-BYWYxLcp.d.mts.map +1 -0
  107. package/dist/{types-CIsTnQvJ.d.mts → types-BmkQR1En.d.mts} +1 -1
  108. package/dist/{types-CIsTnQvJ.d.mts.map → types-BmkQR1En.d.mts.map} +1 -1
  109. package/dist/{types-BljtYPSd.d.mts → types-DNZpaCBk.d.mts} +14 -6
  110. package/dist/types-DNZpaCBk.d.mts.map +1 -0
  111. package/dist/{types-Bec-r_3_.mjs → types-Dz9_WMS6.mjs} +1 -1
  112. package/dist/types-Dz9_WMS6.mjs.map +1 -0
  113. package/dist/{types-CcreFIIH.d.mts → types-gLYVCXCQ.d.mts} +1 -1
  114. package/dist/{types-CcreFIIH.d.mts.map → types-gLYVCXCQ.d.mts.map} +1 -1
  115. package/dist/{types-DuNbGKjF.mjs → types-xxCWI3j0.mjs} +1 -1
  116. package/dist/{types-DuNbGKjF.mjs.map → types-xxCWI3j0.mjs.map} +1 -1
  117. package/dist/{validate-B7KP7VLM.d.mts → validate-CcNRWH6I.d.mts} +4 -4
  118. package/dist/{validate-B7KP7VLM.d.mts.map → validate-CcNRWH6I.d.mts.map} +1 -1
  119. package/dist/{validate-CXnRKfJK.mjs → validate-DuZDIxfy.mjs} +2 -2
  120. package/dist/{validate-CXnRKfJK.mjs.map → validate-DuZDIxfy.mjs.map} +1 -1
  121. package/dist/{validate-CqRJb_xU.mjs → validate-VPnKoIzW.mjs} +11 -11
  122. package/dist/{validate-CqRJb_xU.mjs.map → validate-VPnKoIzW.mjs.map} +1 -1
  123. package/dist/version-DlTDRdpv.mjs +7 -0
  124. package/dist/version-DlTDRdpv.mjs.map +1 -0
  125. package/package.json +7 -5
  126. package/src/api/handlers/content.ts +36 -25
  127. package/src/api/handlers/menus.ts +19 -16
  128. package/src/api/handlers/redirects.ts +95 -3
  129. package/src/api/schemas/redirects.ts +1 -0
  130. package/src/astro/integration/index.ts +2 -3
  131. package/src/astro/integration/runtime.ts +8 -14
  132. package/src/astro/integration/vite-config.ts +14 -4
  133. package/src/astro/middleware/redirect.ts +30 -15
  134. package/src/astro/middleware.ts +11 -19
  135. package/src/astro/routes/admin.astro +2 -2
  136. package/src/astro/routes/api/admin/bylines/[id]/index.ts +3 -0
  137. package/src/astro/routes/api/admin/bylines/index.ts +2 -0
  138. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -0
  139. package/src/astro/routes/api/import/wordpress/rewrite-urls.ts +2 -0
  140. package/src/astro/routes/api/manifest.ts +3 -1
  141. package/src/astro/routes/api/redirects/[id].ts +3 -0
  142. package/src/astro/routes/api/redirects/index.ts +2 -0
  143. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -0
  144. package/src/astro/routes/api/schema/collections/index.ts +1 -0
  145. package/src/astro/storage/adapters.ts +19 -5
  146. package/src/astro/storage/types.ts +12 -4
  147. package/src/astro/types.ts +1 -0
  148. package/src/bylines/index.ts +50 -2
  149. package/src/cleanup.ts +3 -3
  150. package/src/cli/commands/bundle-utils.ts +5 -5
  151. package/src/database/dialect-helpers.ts +3 -0
  152. package/src/database/migrations/011_sections.ts +2 -2
  153. package/src/database/migrations/runner.ts +23 -2
  154. package/src/database/repositories/byline.ts +2 -1
  155. package/src/database/repositories/content.ts +5 -0
  156. package/src/database/repositories/redirect.ts +13 -0
  157. package/src/database/validate.ts +10 -10
  158. package/src/emdash-runtime.ts +23 -9
  159. package/src/index.ts +3 -0
  160. package/src/loader.ts +2 -0
  161. package/src/mcp/server.ts +40 -67
  162. package/src/menus/index.ts +4 -0
  163. package/src/plugins/context.ts +28 -4
  164. package/src/plugins/cron.ts +29 -4
  165. package/src/plugins/hooks.ts +22 -10
  166. package/src/plugins/index.ts +1 -0
  167. package/src/plugins/manager.ts +6 -2
  168. package/src/plugins/marketplace.ts +33 -3
  169. package/src/plugins/routes.ts +3 -3
  170. package/src/plugins/types.ts +7 -0
  171. package/src/query.ts +37 -14
  172. package/src/redirects/cache.ts +68 -0
  173. package/src/redirects/loops.ts +318 -0
  174. package/src/schema/registry.ts +3 -0
  175. package/src/search/fts-manager.ts +24 -11
  176. package/src/search/query.ts +8 -9
  177. package/src/seed/apply.ts +49 -28
  178. package/src/storage/s3.ts +94 -25
  179. package/src/storage/types.ts +13 -5
  180. package/src/utils/slugify.ts +11 -0
  181. package/src/version.ts +12 -0
  182. package/src/visual-editing/toolbar.ts +11 -1
  183. package/dist/apply-wmVEOSbR.mjs.map +0 -1
  184. package/dist/byline-1WQPlISL.mjs.map +0 -1
  185. package/dist/bylines-BYdTYmia.mjs.map +0 -1
  186. package/dist/content-BmXndhdi.mjs.map +0 -1
  187. package/dist/dialect-helpers-B9uSp2GJ.mjs.map +0 -1
  188. package/dist/index-UHEVQMus.d.mts.map +0 -1
  189. package/dist/loader-CHb2v0jm.mjs.map +0 -1
  190. package/dist/redirect-DIfIni3r.mjs.map +0 -1
  191. package/dist/registry-1EvbAfsC.mjs.map +0 -1
  192. package/dist/runner-BoN0-FPi.mjs.map +0 -1
  193. package/dist/runner-DTqkzOzc.d.mts.map +0 -1
  194. package/dist/search-BsYMed12.mjs.map +0 -1
  195. package/dist/types-6dqxBqsH.d.mts.map +0 -1
  196. package/dist/types-Bec-r_3_.mjs.map +0 -1
  197. package/dist/types-BljtYPSd.d.mts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"validate-CqRJb_xU.mjs","names":[],"sources":["../src/database/validate.ts"],"sourcesContent":["/**\n * SQL Identifier Validation\n *\n * Validates identifiers (table names, column names, index names) before\n * they are used in raw SQL expressions. This is the primary defense against\n * SQL injection via dynamic identifier interpolation.\n *\n * @see AGENTS.md § Database: Never Interpolate Into SQL\n */\n\n/**\n * Pattern for safe SQL identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, or underscores.\n */\nconst IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n/**\n * Pattern for generic alphanumeric identifiers (case-insensitive).\n * Must start with a letter, followed by letters, digits, or underscores.\n */\nconst GENERIC_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * Pattern for plugin identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, underscores, or hyphens.\n */\nconst PLUGIN_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Maximum length for SQL identifiers.\n * SQLite has no formal limit, but we cap at 128 for sanity.\n */\nconst MAX_IDENTIFIER_LENGTH = 128;\n\n/**\n * Error thrown when an identifier fails validation.\n */\nexport class IdentifierError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic identifier: string,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"IdentifierError\";\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier.\n *\n * Safe identifiers match `/^[a-z][a-z0-9_]*$/` and are at most 128 characters.\n * This prevents SQL injection when identifiers must be interpolated into raw SQL\n * (e.g., dynamic table names, column names in json_extract paths).\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages (e.g., \"field name\", \"table name\")\n * @throws {IdentifierError} If the value is not a valid identifier\n *\n * @example\n * ```typescript\n * validateIdentifier(fieldName, \"field name\");\n * // safe to use in: json_extract(data, '$.${fieldName}')\n * ```\n */\nexport function validateIdentifier(value: string, label = \"identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_]*$/ (got \"${value}\")`, value);\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier, allowing hyphens.\n *\n * Like `validateIdentifier` but also permits hyphens, which appear in\n * plugin IDs (e.g., \"my-plugin\"). Matches `/^[a-z][a-z0-9_-]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\n/**\n * Validate that a string is a safe JSON field name for use in json_extract paths.\n *\n * More permissive than `validateIdentifier` — allows camelCase (mixed case)\n * since JSON keys in plugin storage data blobs commonly use camelCase.\n * Matches `/^[a-zA-Z][a-zA-Z0-9_]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\nexport function validateJsonFieldName(value: string, label = \"JSON field name\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!GENERIC_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must match /^[a-zA-Z][a-zA-Z0-9_]*$/ (got \"${value}\")`,\n\t\t\tvalue,\n\t\t);\n\t}\n}\n\nexport function validatePluginIdentifier(value: string, label = \"plugin identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!PLUGIN_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_-]*$/ (got \"${value}\")`, value);\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,qBAAqB;;;;;AAM3B,MAAM,6BAA6B;;;;;AAMnC,MAAM,4BAA4B;;;;;AAMlC,MAAM,wBAAwB;;;;AAK9B,IAAa,kBAAb,cAAqC,MAAM;CAC1C,YACC,SACA,AAAO,YACN;AACD,QAAM,QAAQ;EAFP;AAGP,OAAK,OAAO;;;;;;;;;;;;;;;;;;;;AAqBd,SAAgB,mBAAmB,OAAe,QAAQ,cAAoB;AAC7E,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,mBAAmB,KAAK,MAAM,CAClC,OAAM,IAAI,gBAAgB,GAAG,MAAM,wCAAwC,MAAM,KAAK,MAAM;;;;;;;;;;;;;;;;;;;;;;;AAyB9F,SAAgB,sBAAsB,OAAe,QAAQ,mBAAyB;AACrF,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,2BAA2B,KAAK,MAAM,CAC1C,OAAM,IAAI,gBACT,GAAG,MAAM,8CAA8C,MAAM,KAC7D,MACA;;AAIH,SAAgB,yBAAyB,OAAe,QAAQ,qBAA2B;AAC1F,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,0BAA0B,KAAK,MAAM,CACzC,OAAM,IAAI,gBAAgB,GAAG,MAAM,yCAAyC,MAAM,KAAK,MAAM"}
1
+ {"version":3,"file":"validate-VPnKoIzW.mjs","names":[],"sources":["../src/database/validate.ts"],"sourcesContent":["/**\n * SQL Identifier Validation\n *\n * Validates identifiers (table names, column names, index names) before\n * they are used in raw SQL expressions. This is the primary defense against\n * SQL injection via dynamic identifier interpolation.\n *\n * @see AGENTS.md § Database: Never Interpolate Into SQL\n */\n\n/**\n * Pattern for safe SQL identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, or underscores.\n */\nconst IDENTIFIER_PATTERN = /^[a-z][a-z0-9_]*$/;\n\n/**\n * Pattern for generic alphanumeric identifiers (case-insensitive).\n * Must start with a letter, followed by letters, digits, or underscores.\n */\nconst GENERIC_IDENTIFIER_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;\n\n/**\n * Pattern for plugin identifiers.\n * Must start with a lowercase letter, followed by lowercase letters, digits, underscores, or hyphens.\n */\nconst PLUGIN_IDENTIFIER_PATTERN = /^[a-z][a-z0-9_-]*$/;\n\n/**\n * Maximum length for SQL identifiers.\n * SQLite has no formal limit, but we cap at 128 for sanity.\n */\nconst MAX_IDENTIFIER_LENGTH = 128;\n\n/**\n * Error thrown when an identifier fails validation.\n */\nexport class IdentifierError extends Error {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic identifier: string,\n\t) {\n\t\tsuper(message);\n\t\tthis.name = \"IdentifierError\";\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier.\n *\n * Safe identifiers match `/^[a-z][a-z0-9_]*$/` and are at most 128 characters.\n * This prevents SQL injection when identifiers must be interpolated into raw SQL\n * (e.g., dynamic table names, column names in json_extract paths).\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages (e.g., \"field name\", \"table name\")\n * @throws {IdentifierError} If the value is not a valid identifier\n *\n * @example\n * ```typescript\n * validateIdentifier(fieldName, \"field name\");\n * // safe to use in: json_extract(data, '$.${fieldName}')\n * ```\n */\nexport function validateIdentifier(value: string, label = \"identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_]*$/ (got \"${value}\")`, value);\n\t}\n}\n\n/**\n * Validate that a string is a safe JSON field name for use in json_extract paths.\n *\n * More permissive than `validateIdentifier` — allows camelCase (mixed case)\n * since JSON keys in plugin storage data blobs commonly use camelCase.\n * Matches `/^[a-zA-Z][a-zA-Z0-9_]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\nexport function validateJsonFieldName(value: string, label = \"JSON field name\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!GENERIC_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must match /^[a-zA-Z][a-zA-Z0-9_]*$/ (got \"${value}\")`,\n\t\t\tvalue,\n\t\t);\n\t}\n}\n\n/**\n * Validate that a string is a safe SQL identifier, allowing hyphens.\n *\n * Like `validateIdentifier` but also permits hyphens, which appear in\n * plugin IDs (e.g., \"my-plugin\"). Matches `/^[a-z][a-z0-9_-]*$/`.\n *\n * @param value - The string to validate\n * @param label - Human-readable label for error messages\n * @throws {IdentifierError} If the value is not valid\n */\nexport function validatePluginIdentifier(value: string, label = \"plugin identifier\"): void {\n\tif (!value || typeof value !== \"string\") {\n\t\tthrow new IdentifierError(`${label} must be a non-empty string`, String(value));\n\t}\n\n\tif (value.length > MAX_IDENTIFIER_LENGTH) {\n\t\tthrow new IdentifierError(\n\t\t\t`${label} must be ${MAX_IDENTIFIER_LENGTH} characters or less, got ${value.length}`,\n\t\t\tvalue,\n\t\t);\n\t}\n\n\tif (!PLUGIN_IDENTIFIER_PATTERN.test(value)) {\n\t\tthrow new IdentifierError(`${label} must match /^[a-z][a-z0-9_-]*$/ (got \"${value}\")`, value);\n\t}\n}\n"],"mappings":";;;;;;;;;;;;;;AAcA,MAAM,qBAAqB;;;;;AAM3B,MAAM,6BAA6B;;;;;AAMnC,MAAM,4BAA4B;;;;;AAMlC,MAAM,wBAAwB;;;;AAK9B,IAAa,kBAAb,cAAqC,MAAM;CAC1C,YACC,SACA,AAAO,YACN;AACD,QAAM,QAAQ;EAFP;AAGP,OAAK,OAAO;;;;;;;;;;;;;;;;;;;;AAqBd,SAAgB,mBAAmB,OAAe,QAAQ,cAAoB;AAC7E,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,mBAAmB,KAAK,MAAM,CAClC,OAAM,IAAI,gBAAgB,GAAG,MAAM,wCAAwC,MAAM,KAAK,MAAM;;;;;;;;;;;;;AAe9F,SAAgB,sBAAsB,OAAe,QAAQ,mBAAyB;AACrF,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,2BAA2B,KAAK,MAAM,CAC1C,OAAM,IAAI,gBACT,GAAG,MAAM,8CAA8C,MAAM,KAC7D,MACA;;;;;;;;;;;;AAcH,SAAgB,yBAAyB,OAAe,QAAQ,qBAA2B;AAC1F,KAAI,CAAC,SAAS,OAAO,UAAU,SAC9B,OAAM,IAAI,gBAAgB,GAAG,MAAM,8BAA8B,OAAO,MAAM,CAAC;AAGhF,KAAI,MAAM,SAAS,sBAClB,OAAM,IAAI,gBACT,GAAG,MAAM,WAAW,sBAAsB,2BAA2B,MAAM,UAC3E,MACA;AAGF,KAAI,CAAC,0BAA0B,KAAK,MAAM,CACzC,OAAM,IAAI,gBAAgB,GAAG,MAAM,yCAAyC,MAAM,KAAK,MAAM"}
@@ -0,0 +1,7 @@
1
+ //#region src/version.ts
2
+ const VERSION = "0.4.0";
3
+ const COMMIT = "8fb9145";
4
+
5
+ //#endregion
6
+ export { VERSION as n, COMMIT as t };
7
+ //# sourceMappingURL=version-DlTDRdpv.mjs.map
@@ -0,0 +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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "emdash",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Astro-native CMS with WordPress migration support",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -141,6 +141,8 @@
141
141
  "#media/*": "./src/media/*",
142
142
  "#mcp/*": "./src/mcp/*",
143
143
  "#comments/*": "./src/comments/*",
144
+ "#bylines/*": "./src/bylines/*",
145
+ "#redirects/*": "./src/redirects/*",
144
146
  "#types": "./src/astro/types.js"
145
147
  },
146
148
  "dependencies": {
@@ -178,9 +180,9 @@
178
180
  "ulidx": "^2.4.1",
179
181
  "upng-js": "^2.1.0",
180
182
  "zod": "^4.3.5",
181
- "@emdash-cms/admin": "0.2.0",
182
- "@emdash-cms/auth": "0.2.0",
183
- "@emdash-cms/gutenberg-to-portable-text": "0.2.0"
183
+ "@emdash-cms/admin": "0.4.0",
184
+ "@emdash-cms/auth": "0.4.0",
185
+ "@emdash-cms/gutenberg-to-portable-text": "0.4.0"
184
186
  },
185
187
  "optionalDependencies": {
186
188
  "@libsql/kysely-libsql": "^0.4.0",
@@ -208,7 +210,7 @@
208
210
  "vite": "^6.0.0",
209
211
  "vitest": "^4.0.18",
210
212
  "zod-openapi": "^5.4.6",
211
- "@emdash-cms/blocks": "0.2.0"
213
+ "@emdash-cms/blocks": "0.4.0"
212
214
  },
213
215
  "repository": {
214
216
  "type": "git",
@@ -22,6 +22,7 @@ import { withTransaction } from "../../database/transaction.js";
22
22
  import type { Database } from "../../database/types.js";
23
23
  import { validateIdentifier } from "../../database/validate.js";
24
24
  import { isI18nEnabled } from "../../i18n/config.js";
25
+ import { invalidateRedirectCache } from "../../redirects/cache.js";
25
26
  import { encodeRev, validateRev } from "../rev.js";
26
27
  import type { ApiResult, ContentListResponse, ContentResponse } from "../types.js";
27
28
 
@@ -503,37 +504,37 @@ export async function handleContentUpdate(
503
504
  // Resolve slug → ID if needed
504
505
  const resolvedId = (await resolveId(repo, collection, id)) ?? id;
505
506
 
506
- // Validate _rev if provided (optimistic concurrency)
507
- if (body._rev) {
508
- const existing = await repo.findById(collection, resolvedId);
509
- if (!existing) {
510
- return {
511
- success: false,
512
- error: { code: "NOT_FOUND", message: `Content item not found: ${id}` },
513
- };
514
- }
515
-
516
- const revCheck = validateRev(body._rev, existing);
517
- if (!revCheck.valid) {
518
- return {
519
- success: false,
520
- error: { code: "CONFLICT", message: revCheck.message },
521
- };
522
- }
523
- }
524
-
525
- // Wrap content + SEO writes in a transaction for atomicity
507
+ // Wrap content + SEO writes in a transaction for atomicity.
508
+ // The _rev check is inside the transaction so the read-then-write
509
+ // is atomic -- no concurrent write can slip between the check and update.
526
510
  const item = await withTransaction(db, async (trx) => {
527
511
  const trxRepo = new ContentRepository(trx);
528
512
  const bylineRepo = new BylineRepository(trx);
529
513
 
514
+ // Read existing item once for both _rev check and old slug capture
515
+ const existing =
516
+ body._rev || body.slug ? await trxRepo.findById(collection, resolvedId) : null;
517
+
518
+ // Validate _rev if provided (optimistic concurrency)
519
+ if (body._rev) {
520
+ if (!existing) {
521
+ throw Object.assign(new Error(`Content item not found: ${id}`), {
522
+ apiError: { code: "NOT_FOUND" as const },
523
+ });
524
+ }
525
+
526
+ const revCheck = validateRev(body._rev, existing);
527
+ if (!revCheck.valid) {
528
+ throw Object.assign(new Error(revCheck.message), {
529
+ apiError: { code: "CONFLICT" as const },
530
+ });
531
+ }
532
+ }
533
+
530
534
  // Capture old slug before update for auto-redirect
531
535
  let oldSlug: string | undefined;
532
- if (body.slug) {
533
- const existing = await trxRepo.findById(collection, resolvedId);
534
- if (existing?.slug && existing.slug !== body.slug) {
535
- oldSlug = existing.slug;
536
- }
536
+ if (body.slug && existing?.slug && existing.slug !== body.slug) {
537
+ oldSlug = existing.slug;
537
538
  }
538
539
 
539
540
  const updated = await trxRepo.update(collection, resolvedId, {
@@ -564,6 +565,7 @@ export async function handleContentUpdate(
564
565
  resolvedId,
565
566
  collectionRow?.url_pattern ?? null,
566
567
  );
568
+ invalidateRedirectCache();
567
569
  }
568
570
 
569
571
  // Sync non-translatable fields to sibling locales in the same
@@ -598,6 +600,15 @@ export async function handleContentUpdate(
598
600
  data: { item, _rev: encodeRev(item) },
599
601
  };
600
602
  } catch (error) {
603
+ // Handle structured errors thrown from inside the transaction
604
+ // (rev check failures, not-found)
605
+ if (error instanceof Error && "apiError" in error) {
606
+ const { code } = (error as Error & { apiError: { code: string } }).apiError;
607
+ return {
608
+ success: false,
609
+ error: { code, message: error.message },
610
+ };
611
+ }
601
612
  console.error("Content update error:", error);
602
613
  return {
603
614
  success: false,
@@ -8,6 +8,7 @@
8
8
  import type { Kysely } from "kysely";
9
9
  import { ulid } from "ulidx";
10
10
 
11
+ import { withTransaction } from "../../database/transaction.js";
11
12
  import type { Database, MenuItemTable, MenuTable } from "../../database/types.js";
12
13
  import type { ApiResult } from "../types.js";
13
14
 
@@ -464,24 +465,26 @@ export async function handleMenuItemReorder(
464
465
  };
465
466
  }
466
467
 
467
- for (const item of items) {
468
- await db
469
- .updateTable("_emdash_menu_items")
470
- .set({
471
- parent_id: item.parentId,
472
- sort_order: item.sortOrder,
473
- })
474
- .where("id", "=", item.id)
468
+ const updatedItems = await withTransaction(db, async (trx) => {
469
+ for (const item of items) {
470
+ await trx
471
+ .updateTable("_emdash_menu_items")
472
+ .set({
473
+ parent_id: item.parentId,
474
+ sort_order: item.sortOrder,
475
+ })
476
+ .where("id", "=", item.id)
477
+ .where("menu_id", "=", menu.id)
478
+ .execute();
479
+ }
480
+
481
+ return trx
482
+ .selectFrom("_emdash_menu_items")
483
+ .selectAll()
475
484
  .where("menu_id", "=", menu.id)
485
+ .orderBy("sort_order", "asc")
476
486
  .execute();
477
- }
478
-
479
- const updatedItems = await db
480
- .selectFrom("_emdash_menu_items")
481
- .selectAll()
482
- .where("menu_id", "=", menu.id)
483
- .orderBy("sort_order", "asc")
484
- .execute();
487
+ });
485
488
 
486
489
  return { success: true, data: updatedItems };
487
490
  } catch {
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { Kysely } from "kysely";
6
6
 
7
+ import { OptionsRepository } from "../../database/repositories/options.js";
7
8
  import {
8
9
  RedirectRepository,
9
10
  type Redirect,
@@ -12,6 +13,7 @@ import {
12
13
  } from "../../database/repositories/redirect.js";
13
14
  import type { FindManyResult } from "../../database/repositories/types.js";
14
15
  import type { Database } from "../../database/types.js";
16
+ import { wouldCreateLoop, detectLoops, type RedirectEdge } from "../../redirects/loops.js";
15
17
  import { validatePattern, validateDestinationParams, isPattern } from "../../redirects/patterns.js";
16
18
  import type { ApiResult } from "../types.js";
17
19
 
@@ -32,11 +34,20 @@ export async function handleRedirectList(
32
34
  enabled?: boolean;
33
35
  auto?: boolean;
34
36
  },
35
- ): Promise<ApiResult<FindManyResult<Redirect>>> {
37
+ ): Promise<ApiResult<FindManyResult<Redirect> & { loopRedirectIds?: string[] }>> {
36
38
  try {
37
39
  const repo = new RedirectRepository(db);
38
40
  const result = await repo.findMany(params);
39
- return { success: true, data: result };
41
+
42
+ const loopRedirectIds = await getLoopRedirectIds(db);
43
+
44
+ return {
45
+ success: true,
46
+ data: {
47
+ ...result,
48
+ ...(loopRedirectIds.length > 0 ? { loopRedirectIds } : {}),
49
+ },
50
+ };
40
51
  } catch {
41
52
  return {
42
53
  success: false,
@@ -105,6 +116,13 @@ export async function handleRedirectCreate(
105
116
  };
106
117
  }
107
118
 
119
+ // Check for redirect loops (skip if creating as disabled)
120
+ if (input.enabled !== false) {
121
+ const edges = toEdges(await repo.findAllEnabled());
122
+ const loopPath = wouldCreateLoop(input.source, input.destination, edges);
123
+ if (loopPath) return loopError(loopPath);
124
+ }
125
+
108
126
  const redirect = await repo.create({
109
127
  source: input.source,
110
128
  destination: input.destination,
@@ -219,7 +237,8 @@ export async function handleRedirectUpdate(
219
237
  }
220
238
 
221
239
  // Validate destination params against the (possibly updated) source
222
- if (isPattern(newSource)) {
240
+ const newSourceIsPattern = isPattern(newSource);
241
+ if (newSourceIsPattern) {
223
242
  const destError = validateDestinationParams(newSource, newDest);
224
243
  if (destError) {
225
244
  return {
@@ -229,6 +248,13 @@ export async function handleRedirectUpdate(
229
248
  }
230
249
  }
231
250
 
251
+ // Check for redirect loops if source or destination changed
252
+ if (input.source !== undefined || input.destination !== undefined) {
253
+ const edges = toEdges(await repo.findAllEnabled());
254
+ const loopPath = wouldCreateLoop(newSource, newDest, edges, id);
255
+ if (loopPath) return loopError(loopPath);
256
+ }
257
+
232
258
  const updated = await repo.update(id, {
233
259
  source: input.source,
234
260
  destination: input.destination,
@@ -244,6 +270,9 @@ export async function handleRedirectUpdate(
244
270
  };
245
271
  }
246
272
 
273
+ // Recompute cache — redirect was modified, so re-fetch
274
+ await updateLoopCache(db);
275
+
247
276
  return { success: true, data: updated };
248
277
  } catch {
249
278
  return {
@@ -271,6 +300,8 @@ export async function handleRedirectDelete(
271
300
  };
272
301
  }
273
302
 
303
+ await updateLoopCache(db);
304
+
274
305
  return { success: true, data: { deleted: true } };
275
306
  } catch {
276
307
  return {
@@ -280,6 +311,67 @@ export async function handleRedirectDelete(
280
311
  }
281
312
  }
282
313
 
314
+ // ---------------------------------------------------------------------------
315
+ // Loop analysis cache
316
+ // ---------------------------------------------------------------------------
317
+
318
+ function loopError(loopPath: string[]): ApiResult<never> {
319
+ const hops = loopPath
320
+ .slice(0, -1)
321
+ .map((p, i) => `${p} \u2192 ${loopPath[i + 1]!}`)
322
+ .join("\n");
323
+ return {
324
+ success: false,
325
+ error: {
326
+ code: "VALIDATION_ERROR",
327
+ message: `This redirect would create a loop:\n${hops}`,
328
+ },
329
+ };
330
+ }
331
+
332
+ function toEdges(redirects: Redirect[]): RedirectEdge[] {
333
+ return redirects.map((r) => ({
334
+ id: r.id,
335
+ source: r.source,
336
+ destination: r.destination,
337
+ enabled: r.enabled,
338
+ isPattern: r.isPattern,
339
+ }));
340
+ }
341
+
342
+ const LOOP_CACHE_KEY = "_redirect_loop_ids";
343
+
344
+ /**
345
+ * Recompute loop redirect IDs and store in the options table.
346
+ */
347
+ async function updateLoopCache(db: Kysely<Database>): Promise<void> {
348
+ try {
349
+ const options = new OptionsRepository(db);
350
+ const edges = toEdges(await new RedirectRepository(db).findAllEnabled());
351
+ const loopRedirectIds = detectLoops(edges);
352
+ await options.set(LOOP_CACHE_KEY, loopRedirectIds);
353
+ } catch (error) {
354
+ console.error("Failed to update redirect loop cache:", error);
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Get loop redirect IDs from cache, computing lazily on first access.
360
+ */
361
+ async function getLoopRedirectIds(db: Kysely<Database>): Promise<string[]> {
362
+ try {
363
+ const options = new OptionsRepository(db);
364
+ const cached = await options.get<string[]>(LOOP_CACHE_KEY);
365
+ if (cached !== null) return cached;
366
+
367
+ // First access after upgrade — compute and cache
368
+ await updateLoopCache(db);
369
+ return (await options.get<string[]>(LOOP_CACHE_KEY)) ?? [];
370
+ } catch {
371
+ return [];
372
+ }
373
+ }
374
+
283
375
  // ---------------------------------------------------------------------------
284
376
  // 404 Log
285
377
  // ---------------------------------------------------------------------------
@@ -120,6 +120,7 @@ export const redirectListResponseSchema = z
120
120
  .object({
121
121
  items: z.array(redirectSchema),
122
122
  nextCursor: z.string().optional(),
123
+ loopRedirectIds: z.array(z.string()).optional(),
123
124
  })
124
125
  .meta({ id: "RedirectListResponse" });
125
126
 
@@ -228,10 +228,9 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration {
228
228
  injectBuiltinAuthRoutes(injectRoute);
229
229
  }
230
230
 
231
- // Inject MCP endpoint when enabled
232
- if (resolvedConfig.mcp) {
231
+ // Inject MCP endpoint (always on — bearer-token-only, no cost if unused)
232
+ if (resolvedConfig.mcp !== false) {
233
233
  injectMcpRoute(injectRoute);
234
- logger.info("MCP server enabled at /_emdash/api/mcp");
235
234
  }
236
235
 
237
236
  // In playground mode, inject the playground middleware FIRST.
@@ -223,23 +223,17 @@ export interface EmDashConfig {
223
223
  auth?: AuthDescriptor;
224
224
 
225
225
  /**
226
- * Enable the MCP (Model Context Protocol) server endpoint.
226
+ * MCP (Model Context Protocol) server endpoint.
227
227
  *
228
- * When enabled, exposes an MCP Streamable HTTP server at
229
- * `/_emdash/api/mcp` that allows AI agents and tools to interact
230
- * with the CMS using the standardized MCP protocol.
228
+ * Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp`
229
+ * that allows AI agents and tools to interact with the CMS using
230
+ * the standardized MCP protocol.
231
231
  *
232
- * Authentication is handled by the existing EmDash auth middleware —
233
- * agents must authenticate with an API token or session cookie.
232
+ * Enabled by default. The endpoint requires bearer token auth, so
233
+ * it has no effect unless the user creates an API token and
234
+ * configures a client. Set to `false` to disable.
234
235
  *
235
- * @default false
236
- *
237
- * @example
238
- * ```ts
239
- * emdash({
240
- * mcp: true,
241
- * })
242
- * ```
236
+ * @default true
243
237
  */
244
238
  mcp?: boolean;
245
239
 
@@ -13,6 +13,7 @@ import { fileURLToPath } from "node:url";
13
13
  import type { AstroConfig } from "astro";
14
14
  import type { Plugin } from "vite";
15
15
 
16
+ import { COMMIT, VERSION } from "../../version.js";
16
17
  import type { EmDashConfig, PluginDescriptor } from "./runtime.js";
17
18
  import {
18
19
  VIRTUAL_CONFIG_ID,
@@ -278,6 +279,15 @@ export function createViteConfig(
278
279
  const useSource = adminSourcePath !== undefined;
279
280
 
280
281
  return {
282
+ // Astro SSR routes resolve version.ts from source (not tsdown dist),
283
+ // so Vite needs its own define pass for the __EMDASH_*__ placeholders.
284
+ define: {
285
+ __EMDASH_VERSION__: JSON.stringify(VERSION),
286
+ __EMDASH_COMMIT__: JSON.stringify(COMMIT),
287
+ __EMDASH_PSEUDO_LOCALE__: JSON.stringify(
288
+ isDev && process.env["EMDASH_PSEUDO_LOCALE"] === "1",
289
+ ),
290
+ },
281
291
  resolve: {
282
292
  dedupe: ["@emdash-cms/admin", "react", "react-dom"],
283
293
  // Array form so more-specific entries are checked first.
@@ -286,10 +296,6 @@ export function createViteConfig(
286
296
  // "@emdash-cms/admin/styles.css" through the source directory.
287
297
  alias: [
288
298
  { find: "@emdash-cms/admin/styles.css", replacement: resolve(adminDistPath, "styles.css") },
289
- {
290
- find: "@emdash-cms/admin/locales/*",
291
- replacement: resolve(adminDistPath, "locales", "*"),
292
- },
293
299
  { find: "@emdash-cms/admin", replacement: useSource ? adminSourcePath : adminDistPath },
294
300
  ],
295
301
  },
@@ -342,6 +348,10 @@ export function createViteConfig(
342
348
  "emdash > @emdash-cms/auth > @oslojs/crypto/ecdsa",
343
349
  "emdash > @emdash-cms/auth > @oslojs/crypto/sha2",
344
350
  "emdash > @emdash-cms/auth > @oslojs/webauthn",
351
+ // MCP SDK — server/index.js statically imports ajv (CJS-only).
352
+ // Pre-bundling converts CJS to ESM so workerd can load it.
353
+ "emdash > @modelcontextprotocol/sdk > ajv",
354
+ "emdash > @modelcontextprotocol/sdk > ajv-formats",
345
355
  // React (commonly used, may be hoisted)
346
356
  "react",
347
357
  "react/jsx-dev-runtime",
@@ -17,6 +17,11 @@
17
17
  import { defineMiddleware } from "astro:middleware";
18
18
 
19
19
  import { RedirectRepository } from "../../database/repositories/redirect.js";
20
+ import {
21
+ getCachedPatternRules,
22
+ matchCachedPatterns,
23
+ setCachedPatternRules,
24
+ } from "../../redirects/cache.js";
20
25
 
21
26
  /** Paths that should never be intercepted by redirects */
22
27
  const SKIP_PREFIXES = ["/_emdash", "/_image"];
@@ -48,21 +53,31 @@ export const onRequest = defineMiddleware(async (context, next) => {
48
53
 
49
54
  try {
50
55
  const repo = new RedirectRepository(emdash.db);
51
- const match = await repo.matchPath(pathname);
52
-
53
- if (match) {
54
- // Reject protocol-relative URLs (e.g. //evil.com or /\evil.com) from interpolation.
55
- // Browsers normalize backslashes to forward slashes, so /\ is equivalent to //.
56
- if (
57
- match.resolvedDestination.startsWith("//") ||
58
- match.resolvedDestination.startsWith("/\\")
59
- ) {
60
- return next();
61
- }
62
- // Fire-and-forget hit recording (don't block the redirect)
63
- repo.recordHit(match.redirect.id).catch(() => {});
64
- const code = isRedirectCode(match.redirect.type) ? match.redirect.type : 301;
65
- return context.redirect(match.resolvedDestination, code);
56
+
57
+ // 1. Exact match (fast, indexed)
58
+ const exact = await repo.findExactMatch(pathname);
59
+ if (exact) {
60
+ const dest = exact.destination;
61
+ if (dest.startsWith("//") || dest.startsWith("/\\")) return next();
62
+ repo.recordHit(exact.id).catch(() => {});
63
+ const code = isRedirectCode(exact.type) ? exact.type : 301;
64
+ return context.redirect(dest, code);
65
+ }
66
+
67
+ // 2. Pattern match (cached: compile once, match every request)
68
+ let rules = getCachedPatternRules();
69
+ if (!rules) {
70
+ const patterns = await repo.findEnabledPatternRules();
71
+ rules = setCachedPatternRules(patterns);
72
+ }
73
+
74
+ const patternMatch = matchCachedPatterns(rules, pathname);
75
+ if (patternMatch) {
76
+ const { redirect, destination } = patternMatch;
77
+ if (destination.startsWith("//") || destination.startsWith("/\\")) return next();
78
+ repo.recordHit(redirect.id).catch(() => {});
79
+ const code = isRedirectCode(redirect.type) ? redirect.type : 301;
80
+ return context.redirect(destination, code);
66
81
  }
67
82
 
68
83
  // No redirect matched -- proceed and check for 404
@@ -160,20 +160,15 @@ async function getRuntime(config: EmDashConfig): Promise<EmDashRuntime> {
160
160
  * Baseline security headers applied to all responses.
161
161
  * Admin routes get additional headers (strict CSP) from auth middleware.
162
162
  */
163
- function setBaselineSecurityHeaders(response: Response): void {
164
- // Prevent MIME type sniffing
165
- response.headers.set("X-Content-Type-Options", "nosniff");
166
- // Control referrer information
167
- response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
168
- // Restrict access to sensitive browser APIs
169
- response.headers.set(
170
- "Permissions-Policy",
171
- "camera=(), microphone=(), geolocation=(), payment=()",
172
- );
173
- // Prevent clickjacking (non-admin routes; admin CSP uses frame-ancestors)
174
- if (!response.headers.has("Content-Security-Policy")) {
175
- response.headers.set("X-Frame-Options", "SAMEORIGIN");
163
+ function setBaselineSecurityHeaders(response: Response): Response {
164
+ const res = new Response(response.body, response);
165
+ res.headers.set("X-Content-Type-Options", "nosniff");
166
+ res.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
167
+ res.headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()");
168
+ if (!res.headers.has("Content-Security-Policy")) {
169
+ res.headers.set("X-Frame-Options", "SAMEORIGIN");
176
170
  }
171
+ return res;
177
172
  }
178
173
 
179
174
  /** Public routes that require the runtime (sitemap, robots.txt, etc.) */
@@ -245,8 +240,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
245
240
  }
246
241
 
247
242
  const response = await next();
248
- setBaselineSecurityHeaders(response);
249
- return response;
243
+ return setBaselineSecurityHeaders(response);
250
244
  }
251
245
  }
252
246
 
@@ -416,8 +410,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
416
410
 
417
411
  // Wrap the request in ALS with the per-request db
418
412
  return runWithContext({ editMode: false, db: sessionDb }, async () => {
419
- const response = await next();
420
- setBaselineSecurityHeaders(response);
413
+ const response = setBaselineSecurityHeaders(await next());
421
414
 
422
415
  // Set bookmark cookie for authenticated users only — they need
423
416
  // read-your-writes consistency across requests. Anonymous visitors
@@ -445,8 +438,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
445
438
  }
446
439
 
447
440
  const response = await next();
448
- setBaselineSecurityHeaders(response);
449
- return response;
441
+ return setBaselineSecurityHeaders(response);
450
442
  }; // end doInit
451
443
 
452
444
  if (playgroundDb) {
@@ -12,10 +12,10 @@ import AdminWrapper from "emdash/routes/PluginRegistry";
12
12
 
13
13
  export const prerender = false;
14
14
 
15
- import { resolveLocale } from "@emdash-cms/admin/locales";
15
+ import { resolveLocale, loadMessages } from "@emdash-cms/admin/locales";
16
16
 
17
17
  const resolvedLocale = resolveLocale(Astro.request);
18
- const { messages } = await import(`@emdash-cms/admin/locales/${resolvedLocale}/messages.mjs`);
18
+ const messages = await loadMessages(resolvedLocale);
19
19
  ---
20
20
 
21
21
  <!doctype html>
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
5
5
  import { apiError, apiSuccess, handleError } from "#api/error.js";
6
6
  import { isParseError, parseBody } from "#api/parse.js";
7
7
  import { bylineUpdateBody } from "#api/schemas.js";
8
+ import { invalidateBylineCache } from "#bylines/index.js";
8
9
  import { BylineRepository } from "#db/repositories/byline.js";
9
10
 
10
11
  export const prerender = false;
@@ -61,6 +62,7 @@ export const PUT: APIRoute = async ({ params, request, locals }) => {
61
62
  });
62
63
 
63
64
  if (!byline) return apiError("NOT_FOUND", "Byline not found", 404);
65
+ invalidateBylineCache();
64
66
  return apiSuccess(byline);
65
67
  } catch (error) {
66
68
  return handleError(error, "Failed to update byline", "BYLINE_UPDATE_ERROR");
@@ -80,6 +82,7 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
80
82
  const repo = new BylineRepository(emdash.db);
81
83
  const deleted = await repo.delete(params.id!);
82
84
  if (!deleted) return apiError("NOT_FOUND", "Byline not found", 404);
85
+ invalidateBylineCache();
83
86
  return apiSuccess({ deleted: true });
84
87
  } catch (error) {
85
88
  return handleError(error, "Failed to delete byline", "BYLINE_DELETE_ERROR");
@@ -5,6 +5,7 @@ import { requirePerm } from "#api/authorize.js";
5
5
  import { apiError, apiSuccess, handleError } from "#api/error.js";
6
6
  import { isParseError, parseBody, parseQuery } from "#api/parse.js";
7
7
  import { bylineCreateBody, bylinesListQuery } from "#api/schemas.js";
8
+ import { invalidateBylineCache } from "#bylines/index.js";
8
9
  import { BylineRepository } from "#db/repositories/byline.js";
9
10
 
10
11
  export const prerender = false;
@@ -65,6 +66,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
65
66
  isGuest: body.isGuest,
66
67
  });
67
68
 
69
+ invalidateBylineCache();
68
70
  return apiSuccess(byline, 201);
69
71
  } catch (error) {
70
72
  return handleError(error, "Failed to create byline", "BYLINE_CREATE_ERROR");