emdash 0.8.0 → 0.9.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 (286) hide show
  1. package/dist/{adapters-BKSf3T9R.d.mts → adapters-DoNJiveC.d.mts} +1 -1
  2. package/dist/{adapters-BKSf3T9R.d.mts.map → adapters-DoNJiveC.d.mts.map} +1 -1
  3. package/dist/{apply-x0eMK1lX.mjs → apply-BzltprvY.mjs} +85 -135
  4. package/dist/apply-BzltprvY.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 +110 -4
  8. package/dist/astro/index.mjs.map +1 -1
  9. package/dist/astro/middleware/auth.d.mts +6 -7
  10. package/dist/astro/middleware/auth.d.mts.map +1 -1
  11. package/dist/astro/middleware/auth.mjs +16 -59
  12. package/dist/astro/middleware/auth.mjs.map +1 -1
  13. package/dist/astro/middleware/redirect.d.mts.map +1 -1
  14. package/dist/astro/middleware/redirect.mjs +17 -12
  15. package/dist/astro/middleware/redirect.mjs.map +1 -1
  16. package/dist/astro/middleware/request-context.d.mts.map +1 -1
  17. package/dist/astro/middleware/request-context.mjs +9 -6
  18. package/dist/astro/middleware/request-context.mjs.map +1 -1
  19. package/dist/astro/middleware/setup.mjs +1 -1
  20. package/dist/astro/middleware.d.mts.map +1 -1
  21. package/dist/astro/middleware.mjs +72 -124
  22. package/dist/astro/middleware.mjs.map +1 -1
  23. package/dist/astro/types.d.mts +26 -10
  24. package/dist/astro/types.d.mts.map +1 -1
  25. package/dist/{base64-MBPo9ozB.mjs → base64-BRICGH2l.mjs} +1 -1
  26. package/dist/{base64-MBPo9ozB.mjs.map → base64-BRICGH2l.mjs.map} +1 -1
  27. package/dist/{byline-Chbr2GoP.mjs → byline-BSaNL1w7.mjs} +4 -4
  28. package/dist/{byline-Chbr2GoP.mjs.map → byline-BSaNL1w7.mjs.map} +1 -1
  29. package/dist/bylines-CvJ3PYz2.mjs +113 -0
  30. package/dist/bylines-CvJ3PYz2.mjs.map +1 -0
  31. package/dist/cache-C6N_hhN7.mjs +65 -0
  32. package/dist/cache-C6N_hhN7.mjs.map +1 -0
  33. package/dist/{chunks-HGz06Soa.mjs → chunks-NBQVDOci.mjs} +8 -2
  34. package/dist/{chunks-HGz06Soa.mjs.map → chunks-NBQVDOci.mjs.map} +1 -1
  35. package/dist/cli/index.mjs +224 -30
  36. package/dist/cli/index.mjs.map +1 -1
  37. package/dist/client/cf-access.d.mts +1 -1
  38. package/dist/client/index.d.mts +1 -1
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/client/index.mjs.map +1 -1
  41. package/dist/{config-BXwuX8Bx.mjs → config-BI0V3ICQ.mjs} +1 -1
  42. package/dist/{config-BXwuX8Bx.mjs.map → config-BI0V3ICQ.mjs.map} +1 -1
  43. package/dist/{content-BcQPYxdV.mjs → content-8lOYF0pr.mjs} +32 -15
  44. package/dist/{content-BcQPYxdV.mjs.map → content-8lOYF0pr.mjs.map} +1 -1
  45. package/dist/db/index.d.mts +3 -3
  46. package/dist/db/index.mjs +2 -2
  47. package/dist/db/libsql.d.mts +1 -1
  48. package/dist/db/libsql.d.mts.map +1 -1
  49. package/dist/db/libsql.mjs +7 -2
  50. package/dist/db/libsql.mjs.map +1 -1
  51. package/dist/db/postgres.d.mts +1 -1
  52. package/dist/db/sqlite.d.mts +1 -1
  53. package/dist/db/sqlite.d.mts.map +1 -1
  54. package/dist/db/sqlite.mjs +8 -3
  55. package/dist/db/sqlite.mjs.map +1 -1
  56. package/dist/{db-errors-l1Qh2RPR.mjs → db-errors-WRezodiz.mjs} +1 -1
  57. package/dist/{db-errors-l1Qh2RPR.mjs.map → db-errors-WRezodiz.mjs.map} +1 -1
  58. package/dist/{default-DCVqE5ib.mjs → default-D8ksjWhO.mjs} +1 -1
  59. package/dist/{default-DCVqE5ib.mjs.map → default-D8ksjWhO.mjs.map} +1 -1
  60. package/dist/{dialect-helpers-DhTzaUxP.mjs → dialect-helpers-BKCvISIQ.mjs} +19 -2
  61. package/dist/dialect-helpers-BKCvISIQ.mjs.map +1 -0
  62. package/dist/{error-zG5T1UGA.mjs → error-D_-tqP-I.mjs} +1 -1
  63. package/dist/{error-zG5T1UGA.mjs.map → error-D_-tqP-I.mjs.map} +1 -1
  64. package/dist/{index-DIb-CzNx.d.mts → index-BFRaVcD6.d.mts} +94 -34
  65. package/dist/index-BFRaVcD6.d.mts.map +1 -0
  66. package/dist/index.d.mts +11 -11
  67. package/dist/index.mjs +29 -27
  68. package/dist/{load-CyEoextb.mjs → load-DDqMMvZL.mjs} +2 -2
  69. package/dist/{load-CyEoextb.mjs.map → load-DDqMMvZL.mjs.map} +1 -1
  70. package/dist/{loader-CndGj8kM.mjs → loader-CKLbBnhK.mjs} +27 -7
  71. package/dist/loader-CKLbBnhK.mjs.map +1 -0
  72. package/dist/{manifest-schema-DH9xhc6t.mjs → manifest-schema-DqWNC3lM.mjs} +33 -3
  73. package/dist/manifest-schema-DqWNC3lM.mjs.map +1 -0
  74. package/dist/media/index.d.mts +1 -1
  75. package/dist/media/index.mjs +1 -1
  76. package/dist/media/local-runtime.d.mts +7 -7
  77. package/dist/media/local-runtime.mjs +3 -3
  78. package/dist/{media-D8FbNsl0.mjs → media-BW32b4gi.mjs} +2 -2
  79. package/dist/{media-D8FbNsl0.mjs.map → media-BW32b4gi.mjs.map} +1 -1
  80. package/dist/{mode-BnAOqItE.mjs → mode-ier8jbBk.mjs} +1 -1
  81. package/dist/{mode-BnAOqItE.mjs.map → mode-ier8jbBk.mjs.map} +1 -1
  82. package/dist/options-BVp3UsTS.mjs +117 -0
  83. package/dist/options-BVp3UsTS.mjs.map +1 -0
  84. package/dist/page/index.d.mts +2 -2
  85. package/dist/{placeholder-D29tWZ7o.d.mts → placeholder-BE4o_2dc.d.mts} +1 -1
  86. package/dist/{placeholder-D29tWZ7o.d.mts.map → placeholder-BE4o_2dc.d.mts.map} +1 -1
  87. package/dist/{placeholder-C-fk5hYI.mjs → placeholder-CIJejMlK.mjs} +1 -1
  88. package/dist/{placeholder-C-fk5hYI.mjs.map → placeholder-CIJejMlK.mjs.map} +1 -1
  89. package/dist/plugins/adapt-sandbox-entry.d.mts +5 -5
  90. package/dist/plugins/adapt-sandbox-entry.d.mts.map +1 -1
  91. package/dist/plugins/adapt-sandbox-entry.mjs +6 -5
  92. package/dist/plugins/adapt-sandbox-entry.mjs.map +1 -1
  93. package/dist/public-url-DByxYjUw.mjs +51 -0
  94. package/dist/public-url-DByxYjUw.mjs.map +1 -0
  95. package/dist/{query-fqEdLFms.mjs → query-Cg9ZKRQ0.mjs} +114 -16
  96. package/dist/query-Cg9ZKRQ0.mjs.map +1 -0
  97. package/dist/{redirect-D_pshWdf.mjs → redirect-BhUBKRc1.mjs} +11 -6
  98. package/dist/redirect-BhUBKRc1.mjs.map +1 -0
  99. package/dist/{registry-C3Mr0ODu.mjs → registry-Dw70ChxB.mjs} +38 -4
  100. package/dist/registry-Dw70ChxB.mjs.map +1 -0
  101. package/dist/{request-cache-Ci7f5pBb.mjs → request-cache-B-bmkipQ.mjs} +1 -1
  102. package/dist/{request-cache-Ci7f5pBb.mjs.map → request-cache-B-bmkipQ.mjs.map} +1 -1
  103. package/dist/runner-Bnoj7vjK.d.mts +44 -0
  104. package/dist/runner-Bnoj7vjK.d.mts.map +1 -0
  105. package/dist/{runner-tQ7BJ4T7.mjs → runner-C7ADox5q.mjs} +185 -55
  106. package/dist/{runner-tQ7BJ4T7.mjs.map → runner-C7ADox5q.mjs.map} +1 -1
  107. package/dist/runtime.d.mts +6 -6
  108. package/dist/runtime.mjs +4 -4
  109. package/dist/{search-BoZYFuUk.mjs → search-dOGEccMa.mjs} +129 -83
  110. package/dist/search-dOGEccMa.mjs.map +1 -0
  111. package/dist/secrets-CW3reAnU.mjs +314 -0
  112. package/dist/secrets-CW3reAnU.mjs.map +1 -0
  113. package/dist/seed/index.d.mts +2 -2
  114. package/dist/seed/index.mjs +15 -14
  115. package/dist/seo/index.d.mts +1 -1
  116. package/dist/storage/local.d.mts +1 -1
  117. package/dist/storage/local.mjs +1 -1
  118. package/dist/storage/s3.d.mts +1 -1
  119. package/dist/storage/s3.mjs +1 -1
  120. package/dist/{taxonomies-B4IAshV8.mjs → taxonomies-ZlRtD6AG.mjs} +14 -7
  121. package/dist/taxonomies-ZlRtD6AG.mjs.map +1 -0
  122. package/dist/{tokens-D9vnZqYS.mjs → tokens-D7zMmWi2.mjs} +2 -2
  123. package/dist/{tokens-D9vnZqYS.mjs.map → tokens-D7zMmWi2.mjs.map} +1 -1
  124. package/dist/{transport-C9ugt2Nr.mjs → transport-BeMCmin1.mjs} +6 -5
  125. package/dist/{transport-C9ugt2Nr.mjs.map → transport-BeMCmin1.mjs.map} +1 -1
  126. package/dist/{transport-CUnEL3Vs.d.mts → transport-DNEfeMaU.d.mts} +1 -1
  127. package/dist/{transport-CUnEL3Vs.d.mts.map → transport-DNEfeMaU.d.mts.map} +1 -1
  128. package/dist/types-4fVtCIm0.mjs +68 -0
  129. package/dist/types-4fVtCIm0.mjs.map +1 -0
  130. package/dist/{types-BmPPSUEx.d.mts → types-BSyXeCFW.d.mts} +24 -2
  131. package/dist/{types-BmPPSUEx.d.mts.map → types-BSyXeCFW.d.mts.map} +1 -1
  132. package/dist/{types-i36XcA_X.d.mts → types-BuBIptGk.d.mts} +65 -134
  133. package/dist/types-BuBIptGk.d.mts.map +1 -0
  134. package/dist/{types-CgqmmMJB.mjs → types-CDbKp7ND.mjs} +1 -1
  135. package/dist/{types-CgqmmMJB.mjs.map → types-CDbKp7ND.mjs.map} +1 -1
  136. package/dist/{types-Bm1dn-q3.mjs → types-CIOg5AR8.mjs} +1 -1
  137. package/dist/{types-Bm1dn-q3.mjs.map → types-CIOg5AR8.mjs.map} +1 -1
  138. package/dist/{types-BrA0xf5I.d.mts → types-CJsYGpco.d.mts} +1 -1
  139. package/dist/{types-BrA0xf5I.d.mts.map → types-CJsYGpco.d.mts.map} +1 -1
  140. package/dist/{types-BIgulNsW.mjs → types-CRxNbK-Z.mjs} +2 -2
  141. package/dist/{types-BIgulNsW.mjs.map → types-CRxNbK-Z.mjs.map} +1 -1
  142. package/dist/{types-CS8FIX7L.d.mts → types-CrtWgIvl.d.mts} +1 -1
  143. package/dist/{types-CS8FIX7L.d.mts.map → types-CrtWgIvl.d.mts.map} +1 -1
  144. package/dist/{types-DIMwPFub.d.mts → types-M78DQ1lx.d.mts} +1 -1
  145. package/dist/{types-DIMwPFub.d.mts.map → types-M78DQ1lx.d.mts.map} +1 -1
  146. package/dist/{validate-CxVsLehf.mjs → validate-Baqf0slj.mjs} +3 -3
  147. package/dist/{validate-CxVsLehf.mjs.map → validate-Baqf0slj.mjs.map} +1 -1
  148. package/dist/{validate-DHxmpFJt.d.mts → validate-BfQh_C_y.d.mts} +4 -4
  149. package/dist/{validate-DHxmpFJt.d.mts.map → validate-BfQh_C_y.d.mts.map} +1 -1
  150. package/dist/{validation-C-ZpN2GI.mjs → validation-BfEI7tNe.mjs} +6 -6
  151. package/dist/{validation-C-ZpN2GI.mjs.map → validation-BfEI7tNe.mjs.map} +1 -1
  152. package/dist/version-DoxrVdYf.mjs +7 -0
  153. package/dist/{version-Bbq8TCrz.mjs.map → version-DoxrVdYf.mjs.map} +1 -1
  154. package/dist/{zod-generator-CpwccCIv.mjs → zod-generator-CC0xNe_K.mjs} +4 -4
  155. package/dist/zod-generator-CC0xNe_K.mjs.map +1 -0
  156. package/locals.d.ts +1 -6
  157. package/package.json +9 -8
  158. package/src/api/handlers/comments.ts +6 -4
  159. package/src/api/handlers/content.ts +29 -1
  160. package/src/api/handlers/device-flow.ts +5 -0
  161. package/src/api/handlers/marketplace.ts +11 -4
  162. package/src/api/handlers/oauth-authorization.ts +72 -33
  163. package/src/api/handlers/revision.ts +23 -14
  164. package/src/api/handlers/taxonomies.ts +3 -6
  165. package/src/api/public-url.ts +48 -2
  166. package/src/api/schemas/comments.ts +2 -2
  167. package/src/api/schemas/content.ts +17 -0
  168. package/src/api/schemas/sections.ts +3 -3
  169. package/src/api/schemas/users.ts +1 -1
  170. package/src/api/types.ts +5 -1
  171. package/src/astro/integration/index.ts +17 -0
  172. package/src/astro/integration/runtime.ts +30 -0
  173. package/src/astro/integration/virtual-modules.ts +32 -2
  174. package/src/astro/integration/vite-config.ts +6 -1
  175. package/src/astro/middleware/auth.ts +13 -6
  176. package/src/astro/middleware/redirect.ts +29 -16
  177. package/src/astro/middleware/request-context.ts +15 -5
  178. package/src/astro/middleware.ts +23 -9
  179. package/src/astro/routes/api/auth/invite/complete.ts +6 -1
  180. package/src/astro/routes/api/auth/passkey/register/verify.ts +6 -1
  181. package/src/astro/routes/api/auth/passkey/verify.ts +6 -1
  182. package/src/astro/routes/api/auth/signup/complete.ts +6 -1
  183. package/src/astro/routes/api/comments/[collection]/[contentId]/index.ts +2 -2
  184. package/src/astro/routes/api/content/[collection]/[id]/discard-draft.ts +4 -2
  185. package/src/astro/routes/api/content/[collection]/[id]/preview-url.ts +34 -12
  186. package/src/astro/routes/api/content/[collection]/[id]/publish.ts +32 -2
  187. package/src/astro/routes/api/content/[collection]/[id]/restore.ts +4 -2
  188. package/src/astro/routes/api/content/[collection]/[id]/revisions.ts +3 -2
  189. package/src/astro/routes/api/content/[collection]/[id]/terms/[taxonomy].ts +8 -4
  190. package/src/astro/routes/api/content/[collection]/[id].ts +12 -0
  191. package/src/astro/routes/api/import/wordpress/execute.ts +3 -1
  192. package/src/astro/routes/api/import/wordpress/prepare.ts +7 -8
  193. package/src/astro/routes/api/import/wordpress-plugin/execute.ts +3 -1
  194. package/src/astro/routes/api/manifest.ts +62 -45
  195. package/src/astro/routes/api/media/[id]/confirm.ts +10 -1
  196. package/src/astro/routes/api/media/providers/[providerId]/index.ts +12 -3
  197. package/src/astro/routes/api/openapi.json.ts +27 -10
  198. package/src/astro/routes/api/redirects/404s/index.ts +10 -4
  199. package/src/astro/routes/api/redirects/404s/summary.ts +4 -2
  200. package/src/astro/routes/api/redirects/[id].ts +10 -4
  201. package/src/astro/routes/api/redirects/index.ts +7 -3
  202. package/src/astro/routes/api/revisions/[revisionId]/index.ts +1 -1
  203. package/src/astro/routes/api/schema/collections/[slug]/fields/[fieldSlug].ts +0 -2
  204. package/src/astro/routes/api/schema/collections/[slug]/fields/index.ts +0 -1
  205. package/src/astro/routes/api/schema/collections/[slug]/fields/reorder.ts +0 -1
  206. package/src/astro/routes/api/schema/collections/[slug]/index.ts +2 -2
  207. package/src/astro/routes/api/schema/collections/index.ts +1 -1
  208. package/src/astro/routes/api/search/index.ts +10 -2
  209. package/src/astro/routes/api/sections/[slug].ts +10 -4
  210. package/src/astro/routes/api/sections/index.ts +7 -3
  211. package/src/astro/routes/api/setup/admin-verify.ts +6 -1
  212. package/src/astro/routes/api/snapshot.ts +44 -18
  213. package/src/astro/routes/api/taxonomies/index.ts +0 -1
  214. package/src/astro/routes/api/themes/preview.ts +11 -5
  215. package/src/astro/types.ts +23 -3
  216. package/src/auth/allowed-origins.ts +168 -0
  217. package/src/auth/passkey-config.ts +35 -13
  218. package/src/bylines/index.ts +37 -88
  219. package/src/cli/commands/auth.ts +28 -6
  220. package/src/cli/commands/bundle-utils.ts +11 -2
  221. package/src/cli/commands/bundle.ts +28 -8
  222. package/src/cli/commands/content.ts +13 -0
  223. package/src/cli/commands/login.ts +8 -1
  224. package/src/cli/commands/publish.ts +24 -0
  225. package/src/cli/commands/secrets.ts +183 -0
  226. package/src/cli/credentials.ts +1 -1
  227. package/src/cli/index.ts +5 -1
  228. package/src/client/index.ts +4 -4
  229. package/src/client/transport.ts +17 -7
  230. package/src/components/Break.astro +2 -2
  231. package/src/components/EmDashHead.astro +18 -13
  232. package/src/components/Embed.astro +1 -1
  233. package/src/components/Gallery.astro +1 -1
  234. package/src/components/Image.astro +1 -1
  235. package/src/components/InlinePortableTextEditor.tsx +104 -18
  236. package/src/config/secrets.ts +528 -0
  237. package/src/database/dialect-helpers.ts +50 -0
  238. package/src/database/migrations/034_published_at_index.ts +1 -1
  239. package/src/database/migrations/035_bounded_404_log.ts +56 -39
  240. package/src/database/migrations/runner.ts +156 -23
  241. package/src/database/repositories/content.ts +36 -12
  242. package/src/database/repositories/redirect.ts +14 -3
  243. package/src/database/repositories/taxonomy.ts +26 -0
  244. package/src/db/libsql.ts +1 -3
  245. package/src/db/sqlite.ts +2 -5
  246. package/src/emdash-runtime.ts +84 -159
  247. package/src/index.ts +9 -0
  248. package/src/loader.ts +24 -1
  249. package/src/mcp/server.ts +103 -36
  250. package/src/page/site-identity.ts +58 -0
  251. package/src/plugins/adapt-sandbox-entry.ts +22 -10
  252. package/src/plugins/context.ts +13 -10
  253. package/src/plugins/define-plugin.ts +40 -12
  254. package/src/plugins/hooks.ts +23 -19
  255. package/src/plugins/index.ts +9 -0
  256. package/src/plugins/manifest-schema.ts +37 -2
  257. package/src/plugins/types.ts +151 -11
  258. package/src/preview/urls.ts +23 -3
  259. package/src/query.ts +148 -5
  260. package/src/redirects/cache.ts +38 -18
  261. package/src/schema/registry.ts +56 -0
  262. package/src/schema/zod-generator.ts +27 -5
  263. package/src/seed/apply.ts +2 -0
  264. package/src/settings/index.ts +80 -6
  265. package/src/settings/types.ts +23 -1
  266. package/src/taxonomies/index.ts +11 -1
  267. package/dist/apply-x0eMK1lX.mjs.map +0 -1
  268. package/dist/bylines-CRNsVG88.mjs +0 -157
  269. package/dist/bylines-CRNsVG88.mjs.map +0 -1
  270. package/dist/cache-BkKBuIvS.mjs +0 -56
  271. package/dist/cache-BkKBuIvS.mjs.map +0 -1
  272. package/dist/chunk-ClPoSABd.mjs +0 -21
  273. package/dist/dialect-helpers-DhTzaUxP.mjs.map +0 -1
  274. package/dist/index-DIb-CzNx.d.mts.map +0 -1
  275. package/dist/loader-CndGj8kM.mjs.map +0 -1
  276. package/dist/manifest-schema-DH9xhc6t.mjs.map +0 -1
  277. package/dist/query-fqEdLFms.mjs.map +0 -1
  278. package/dist/redirect-D_pshWdf.mjs.map +0 -1
  279. package/dist/registry-C3Mr0ODu.mjs.map +0 -1
  280. package/dist/runner-OURCaApa.d.mts +0 -34
  281. package/dist/runner-OURCaApa.d.mts.map +0 -1
  282. package/dist/search-BoZYFuUk.mjs.map +0 -1
  283. package/dist/taxonomies-B4IAshV8.mjs.map +0 -1
  284. package/dist/types-i36XcA_X.d.mts.map +0 -1
  285. package/dist/version-Bbq8TCrz.mjs +0 -7
  286. package/dist/zod-generator-CpwccCIv.mjs.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -14,6 +14,8 @@ import { canActOnOwn, hasPermission, Role } from "@emdash-cms/auth";
14
14
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
15
  import { z } from "zod";
16
16
 
17
+ import { contentBylineInputSchema, contentSeoInput } from "#api/schemas.js";
18
+
17
19
  import type { EmDashHandlers } from "../astro/types.js";
18
20
  import { hasScope } from "../auth/api-tokens.js";
19
21
 
@@ -623,7 +625,10 @@ export function createMcpServer(): McpServer {
623
625
  "Update an existing content item. Only include fields you want to change " +
624
626
  "in the 'data' object — unspecified fields are left unchanged. Pass the " +
625
627
  "_rev token from content_get to enable optimistic concurrency checking " +
626
- "(the update fails if the item was modified since you read it).",
628
+ "(the update fails if the item was modified since you read it). " +
629
+ "`seo` and `bylines` are persisted alongside the field updates in a " +
630
+ "single transaction. `publishedAt` requires the content:publish_any " +
631
+ "permission and is useful for migrations or correcting historical dates.",
627
632
  inputSchema: z.object({
628
633
  collection: z.string().describe("Collection slug"),
629
634
  id: z.string().describe("Content item ID or slug"),
@@ -638,6 +643,28 @@ export function createMcpServer(): McpServer {
638
643
  .describe(
639
644
  "New status. Setting to 'published' requires publish permission. Setting to 'draft' unpublishes the item and also requires publish permission.",
640
645
  ),
646
+ // Reuse the REST schema rather than redefining inline. The REST schema's
647
+ // `canonical` field is gated through `httpUrl` (validates the URL parses
648
+ // AND has an http(s) scheme) which rejects javascript:/data: URIs that
649
+ // would otherwise become stored XSS in the rendered <link rel="canonical">.
650
+ // Inlining a looser shape here would let MCP callers bypass that.
651
+ seo: contentSeoInput
652
+ .optional()
653
+ .describe(
654
+ "Per-content SEO metadata. Only valid for collections with SEO enabled (see schema_get_collection.hasSeo). Fields not included are left unchanged; pass null to clear.",
655
+ ),
656
+ bylines: z
657
+ .array(contentBylineInputSchema)
658
+ .optional()
659
+ .describe(
660
+ "Replace the byline list for this item. The first entry becomes the primary byline. Pass an empty array to clear all bylines.",
661
+ ),
662
+ publishedAt: z.iso
663
+ .datetime({ offset: true, message: "must be an ISO 8601 datetime" })
664
+ .nullish()
665
+ .describe(
666
+ "Override the publication timestamp (ISO 8601). Requires content:publish_any permission. Pass null to clear. Useful for content migrations.",
667
+ ),
641
668
  _rev: z
642
669
  .string()
643
670
  .optional()
@@ -647,35 +674,48 @@ export function createMcpServer(): McpServer {
647
674
  async (args, extra) => {
648
675
  requireScope(extra, "content:write");
649
676
  requireRole(extra, Role.AUTHOR);
650
- const { emdash, userId } = getExtra(extra);
677
+ const { emdash, userId, userRole } = getExtra(extra);
651
678
 
652
679
  // Fetch item to check ownership
653
680
  const existing = await emdash.handleContentGet(args.collection, args.id);
654
681
  if (!existing.success) {
655
682
  return unwrap(existing);
656
683
  }
657
- requireOwnership(
658
- extra,
659
- extractContentAuthorId(existing.data),
660
- "content:edit_own",
661
- "content:edit_any",
662
- );
684
+ const ownerId = extractContentAuthorId(existing.data);
685
+ requireOwnership(extra, ownerId, "content:edit_own", "content:edit_any");
686
+
687
+ // Writing publishedAt directly (incl. clearing to null) overwrites
688
+ // historical record — gate behind publish_any, mirroring the REST PUT
689
+ // route. Status-driven publishes are gated separately below.
690
+ if (args.publishedAt !== undefined) {
691
+ const user = { id: userId, role: userRole };
692
+ if (!hasPermission(user, "content:publish_any" as Permission)) {
693
+ throw new EmDashAuthError(
694
+ "Setting publishedAt requires content:publish_any permission",
695
+ "INSUFFICIENT_PERMISSIONS",
696
+ );
697
+ }
698
+ }
663
699
 
664
700
  const resolvedId = extractContentId(existing.data) ?? args.id;
665
701
 
666
702
  // Status transitions route through dedicated handlers for proper revision management
667
703
  if (args.status === "published") {
668
- requireOwnership(
669
- extra,
670
- extractContentAuthorId(existing.data),
671
- "content:publish_own",
672
- "content:publish_any",
673
- );
674
- if (args.data || args.slug) {
704
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
705
+ if (
706
+ args.data ||
707
+ args.slug ||
708
+ args.seo !== undefined ||
709
+ args.bylines !== undefined ||
710
+ args.publishedAt !== undefined
711
+ ) {
675
712
  const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
676
713
  data: args.data,
677
714
  slug: args.slug,
678
715
  authorId: userId,
716
+ seo: args.seo,
717
+ bylines: args.bylines,
718
+ publishedAt: args.publishedAt,
679
719
  _rev: args._rev,
680
720
  });
681
721
  if (!updateResult.success) return unwrap(updateResult);
@@ -684,17 +724,21 @@ export function createMcpServer(): McpServer {
684
724
  }
685
725
 
686
726
  if (args.status === "draft") {
687
- requireOwnership(
688
- extra,
689
- extractContentAuthorId(existing.data),
690
- "content:publish_own",
691
- "content:publish_any",
692
- );
693
- if (args.data || args.slug) {
727
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
728
+ if (
729
+ args.data ||
730
+ args.slug ||
731
+ args.seo !== undefined ||
732
+ args.bylines !== undefined ||
733
+ args.publishedAt !== undefined
734
+ ) {
694
735
  const updateResult = await emdash.handleContentUpdate(args.collection, resolvedId, {
695
736
  data: args.data,
696
737
  slug: args.slug,
697
738
  authorId: userId,
739
+ seo: args.seo,
740
+ bylines: args.bylines,
741
+ publishedAt: args.publishedAt,
698
742
  _rev: args._rev,
699
743
  });
700
744
  if (!updateResult.success) return unwrap(updateResult);
@@ -707,6 +751,9 @@ export function createMcpServer(): McpServer {
707
751
  data: args.data,
708
752
  slug: args.slug,
709
753
  authorId: userId,
754
+ seo: args.seo,
755
+ bylines: args.bylines,
756
+ publishedAt: args.publishedAt,
710
757
  _rev: args._rev,
711
758
  }),
712
759
  );
@@ -809,31 +856,53 @@ export function createMcpServer(): McpServer {
809
856
  description:
810
857
  "Publish a content item, making it live on the site. Creates a published " +
811
858
  "revision from the current draft. Further edits create a new draft without " +
812
- "affecting the live version until re-published.",
859
+ "affecting the live version until re-published. Pass `publishedAt` to " +
860
+ "backdate (e.g. when migrating content from another CMS) — this requires " +
861
+ "the content:publish_any permission. Without `publishedAt`, the existing " +
862
+ "`published_at` is preserved on re-publish (idempotent) and falls back to " +
863
+ "the current time on first publish.",
813
864
  inputSchema: z.object({
814
865
  collection: z.string().describe("Collection slug"),
815
866
  id: z.string().describe("Content item ID or slug"),
867
+ publishedAt: z.iso
868
+ .datetime({ offset: true, message: "must be an ISO 8601 datetime" })
869
+ .optional()
870
+ .describe(
871
+ "Override publication timestamp (ISO 8601). Requires content:publish_any permission. Useful when importing content with original publish dates.",
872
+ ),
816
873
  }),
817
874
  },
818
875
  async (args, extra) => {
819
876
  requireScope(extra, "content:write");
820
877
  requireRole(extra, Role.AUTHOR);
821
- const ec = getEmDash(extra);
878
+ const { emdash, userId, userRole } = getExtra(extra);
822
879
 
823
880
  // Fetch item to check ownership
824
- const existing = await ec.handleContentGet(args.collection, args.id);
881
+ const existing = await emdash.handleContentGet(args.collection, args.id);
825
882
  if (!existing.success) {
826
883
  return unwrap(existing);
827
884
  }
828
- requireOwnership(
829
- extra,
830
- extractContentAuthorId(existing.data),
831
- "content:publish_own",
832
- "content:publish_any",
833
- );
885
+ const ownerId = extractContentAuthorId(existing.data);
886
+ requireOwnership(extra, ownerId, "content:publish_own", "content:publish_any");
887
+
888
+ // Backdating overwrites historical record — gate behind publish_any
889
+ // regardless of ownership (mirrors the REST PUT route's publishedAt gate).
890
+ if (args.publishedAt !== undefined) {
891
+ const user = { id: userId, role: userRole };
892
+ if (!hasPermission(user, "content:publish_any" as Permission)) {
893
+ throw new EmDashAuthError(
894
+ "Setting publishedAt requires content:publish_any permission",
895
+ "INSUFFICIENT_PERMISSIONS",
896
+ );
897
+ }
898
+ }
834
899
 
835
900
  const resolvedId = extractContentId(existing.data) ?? args.id;
836
- return unwrap(await ec.handleContentPublish(args.collection, resolvedId));
901
+ return unwrap(
902
+ await emdash.handleContentPublish(args.collection, resolvedId, {
903
+ publishedAt: args.publishedAt,
904
+ }),
905
+ );
837
906
  },
838
907
  );
839
908
 
@@ -1195,7 +1264,7 @@ export function createMcpServer(): McpServer {
1195
1264
  // ['drafts', 'revisions'] when undefined; pass through verbatim.
1196
1265
  supports: args.supports,
1197
1266
  });
1198
- ec.invalidateManifest();
1267
+ ec.invalidateUrlPatternCache();
1199
1268
  return jsonResult(collection);
1200
1269
  } catch (error) {
1201
1270
  return respondHandlerError(error, "SCHEMA_CREATE_ERROR");
@@ -1227,7 +1296,7 @@ export function createMcpServer(): McpServer {
1227
1296
  const { SchemaRegistry } = await import("../schema/index.js");
1228
1297
  const registry = new SchemaRegistry(ec.db);
1229
1298
  await registry.deleteCollection(args.slug, { force: args.force });
1230
- ec.invalidateManifest();
1299
+ ec.invalidateUrlPatternCache();
1231
1300
  return jsonResult({ deleted: args.slug });
1232
1301
  } catch (error) {
1233
1302
  return respondHandlerError(error, "SCHEMA_DELETE_ERROR");
@@ -1331,7 +1400,6 @@ export function createMcpServer(): McpServer {
1331
1400
  searchable: args.searchable,
1332
1401
  translatable: args.translatable,
1333
1402
  });
1334
- ec.invalidateManifest();
1335
1403
  return jsonResult(field);
1336
1404
  } catch (error) {
1337
1405
  return respondHandlerError(error, "FIELD_CREATE_ERROR");
@@ -1360,7 +1428,6 @@ export function createMcpServer(): McpServer {
1360
1428
  const { SchemaRegistry } = await import("../schema/index.js");
1361
1429
  const registry = new SchemaRegistry(ec.db);
1362
1430
  await registry.deleteField(args.collection, args.fieldSlug);
1363
- ec.invalidateManifest();
1364
1431
  return jsonResult({ deleted: args.fieldSlug, collection: args.collection });
1365
1432
  } catch (error) {
1366
1433
  return respondHandlerError(error, "FIELD_DELETE_ERROR");
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Site identity head injection.
3
+ *
4
+ * Emits first-party `<head>` tags sourced from the user-configured Site
5
+ * Identity. These are rendered alongside, but separate from, the plugin
6
+ * contribution pipeline (`page/metadata.ts`) because:
7
+ *
8
+ * - Site identity is first-party, not plugin-supplied. The contribution
9
+ * pipeline's `isSafeHref` allowlist rejects same-origin paths like
10
+ * `/_emdash/api/media/file/...` (which is correct for sandboxed plugin
11
+ * contributions, but blocks our own favicon URLs).
12
+ * - The data shape is fixed and small. Routing it through a generic
13
+ * deduper buys nothing.
14
+ *
15
+ * Currently emits only `<link rel="icon">`. Other site-identity tags
16
+ * (`apple-touch-icon`, `theme-color`, `application-name`) need their own
17
+ * configurable fields in `SiteSettings` before they ship; emitting them
18
+ * automatically from the favicon would produce broken icons on iOS for
19
+ * SVG favicons or blurry home-screen icons when the favicon is a small
20
+ * PNG. Tracked separately.
21
+ *
22
+ * Templates that previously emitted their own `<link rel="icon">` are
23
+ * getting their lines dropped in the same change that introduced this
24
+ * helper.
25
+ */
26
+
27
+ import type { MediaReference } from "../settings/types.js";
28
+ import { escapeHtmlAttr } from "./metadata.js";
29
+
30
+ /**
31
+ * Subset of site settings consumed by `renderSiteIdentity`. Kept narrow
32
+ * so callers don't have to fetch fields they don't use.
33
+ */
34
+ export interface SiteIdentityInput {
35
+ favicon?: MediaReference;
36
+ }
37
+
38
+ /**
39
+ * Build the `<head>` HTML for site identity tags. Returns an empty string
40
+ * when no identity fields are configured.
41
+ */
42
+ export function renderSiteIdentity(input: SiteIdentityInput | undefined): string {
43
+ if (!input) return "";
44
+
45
+ const parts: string[] = [];
46
+
47
+ const favicon = input.favicon;
48
+ if (favicon?.url) {
49
+ let tag = `<link rel="icon" href="${escapeHtmlAttr(favicon.url)}"`;
50
+ if (favicon.contentType) {
51
+ tag += ` type="${escapeHtmlAttr(favicon.contentType)}"`;
52
+ }
53
+ tag += ">";
54
+ parts.push(tag);
55
+ }
56
+
57
+ return parts.join("\n");
58
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import type { PluginDescriptor } from "../astro/integration/runtime.js";
14
14
  import { PLUGIN_CAPABILITIES, HOOK_NAMES } from "./manifest-schema.js";
15
+ import { normalizeCapabilities } from "./types.js";
15
16
  import type {
16
17
  StandardPluginDefinition,
17
18
  StandardHookEntry,
@@ -147,7 +148,10 @@ export function adaptSandboxEntry(
147
148
  }
148
149
 
149
150
  // Build capabilities from descriptor.
150
- // Validate against the known set (same as defineNativePlugin).
151
+ // Validate against the known set (same as defineNativePlugin). Both
152
+ // current and deprecated names are accepted; deprecated names are
153
+ // silently normalized to current names below so the runtime only ever
154
+ // sees the canonical form.
151
155
  const rawCapabilities = descriptor.capabilities ?? [];
152
156
  for (const cap of rawCapabilities) {
153
157
  if (!VALID_CAPABILITIES_SET.has(cap)) {
@@ -157,20 +161,28 @@ export function adaptSandboxEntry(
157
161
  );
158
162
  }
159
163
  }
160
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated against VALID_CAPABILITIES_SET above; descriptor uses string[] for flexibility
161
- const capabilities = [...rawCapabilities] as PluginCapability[];
164
+
165
+ // Silent normalization: rewrite deprecated names to current names.
166
+ // Safe assertion — `normalizeCapabilities` only emits validated input
167
+ // plus current names from the rename map, all of which are in the union.
168
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above; normalizeCapabilities only returns capabilities from the union
169
+ const capabilities = normalizeCapabilities(rawCapabilities) as PluginCapability[];
162
170
  const allowedHosts = descriptor.allowedHosts ?? [];
163
171
 
164
172
  // Capability implications: broader capabilities imply narrower ones
165
- // (mirrors the normalization in define-plugin.ts for native format)
166
- if (capabilities.includes("write:content") && !capabilities.includes("read:content")) {
167
- capabilities.push("read:content");
173
+ // (mirrors the normalization in define-plugin.ts for native format).
174
+ // Operates on canonical names only.
175
+ if (capabilities.includes("content:write") && !capabilities.includes("content:read")) {
176
+ capabilities.push("content:read");
168
177
  }
169
- if (capabilities.includes("write:media") && !capabilities.includes("read:media")) {
170
- capabilities.push("read:media");
178
+ if (capabilities.includes("media:write") && !capabilities.includes("media:read")) {
179
+ capabilities.push("media:read");
171
180
  }
172
- if (capabilities.includes("network:fetch:any") && !capabilities.includes("network:fetch")) {
173
- capabilities.push("network:fetch");
181
+ if (
182
+ capabilities.includes("network:request:unrestricted") &&
183
+ !capabilities.includes("network:request")
184
+ ) {
185
+ capabilities.push("network:request");
174
186
  }
175
187
 
176
188
  // Build storage config from descriptor.
@@ -647,14 +647,14 @@ export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess {
647
647
  }
648
648
 
649
649
  /**
650
- * Create blocked HTTP access (for plugins without network:fetch capability)
650
+ * Create blocked HTTP access (for plugins without network:request capability)
651
651
  */
652
652
  export function createBlockedHttpAccess(pluginId: string): HttpAccess {
653
653
  return {
654
654
  async fetch(): Promise<never> {
655
655
  throw new Error(
656
- `Plugin "${pluginId}" does not have the "network:fetch" capability. ` +
657
- `Add "network:fetch" to the plugin's capabilities to enable HTTP requests.`,
656
+ `Plugin "${pluginId}" does not have the "network:request" capability. ` +
657
+ `Add "network:request" to the plugin's capabilities to enable HTTP requests.`,
658
658
  );
659
659
  },
660
660
  };
@@ -902,32 +902,35 @@ export class PluginContextFactory {
902
902
  const storage = createStorageAccess(this.db, plugin.id, plugin.storage);
903
903
 
904
904
  // Capability-gated: content
905
+ // Note: capabilities reach this point already normalized to the
906
+ // canonical names by definePlugin / adaptSandboxEntry. Deprecated
907
+ // names ("read:content", "write:content") never appear here.
905
908
  let content: ContentAccess | ContentAccessWithWrite | undefined;
906
- if (capabilities.has("write:content")) {
909
+ if (capabilities.has("content:write")) {
907
910
  content = createContentAccessWithWrite(this.db);
908
- } else if (capabilities.has("read:content")) {
911
+ } else if (capabilities.has("content:read")) {
909
912
  content = createContentAccess(this.db);
910
913
  }
911
914
 
912
915
  // Capability-gated: media
913
916
  let media: MediaAccess | MediaAccessWithWrite | undefined;
914
- if (capabilities.has("write:media") && this.getUploadUrl) {
917
+ if (capabilities.has("media:write") && this.getUploadUrl) {
915
918
  media = createMediaAccessWithWrite(this.db, this.getUploadUrl, this.storage);
916
- } else if (capabilities.has("read:media")) {
919
+ } else if (capabilities.has("media:read")) {
917
920
  media = createMediaAccess(this.db);
918
921
  }
919
922
 
920
923
  // Capability-gated: http
921
924
  let http: HttpAccess | undefined;
922
- if (capabilities.has("network:fetch:any")) {
925
+ if (capabilities.has("network:request:unrestricted")) {
923
926
  http = createUnrestrictedHttpAccess(plugin.id);
924
- } else if (capabilities.has("network:fetch")) {
927
+ } else if (capabilities.has("network:request")) {
925
928
  http = createHttpAccess(plugin.id, plugin.allowedHosts);
926
929
  }
927
930
 
928
931
  // Capability-gated: users
929
932
  let users: UserAccess | undefined;
930
- if (capabilities.has("read:users")) {
933
+ if (capabilities.has("users:read")) {
931
934
  users = createUserAccess(this.db);
932
935
  }
933
936
 
@@ -13,6 +13,7 @@
13
13
  *
14
14
  */
15
15
 
16
+ import { normalizeCapabilities } from "./types.js";
16
17
  import type {
17
18
  PluginDefinition,
18
19
  ResolvedPlugin,
@@ -20,6 +21,7 @@ import type {
20
21
  ResolvedPluginHooks,
21
22
  ResolvedHook,
22
23
  HookConfig,
24
+ PluginCapability,
23
25
  PluginStorageConfig,
24
26
  StandardPluginDefinition,
25
27
  } from "./types.js";
@@ -65,7 +67,7 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+/;
65
67
  * export default definePlugin({
66
68
  * id: "my-plugin",
67
69
  * version: "1.0.0",
68
- * capabilities: ["read:content"],
70
+ * capabilities: ["content:read"],
69
71
  * hooks: {
70
72
  * "content:beforeSave": async (event, ctx) => {
71
73
  * ctx.log.info("Saving content", { collection: event.collection });
@@ -143,8 +145,24 @@ function defineNativePlugin<TStorage extends PluginStorageConfig>(
143
145
  throw new Error(`Invalid plugin version "${version}". Must be semver format (e.g., "1.0.0").`);
144
146
  }
145
147
 
146
- // Validate capabilities
147
- const validCapabilities = new Set([
148
+ // Validate capabilities. Both current names and deprecated aliases are
149
+ // accepted; aliases are silently rewritten to current names below so the
150
+ // runtime only ever sees the canonical form. Authors are warned at
151
+ // bundle/validate and hard-failed at publish.
152
+ const validCapabilities = new Set<string>([
153
+ // Current names
154
+ "network:request",
155
+ "network:request:unrestricted",
156
+ "content:read",
157
+ "content:write",
158
+ "media:read",
159
+ "media:write",
160
+ "users:read",
161
+ "email:send",
162
+ "hooks.email-transport:register",
163
+ "hooks.email-events:register",
164
+ "hooks.page-fragments:register",
165
+ // Deprecated aliases
148
166
  "network:fetch",
149
167
  "network:fetch:any",
150
168
  "read:content",
@@ -152,7 +170,6 @@ function defineNativePlugin<TStorage extends PluginStorageConfig>(
152
170
  "read:media",
153
171
  "write:media",
154
172
  "read:users",
155
- "email:send",
156
173
  "email:provide",
157
174
  "email:intercept",
158
175
  "page:inject",
@@ -163,16 +180,27 @@ function defineNativePlugin<TStorage extends PluginStorageConfig>(
163
180
  }
164
181
  }
165
182
 
166
- // Capability implications: broader capabilities imply narrower ones
167
- const normalizedCapabilities = [...capabilities];
168
- if (capabilities.includes("write:content") && !capabilities.includes("read:content")) {
169
- normalizedCapabilities.push("read:content");
183
+ // Silent normalization: rewrite deprecated names to current names. Done
184
+ // before the implication pass so implications work on canonical names.
185
+ // `as PluginCapability[]` is safe because `normalizeCapabilities` only
186
+ // returns strings from the validated input plus current names from the
187
+ // rename map, all of which are in the union.
188
+ const canonical = normalizeCapabilities(capabilities) as PluginCapability[];
189
+
190
+ // Capability implications: broader capabilities imply narrower ones.
191
+ // Operates on canonical names only.
192
+ const normalizedCapabilities: PluginCapability[] = [...canonical];
193
+ if (canonical.includes("content:write") && !canonical.includes("content:read")) {
194
+ normalizedCapabilities.push("content:read");
170
195
  }
171
- if (capabilities.includes("write:media") && !capabilities.includes("read:media")) {
172
- normalizedCapabilities.push("read:media");
196
+ if (canonical.includes("media:write") && !canonical.includes("media:read")) {
197
+ normalizedCapabilities.push("media:read");
173
198
  }
174
- if (capabilities.includes("network:fetch:any") && !capabilities.includes("network:fetch")) {
175
- normalizedCapabilities.push("network:fetch");
199
+ if (
200
+ canonical.includes("network:request:unrestricted") &&
201
+ !canonical.includes("network:request")
202
+ ) {
203
+ normalizedCapabilities.push("network:request");
176
204
  }
177
205
 
178
206
  // Normalize hooks
@@ -248,28 +248,32 @@ export class HookPipeline {
248
248
  * capability will have that hook silently skipped at registration time.
249
249
  */
250
250
  private static readonly HOOK_REQUIRED_CAPABILITY: ReadonlyMap<string, string> = new Map([
251
- // Email
252
- ["email:beforeSend", "email:intercept"],
253
- ["email:afterSend", "email:intercept"],
254
- ["email:deliver", "email:provide"],
255
- // Content — beforeSave can mutate content, so requires write:content.
256
- // afterSave is read-only notification, so read:content suffices.
257
- ["content:beforeSave", "write:content"],
258
- ["content:afterSave", "read:content"],
259
- ["content:beforeDelete", "read:content"],
260
- ["content:afterDelete", "read:content"],
261
- ["content:afterPublish", "read:content"],
262
- ["content:afterUnpublish", "read:content"],
251
+ // Email — registering email:beforeSend/afterSend/deliver requires the
252
+ // matching `hooks.email-*:register` capability. These are distinct
253
+ // from `email:send` (which gates ctx.email) so that "this plugin
254
+ // reads/writes email events" is visible separately from "this
255
+ // plugin can send email".
256
+ ["email:beforeSend", "hooks.email-events:register"],
257
+ ["email:afterSend", "hooks.email-events:register"],
258
+ ["email:deliver", "hooks.email-transport:register"],
259
+ // Content — beforeSave can mutate content, so requires content:write.
260
+ // afterSave is read-only notification, so content:read suffices.
261
+ ["content:beforeSave", "content:write"],
262
+ ["content:afterSave", "content:read"],
263
+ ["content:beforeDelete", "content:read"],
264
+ ["content:afterDelete", "content:read"],
265
+ ["content:afterPublish", "content:read"],
266
+ ["content:afterUnpublish", "content:read"],
263
267
  // Media
264
- ["media:beforeUpload", "write:media"],
265
- ["media:afterUpload", "read:media"],
268
+ ["media:beforeUpload", "media:write"],
269
+ ["media:afterUpload", "media:read"],
266
270
  // Comments — hooks expose author email, IP hash, user agent
267
- ["comment:beforeCreate", "read:users"],
268
- ["comment:moderate", "read:users"],
269
- ["comment:afterCreate", "read:users"],
270
- ["comment:afterModerate", "read:users"],
271
+ ["comment:beforeCreate", "users:read"],
272
+ ["comment:moderate", "users:read"],
273
+ ["comment:afterCreate", "users:read"],
274
+ ["comment:afterModerate", "users:read"],
271
275
  // Page fragments — can inject arbitrary scripts into every public page
272
- ["page:fragments", "page:inject"],
276
+ ["page:fragments", "hooks.page-fragments:register"],
273
277
  ]);
274
278
 
275
279
  /**
@@ -192,3 +192,12 @@ export type {
192
192
  StandardRouteEntry,
193
193
  } from "./types.js";
194
194
  export { isStandardPluginDefinition } from "./types.js";
195
+
196
+ // Capability normalization (legacy → canonical alias layer)
197
+ export {
198
+ CAPABILITY_RENAMES,
199
+ isDeprecatedCapability,
200
+ normalizeCapability,
201
+ normalizeCapabilities,
202
+ } from "./types.js";
203
+ export type { CurrentPluginCapability, DeprecatedPluginCapability } from "./types.js";
@@ -12,7 +12,31 @@ import { z } from "zod";
12
12
 
13
13
  // ── Enum values (must stay in sync with types.ts) ───────────────
14
14
 
15
- export const PLUGIN_CAPABILITIES = [
15
+ /**
16
+ * Current capability names — the ones authors should use going forward.
17
+ * See `PluginCapability` in `types.ts` for documentation of each.
18
+ */
19
+ export const CURRENT_PLUGIN_CAPABILITIES = [
20
+ "network:request",
21
+ "network:request:unrestricted",
22
+ "content:read",
23
+ "content:write",
24
+ "media:read",
25
+ "media:write",
26
+ "users:read",
27
+ "email:send",
28
+ "hooks.email-transport:register",
29
+ "hooks.email-events:register",
30
+ "hooks.page-fragments:register",
31
+ ] as const;
32
+
33
+ /**
34
+ * Legacy capability names accepted during the deprecation window.
35
+ * Normalized to current names via `normalizeCapability()` in types.ts
36
+ * before reaching the runtime. Plugin authors are warned at bundle/validate
37
+ * and hard-failed at publish.
38
+ */
39
+ export const DEPRECATED_PLUGIN_CAPABILITIES = [
16
40
  "network:fetch",
17
41
  "network:fetch:any",
18
42
  "read:content",
@@ -20,12 +44,23 @@ export const PLUGIN_CAPABILITIES = [
20
44
  "read:media",
21
45
  "write:media",
22
46
  "read:users",
23
- "email:send",
24
47
  "email:provide",
25
48
  "email:intercept",
26
49
  "page:inject",
27
50
  ] as const;
28
51
 
52
+ /**
53
+ * Full set of accepted capability strings — current + deprecated.
54
+ *
55
+ * The manifest schema accepts both during the transition. The runtime only
56
+ * ever sees current names because `normalizeCapability()` rewrites legacy
57
+ * names at every external boundary (definePlugin, adaptSandboxEntry).
58
+ */
59
+ export const PLUGIN_CAPABILITIES = [
60
+ ...CURRENT_PLUGIN_CAPABILITIES,
61
+ ...DEPRECATED_PLUGIN_CAPABILITIES,
62
+ ] as const;
63
+
29
64
  /** Must stay in sync with FieldType in schema/types.ts */
30
65
  const FIELD_TYPES = [
31
66
  "string",