convex-cms 0.0.1

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 (379) hide show
  1. package/dist/cli/commands/admin.d.ts +16 -0
  2. package/dist/cli/commands/admin.d.ts.map +1 -0
  3. package/dist/cli/commands/admin.js +88 -0
  4. package/dist/cli/commands/admin.js.map +1 -0
  5. package/dist/cli/index.d.ts +3 -0
  6. package/dist/cli/index.d.ts.map +1 -0
  7. package/dist/cli/index.js +18 -0
  8. package/dist/cli/index.js.map +1 -0
  9. package/dist/cli/utils/detectConvexUrl.d.ts +13 -0
  10. package/dist/cli/utils/detectConvexUrl.d.ts.map +1 -0
  11. package/dist/cli/utils/detectConvexUrl.js +48 -0
  12. package/dist/cli/utils/detectConvexUrl.js.map +1 -0
  13. package/dist/cli/utils/openBrowser.d.ts +7 -0
  14. package/dist/cli/utils/openBrowser.d.ts.map +1 -0
  15. package/dist/cli/utils/openBrowser.js +17 -0
  16. package/dist/cli/utils/openBrowser.js.map +1 -0
  17. package/dist/client/admin-config.d.ts +126 -0
  18. package/dist/client/admin-config.d.ts.map +1 -0
  19. package/dist/client/admin-config.js +117 -0
  20. package/dist/client/admin-config.js.map +1 -0
  21. package/dist/client/adminApi.d.ts +2273 -0
  22. package/dist/client/adminApi.d.ts.map +1 -0
  23. package/dist/client/adminApi.js +716 -0
  24. package/dist/client/adminApi.js.map +1 -0
  25. package/dist/client/agentTools.d.ts +933 -0
  26. package/dist/client/agentTools.d.ts.map +1 -0
  27. package/dist/client/agentTools.js +1004 -0
  28. package/dist/client/agentTools.js.map +1 -0
  29. package/dist/client/argTypes.d.ts +212 -0
  30. package/dist/client/argTypes.d.ts.map +1 -0
  31. package/dist/client/argTypes.js +5 -0
  32. package/dist/client/argTypes.js.map +1 -0
  33. package/dist/client/field-types.d.ts +55 -0
  34. package/dist/client/field-types.d.ts.map +1 -0
  35. package/dist/client/field-types.js +152 -0
  36. package/dist/client/field-types.js.map +1 -0
  37. package/dist/client/index.d.ts +189 -0
  38. package/dist/client/index.d.ts.map +1 -0
  39. package/dist/client/index.js +668 -0
  40. package/dist/client/index.js.map +1 -0
  41. package/dist/client/queryBuilder.d.ts +765 -0
  42. package/dist/client/queryBuilder.d.ts.map +1 -0
  43. package/dist/client/queryBuilder.js +970 -0
  44. package/dist/client/queryBuilder.js.map +1 -0
  45. package/dist/client/schema/codegen.d.ts +128 -0
  46. package/dist/client/schema/codegen.d.ts.map +1 -0
  47. package/dist/client/schema/codegen.js +318 -0
  48. package/dist/client/schema/codegen.js.map +1 -0
  49. package/dist/client/schema/defineContentType.d.ts +221 -0
  50. package/dist/client/schema/defineContentType.d.ts.map +1 -0
  51. package/dist/client/schema/defineContentType.js +380 -0
  52. package/dist/client/schema/defineContentType.js.map +1 -0
  53. package/dist/client/schema/index.d.ts +85 -0
  54. package/dist/client/schema/index.d.ts.map +1 -0
  55. package/dist/client/schema/index.js +92 -0
  56. package/dist/client/schema/index.js.map +1 -0
  57. package/dist/client/schema/schemaDrift.d.ts +199 -0
  58. package/dist/client/schema/schemaDrift.d.ts.map +1 -0
  59. package/dist/client/schema/schemaDrift.js +340 -0
  60. package/dist/client/schema/schemaDrift.js.map +1 -0
  61. package/dist/client/schema/typedClient.d.ts +401 -0
  62. package/dist/client/schema/typedClient.d.ts.map +1 -0
  63. package/dist/client/schema/typedClient.js +269 -0
  64. package/dist/client/schema/typedClient.js.map +1 -0
  65. package/dist/client/schema/types.d.ts +477 -0
  66. package/dist/client/schema/types.d.ts.map +1 -0
  67. package/dist/client/schema/types.js +39 -0
  68. package/dist/client/schema/types.js.map +1 -0
  69. package/dist/client/types.d.ts +449 -0
  70. package/dist/client/types.d.ts.map +1 -0
  71. package/dist/client/types.js +149 -0
  72. package/dist/client/types.js.map +1 -0
  73. package/dist/client/workflows.d.ts +51 -0
  74. package/dist/client/workflows.d.ts.map +1 -0
  75. package/dist/client/workflows.js +103 -0
  76. package/dist/client/workflows.js.map +1 -0
  77. package/dist/client/wrapper.d.ts +2198 -0
  78. package/dist/client/wrapper.d.ts.map +1 -0
  79. package/dist/client/wrapper.js +2651 -0
  80. package/dist/client/wrapper.js.map +1 -0
  81. package/dist/component/_generated/api.d.ts +124 -0
  82. package/dist/component/_generated/api.d.ts.map +1 -0
  83. package/dist/component/_generated/api.js +31 -0
  84. package/dist/component/_generated/api.js.map +1 -0
  85. package/dist/component/_generated/component.d.ts +4321 -0
  86. package/dist/component/_generated/component.d.ts.map +1 -0
  87. package/dist/component/_generated/component.js +11 -0
  88. package/dist/component/_generated/component.js.map +1 -0
  89. package/dist/component/_generated/dataModel.d.ts +46 -0
  90. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  91. package/dist/component/_generated/dataModel.js +11 -0
  92. package/dist/component/_generated/dataModel.js.map +1 -0
  93. package/dist/component/_generated/server.d.ts +121 -0
  94. package/dist/component/_generated/server.d.ts.map +1 -0
  95. package/dist/component/_generated/server.js +78 -0
  96. package/dist/component/_generated/server.js.map +1 -0
  97. package/dist/component/auditLog.d.ts +410 -0
  98. package/dist/component/auditLog.d.ts.map +1 -0
  99. package/dist/component/auditLog.js +607 -0
  100. package/dist/component/auditLog.js.map +1 -0
  101. package/dist/component/authorization.d.ts +323 -0
  102. package/dist/component/authorization.d.ts.map +1 -0
  103. package/dist/component/authorization.js +464 -0
  104. package/dist/component/authorization.js.map +1 -0
  105. package/dist/component/authorizationHooks.d.ts +184 -0
  106. package/dist/component/authorizationHooks.d.ts.map +1 -0
  107. package/dist/component/authorizationHooks.js +521 -0
  108. package/dist/component/authorizationHooks.js.map +1 -0
  109. package/dist/component/bulkOperations.d.ts +200 -0
  110. package/dist/component/bulkOperations.d.ts.map +1 -0
  111. package/dist/component/bulkOperations.js +568 -0
  112. package/dist/component/bulkOperations.js.map +1 -0
  113. package/dist/component/contentEntries.d.ts +719 -0
  114. package/dist/component/contentEntries.d.ts.map +1 -0
  115. package/dist/component/contentEntries.js +1617 -0
  116. package/dist/component/contentEntries.js.map +1 -0
  117. package/dist/component/contentEntryMutations.d.ts +505 -0
  118. package/dist/component/contentEntryMutations.d.ts.map +1 -0
  119. package/dist/component/contentEntryMutations.js +1009 -0
  120. package/dist/component/contentEntryMutations.js.map +1 -0
  121. package/dist/component/contentEntryValidation.d.ts +115 -0
  122. package/dist/component/contentEntryValidation.d.ts.map +1 -0
  123. package/dist/component/contentEntryValidation.js +546 -0
  124. package/dist/component/contentEntryValidation.js.map +1 -0
  125. package/dist/component/contentLock.d.ts +328 -0
  126. package/dist/component/contentLock.d.ts.map +1 -0
  127. package/dist/component/contentLock.js +471 -0
  128. package/dist/component/contentLock.js.map +1 -0
  129. package/dist/component/contentTypeMigration.d.ts +411 -0
  130. package/dist/component/contentTypeMigration.d.ts.map +1 -0
  131. package/dist/component/contentTypeMigration.js +805 -0
  132. package/dist/component/contentTypeMigration.js.map +1 -0
  133. package/dist/component/contentTypeMutations.d.ts +975 -0
  134. package/dist/component/contentTypeMutations.d.ts.map +1 -0
  135. package/dist/component/contentTypeMutations.js +768 -0
  136. package/dist/component/contentTypeMutations.js.map +1 -0
  137. package/dist/component/contentTypes.d.ts +538 -0
  138. package/dist/component/contentTypes.d.ts.map +1 -0
  139. package/dist/component/contentTypes.js +304 -0
  140. package/dist/component/contentTypes.js.map +1 -0
  141. package/dist/component/convex.config.d.ts +42 -0
  142. package/dist/component/convex.config.d.ts.map +1 -0
  143. package/dist/component/convex.config.js +43 -0
  144. package/dist/component/convex.config.js.map +1 -0
  145. package/dist/component/documentTypes.d.ts +186 -0
  146. package/dist/component/documentTypes.d.ts.map +1 -0
  147. package/dist/component/documentTypes.js +23 -0
  148. package/dist/component/documentTypes.js.map +1 -0
  149. package/dist/component/eventEmitter.d.ts +281 -0
  150. package/dist/component/eventEmitter.d.ts.map +1 -0
  151. package/dist/component/eventEmitter.js +300 -0
  152. package/dist/component/eventEmitter.js.map +1 -0
  153. package/dist/component/exportImport.d.ts +1120 -0
  154. package/dist/component/exportImport.d.ts.map +1 -0
  155. package/dist/component/exportImport.js +931 -0
  156. package/dist/component/exportImport.js.map +1 -0
  157. package/dist/component/index.d.ts +28 -0
  158. package/dist/component/index.d.ts.map +1 -0
  159. package/dist/component/index.js +142 -0
  160. package/dist/component/index.js.map +1 -0
  161. package/dist/component/lib/deepReferenceResolver.d.ts +252 -0
  162. package/dist/component/lib/deepReferenceResolver.d.ts.map +1 -0
  163. package/dist/component/lib/deepReferenceResolver.js +601 -0
  164. package/dist/component/lib/deepReferenceResolver.js.map +1 -0
  165. package/dist/component/lib/errors.d.ts +306 -0
  166. package/dist/component/lib/errors.d.ts.map +1 -0
  167. package/dist/component/lib/errors.js +407 -0
  168. package/dist/component/lib/errors.js.map +1 -0
  169. package/dist/component/lib/index.d.ts +10 -0
  170. package/dist/component/lib/index.d.ts.map +1 -0
  171. package/dist/component/lib/index.js +33 -0
  172. package/dist/component/lib/index.js.map +1 -0
  173. package/dist/component/lib/mediaReferenceResolver.d.ts +217 -0
  174. package/dist/component/lib/mediaReferenceResolver.d.ts.map +1 -0
  175. package/dist/component/lib/mediaReferenceResolver.js +326 -0
  176. package/dist/component/lib/mediaReferenceResolver.js.map +1 -0
  177. package/dist/component/lib/metadataExtractor.d.ts +245 -0
  178. package/dist/component/lib/metadataExtractor.d.ts.map +1 -0
  179. package/dist/component/lib/metadataExtractor.js +548 -0
  180. package/dist/component/lib/metadataExtractor.js.map +1 -0
  181. package/dist/component/lib/mutationAuth.d.ts +95 -0
  182. package/dist/component/lib/mutationAuth.d.ts.map +1 -0
  183. package/dist/component/lib/mutationAuth.js +146 -0
  184. package/dist/component/lib/mutationAuth.js.map +1 -0
  185. package/dist/component/lib/queries.d.ts +17 -0
  186. package/dist/component/lib/queries.d.ts.map +1 -0
  187. package/dist/component/lib/queries.js +49 -0
  188. package/dist/component/lib/queries.js.map +1 -0
  189. package/dist/component/lib/ragContentChunker.d.ts +423 -0
  190. package/dist/component/lib/ragContentChunker.d.ts.map +1 -0
  191. package/dist/component/lib/ragContentChunker.js +897 -0
  192. package/dist/component/lib/ragContentChunker.js.map +1 -0
  193. package/dist/component/lib/referenceResolver.d.ts +175 -0
  194. package/dist/component/lib/referenceResolver.d.ts.map +1 -0
  195. package/dist/component/lib/referenceResolver.js +293 -0
  196. package/dist/component/lib/referenceResolver.js.map +1 -0
  197. package/dist/component/lib/slugGenerator.d.ts +71 -0
  198. package/dist/component/lib/slugGenerator.d.ts.map +1 -0
  199. package/dist/component/lib/slugGenerator.js +207 -0
  200. package/dist/component/lib/slugGenerator.js.map +1 -0
  201. package/dist/component/lib/slugUniqueness.d.ts +131 -0
  202. package/dist/component/lib/slugUniqueness.d.ts.map +1 -0
  203. package/dist/component/lib/slugUniqueness.js +229 -0
  204. package/dist/component/lib/slugUniqueness.js.map +1 -0
  205. package/dist/component/lib/softDelete.d.ts +18 -0
  206. package/dist/component/lib/softDelete.d.ts.map +1 -0
  207. package/dist/component/lib/softDelete.js +29 -0
  208. package/dist/component/lib/softDelete.js.map +1 -0
  209. package/dist/component/localeFallbackChain.d.ts +410 -0
  210. package/dist/component/localeFallbackChain.d.ts.map +1 -0
  211. package/dist/component/localeFallbackChain.js +467 -0
  212. package/dist/component/localeFallbackChain.js.map +1 -0
  213. package/dist/component/localeFields.d.ts +508 -0
  214. package/dist/component/localeFields.d.ts.map +1 -0
  215. package/dist/component/localeFields.js +592 -0
  216. package/dist/component/localeFields.js.map +1 -0
  217. package/dist/component/mediaAssetMutations.d.ts +235 -0
  218. package/dist/component/mediaAssetMutations.d.ts.map +1 -0
  219. package/dist/component/mediaAssetMutations.js +558 -0
  220. package/dist/component/mediaAssetMutations.js.map +1 -0
  221. package/dist/component/mediaAssets.d.ts +168 -0
  222. package/dist/component/mediaAssets.d.ts.map +1 -0
  223. package/dist/component/mediaAssets.js +618 -0
  224. package/dist/component/mediaAssets.js.map +1 -0
  225. package/dist/component/mediaFolderMutations.d.ts +642 -0
  226. package/dist/component/mediaFolderMutations.d.ts.map +1 -0
  227. package/dist/component/mediaFolderMutations.js +849 -0
  228. package/dist/component/mediaFolderMutations.js.map +1 -0
  229. package/dist/component/mediaUploadMutations.d.ts +136 -0
  230. package/dist/component/mediaUploadMutations.d.ts.map +1 -0
  231. package/dist/component/mediaUploadMutations.js +205 -0
  232. package/dist/component/mediaUploadMutations.js.map +1 -0
  233. package/dist/component/mediaVariantMutations.d.ts +468 -0
  234. package/dist/component/mediaVariantMutations.d.ts.map +1 -0
  235. package/dist/component/mediaVariantMutations.js +737 -0
  236. package/dist/component/mediaVariantMutations.js.map +1 -0
  237. package/dist/component/mediaVariants.d.ts +525 -0
  238. package/dist/component/mediaVariants.d.ts.map +1 -0
  239. package/dist/component/mediaVariants.js +661 -0
  240. package/dist/component/mediaVariants.js.map +1 -0
  241. package/dist/component/ragContentIndexer.d.ts +595 -0
  242. package/dist/component/ragContentIndexer.d.ts.map +1 -0
  243. package/dist/component/ragContentIndexer.js +794 -0
  244. package/dist/component/ragContentIndexer.js.map +1 -0
  245. package/dist/component/rateLimitHooks.d.ts +266 -0
  246. package/dist/component/rateLimitHooks.d.ts.map +1 -0
  247. package/dist/component/rateLimitHooks.js +412 -0
  248. package/dist/component/rateLimitHooks.js.map +1 -0
  249. package/dist/component/roles.d.ts +649 -0
  250. package/dist/component/roles.d.ts.map +1 -0
  251. package/dist/component/roles.js +884 -0
  252. package/dist/component/roles.js.map +1 -0
  253. package/dist/component/scheduledPublish.d.ts +182 -0
  254. package/dist/component/scheduledPublish.d.ts.map +1 -0
  255. package/dist/component/scheduledPublish.js +304 -0
  256. package/dist/component/scheduledPublish.js.map +1 -0
  257. package/dist/component/schema.d.ts +4114 -0
  258. package/dist/component/schema.d.ts.map +1 -0
  259. package/dist/component/schema.js +469 -0
  260. package/dist/component/schema.js.map +1 -0
  261. package/dist/component/taxonomies.d.ts +476 -0
  262. package/dist/component/taxonomies.d.ts.map +1 -0
  263. package/dist/component/taxonomies.js +785 -0
  264. package/dist/component/taxonomies.js.map +1 -0
  265. package/dist/component/taxonomyMutations.d.ts +206 -0
  266. package/dist/component/taxonomyMutations.d.ts.map +1 -0
  267. package/dist/component/taxonomyMutations.js +1001 -0
  268. package/dist/component/taxonomyMutations.js.map +1 -0
  269. package/dist/component/trash.d.ts +265 -0
  270. package/dist/component/trash.d.ts.map +1 -0
  271. package/dist/component/trash.js +621 -0
  272. package/dist/component/trash.js.map +1 -0
  273. package/dist/component/types.d.ts +4 -0
  274. package/dist/component/types.d.ts.map +1 -0
  275. package/dist/component/types.js +2 -0
  276. package/dist/component/types.js.map +1 -0
  277. package/dist/component/userContext.d.ts +508 -0
  278. package/dist/component/userContext.d.ts.map +1 -0
  279. package/dist/component/userContext.js +615 -0
  280. package/dist/component/userContext.js.map +1 -0
  281. package/dist/component/validation.d.ts +387 -0
  282. package/dist/component/validation.d.ts.map +1 -0
  283. package/dist/component/validation.js +1052 -0
  284. package/dist/component/validation.js.map +1 -0
  285. package/dist/component/validators.d.ts +4645 -0
  286. package/dist/component/validators.d.ts.map +1 -0
  287. package/dist/component/validators.js +641 -0
  288. package/dist/component/validators.js.map +1 -0
  289. package/dist/component/versionMutations.d.ts +216 -0
  290. package/dist/component/versionMutations.d.ts.map +1 -0
  291. package/dist/component/versionMutations.js +321 -0
  292. package/dist/component/versionMutations.js.map +1 -0
  293. package/dist/component/webhookTrigger.d.ts +770 -0
  294. package/dist/component/webhookTrigger.d.ts.map +1 -0
  295. package/dist/component/webhookTrigger.js +1413 -0
  296. package/dist/component/webhookTrigger.js.map +1 -0
  297. package/dist/react/index.d.ts +316 -0
  298. package/dist/react/index.d.ts.map +1 -0
  299. package/dist/react/index.js +558 -0
  300. package/dist/react/index.js.map +1 -0
  301. package/dist/test.d.ts +2230 -0
  302. package/dist/test.d.ts.map +1 -0
  303. package/dist/test.js +1107 -0
  304. package/dist/test.js.map +1 -0
  305. package/package.json +95 -0
  306. package/src/cli/commands/admin.ts +104 -0
  307. package/src/cli/index.ts +21 -0
  308. package/src/cli/utils/detectConvexUrl.ts +54 -0
  309. package/src/cli/utils/openBrowser.ts +16 -0
  310. package/src/client/admin-config.ts +138 -0
  311. package/src/client/adminApi.ts +942 -0
  312. package/src/client/agentTools.ts +1311 -0
  313. package/src/client/argTypes.ts +316 -0
  314. package/src/client/field-types.ts +187 -0
  315. package/src/client/index.ts +1301 -0
  316. package/src/client/queryBuilder.ts +1100 -0
  317. package/src/client/schema/codegen.ts +500 -0
  318. package/src/client/schema/defineContentType.ts +501 -0
  319. package/src/client/schema/index.ts +169 -0
  320. package/src/client/schema/schemaDrift.ts +574 -0
  321. package/src/client/schema/typedClient.ts +688 -0
  322. package/src/client/schema/types.ts +666 -0
  323. package/src/client/types.ts +723 -0
  324. package/src/client/workflows.ts +141 -0
  325. package/src/client/wrapper.ts +4304 -0
  326. package/src/component/_generated/api.ts +140 -0
  327. package/src/component/_generated/component.ts +5029 -0
  328. package/src/component/_generated/dataModel.ts +60 -0
  329. package/src/component/_generated/server.ts +156 -0
  330. package/src/component/authorization.ts +647 -0
  331. package/src/component/authorizationHooks.ts +668 -0
  332. package/src/component/bulkOperations.ts +687 -0
  333. package/src/component/contentEntries.ts +1976 -0
  334. package/src/component/contentEntryMutations.ts +1223 -0
  335. package/src/component/contentEntryValidation.ts +707 -0
  336. package/src/component/contentLock.ts +550 -0
  337. package/src/component/contentTypeMigration.ts +1064 -0
  338. package/src/component/contentTypeMutations.ts +969 -0
  339. package/src/component/contentTypes.ts +346 -0
  340. package/src/component/convex.config.ts +44 -0
  341. package/src/component/documentTypes.ts +240 -0
  342. package/src/component/eventEmitter.ts +485 -0
  343. package/src/component/exportImport.ts +1169 -0
  344. package/src/component/index.ts +491 -0
  345. package/src/component/lib/deepReferenceResolver.ts +999 -0
  346. package/src/component/lib/errors.ts +816 -0
  347. package/src/component/lib/index.ts +145 -0
  348. package/src/component/lib/mediaReferenceResolver.ts +495 -0
  349. package/src/component/lib/metadataExtractor.ts +792 -0
  350. package/src/component/lib/mutationAuth.ts +199 -0
  351. package/src/component/lib/queries.ts +79 -0
  352. package/src/component/lib/ragContentChunker.ts +1371 -0
  353. package/src/component/lib/referenceResolver.ts +430 -0
  354. package/src/component/lib/slugGenerator.ts +262 -0
  355. package/src/component/lib/slugUniqueness.ts +333 -0
  356. package/src/component/lib/softDelete.ts +44 -0
  357. package/src/component/localeFallbackChain.ts +673 -0
  358. package/src/component/localeFields.ts +896 -0
  359. package/src/component/mediaAssetMutations.ts +725 -0
  360. package/src/component/mediaAssets.ts +932 -0
  361. package/src/component/mediaFolderMutations.ts +1046 -0
  362. package/src/component/mediaUploadMutations.ts +224 -0
  363. package/src/component/mediaVariantMutations.ts +900 -0
  364. package/src/component/mediaVariants.ts +793 -0
  365. package/src/component/ragContentIndexer.ts +1067 -0
  366. package/src/component/rateLimitHooks.ts +572 -0
  367. package/src/component/roles.ts +1360 -0
  368. package/src/component/scheduledPublish.ts +358 -0
  369. package/src/component/schema.ts +617 -0
  370. package/src/component/taxonomies.ts +949 -0
  371. package/src/component/taxonomyMutations.ts +1210 -0
  372. package/src/component/trash.ts +724 -0
  373. package/src/component/userContext.ts +898 -0
  374. package/src/component/validation.ts +1388 -0
  375. package/src/component/validators.ts +949 -0
  376. package/src/component/versionMutations.ts +392 -0
  377. package/src/component/webhookTrigger.ts +1922 -0
  378. package/src/react/index.ts +898 -0
  379. package/src/test.ts +1580 -0
@@ -0,0 +1,4304 @@
1
+ /** CMS Client Wrapper with typed method APIs */
2
+
3
+ // Import Convex's native FunctionReference type for proper type safety
4
+ import type {
5
+ FunctionReference as ConvexFunctionReference,
6
+ OptionalRestArgs,
7
+ FunctionReturnType,
8
+ } from "convex/server";
9
+
10
+ import type {
11
+ GenericDataModel,
12
+ GenericMutationCtx,
13
+ GenericQueryCtx,
14
+ } from "convex/server";
15
+
16
+ type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation" | "runQuery">;
17
+ type QueryCtx = Pick<GenericQueryCtx<GenericDataModel>, "runQuery">;
18
+
19
+ import type { ComponentApi as GeneratedComponentApi } from "../component/_generated/component.js";
20
+
21
+ /** @internal Bridges wrapper's simplified types to generated Convex types */
22
+ function _callMutation<
23
+ T extends ConvexFunctionReference<"mutation", "public" | "internal">,
24
+ A extends Record<string, unknown> = Record<string, unknown>
25
+ >(ctx: MutationCtx, fn: T, args: A): Promise<FunctionReturnType<T>> {
26
+ return ctx.runMutation(fn, args as OptionalRestArgs<T>[0]);
27
+ }
28
+
29
+ /** @internal */
30
+ function callQuery<
31
+ T extends ConvexFunctionReference<"query", "public" | "internal">,
32
+ A extends Record<string, unknown> = Record<string, unknown>
33
+ >(ctx: MutationCtx | QueryCtx, fn: T, args: A): Promise<FunctionReturnType<T>> {
34
+ return ctx.runQuery(fn, args as OptionalRestArgs<T>[0]);
35
+ }
36
+
37
+ import type {
38
+ ComponentConfig,
39
+ ResolvedComponentConfig,
40
+ FeatureFlags,
41
+ LocaleCode,
42
+ ContentType,
43
+ ContentEntry,
44
+ ContentVersion,
45
+ MediaAsset,
46
+ MediaFolder,
47
+ PaginationResult,
48
+ PaginationOpts,
49
+ GetUserRoleResult,
50
+ AuthorizationHookContext,
51
+ CmsOperation,
52
+ VersionComparison,
53
+ FieldChange,
54
+ FieldChangeType,
55
+ MediaVariant,
56
+ MediaVariantWithUrl,
57
+ } from "./types.js";
58
+
59
+ import { resolveConfig, AuthorizationNotConfiguredError } from "./types.js";
60
+
61
+ // Import query builder
62
+ import { ContentQueryBuilder, createQueryBuilder } from "./queryBuilder.js";
63
+
64
+ // Import authorization hooks execution
65
+ import {
66
+ executeAuthorizationHooks,
67
+ contextToRbacOptions,
68
+ operationToRbac,
69
+ type AuthorizationResult,
70
+ } from "../component/authorizationHooks.js";
71
+
72
+ // Import rate limit hooks execution
73
+ import {
74
+ requireRateLimit,
75
+ createRateLimitContext,
76
+ type RateLimitResult,
77
+ } from "../component/rateLimitHooks.js";
78
+
79
+ // Import RBAC utilities from component
80
+ import {
81
+ hasPermission,
82
+ hasContentTypePermission,
83
+ getPermittedContentTypes,
84
+ DEFAULT_ROLES,
85
+ type Resource,
86
+ type Action,
87
+ type OwnershipScope,
88
+ type RoleDefinition,
89
+ } from "../component/roles.js";
90
+
91
+ // Import locale fallback chain utilities
92
+ import {
93
+ resolveFallbackChain,
94
+ getFallbackChain,
95
+ type LocaleFallbackConfig,
96
+ type ResolvedFallbackChain,
97
+ } from "../component/localeFallbackChain.js";
98
+ import {
99
+ resolveLocaleContent,
100
+ resolveLocaleContentBatch,
101
+ type LocaleResolvedEntry,
102
+ type ResolveLocaleOptions,
103
+ } from "../component/localeFields.js";
104
+
105
+ /**
106
+ * Options for resolving locale content in client wrapper methods.
107
+ * Extends ResolveLocaleOptions but makes fields required since
108
+ * they are needed for proper locale resolution.
109
+ */
110
+ export type ResolveLocaleContentOptions = ResolveLocaleOptions;
111
+
112
+ // =============================================================================
113
+ // Authorization Helper Types
114
+ // =============================================================================
115
+
116
+ /**
117
+ * Authorization helper interface passed to API classes.
118
+ * This allows API methods to perform authorization checks before mutations.
119
+ *
120
+ * When authorization is not configured (getUserRole not provided), the helper
121
+ * will be undefined and authorization checks will be skipped.
122
+ */
123
+ export interface AuthorizationHelper {
124
+ /**
125
+ * Get the user's CMS role.
126
+ * @param ctx - The Convex context (provides database and auth access to hooks)
127
+ * @param userId - The user ID to look up
128
+ * @returns The role name or null if user has no role
129
+ */
130
+ getUserRole(ctx: ConvexContext, userId: string): Promise<string | null>;
131
+
132
+ /**
133
+ * Perform authorization check and throw if denied.
134
+ * @param ctx - The Convex context (provides database and auth access to hooks)
135
+ * @param context - The authorization context
136
+ * @throws UnauthorizedError if the operation is not allowed
137
+ */
138
+ requireAuthorization(ctx: ConvexContext, context: Omit<AuthorizationHookContext, 'ctx'>): Promise<AuthorizationResult>;
139
+
140
+ /**
141
+ * Whether RBAC should be skipped (from config.skipRbac).
142
+ */
143
+ skipRbac: boolean;
144
+ }
145
+
146
+ // =============================================================================
147
+ // Rate Limit Helper Types
148
+ // =============================================================================
149
+
150
+ /**
151
+ * Rate limit helper interface passed to API classes.
152
+ * This allows API methods to enforce rate limits before mutations.
153
+ *
154
+ * When rate limiting is not configured (no rateLimitHooks provided), the helper
155
+ * will be undefined and rate limiting checks will be skipped.
156
+ */
157
+ export interface RateLimitHelper {
158
+ /**
159
+ * Get the user's CMS role for rate limit context.
160
+ * @param ctx - The Convex context (for database access)
161
+ * @param userId - The user ID to look up
162
+ * @returns The role name or null if user has no role
163
+ */
164
+ getUserRole(ctx: ConvexContext, userId: string): Promise<string | null>;
165
+
166
+ /**
167
+ * Enforce rate limit for an operation. Throws RateLimitedError if rate limited.
168
+ * @param operation - The CMS operation being performed
169
+ * @param options - Additional context for rate limiting
170
+ * @throws RateLimitedError if the operation is rate limited
171
+ */
172
+ requireRateLimit(
173
+ operation: CmsOperation,
174
+ options: {
175
+ userId?: string;
176
+ role?: string | null;
177
+ contentTypeId?: string;
178
+ contentTypeName?: string;
179
+ metadata?: Record<string, unknown>;
180
+ }
181
+ ): Promise<RateLimitResult>;
182
+ }
183
+
184
+ // =============================================================================
185
+ // Context Types
186
+ // =============================================================================
187
+
188
+ /** Convex context for CMS operations - includes db/auth for authorization hooks */
189
+ export type ConvexContext = Pick<
190
+ GenericMutationCtx<GenericDataModel>,
191
+ "runMutation" | "runQuery" | "db" | "auth"
192
+ >;
193
+
194
+ /** Component API type from `components.convexCms` */
195
+ export type TypedComponentApi = GeneratedComponentApi;
196
+ /**
197
+ * Partial component API type for testing purposes.
198
+ *
199
+ * This type allows partial/mock implementations of the component API
200
+ * for unit testing without requiring full type conformance.
201
+ * In production, use TypedComponentApi instead.
202
+ *
203
+ * @example
204
+ * ```typescript
205
+ * // In test files
206
+ * const mockApi: MockComponentApi = {
207
+ * contentEntries: {
208
+ * list: { _type: "query" } ,
209
+ * },
210
+ * } as MockComponentApi;
211
+ * ```
212
+ */
213
+ export type MockComponentApi = Partial<{
214
+ [K in keyof TypedComponentApi]: Partial<TypedComponentApi[K]>;
215
+ }>;
216
+
217
+ // =============================================================================
218
+ // Argument Types for Component Functions
219
+ // Re-exported from argTypes.ts where they are derived from validators
220
+ // =============================================================================
221
+
222
+ export type {
223
+ // Content Type Arguments
224
+ CreateContentTypeArgs,
225
+ UpdateContentTypeArgs,
226
+ DeleteContentTypeArgs,
227
+ GetContentTypeArgs,
228
+ ListContentTypesArgs,
229
+ // Content Entry Arguments
230
+ CreateContentEntryArgs,
231
+ UpdateContentEntryArgs,
232
+ DeleteContentEntryArgs,
233
+ GetContentEntryArgs,
234
+ GetContentEntryBySlugArgs,
235
+ ListContentEntriesArgs,
236
+ PublishEntryArgs,
237
+ UnpublishEntryArgs,
238
+ ScheduleEntryArgs,
239
+ RestoreEntryArgs,
240
+ DuplicateEntryArgs,
241
+ // Bulk Operation Arguments
242
+ BulkPublishArgs,
243
+ BulkUnpublishArgs,
244
+ BulkDeleteArgs,
245
+ BulkUpdateArgs,
246
+ BulkRestoreArgs,
247
+ // Version Arguments
248
+ GetVersionArgs,
249
+ GetVersionHistoryArgs,
250
+ RollbackVersionArgs,
251
+ CompareVersionsArgs,
252
+ // Media Asset Arguments
253
+ CreateMediaAssetArgs,
254
+ UpdateMediaAssetArgs,
255
+ DeleteMediaAssetArgs,
256
+ GetMediaAssetArgs,
257
+ ListMediaAssetsArgs,
258
+ RestoreMediaAssetArgs,
259
+ FindMediaAssetReferencesArgs,
260
+ // Media Folder Arguments
261
+ CreateMediaFolderArgs,
262
+ UpdateMediaFolderArgs,
263
+ DeleteMediaFolderArgs,
264
+ GetMediaFolderArgs,
265
+ ListMediaFoldersArgs,
266
+ MoveFolderArgs,
267
+ RestoreMediaFolderArgs,
268
+ GetMediaFolderByPathArgs,
269
+ GetFolderTreeArgs,
270
+ MoveMediaAssetsArgs,
271
+ // Media Variant Arguments
272
+ CreateMediaVariantArgs,
273
+ RequestVariantGenerationArgs,
274
+ DeleteMediaVariantArgs,
275
+ DeleteAssetVariantsArgs,
276
+ GetMediaVariantArgs,
277
+ ListMediaVariantsArgs,
278
+ GetBestVariantArgs,
279
+ GenerateFromPresetsArgs,
280
+ // Upload Arguments
281
+ GenerateUploadUrlArgs,
282
+ // Result Types
283
+ BreakingChange,
284
+ UpdateContentTypeResult,
285
+ DeleteContentTypeResult,
286
+ BulkOperationItemResult,
287
+ BulkOperationResult,
288
+ GenerateUploadUrlResult,
289
+ MediaAssetReference,
290
+ GenerateVariantsResult,
291
+ SrcsetEntry,
292
+ ResponsiveSrcsetResult,
293
+ VariantPreset,
294
+ AssetWithVariants,
295
+ } from "./argTypes.js";
296
+
297
+ // Import types locally for use in this file
298
+ import type {
299
+ CreateContentTypeArgs,
300
+ UpdateContentTypeArgs,
301
+ DeleteContentTypeArgs,
302
+ GetContentTypeArgs,
303
+ ListContentTypesArgs,
304
+ CreateContentEntryArgs,
305
+ UpdateContentEntryArgs,
306
+ DeleteContentEntryArgs,
307
+ GetContentEntryArgs,
308
+ GetContentEntryBySlugArgs,
309
+ ListContentEntriesArgs,
310
+ PublishEntryArgs,
311
+ UnpublishEntryArgs,
312
+ ScheduleEntryArgs,
313
+ RestoreEntryArgs,
314
+ DuplicateEntryArgs,
315
+ BulkPublishArgs,
316
+ BulkUnpublishArgs,
317
+ BulkDeleteArgs,
318
+ BulkUpdateArgs,
319
+ BulkRestoreArgs,
320
+ GetVersionArgs,
321
+ GetVersionHistoryArgs,
322
+ RollbackVersionArgs,
323
+ CompareVersionsArgs,
324
+ CreateMediaAssetArgs,
325
+ UpdateMediaAssetArgs,
326
+ DeleteMediaAssetArgs,
327
+ GetMediaAssetArgs,
328
+ ListMediaAssetsArgs,
329
+ RestoreMediaAssetArgs,
330
+ FindMediaAssetReferencesArgs,
331
+ CreateMediaFolderArgs,
332
+ UpdateMediaFolderArgs,
333
+ DeleteMediaFolderArgs,
334
+ GetMediaFolderArgs,
335
+ ListMediaFoldersArgs,
336
+ MoveFolderArgs,
337
+ RestoreMediaFolderArgs,
338
+ GetMediaFolderByPathArgs,
339
+ GetFolderTreeArgs,
340
+ CreateMediaVariantArgs,
341
+ RequestVariantGenerationArgs,
342
+ DeleteMediaVariantArgs,
343
+ DeleteAssetVariantsArgs,
344
+ ListMediaVariantsArgs,
345
+ GetBestVariantArgs,
346
+ GenerateFromPresetsArgs,
347
+ GenerateUploadUrlArgs,
348
+ UpdateContentTypeResult,
349
+ DeleteContentTypeResult,
350
+ BulkOperationResult,
351
+ GenerateUploadUrlResult,
352
+ MediaAssetReference,
353
+ GenerateVariantsResult,
354
+ ResponsiveSrcsetResult,
355
+ VariantPreset,
356
+ AssetWithVariants,
357
+ } from "./argTypes.js";
358
+
359
+ // =============================================================================
360
+ // Content Types API Wrapper
361
+ // =============================================================================
362
+
363
+ /** Content type CRUD operations */
364
+ export class ContentTypesApi {
365
+ constructor(
366
+ private readonly api: TypedComponentApi,
367
+ private readonly config: ResolvedComponentConfig,
368
+ private readonly authHelper?: AuthorizationHelper,
369
+ private readonly rateLimitHelper?: RateLimitHelper
370
+ ) {}
371
+
372
+ /** @throws AuthorizationNotConfiguredError if not configured and not in permissiveMode */
373
+ private async authorize(
374
+ ctx: ConvexContext,
375
+ operation: CmsOperation,
376
+ userId: string | undefined,
377
+ resourceId?: string
378
+ ): Promise<void> {
379
+ // Check if authorization is configured
380
+ if (!this.authHelper) {
381
+ if (this.config.permissiveMode) {
382
+ console.warn(
383
+ `[ConvexCMS] Authorization not configured for "${operation}". ` +
384
+ "Operations are allowed in permissiveMode, but this should NOT be used in production. " +
385
+ "Configure getUserRole hook to enable proper authorization."
386
+ );
387
+ return;
388
+ }
389
+ throw new AuthorizationNotConfiguredError(operation);
390
+ }
391
+
392
+ // Skip RBAC checks if explicitly disabled
393
+ if (this.authHelper.skipRbac) {
394
+ return;
395
+ }
396
+
397
+ // Check if userId is provided
398
+ if (!userId) {
399
+ if (this.config.permissiveMode) {
400
+ console.warn(
401
+ `[ConvexCMS] Anonymous operation attempted for "${operation}". ` +
402
+ "Operations without userId are allowed in permissiveMode, but this should NOT be used in production."
403
+ );
404
+ return;
405
+ }
406
+ throw new AuthorizationNotConfiguredError(
407
+ `${operation} (no userId provided - anonymous operations require permissiveMode)`
408
+ );
409
+ }
410
+
411
+ const role = await this.authHelper.getUserRole(ctx, userId);
412
+
413
+ await this.authHelper.requireAuthorization(ctx, {
414
+ operation,
415
+ userId,
416
+ role,
417
+ resourceId,
418
+ });
419
+ }
420
+
421
+ private async rateLimit(
422
+ ctx: ConvexContext,
423
+ operation: CmsOperation,
424
+ userId: string | undefined
425
+ ): Promise<void> {
426
+ if (!this.rateLimitHelper) return;
427
+ const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
428
+ await this.rateLimitHelper.requireRateLimit(operation, { userId, role });
429
+ }
430
+
431
+ async create(ctx: ConvexContext, args: CreateContentTypeArgs): Promise<ContentType> {
432
+ await this.authorize(ctx, "contentTypes.create", args.createdBy);
433
+ await this.rateLimit(ctx, "contentTypes.create", args.createdBy);
434
+ return ctx.runMutation(this.api.contentTypeMutations.createContentType, args);
435
+ }
436
+
437
+ /** Detects breaking changes; fails unless force:true is specified */
438
+ async update(ctx: ConvexContext, args: UpdateContentTypeArgs): Promise<UpdateContentTypeResult> {
439
+ await this.authorize(ctx, "contentTypes.update", args.updatedBy, args.id);
440
+ await this.rateLimit(ctx, "contentTypes.update", args.updatedBy);
441
+ return ctx.runMutation(this.api.contentTypeMutations.updateContentType, args);
442
+ }
443
+
444
+ /** Soft delete by default; use hardDelete:true for permanent, cascade:true to delete entries */
445
+ async delete(ctx: ConvexContext, args: DeleteContentTypeArgs): Promise<DeleteContentTypeResult> {
446
+ await this.authorize(ctx, "contentTypes.delete", args.deletedBy, args.id);
447
+ await this.rateLimit(ctx, "contentTypes.delete", args.deletedBy);
448
+ return ctx.runMutation(this.api.contentTypeMutations.deleteContentType, args);
449
+ }
450
+
451
+ /**
452
+ * Get a content type by ID or name.
453
+ *
454
+ * @param ctx - Convex query context
455
+ * @param args - Get arguments (id or name)
456
+ * @returns The content type or null if not found
457
+ *
458
+ * @example
459
+ * ```typescript
460
+ * // Get by ID (fastest - direct document lookup)
461
+ * const type = await cms.contentTypes.get(ctx, { id: typeId });
462
+ *
463
+ * // Get by name (uses index)
464
+ * const type = await cms.contentTypes.get(ctx, { name: "blog_post" });
465
+ *
466
+ * // Include soft-deleted types
467
+ * const type = await cms.contentTypes.get(ctx, {
468
+ * name: "archived_type",
469
+ * includeDeleted: true,
470
+ * });
471
+ * ```
472
+ */
473
+ async get(
474
+ ctx: ConvexContext,
475
+ args: GetContentTypeArgs
476
+ ): Promise<ContentType | null> {
477
+ return ctx.runQuery(this.api.contentTypes.get, args);
478
+ }
479
+
480
+ /**
481
+ * Get a content type by name.
482
+ *
483
+ * Convenience method that wraps `get()` for name-based lookup.
484
+ *
485
+ * @param ctx - Convex query context
486
+ * @param name - The machine-readable name of the content type
487
+ * @param includeDeleted - Whether to include soft-deleted types
488
+ * @returns The content type or null if not found
489
+ *
490
+ * @example
491
+ * ```typescript
492
+ * const blogType = await cms.contentTypes.getByName(ctx, "blog_post");
493
+ * if (blogType) {
494
+ * console.log("Fields:", blogType.fields);
495
+ * }
496
+ * ```
497
+ */
498
+ async getByName(
499
+ ctx: ConvexContext,
500
+ name: string,
501
+ includeDeleted = false
502
+ ): Promise<ContentType | null> {
503
+ return this.get(ctx, { name, includeDeleted });
504
+ }
505
+
506
+ async getById(ctx: ConvexContext, id: string, includeDeleted = false): Promise<ContentType | null> {
507
+ return this.get(ctx, { id, includeDeleted });
508
+ }
509
+
510
+ async exists(ctx: ConvexContext, name: string, includeDeleted = false): Promise<boolean> {
511
+ const type = await this.getByName(ctx, name, includeDeleted);
512
+ return type !== null;
513
+ }
514
+
515
+ async list(ctx: ConvexContext, args: ListContentTypesArgs = {}): Promise<PaginationResult<ContentType>> {
516
+ return ctx.runQuery(this.api.contentTypes.list, args);
517
+ }
518
+
519
+ async listActive(ctx: ConvexContext, paginationOpts?: PaginationOpts): Promise<PaginationResult<ContentType>> {
520
+ return this.list(ctx, { isActive: true, includeDeleted: false, paginationOpts });
521
+ }
522
+
523
+ async getAll(ctx: ConvexContext, includeInactive = false): Promise<ContentType[]> {
524
+ const result = await this.list(ctx, { isActive: includeInactive ? undefined : true, includeDeleted: false });
525
+ return result.page;
526
+ }
527
+
528
+ /**
529
+ * @example
530
+ * ```typescript
531
+ * const count = await cms.contentTypes.count(ctx);
532
+ * console.log(`You have ${count} content types`);
533
+ * ```
534
+ */
535
+ async count(
536
+ ctx: ConvexContext,
537
+ includeInactive = false
538
+ ): Promise<number> {
539
+ const all = await this.getAll(ctx, includeInactive);
540
+ return all.length;
541
+ }
542
+
543
+ /**
544
+ * Deactivate a content type without deleting it.
545
+ *
546
+ * Deactivated types remain in the database but are filtered out by default
547
+ * when listing content types. Existing content entries remain accessible.
548
+ *
549
+ * @param ctx - Convex mutation context
550
+ * @param id - The content type ID to deactivate
551
+ * @param updatedBy - User ID making the change
552
+ * @returns The updated content type
553
+ *
554
+ * @example
555
+ * ```typescript
556
+ * await cms.contentTypes.deactivate(ctx, contentTypeId, currentUserId);
557
+ * ```
558
+ */
559
+ async deactivate(
560
+ ctx: ConvexContext,
561
+ id: string,
562
+ updatedBy?: string
563
+ ): Promise<UpdateContentTypeResult> {
564
+ return this.update(ctx, { id, isActive: false, updatedBy });
565
+ }
566
+
567
+ /**
568
+ * Reactivate a previously deactivated content type.
569
+ *
570
+ * @param ctx - Convex mutation context
571
+ * @param id - The content type ID to reactivate
572
+ * @param updatedBy - User ID making the change
573
+ * @returns The updated content type
574
+ *
575
+ * @example
576
+ * ```typescript
577
+ * await cms.contentTypes.reactivate(ctx, contentTypeId, currentUserId);
578
+ * ```
579
+ */
580
+ async reactivate(
581
+ ctx: ConvexContext,
582
+ id: string,
583
+ updatedBy?: string
584
+ ): Promise<UpdateContentTypeResult> {
585
+ return this.update(ctx, { id, isActive: true, updatedBy });
586
+ }
587
+ }
588
+
589
+ // =============================================================================
590
+ // Content Entries API Wrapper
591
+ // =============================================================================
592
+
593
+ /** Content entry CRUD and workflow operations */
594
+ export class ContentEntriesApi {
595
+ constructor(
596
+ private readonly api: TypedComponentApi,
597
+ private readonly config: ResolvedComponentConfig,
598
+ private readonly authHelper?: AuthorizationHelper,
599
+ private readonly rateLimitHelper?: RateLimitHelper
600
+ ) {}
601
+
602
+ /** @throws AuthorizationNotConfiguredError if not configured and not in permissiveMode */
603
+ private async authorize(
604
+ ctx: ConvexContext,
605
+ operation: CmsOperation,
606
+ userId: string | undefined,
607
+ resourceId?: string,
608
+ resourceOwnerId?: string,
609
+ contentTypeId?: string
610
+ ): Promise<void> {
611
+ if (!this.authHelper) {
612
+ if (this.config.permissiveMode) {
613
+ console.warn(
614
+ `[ConvexCMS] Authorization not configured for "${operation}". ` +
615
+ "Operations are allowed in permissiveMode, but this should NOT be used in production."
616
+ );
617
+ return;
618
+ }
619
+ throw new AuthorizationNotConfiguredError(operation);
620
+ }
621
+
622
+ if (this.authHelper.skipRbac) return;
623
+
624
+ if (!userId) {
625
+ if (this.config.permissiveMode) {
626
+ console.warn(
627
+ `[ConvexCMS] Anonymous operation attempted for "${operation}". ` +
628
+ "Operations without userId are allowed in permissiveMode, but this should NOT be used in production."
629
+ );
630
+ return;
631
+ }
632
+ throw new AuthorizationNotConfiguredError(
633
+ `${operation} (no userId provided - anonymous operations require permissiveMode)`
634
+ );
635
+ }
636
+
637
+ const role = await this.authHelper.getUserRole(ctx, userId);
638
+ await this.authHelper.requireAuthorization(ctx, { operation, userId, role, resourceId, resourceOwnerId, contentTypeId });
639
+ }
640
+
641
+ private async rateLimit(
642
+ ctx: ConvexContext,
643
+ operation: CmsOperation,
644
+ userId: string | undefined,
645
+ contentTypeId?: string
646
+ ): Promise<void> {
647
+ if (!this.rateLimitHelper) return;
648
+ const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
649
+ await this.rateLimitHelper.requireRateLimit(operation, { userId, role, contentTypeId });
650
+ }
651
+
652
+ async create(ctx: ConvexContext, args: CreateContentEntryArgs): Promise<ContentEntry> {
653
+ await this.authorize(ctx, "contentEntries.create", args.createdBy, undefined, undefined, args.contentTypeId);
654
+ await this.rateLimit(ctx, "contentEntries.create", args.createdBy, args.contentTypeId);
655
+ const argsWithDefaults = { ...args, locale: args.locale ?? this.config.defaultLocale };
656
+ return ctx.runMutation(this.api.contentEntryMutations.createEntry, argsWithDefaults);
657
+ }
658
+
659
+ async update(ctx: ConvexContext, args: UpdateContentEntryArgs): Promise<ContentEntry> {
660
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
661
+ if (!entry) throw new Error(`Content entry not found: ${args.id}`);
662
+ await this.authorize(ctx, "contentEntries.update", args.updatedBy, args.id, entry.createdBy, entry.contentTypeId);
663
+ await this.rateLimit(ctx, "contentEntries.update", args.updatedBy, entry.contentTypeId);
664
+ return ctx.runMutation(this.api.contentEntryMutations.updateEntry, args);
665
+ }
666
+
667
+ async delete(ctx: ConvexContext, args: DeleteContentEntryArgs): Promise<ContentEntry> {
668
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
669
+ if (!entry) throw new Error(`Content entry not found: ${args.id}`);
670
+ await this.authorize(ctx, "contentEntries.delete", args.deletedBy, args.id, entry.createdBy, entry.contentTypeId);
671
+ await this.rateLimit(ctx, "contentEntries.delete", args.deletedBy, entry.contentTypeId);
672
+ return ctx.runMutation(this.api.contentEntryMutations.deleteEntry, args);
673
+ }
674
+
675
+ async get(ctx: ConvexContext, args: GetContentEntryArgs): Promise<ContentEntry | null> {
676
+ return ctx.runQuery(this.api.contentEntries.get, args);
677
+ }
678
+
679
+ /** Looks up by contentTypeId+slug or contentTypeName+slug */
680
+ async getBySlug(ctx: ConvexContext, args: GetContentEntryBySlugArgs): Promise<ContentEntry | null> {
681
+ // The wrapper's unified interface adapts to the component's split API
682
+ if (args.contentTypeId) {
683
+ return ctx.runQuery(this.api.contentEntries.getBySlug, {
684
+ contentTypeId: args.contentTypeId,
685
+ slug: args.slug,
686
+ includeDeleted: false,
687
+ });
688
+ }
689
+ if (args.contentTypeName) {
690
+ return ctx.runQuery(this.api.contentEntries.getBySlugAndTypeName, {
691
+ contentTypeName: args.contentTypeName,
692
+ slug: args.slug,
693
+ includeDeleted: false,
694
+ });
695
+ }
696
+ throw new Error("getBySlug requires either contentTypeId or contentTypeName");
697
+ }
698
+
699
+ /** Standard Convex pagination format compatible with usePaginatedQuery */
700
+ async list(
701
+ ctx: ConvexContext,
702
+ args: ListContentEntriesArgs
703
+ ): Promise<PaginationResult<ContentEntry>> {
704
+ return ctx.runQuery(this.api.contentEntries.list, args);
705
+ }
706
+
707
+ /**
708
+ * Publish a content entry.
709
+ *
710
+ * @param ctx - Convex mutation context
711
+ * @param args - Publish arguments
712
+ * @returns The published entry
713
+ */
714
+ async publish(
715
+ ctx: ConvexContext,
716
+ args: PublishEntryArgs
717
+ ): Promise<ContentEntry> {
718
+ // Fetch entry for ownership-based authorization
719
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
720
+ if (!entry) {
721
+ throw new Error(`Content entry not found: ${args.id}`);
722
+ }
723
+
724
+ // Authorization check - contentEntries.publish (with ownership info)
725
+ await this.authorize(
726
+ ctx,
727
+ "contentEntries.publish",
728
+ args.updatedBy,
729
+ args.id,
730
+ entry.createdBy,
731
+ entry.contentTypeId
732
+ );
733
+ // Rate limit check - contentEntries.publish
734
+ await this.rateLimit(ctx, "contentEntries.publish", args.updatedBy, entry.contentTypeId);
735
+ return ctx.runMutation(this.api.contentEntryMutations.publishEntry, args);
736
+ }
737
+
738
+ /**
739
+ * Unpublish a content entry (revert to draft).
740
+ *
741
+ * @param ctx - Convex mutation context
742
+ * @param args - Unpublish arguments
743
+ * @returns The unpublished entry
744
+ */
745
+ async unpublish(
746
+ ctx: ConvexContext,
747
+ args: UnpublishEntryArgs
748
+ ): Promise<ContentEntry> {
749
+ // Fetch entry for ownership-based authorization
750
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
751
+ if (!entry) {
752
+ throw new Error(`Content entry not found: ${args.id}`);
753
+ }
754
+
755
+ // Authorization check - contentEntries.unpublish (with ownership info)
756
+ await this.authorize(
757
+ ctx,
758
+ "contentEntries.unpublish",
759
+ args.updatedBy,
760
+ args.id,
761
+ entry.createdBy,
762
+ entry.contentTypeId
763
+ );
764
+ // Rate limit check - contentEntries.unpublish
765
+ await this.rateLimit(ctx, "contentEntries.unpublish", args.updatedBy, entry.contentTypeId);
766
+ return ctx.runMutation(this.api.contentEntryMutations.unpublishEntry, args);
767
+ }
768
+
769
+ /**
770
+ * Schedule a content entry for future publication.
771
+ *
772
+ * @param ctx - Convex mutation context
773
+ * @param args - Schedule arguments
774
+ * @returns The scheduled entry
775
+ *
776
+ * @example
777
+ * ```typescript
778
+ * await cms.contentEntries.schedule(ctx, {
779
+ * id: entryId,
780
+ * publishAt: Date.now() + 24 * 60 * 60 * 1000, // Tomorrow
781
+ * });
782
+ * ```
783
+ */
784
+ async schedule(
785
+ ctx: ConvexContext,
786
+ args: ScheduleEntryArgs
787
+ ): Promise<ContentEntry> {
788
+ if (!this.config.features.scheduling) {
789
+ throw new Error("Scheduling feature is not enabled");
790
+ }
791
+
792
+ // Fetch entry for ownership-based authorization
793
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
794
+ if (!entry) {
795
+ throw new Error(`Content entry not found: ${args.id}`);
796
+ }
797
+
798
+ // Authorization check - contentEntries.schedule (with ownership info)
799
+ await this.authorize(
800
+ ctx,
801
+ "contentEntries.schedule",
802
+ args.updatedBy,
803
+ args.id,
804
+ entry.createdBy,
805
+ entry.contentTypeId
806
+ );
807
+ // Rate limit check - contentEntries.schedule
808
+ await this.rateLimit(ctx, "contentEntries.schedule", args.updatedBy, entry.contentTypeId);
809
+ return ctx.runMutation(this.api.scheduledPublish.scheduleEntry, args);
810
+ }
811
+
812
+ /**
813
+ * Restore a soft-deleted content entry.
814
+ *
815
+ * Removes the deletedAt timestamp from a soft-deleted entry,
816
+ * making it active again. Only works for soft-deleted entries;
817
+ * hard-deleted entries cannot be recovered.
818
+ *
819
+ * @param ctx - Convex mutation context
820
+ * @param args - Restore arguments
821
+ * @returns The restored entry
822
+ *
823
+ * @example
824
+ * ```typescript
825
+ * // Restore a soft-deleted entry
826
+ * const restored = await cms.contentEntries.restore(ctx, {
827
+ * id: entryId,
828
+ * restoredBy: currentUserId,
829
+ * });
830
+ * console.log(restored.deletedAt); // undefined
831
+ * ```
832
+ */
833
+ async restore(
834
+ ctx: ConvexContext,
835
+ args: RestoreEntryArgs
836
+ ): Promise<ContentEntry> {
837
+ if (!this.config.features.softDelete) {
838
+ throw new Error("Soft delete feature is not enabled");
839
+ }
840
+
841
+ // Fetch entry for ownership-based authorization
842
+ const entry = await ctx.runQuery(this.api.contentEntries.get, { id: args.id });
843
+ if (!entry) {
844
+ throw new Error(`Content entry not found: ${args.id}`);
845
+ }
846
+
847
+ // Authorization check - contentEntries.restore (with ownership info)
848
+ await this.authorize(
849
+ ctx,
850
+ "contentEntries.restore",
851
+ args.restoredBy,
852
+ args.id,
853
+ entry.createdBy,
854
+ entry.contentTypeId
855
+ );
856
+ // Rate limit check - contentEntries.restore
857
+ await this.rateLimit(ctx, "contentEntries.restore", args.restoredBy, entry.contentTypeId);
858
+ return ctx.runMutation(this.api.contentEntryMutations.restoreEntry, args);
859
+ }
860
+
861
+ /**
862
+ * Create a fluent query builder for constructing complex content queries.
863
+ *
864
+ * The query builder provides a chainable API for building queries with:
865
+ * - Content type filtering
866
+ * - Status filtering (single or multiple)
867
+ * - Field-level filters with various operators
868
+ * - Full-text search
869
+ * - Locale filtering
870
+ * - Cursor-based pagination
871
+ * - Sort direction
872
+ *
873
+ * @returns A new ContentQueryBuilder instance
874
+ *
875
+ * @example
876
+ * ```typescript
877
+ * // Simple query
878
+ * const posts = await cms.contentEntries
879
+ * .query()
880
+ * .contentType("blog_post")
881
+ * .status("published")
882
+ * .limit(10)
883
+ * .execute(ctx);
884
+ *
885
+ * // Complex query with field filters
886
+ * const featured = await cms.contentEntries
887
+ * .query()
888
+ * .contentType("blog_post")
889
+ * .where("category", "eq", "technology")
890
+ * .whereContains("tags", "featured")
891
+ * .whereGreaterThan("views", 100)
892
+ * .newestFirst()
893
+ * .limit(5)
894
+ * .execute(ctx);
895
+ *
896
+ * // Pagination
897
+ * const page1 = await cms.contentEntries
898
+ * .query()
899
+ * .contentType("blog_post")
900
+ * .limit(20)
901
+ * .execute(ctx);
902
+ *
903
+ * const page2 = await cms.contentEntries
904
+ * .query()
905
+ * .contentType("blog_post")
906
+ * .limit(20)
907
+ * .cursor(page1.continueCursor)
908
+ * .execute(ctx);
909
+ *
910
+ * // Get first result only
911
+ * const latest = await cms.contentEntries
912
+ * .query()
913
+ * .contentType("blog_post")
914
+ * .published()
915
+ * .newestFirst()
916
+ * .first(ctx);
917
+ *
918
+ * // Check if results exist
919
+ * const hasPublished = await cms.contentEntries
920
+ * .query()
921
+ * .contentType("blog_post")
922
+ * .published()
923
+ * .exists(ctx);
924
+ * ```
925
+ */
926
+ query(): ContentQueryBuilder {
927
+ return createQueryBuilder(this.api);
928
+ }
929
+
930
+ /**
931
+ * Resolve locale content for a single content entry.
932
+ *
933
+ * Takes a content entry with potentially localized field values and resolves
934
+ * all localized fields to single values based on the requested locale and
935
+ * fallback chain. This merges localized and default field values.
936
+ *
937
+ * Resolution order for each localized field:
938
+ * 1. Try the requested locale
939
+ * 2. Try each locale in the fallback chain (in order)
940
+ * 3. Try the default locale
941
+ * 4. Return first available locale as last resort
942
+ *
943
+ * @param entry - The content entry to resolve (with raw localized data)
944
+ * @param options - Locale resolution options
945
+ * @returns The entry with resolved data and metadata about resolution
946
+ *
947
+ * @example
948
+ * ```typescript
949
+ * // Get an entry
950
+ * const entry = await cms.contentEntries.get(ctx, { id: entryId });
951
+ *
952
+ * // Resolve to Spanish with English fallback
953
+ * const resolved = cms.contentEntries.resolveLocale(entry, {
954
+ * locale: "es-ES",
955
+ * fallbackChain: ["en-US"],
956
+ * defaultLocale: "en-US",
957
+ * fields: contentType.fields,
958
+ * });
959
+ *
960
+ * // Access resolved data
961
+ * console.log(resolved.data.title); // "Hola" (Spanish) or "Hello" (English fallback)
962
+ *
963
+ * // Check which fields used fallback
964
+ * if (resolved.localeResolution.fieldsFromFallback.includes("title")) {
965
+ * console.log("Title was not translated to Spanish");
966
+ * }
967
+ *
968
+ * // See which locale each field was resolved from
969
+ * console.log(resolved.localeResolution.fieldResolutions);
970
+ * // { content: "en-US" } - content was resolved from English
971
+ * ```
972
+ */
973
+ resolveLocale<T extends ContentEntry>(
974
+ entry: T,
975
+ options: ResolveLocaleContentOptions
976
+ ): T & LocaleResolvedEntry {
977
+ return resolveLocaleContent(entry, options);
978
+ }
979
+
980
+ /**
981
+ * Resolve locale content for multiple content entries.
982
+ *
983
+ * Convenience method for batch-resolving a list of entries.
984
+ * Useful after fetching a paginated list of content entries.
985
+ *
986
+ * @param entries - Array of content entries to resolve
987
+ * @param options - Locale resolution options (applied to all entries)
988
+ * @returns Array of entries with resolved locale data
989
+ *
990
+ * @example
991
+ * ```typescript
992
+ * // Fetch published blog posts
993
+ * const { page } = await cms.contentEntries.list(ctx, {
994
+ * contentTypeName: "blog_post",
995
+ * status: "published",
996
+ * paginationOpts: { numItems: 10 },
997
+ * });
998
+ *
999
+ * // Resolve all entries to Spanish
1000
+ * const resolvedPosts = cms.contentEntries.resolveLocaleBatch(page, {
1001
+ * locale: "es-ES",
1002
+ * fallbackChain: cms.getLocaleFallbackChain("es-ES"),
1003
+ * defaultLocale: cms.config.defaultLocale,
1004
+ * fields: blogPostType.fields,
1005
+ * });
1006
+ *
1007
+ * // Use resolved data
1008
+ * for (const post of resolvedPosts) {
1009
+ * console.log(post.data.title); // Resolved title in Spanish or fallback
1010
+ * }
1011
+ * ```
1012
+ */
1013
+ resolveLocaleBatch<T extends ContentEntry>(
1014
+ entries: T[],
1015
+ options: ResolveLocaleContentOptions
1016
+ ): Array<T & LocaleResolvedEntry> {
1017
+ return resolveLocaleContentBatch(entries, options);
1018
+ }
1019
+
1020
+ /**
1021
+ * List content entries with automatic locale resolution.
1022
+ *
1023
+ * This is a convenience method that combines `list()` and `resolveLocaleBatch()`
1024
+ * into a single call. It fetches content entries and automatically resolves
1025
+ * all localized fields to the requested locale with fallback support.
1026
+ *
1027
+ * Note: This method requires the content type's field definitions to properly
1028
+ * resolve localized fields. You can either pass them explicitly or let the
1029
+ * method fetch them automatically (requires an extra query).
1030
+ *
1031
+ * @param ctx - Convex query context
1032
+ * @param args - Query options with pagination
1033
+ * @param localeOptions - Locale resolution options
1034
+ * @returns Paginated result with locale-resolved entries
1035
+ *
1036
+ * @example
1037
+ * ```typescript
1038
+ * // List with locale resolution
1039
+ * const { page, continueCursor, isDone } = await cms.contentEntries.listWithLocale(
1040
+ * ctx,
1041
+ * {
1042
+ * contentTypeName: "blog_post",
1043
+ * status: "published",
1044
+ * paginationOpts: { numItems: 10 },
1045
+ * },
1046
+ * {
1047
+ * locale: "es-ES",
1048
+ * fields: blogPostType.fields, // Required for resolution
1049
+ * }
1050
+ * );
1051
+ *
1052
+ * // All entries have resolved locale data
1053
+ * for (const post of page) {
1054
+ * console.log(post.data.title); // Resolved title
1055
+ * console.log(post.localeResolution.fieldsFromFallback); // Which fields used fallback
1056
+ * }
1057
+ * ```
1058
+ */
1059
+ async listWithLocale(
1060
+ ctx: ConvexContext,
1061
+ args: ListContentEntriesArgs,
1062
+ localeOptions: ResolveLocaleContentOptions
1063
+ ): Promise<PaginationResult<ContentEntry & LocaleResolvedEntry>> {
1064
+ // Fetch raw entries
1065
+ const result = await this.list(ctx, args);
1066
+
1067
+ // Resolve locale for all entries
1068
+ const resolvedPage = this.resolveLocaleBatch(result.page, localeOptions);
1069
+
1070
+ return {
1071
+ page: resolvedPage,
1072
+ continueCursor: result.continueCursor,
1073
+ isDone: result.isDone,
1074
+ };
1075
+ }
1076
+
1077
+ /**
1078
+ * Get a content entry by ID with automatic locale resolution.
1079
+ *
1080
+ * Fetches the entry and resolves all localized fields to the requested locale.
1081
+ *
1082
+ * @param ctx - Convex query context
1083
+ * @param args - Get arguments
1084
+ * @param localeOptions - Locale resolution options
1085
+ * @returns The entry with resolved locale data, or null if not found
1086
+ *
1087
+ * @example
1088
+ * ```typescript
1089
+ * const post = await cms.contentEntries.getWithLocale(
1090
+ * ctx,
1091
+ * { id: entryId },
1092
+ * {
1093
+ * locale: "es-ES",
1094
+ * fallbackChain: ["en-US"],
1095
+ * defaultLocale: "en-US",
1096
+ * fields: blogPostType.fields,
1097
+ * }
1098
+ * );
1099
+ *
1100
+ * if (post) {
1101
+ * console.log(post.data.title); // Resolved title
1102
+ * }
1103
+ * ```
1104
+ */
1105
+ async getWithLocale(
1106
+ ctx: ConvexContext,
1107
+ args: GetContentEntryArgs,
1108
+ localeOptions: ResolveLocaleContentOptions
1109
+ ): Promise<(ContentEntry & LocaleResolvedEntry) | null> {
1110
+ const entry = await this.get(ctx, args);
1111
+ if (!entry) return null;
1112
+ return this.resolveLocale(entry, localeOptions);
1113
+ }
1114
+
1115
+ /**
1116
+ * Get a content entry by slug with automatic locale resolution.
1117
+ *
1118
+ * Fetches the entry by slug and resolves all localized fields to the requested locale.
1119
+ *
1120
+ * @param ctx - Convex query context
1121
+ * @param args - Get by slug arguments
1122
+ * @param localeOptions - Locale resolution options
1123
+ * @returns The entry with resolved locale data, or null if not found
1124
+ *
1125
+ * @example
1126
+ * ```typescript
1127
+ * const post = await cms.contentEntries.getBySlugWithLocale(
1128
+ * ctx,
1129
+ * {
1130
+ * contentTypeName: "blog_post",
1131
+ * slug: "hello-world",
1132
+ * },
1133
+ * {
1134
+ * locale: "es-ES",
1135
+ * fields: blogPostType.fields,
1136
+ * }
1137
+ * );
1138
+ * ```
1139
+ */
1140
+ async getBySlugWithLocale(
1141
+ ctx: ConvexContext,
1142
+ args: GetContentEntryBySlugArgs,
1143
+ localeOptions: ResolveLocaleContentOptions
1144
+ ): Promise<(ContentEntry & LocaleResolvedEntry) | null> {
1145
+ const entry = await this.getBySlug(ctx, args);
1146
+ if (!entry) return null;
1147
+ return this.resolveLocale(entry, localeOptions);
1148
+ }
1149
+
1150
+ // ===========================================================================
1151
+ // Duplicate Entry
1152
+ // ===========================================================================
1153
+
1154
+ /**
1155
+ * Duplicate a content entry.
1156
+ *
1157
+ * Creates a copy of an existing content entry with a new unique slug.
1158
+ * The duplicate always starts as a draft, regardless of the source entry's status.
1159
+ * Media references are copied by default but can be cleared.
1160
+ *
1161
+ * @param ctx - Convex mutation context
1162
+ * @param args - Duplicate arguments
1163
+ * @returns The duplicated entry
1164
+ *
1165
+ * @example
1166
+ * ```typescript
1167
+ * // Simple duplication with auto-generated slug
1168
+ * const copy = await cms.contentEntries.duplicate(ctx, {
1169
+ * sourceEntryId: originalPost._id,
1170
+ * createdBy: currentUserId,
1171
+ * });
1172
+ *
1173
+ * // Duplicate with custom slug
1174
+ * const copy = await cms.contentEntries.duplicate(ctx, {
1175
+ * sourceEntryId: templateId,
1176
+ * slug: "new-post-from-template",
1177
+ * createdBy: currentUserId,
1178
+ * });
1179
+ *
1180
+ * // Duplicate without media references (for a fresh start)
1181
+ * const copy = await cms.contentEntries.duplicate(ctx, {
1182
+ * sourceEntryId: originalPost._id,
1183
+ * copyMediaReferences: false,
1184
+ * createdBy: currentUserId,
1185
+ * });
1186
+ * ```
1187
+ */
1188
+ async duplicate(
1189
+ ctx: ConvexContext,
1190
+ args: DuplicateEntryArgs
1191
+ ): Promise<ContentEntry> {
1192
+ // Authorization check - duplicating is similar to create
1193
+ await this.authorize(ctx, "contentEntries.create", args.createdBy);
1194
+ // Rate limit check
1195
+ await this.rateLimit(ctx, "contentEntries.create", args.createdBy);
1196
+ return ctx.runMutation(this.api.contentEntryMutations.duplicateEntry, args);
1197
+ }
1198
+
1199
+ // ===========================================================================
1200
+ // Bulk Operations
1201
+ // ===========================================================================
1202
+
1203
+ /**
1204
+ * Publish multiple content entries in a single transaction.
1205
+ *
1206
+ * This is more efficient than publishing entries one by one. Each entry that
1207
+ * is already published will be skipped (idempotent behavior). Deleted or
1208
+ * archived entries will fail with an error message.
1209
+ *
1210
+ * @param ctx - Convex mutation context
1211
+ * @param args - Bulk publish arguments
1212
+ * @returns Bulk operation result with success/failure details for each entry
1213
+ *
1214
+ * @example
1215
+ * ```typescript
1216
+ * const result = await cms.contentEntries.bulkPublish(ctx, {
1217
+ * ids: [entry1._id, entry2._id, entry3._id],
1218
+ * changeDescription: "Publishing launch content",
1219
+ * updatedBy: currentUserId,
1220
+ * });
1221
+ * console.log(`Published ${result.succeeded} of ${result.total} entries`);
1222
+ * if (result.failed > 0) {
1223
+ * result.results.filter(r => !r.success).forEach(r => {
1224
+ * console.error(`Failed to publish ${r.id}: ${r.error}`);
1225
+ * });
1226
+ * }
1227
+ * ```
1228
+ */
1229
+ async bulkPublish(
1230
+ ctx: ConvexContext,
1231
+ args: BulkPublishArgs
1232
+ ): Promise<BulkOperationResult> {
1233
+ // Authorization check for each entry (bulk check)
1234
+ await this.authorize(ctx, "contentEntries.publish", args.updatedBy);
1235
+ // Rate limit check
1236
+ await this.rateLimit(ctx, "contentEntries.publish", args.updatedBy);
1237
+ return ctx.runMutation(this.api.bulkOperations.bulkPublish, args);
1238
+ }
1239
+
1240
+ /**
1241
+ * Unpublish multiple content entries in a single transaction.
1242
+ *
1243
+ * Reverts published entries to draft status. Non-published entries are
1244
+ * skipped (idempotent behavior).
1245
+ *
1246
+ * @param ctx - Convex mutation context
1247
+ * @param args - Bulk unpublish arguments
1248
+ * @returns Bulk operation result with success/failure details for each entry
1249
+ *
1250
+ * @example
1251
+ * ```typescript
1252
+ * const result = await cms.contentEntries.bulkUnpublish(ctx, {
1253
+ * ids: [entry1._id, entry2._id],
1254
+ * updatedBy: currentUserId,
1255
+ * });
1256
+ * ```
1257
+ */
1258
+ async bulkUnpublish(
1259
+ ctx: ConvexContext,
1260
+ args: BulkUnpublishArgs
1261
+ ): Promise<BulkOperationResult> {
1262
+ // Authorization check
1263
+ await this.authorize(ctx, "contentEntries.unpublish", args.updatedBy);
1264
+ // Rate limit check
1265
+ await this.rateLimit(ctx, "contentEntries.unpublish", args.updatedBy);
1266
+ return ctx.runMutation(this.api.bulkOperations.bulkUnpublish, args);
1267
+ }
1268
+
1269
+ /**
1270
+ * Delete multiple content entries in a single transaction.
1271
+ *
1272
+ * By default, performs soft delete (entries can be restored later).
1273
+ * When hardDelete is true, permanently removes entries and all their versions.
1274
+ *
1275
+ * @param ctx - Convex mutation context
1276
+ * @param args - Bulk delete arguments
1277
+ * @returns Bulk operation result with success/failure details for each entry
1278
+ *
1279
+ * @example
1280
+ * ```typescript
1281
+ * // Soft delete (default)
1282
+ * const result = await cms.contentEntries.bulkDelete(ctx, {
1283
+ * ids: [entry1._id, entry2._id],
1284
+ * deletedBy: currentUserId,
1285
+ * });
1286
+ *
1287
+ * // Hard delete (permanent)
1288
+ * const result = await cms.contentEntries.bulkDelete(ctx, {
1289
+ * ids: [entry1._id, entry2._id],
1290
+ * deletedBy: currentUserId,
1291
+ * hardDelete: true,
1292
+ * });
1293
+ * ```
1294
+ */
1295
+ async bulkDelete(
1296
+ ctx: ConvexContext,
1297
+ args: BulkDeleteArgs
1298
+ ): Promise<BulkOperationResult> {
1299
+ // Authorization check
1300
+ await this.authorize(ctx, "contentEntries.delete", args.deletedBy);
1301
+ // Rate limit check
1302
+ await this.rateLimit(ctx, "contentEntries.delete", args.deletedBy);
1303
+ return ctx.runMutation(this.api.bulkOperations.bulkDelete, args);
1304
+ }
1305
+
1306
+ /**
1307
+ * Update multiple content entries with the same changes in a single transaction.
1308
+ *
1309
+ * Applies the same data updates and/or status change to all specified entries.
1310
+ * Data is merged with existing data for each entry (partial updates).
1311
+ * Each entry is validated against its content type schema.
1312
+ *
1313
+ * @param ctx - Convex mutation context
1314
+ * @param args - Bulk update arguments
1315
+ * @returns Bulk operation result with success/failure details for each entry
1316
+ *
1317
+ * @example
1318
+ * ```typescript
1319
+ * // Update data for multiple entries
1320
+ * const result = await cms.contentEntries.bulkUpdate(ctx, {
1321
+ * ids: [entry1._id, entry2._id, entry3._id],
1322
+ * data: { featured: true, category: "news" },
1323
+ * updatedBy: currentUserId,
1324
+ * });
1325
+ *
1326
+ * // Change status for multiple entries
1327
+ * const result = await cms.contentEntries.bulkUpdate(ctx, {
1328
+ * ids: [entry1._id, entry2._id],
1329
+ * status: "archived",
1330
+ * updatedBy: currentUserId,
1331
+ * });
1332
+ * ```
1333
+ */
1334
+ async bulkUpdate(
1335
+ ctx: ConvexContext,
1336
+ args: BulkUpdateArgs
1337
+ ): Promise<BulkOperationResult> {
1338
+ // Authorization check
1339
+ await this.authorize(ctx, "contentEntries.update", args.updatedBy);
1340
+ // Rate limit check
1341
+ await this.rateLimit(ctx, "contentEntries.update", args.updatedBy);
1342
+ return ctx.runMutation(this.api.bulkOperations.bulkUpdate, args);
1343
+ }
1344
+
1345
+ /**
1346
+ * Restore multiple soft-deleted content entries in a single transaction.
1347
+ *
1348
+ * Removes the deletedAt marker from entries, making them active again.
1349
+ * Only works for soft-deleted entries. Non-deleted entries are skipped
1350
+ * (idempotent behavior).
1351
+ *
1352
+ * @param ctx - Convex mutation context
1353
+ * @param args - Bulk restore arguments
1354
+ * @returns Bulk operation result with success/failure details for each entry
1355
+ *
1356
+ * @example
1357
+ * ```typescript
1358
+ * const result = await cms.contentEntries.bulkRestore(ctx, {
1359
+ * ids: [deletedEntry1._id, deletedEntry2._id],
1360
+ * restoredBy: currentUserId,
1361
+ * });
1362
+ * ```
1363
+ */
1364
+ async bulkRestore(
1365
+ ctx: ConvexContext,
1366
+ args: BulkRestoreArgs
1367
+ ): Promise<BulkOperationResult> {
1368
+ if (!this.config.features.softDelete) {
1369
+ throw new Error("Soft delete feature is not enabled");
1370
+ }
1371
+ // Authorization check
1372
+ await this.authorize(ctx, "contentEntries.restore", args.restoredBy);
1373
+ // Rate limit check
1374
+ await this.rateLimit(ctx, "contentEntries.restore", args.restoredBy);
1375
+ return ctx.runMutation(this.api.bulkOperations.bulkRestore, args);
1376
+ }
1377
+ }
1378
+
1379
+ // =============================================================================
1380
+ // Versions API Wrapper
1381
+ // =============================================================================
1382
+
1383
+ /**
1384
+ * Wrapper for content version operations.
1385
+ *
1386
+ * Provides comprehensive version management including:
1387
+ * - Version history retrieval with pagination
1388
+ * - Getting specific versions by ID or number
1389
+ * - Version comparison and diff generation
1390
+ * - Rollback functionality
1391
+ * - Finding latest and published versions
1392
+ *
1393
+ * @example
1394
+ * ```typescript
1395
+ * // Get version history for an entry
1396
+ * const history = await cms.versions.getHistory(ctx, {
1397
+ * entryId: entry._id,
1398
+ * paginationOpts: { numItems: 10 },
1399
+ * });
1400
+ *
1401
+ * // Compare two versions
1402
+ * const diff = await cms.versions.compare(ctx, {
1403
+ * entryId: entry._id,
1404
+ * fromVersionNumber: 1,
1405
+ * toVersionNumber: 5,
1406
+ * });
1407
+ *
1408
+ * // Rollback to a previous version
1409
+ * await cms.versions.rollback(ctx, {
1410
+ * entryId: entry._id,
1411
+ * versionNumber: 3,
1412
+ * });
1413
+ * ```
1414
+ */
1415
+ export class VersionsApi {
1416
+ constructor(
1417
+ private readonly api: TypedComponentApi,
1418
+ private readonly config: ResolvedComponentConfig,
1419
+ private readonly authHelper?: AuthorizationHelper,
1420
+ private readonly rateLimitHelper?: RateLimitHelper
1421
+ ) {}
1422
+
1423
+ /**
1424
+ * Check if versioning feature is enabled.
1425
+ * @throws Error if versioning is not enabled
1426
+ */
1427
+ private ensureVersioningEnabled(): void {
1428
+ if (!this.config.features.versioning) {
1429
+ throw new Error("Versioning feature is not enabled");
1430
+ }
1431
+ }
1432
+
1433
+ /**
1434
+ * Perform authorization check for version operations.
1435
+ * @param ctx - The Convex context (passed to authorization hooks for database access)
1436
+ * @param operation - The CMS operation being performed
1437
+ * @param userId - The user performing the operation
1438
+ * @param resourceId - Optional resource ID (entry ID for version operations)
1439
+ * @throws AuthorizationNotConfiguredError if authorization is not configured and permissiveMode is false
1440
+ */
1441
+ private async authorize(
1442
+ ctx: ConvexContext,
1443
+ operation: CmsOperation,
1444
+ userId: string | undefined,
1445
+ resourceId?: string
1446
+ ): Promise<void> {
1447
+ if (!this.authHelper) {
1448
+ if (this.config.permissiveMode) {
1449
+ console.warn(
1450
+ `[ConvexCMS] Authorization not configured for "${operation}". ` +
1451
+ "Operations are allowed in permissiveMode, but this should NOT be used in production."
1452
+ );
1453
+ return;
1454
+ }
1455
+ throw new AuthorizationNotConfiguredError(operation);
1456
+ }
1457
+
1458
+ if (this.authHelper.skipRbac) {
1459
+ return;
1460
+ }
1461
+
1462
+ if (!userId) {
1463
+ if (this.config.permissiveMode) {
1464
+ console.warn(
1465
+ `[ConvexCMS] Anonymous operation attempted for "${operation}".`
1466
+ );
1467
+ return;
1468
+ }
1469
+ throw new AuthorizationNotConfiguredError(
1470
+ `${operation} (no userId provided - anonymous operations require permissiveMode)`
1471
+ );
1472
+ }
1473
+
1474
+ const role = await this.authHelper.getUserRole(ctx, userId);
1475
+
1476
+ await this.authHelper.requireAuthorization(ctx, {
1477
+ operation,
1478
+ userId,
1479
+ role,
1480
+ resourceId,
1481
+ });
1482
+ }
1483
+
1484
+ /**
1485
+ * Enforce rate limit for version operations.
1486
+ * @param ctx - The Convex context (for database access)
1487
+ * @param operation - The CMS operation being performed
1488
+ * @param userId - The user performing the operation
1489
+ */
1490
+ private async rateLimit(
1491
+ ctx: ConvexContext,
1492
+ operation: CmsOperation,
1493
+ userId: string | undefined
1494
+ ): Promise<void> {
1495
+ // Skip if no rate limit helper configured
1496
+ if (!this.rateLimitHelper) {
1497
+ return;
1498
+ }
1499
+
1500
+ const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
1501
+
1502
+ await this.rateLimitHelper.requireRateLimit(operation, {
1503
+ userId,
1504
+ role,
1505
+ });
1506
+ }
1507
+
1508
+ /**
1509
+ * Get version history with standard Convex pagination.
1510
+ *
1511
+ * Returns versions in reverse chronological order (newest first).
1512
+ * Compatible with `usePaginatedQuery` React hook.
1513
+ *
1514
+ * @param ctx - Convex query context
1515
+ * @param args - History query arguments with pagination
1516
+ * @returns Paginated version history or null if entry not found
1517
+ *
1518
+ * @example
1519
+ * ```typescript
1520
+ * // Get first page of version history
1521
+ * const { page, continueCursor, isDone } = await cms.versions.getHistory(ctx, {
1522
+ * entryId: entry._id,
1523
+ * paginationOpts: { numItems: 10 },
1524
+ * });
1525
+ *
1526
+ * // Get next page
1527
+ * if (!isDone && continueCursor) {
1528
+ * const nextPage = await cms.versions.getHistory(ctx, {
1529
+ * entryId: entry._id,
1530
+ * paginationOpts: { numItems: 10, cursor: continueCursor },
1531
+ * });
1532
+ * }
1533
+ * ```
1534
+ */
1535
+ async getHistory(
1536
+ ctx: ConvexContext,
1537
+ args: GetVersionHistoryArgs
1538
+ ): Promise<PaginationResult<ContentVersion> | null> {
1539
+ this.ensureVersioningEnabled();
1540
+ return ctx.runQuery(this.api.contentEntries.getVersionHistory, args);
1541
+ }
1542
+
1543
+ /**
1544
+ * Get a specific version by ID or version number.
1545
+ *
1546
+ * @param ctx - Convex query context
1547
+ * @param args - Get arguments (entryId required, plus versionId or versionNumber)
1548
+ * @returns The version or null if not found
1549
+ *
1550
+ * @example
1551
+ * ```typescript
1552
+ * // Get by version number
1553
+ * const version = await cms.versions.get(ctx, {
1554
+ * entryId: entry._id,
1555
+ * versionNumber: 3,
1556
+ * });
1557
+ *
1558
+ * // Get by version ID
1559
+ * const version = await cms.versions.get(ctx, {
1560
+ * entryId: entry._id,
1561
+ * versionId: "abc123",
1562
+ * });
1563
+ * ```
1564
+ */
1565
+ async get(
1566
+ ctx: ConvexContext,
1567
+ args: GetVersionArgs
1568
+ ): Promise<ContentVersion | null> {
1569
+ this.ensureVersioningEnabled();
1570
+ return ctx.runQuery(this.api.contentEntries.getVersion, args);
1571
+ }
1572
+
1573
+ /**
1574
+ * Get a version by its version number (convenience method).
1575
+ *
1576
+ * @param ctx - Convex query context
1577
+ * @param entryId - The content entry ID
1578
+ * @param versionNumber - The version number to retrieve
1579
+ * @returns The version or null if not found
1580
+ *
1581
+ * @example
1582
+ * ```typescript
1583
+ * const version3 = await cms.versions.getByNumber(ctx, entry._id, 3);
1584
+ * ```
1585
+ */
1586
+ async getByNumber(
1587
+ ctx: ConvexContext,
1588
+ entryId: string,
1589
+ versionNumber: number
1590
+ ): Promise<ContentVersion | null> {
1591
+ return this.get(ctx, { entryId, versionNumber });
1592
+ }
1593
+
1594
+ /**
1595
+ * Get a version by its document ID (convenience method).
1596
+ *
1597
+ * @param ctx - Convex query context
1598
+ * @param entryId - The content entry ID
1599
+ * @param versionId - The version document ID
1600
+ * @returns The version or null if not found
1601
+ *
1602
+ * @example
1603
+ * ```typescript
1604
+ * const version = await cms.versions.getById(ctx, entry._id, versionDocId);
1605
+ * ```
1606
+ */
1607
+ async getById(
1608
+ ctx: ConvexContext,
1609
+ entryId: string,
1610
+ versionId: string
1611
+ ): Promise<ContentVersion | null> {
1612
+ return this.get(ctx, { entryId, versionId });
1613
+ }
1614
+
1615
+ /**
1616
+ * Get the latest (most recent) version snapshot for an entry.
1617
+ *
1618
+ * @param ctx - Convex query context
1619
+ * @param entryId - The content entry ID
1620
+ * @returns The latest version or null if no versions exist
1621
+ *
1622
+ * @example
1623
+ * ```typescript
1624
+ * const latest = await cms.versions.getLatest(ctx, entry._id);
1625
+ * console.log(`Current version: ${latest?.versionNumber}`);
1626
+ * ```
1627
+ */
1628
+ async getLatest(
1629
+ ctx: ConvexContext,
1630
+ entryId: string
1631
+ ): Promise<ContentVersion | null> {
1632
+ this.ensureVersioningEnabled();
1633
+ const history = await this.getHistory(ctx, {
1634
+ entryId,
1635
+ paginationOpts: { numItems: 1, cursor: null },
1636
+ });
1637
+ return history?.page[0] ?? null;
1638
+ }
1639
+
1640
+ /**
1641
+ * Get the latest published version for an entry.
1642
+ *
1643
+ * Searches through version history to find the most recent version
1644
+ * that was published (wasPublished = true).
1645
+ *
1646
+ * @param ctx - Convex query context
1647
+ * @param entryId - The content entry ID
1648
+ * @returns The latest published version or null if none published
1649
+ *
1650
+ * @example
1651
+ * ```typescript
1652
+ * const published = await cms.versions.getLatestPublished(ctx, entry._id);
1653
+ * if (published) {
1654
+ * console.log(`Published at: ${new Date(published.publishedAt!)}`);
1655
+ * }
1656
+ * ```
1657
+ */
1658
+ async getLatestPublished(
1659
+ ctx: ConvexContext,
1660
+ entryId: string
1661
+ ): Promise<ContentVersion | null> {
1662
+ this.ensureVersioningEnabled();
1663
+
1664
+ // Iterate through pages to find the first published version
1665
+ let cursor: string | null = null;
1666
+ let isDone = false;
1667
+
1668
+ while (!isDone) {
1669
+ const history = await this.getHistory(ctx, {
1670
+ entryId,
1671
+ paginationOpts: { numItems: 50, cursor },
1672
+ });
1673
+
1674
+ if (!history) return null;
1675
+
1676
+ const publishedVersion = history.page.find((v) => v.wasPublished);
1677
+ if (publishedVersion) {
1678
+ return publishedVersion;
1679
+ }
1680
+
1681
+ cursor = history.continueCursor;
1682
+ isDone = history.isDone;
1683
+ }
1684
+
1685
+ return null;
1686
+ }
1687
+
1688
+ /**
1689
+ * Get all published versions for an entry.
1690
+ *
1691
+ * @param ctx - Convex query context
1692
+ * @param entryId - The content entry ID
1693
+ * @param limit - Maximum number of published versions to return (default: 10)
1694
+ * @returns Array of published versions (newest first)
1695
+ *
1696
+ * @example
1697
+ * ```typescript
1698
+ * const publishedVersions = await cms.versions.getPublishedHistory(ctx, entry._id, 5);
1699
+ * console.log(`Found ${publishedVersions.length} published versions`);
1700
+ * ```
1701
+ */
1702
+ async getPublishedHistory(
1703
+ ctx: ConvexContext,
1704
+ entryId: string,
1705
+ limit: number = 10
1706
+ ): Promise<ContentVersion[]> {
1707
+ this.ensureVersioningEnabled();
1708
+
1709
+ const published: ContentVersion[] = [];
1710
+ let cursor: string | null = null;
1711
+ let isDone = false;
1712
+
1713
+ while (!isDone && published.length < limit) {
1714
+ const history = await this.getHistory(ctx, {
1715
+ entryId,
1716
+ paginationOpts: { numItems: 50, cursor },
1717
+ });
1718
+
1719
+ if (!history) break;
1720
+
1721
+ for (const version of history.page) {
1722
+ if (version.wasPublished) {
1723
+ published.push(version);
1724
+ if (published.length >= limit) break;
1725
+ }
1726
+ }
1727
+
1728
+ cursor = history.continueCursor;
1729
+ isDone = history.isDone;
1730
+ }
1731
+
1732
+ return published;
1733
+ }
1734
+
1735
+ /**
1736
+ * Compare two versions and generate a detailed diff.
1737
+ *
1738
+ * Analyzes field-level changes between two versions, identifying:
1739
+ * - Added fields (present in toVersion but not fromVersion)
1740
+ * - Removed fields (present in fromVersion but not toVersion)
1741
+ * - Modified fields (present in both but with different values)
1742
+ *
1743
+ * @param ctx - Convex query context
1744
+ * @param args - Comparison arguments
1745
+ * @returns Detailed version comparison or null if versions not found
1746
+ *
1747
+ * @example
1748
+ * ```typescript
1749
+ * const diff = await cms.versions.compare(ctx, {
1750
+ * entryId: entry._id,
1751
+ * fromVersionNumber: 1,
1752
+ * toVersionNumber: 5,
1753
+ * });
1754
+ *
1755
+ * if (diff) {
1756
+ * console.log(`${diff.summary.totalChanges} changes detected`);
1757
+ * for (const change of diff.changes) {
1758
+ * console.log(`${change.field}: ${change.changeType}`);
1759
+ * }
1760
+ * }
1761
+ * ```
1762
+ */
1763
+ async compare(
1764
+ ctx: ConvexContext,
1765
+ args: CompareVersionsArgs
1766
+ ): Promise<VersionComparison | null> {
1767
+ this.ensureVersioningEnabled();
1768
+
1769
+ // Get the fromVersion
1770
+ const fromVersion = await this.getByNumber(ctx, args.entryId, args.fromVersionNumber);
1771
+ if (!fromVersion) return null;
1772
+
1773
+ // Get the toVersion
1774
+ const toVersion = await this.getByNumber(ctx, args.entryId, args.toVersionNumber);
1775
+ if (!toVersion) return null;
1776
+
1777
+ // Generate the comparison
1778
+ return this.generateComparison(fromVersion, toVersion);
1779
+ }
1780
+
1781
+ /**
1782
+ * Compare the current entry state with a specific version.
1783
+ *
1784
+ * Useful for seeing what has changed since a particular point in time.
1785
+ *
1786
+ * @param ctx - Convex query context
1787
+ * @param entryId - The content entry ID
1788
+ * @param versionNumber - The version number to compare against
1789
+ * @returns Comparison between the version and current state, or null
1790
+ *
1791
+ * @example
1792
+ * ```typescript
1793
+ * // See what changed since version 3
1794
+ * const diff = await cms.versions.compareWithCurrent(ctx, entry._id, 3);
1795
+ * ```
1796
+ */
1797
+ async compareWithCurrent(
1798
+ ctx: ConvexContext,
1799
+ entryId: string,
1800
+ versionNumber: number
1801
+ ): Promise<VersionComparison | null> {
1802
+ this.ensureVersioningEnabled();
1803
+
1804
+ const fromVersion = await this.getByNumber(ctx, entryId, versionNumber);
1805
+ if (!fromVersion) return null;
1806
+
1807
+ const latest = await this.getLatest(ctx, entryId);
1808
+ if (!latest) return null;
1809
+
1810
+ return this.generateComparison(fromVersion, latest);
1811
+ }
1812
+
1813
+ /**
1814
+ * Check if a specific version exists.
1815
+ *
1816
+ * @param ctx - Convex query context
1817
+ * @param entryId - The content entry ID
1818
+ * @param versionNumber - The version number to check
1819
+ * @returns true if the version exists
1820
+ *
1821
+ * @example
1822
+ * ```typescript
1823
+ * if (await cms.versions.exists(ctx, entry._id, 5)) {
1824
+ * // Version 5 exists
1825
+ * }
1826
+ * ```
1827
+ */
1828
+ async exists(
1829
+ ctx: ConvexContext,
1830
+ entryId: string,
1831
+ versionNumber: number
1832
+ ): Promise<boolean> {
1833
+ const version = await this.getByNumber(ctx, entryId, versionNumber);
1834
+ return version !== null;
1835
+ }
1836
+
1837
+ /**
1838
+ * Count total number of versions for an entry.
1839
+ *
1840
+ * @param ctx - Convex query context
1841
+ * @param entryId - The content entry ID
1842
+ * @returns Total number of version snapshots
1843
+ *
1844
+ * @example
1845
+ * ```typescript
1846
+ * const count = await cms.versions.count(ctx, entry._id);
1847
+ * console.log(`Entry has ${count} versions`);
1848
+ * ```
1849
+ */
1850
+ async count(
1851
+ ctx: ConvexContext,
1852
+ entryId: string
1853
+ ): Promise<number> {
1854
+ this.ensureVersioningEnabled();
1855
+
1856
+ let total = 0;
1857
+ let cursor: string | null = null;
1858
+ let isDone = false;
1859
+
1860
+ while (!isDone) {
1861
+ const history = await this.getHistory(ctx, {
1862
+ entryId,
1863
+ paginationOpts: { numItems: 100, cursor },
1864
+ });
1865
+
1866
+ if (!history) break;
1867
+
1868
+ total += history.page.length;
1869
+ cursor = history.continueCursor;
1870
+ isDone = history.isDone;
1871
+ }
1872
+
1873
+ return total;
1874
+ }
1875
+
1876
+ /**
1877
+ * Rollback a content entry to a previous version.
1878
+ *
1879
+ * This is a non-destructive operation that:
1880
+ * 1. Creates a snapshot of the current state (for undo capability)
1881
+ * 2. Restores data and slug from the target version
1882
+ * 3. Increments the version number
1883
+ * 4. Creates a new snapshot documenting the rollback
1884
+ *
1885
+ * The entry's status, scheduled publish time, and publishing timestamps
1886
+ * are preserved (not restored from the target version).
1887
+ *
1888
+ * @param ctx - Convex mutation context
1889
+ * @param args - Rollback arguments
1890
+ * @returns The updated entry with rolled back content
1891
+ *
1892
+ * @example
1893
+ * ```typescript
1894
+ * // Rollback to version 3
1895
+ * const entry = await cms.versions.rollback(ctx, {
1896
+ * entryId: entry._id,
1897
+ * versionNumber: 3,
1898
+ * updatedBy: currentUserId,
1899
+ * });
1900
+ *
1901
+ * // The entry is now at a new version number (e.g., 7)
1902
+ * // but with content from version 3
1903
+ * console.log(`Rolled back, now at version ${entry.version}`);
1904
+ * ```
1905
+ */
1906
+ async rollback(
1907
+ ctx: ConvexContext,
1908
+ args: RollbackVersionArgs
1909
+ ): Promise<ContentEntry> {
1910
+ this.ensureVersioningEnabled();
1911
+ // Authorization check - versions.rollback
1912
+ await this.authorize(ctx, "versions.rollback", args.updatedBy, args.entryId);
1913
+ // Rate limit check - versions.rollback
1914
+ await this.rateLimit(ctx, "versions.rollback", args.updatedBy);
1915
+ return ctx.runMutation(this.api.versionMutations.rollbackVersion, args);
1916
+ }
1917
+
1918
+ // =========================================================================
1919
+ // Private Helper Methods
1920
+ // =========================================================================
1921
+
1922
+ /**
1923
+ * Generate a detailed comparison between two versions.
1924
+ */
1925
+ private generateComparison(
1926
+ fromVersion: ContentVersion,
1927
+ toVersion: ContentVersion
1928
+ ): VersionComparison {
1929
+ const changes: FieldChange[] = [];
1930
+ const fromData = fromVersion.data;
1931
+ const toData = toVersion.data;
1932
+
1933
+ // Get all unique field names from both versions
1934
+ const allFields = new Set([
1935
+ ...Object.keys(fromData),
1936
+ ...Object.keys(toData),
1937
+ ]);
1938
+
1939
+ let fieldsAdded = 0;
1940
+ let fieldsRemoved = 0;
1941
+ let fieldsModified = 0;
1942
+
1943
+ for (const field of allFields) {
1944
+ const oldValue = fromData[field];
1945
+ const newValue = toData[field];
1946
+ const inOld = field in fromData;
1947
+ const inNew = field in toData;
1948
+
1949
+ let changeType: FieldChangeType;
1950
+
1951
+ if (!inOld && inNew) {
1952
+ changeType = "added";
1953
+ fieldsAdded++;
1954
+ } else if (inOld && !inNew) {
1955
+ changeType = "removed";
1956
+ fieldsRemoved++;
1957
+ } else if (!this.deepEqual(oldValue, newValue)) {
1958
+ changeType = "modified";
1959
+ fieldsModified++;
1960
+ } else {
1961
+ changeType = "unchanged";
1962
+ }
1963
+
1964
+ // Only include changes (skip unchanged fields)
1965
+ if (changeType !== "unchanged") {
1966
+ changes.push({
1967
+ field,
1968
+ changeType,
1969
+ oldValue: inOld ? oldValue : undefined,
1970
+ newValue: inNew ? newValue : undefined,
1971
+ });
1972
+ }
1973
+ }
1974
+
1975
+ return {
1976
+ fromVersion,
1977
+ toVersion,
1978
+ changes,
1979
+ slugChanged: fromVersion.slug !== toVersion.slug,
1980
+ statusChanged: fromVersion.status !== toVersion.status,
1981
+ summary: {
1982
+ fieldsAdded,
1983
+ fieldsRemoved,
1984
+ fieldsModified,
1985
+ totalChanges: fieldsAdded + fieldsRemoved + fieldsModified,
1986
+ },
1987
+ };
1988
+ }
1989
+
1990
+ /**
1991
+ * Deep equality check for comparing field values.
1992
+ */
1993
+ private deepEqual(a: unknown, b: unknown): boolean {
1994
+ if (a === b) return true;
1995
+ if (a === null || b === null) return false;
1996
+ if (typeof a !== typeof b) return false;
1997
+
1998
+ if (typeof a === "object") {
1999
+ if (Array.isArray(a) && Array.isArray(b)) {
2000
+ if (a.length !== b.length) return false;
2001
+ return a.every((item, index) => this.deepEqual(item, b[index]));
2002
+ }
2003
+
2004
+ if (!Array.isArray(a) && !Array.isArray(b)) {
2005
+ const aObj = a as Record<string, unknown>;
2006
+ const bObj = b as Record<string, unknown>;
2007
+ const aKeys = Object.keys(aObj);
2008
+ const bKeys = Object.keys(bObj);
2009
+
2010
+ if (aKeys.length !== bKeys.length) return false;
2011
+ return aKeys.every((key) => this.deepEqual(aObj[key], bObj[key]));
2012
+ }
2013
+ }
2014
+
2015
+ return false;
2016
+ }
2017
+ }
2018
+
2019
+ // =============================================================================
2020
+ // Media Assets API Wrapper
2021
+ // =============================================================================
2022
+
2023
+ /**
2024
+ * Wrapper for media asset operations.
2025
+ */
2026
+ export class MediaAssetsApi {
2027
+ constructor(
2028
+ private readonly api: TypedComponentApi,
2029
+ private readonly config: ResolvedComponentConfig,
2030
+ private readonly authHelper?: AuthorizationHelper,
2031
+ private readonly rateLimitHelper?: RateLimitHelper
2032
+ ) {}
2033
+
2034
+ /**
2035
+ * Perform authorization check for media asset operations.
2036
+ * @param ctx - The Convex context (passed to authorization hooks for database access)
2037
+ * @param operation - The CMS operation being performed
2038
+ * @param userId - The user performing the operation
2039
+ * @param resourceId - Optional resource ID (for update/delete operations)
2040
+ * @param resourceOwnerId - Optional owner ID for ownership-based permissions
2041
+ * @throws AuthorizationNotConfiguredError if authorization is not configured and permissiveMode is false
2042
+ */
2043
+ private async authorize(
2044
+ ctx: ConvexContext,
2045
+ operation: CmsOperation,
2046
+ userId: string | undefined,
2047
+ resourceId?: string,
2048
+ resourceOwnerId?: string
2049
+ ): Promise<void> {
2050
+ if (!this.authHelper) {
2051
+ if (this.config.permissiveMode) {
2052
+ console.warn(
2053
+ `[ConvexCMS] Authorization not configured for "${operation}". ` +
2054
+ "Operations are allowed in permissiveMode, but this should NOT be used in production."
2055
+ );
2056
+ return;
2057
+ }
2058
+ throw new AuthorizationNotConfiguredError(operation);
2059
+ }
2060
+
2061
+ if (this.authHelper.skipRbac) {
2062
+ return;
2063
+ }
2064
+
2065
+ if (!userId) {
2066
+ if (this.config.permissiveMode) {
2067
+ console.warn(
2068
+ `[ConvexCMS] Anonymous operation attempted for "${operation}".`
2069
+ );
2070
+ return;
2071
+ }
2072
+ throw new AuthorizationNotConfiguredError(
2073
+ `${operation} (no userId provided - anonymous operations require permissiveMode)`
2074
+ );
2075
+ }
2076
+
2077
+ const role = await this.authHelper.getUserRole(ctx, userId);
2078
+
2079
+ await this.authHelper.requireAuthorization(ctx, {
2080
+ operation,
2081
+ userId,
2082
+ role,
2083
+ resourceId,
2084
+ resourceOwnerId,
2085
+ });
2086
+ }
2087
+
2088
+ /**
2089
+ * Enforce rate limit for media asset operations.
2090
+ * @param ctx - The Convex context (for database access)
2091
+ * @param operation - The CMS operation being performed
2092
+ * @param userId - The user performing the operation
2093
+ */
2094
+ private async rateLimit(
2095
+ ctx: ConvexContext,
2096
+ operation: CmsOperation,
2097
+ userId: string | undefined
2098
+ ): Promise<void> {
2099
+ // Skip if no rate limit helper configured
2100
+ if (!this.rateLimitHelper) {
2101
+ return;
2102
+ }
2103
+
2104
+ const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
2105
+
2106
+ await this.rateLimitHelper.requireRateLimit(operation, {
2107
+ userId,
2108
+ role,
2109
+ });
2110
+ }
2111
+
2112
+ /**
2113
+ * Create a new media asset record.
2114
+ *
2115
+ * @param ctx - Convex mutation context
2116
+ * @param args - Asset creation arguments
2117
+ * @returns The created asset
2118
+ *
2119
+ * @example
2120
+ * ```typescript
2121
+ * // After uploading to Convex storage
2122
+ * const asset = await cms.mediaAssets.create(ctx, {
2123
+ * storageId: storageId,
2124
+ * filename: "photo.jpg",
2125
+ * mimeType: "image/jpeg",
2126
+ * size: 102400,
2127
+ * type: "image",
2128
+ * width: 1920,
2129
+ * height: 1080,
2130
+ * });
2131
+ * ```
2132
+ */
2133
+ async create(
2134
+ ctx: ConvexContext,
2135
+ args: CreateMediaAssetArgs
2136
+ ): Promise<MediaAsset> {
2137
+ if (!this.config.features.mediaManagement) {
2138
+ throw new Error("Media management feature is not enabled");
2139
+ }
2140
+ // Authorization check - mediaAssets.create
2141
+ await this.authorize(ctx, "mediaItems.create", args.createdBy);
2142
+ // Rate limit check - mediaAssets.create (media uploads are high-frequency operations)
2143
+ await this.rateLimit(ctx, "mediaItems.create", args.createdBy);
2144
+ // Validate file size
2145
+ if (args.size && args.size > this.config.maxMediaFileSize) {
2146
+ throw new Error(
2147
+ `File size ${args.size} exceeds maximum allowed size of ${this.config.maxMediaFileSize} bytes`
2148
+ );
2149
+ }
2150
+ // Cast safe: createMediaAsset always returns kind="asset"
2151
+ return ctx.runMutation(this.api.mediaAssetMutations.createMediaAsset, args) as Promise<MediaAsset>;
2152
+ }
2153
+
2154
+ /**
2155
+ * Update media asset metadata.
2156
+ *
2157
+ * @param ctx - Convex mutation context
2158
+ * @param args - Asset update arguments
2159
+ * @returns The updated asset
2160
+ */
2161
+ async update(
2162
+ ctx: ConvexContext,
2163
+ args: UpdateMediaAssetArgs
2164
+ ): Promise<MediaAsset> {
2165
+ if (!this.config.features.mediaManagement) {
2166
+ throw new Error("Media management feature is not enabled");
2167
+ }
2168
+
2169
+ // Fetch asset for ownership-based authorization
2170
+ const asset = await ctx.runQuery(this.api.mediaAssets.get, { id: args.id });
2171
+ if (!asset) {
2172
+ throw new Error(`Media asset not found: ${args.id}`);
2173
+ }
2174
+
2175
+ // Authorization check - mediaAssets.update (with ownership info)
2176
+ await this.authorize(ctx, "mediaItems.update", args.updatedBy, args.id, asset.createdBy);
2177
+ // Rate limit check - mediaAssets.update
2178
+ await this.rateLimit(ctx, "mediaItems.update", args.updatedBy);
2179
+ // Cast safe: updateMediaAsset always returns kind="asset"
2180
+ return ctx.runMutation(this.api.mediaAssetMutations.updateMediaAsset, args) as Promise<MediaAsset>;
2181
+ }
2182
+
2183
+ /**
2184
+ * Soft delete a media asset.
2185
+ *
2186
+ * @param ctx - Convex mutation context
2187
+ * @param args - Delete arguments
2188
+ * @returns The deleted asset
2189
+ */
2190
+ async delete(
2191
+ ctx: ConvexContext,
2192
+ args: DeleteMediaAssetArgs
2193
+ ): Promise<MediaAsset> {
2194
+ if (!this.config.features.mediaManagement) {
2195
+ throw new Error("Media management feature is not enabled");
2196
+ }
2197
+
2198
+ // Fetch asset for ownership-based authorization
2199
+ const asset = await ctx.runQuery(this.api.mediaAssets.get, { id: args.id });
2200
+ if (!asset) {
2201
+ throw new Error(`Media asset not found: ${args.id}`);
2202
+ }
2203
+
2204
+ // Authorization check - mediaAssets.delete (with ownership info)
2205
+ await this.authorize(ctx, "mediaItems.delete", args.deletedBy, args.id, asset.createdBy);
2206
+ // Rate limit check - mediaAssets.delete
2207
+ await this.rateLimit(ctx, "mediaItems.delete", args.deletedBy);
2208
+ // Cast safe: deleteMediaAsset always returns kind="asset"
2209
+ return ctx.runMutation(this.api.mediaAssetMutations.deleteMediaAsset, args) as unknown as Promise<MediaAsset>;
2210
+ }
2211
+
2212
+ /**
2213
+ * Get a media asset by ID.
2214
+ *
2215
+ * @param ctx - Convex query context
2216
+ * @param args - Get arguments
2217
+ * @returns The asset or null if not found
2218
+ */
2219
+ async get(
2220
+ ctx: ConvexContext,
2221
+ args: GetMediaAssetArgs
2222
+ ): Promise<MediaAsset | null> {
2223
+ if (!this.config.features.mediaManagement) {
2224
+ throw new Error("Media management feature is not enabled");
2225
+ }
2226
+ // Cast safe: mediaAssets.get filters for kind="asset"
2227
+ return ctx.runQuery(this.api.mediaAssets.get, args) as Promise<MediaAsset | null>;
2228
+ }
2229
+
2230
+ /**
2231
+ * List media assets with optional filters.
2232
+ *
2233
+ * @param ctx - Convex query context
2234
+ * @param args - Query options
2235
+ * @returns Paginated list of assets
2236
+ */
2237
+ async list(
2238
+ ctx: ConvexContext,
2239
+ args: ListMediaAssetsArgs = {}
2240
+ ): Promise<PaginationResult<MediaAsset>> {
2241
+ if (!this.config.features.mediaManagement) {
2242
+ throw new Error("Media management feature is not enabled");
2243
+ }
2244
+ return await callQuery(ctx, this.api.mediaAssets.list, args);
2245
+ }
2246
+
2247
+ /**
2248
+ * Generate a temporary upload URL for client-side file uploads.
2249
+ *
2250
+ * The upload flow works as follows:
2251
+ * 1. Call this method to get a temporary upload URL
2252
+ * 2. POST the file to the URL with Content-Type header set to the file's MIME type
2253
+ * 3. The response contains a `storageId` that references the uploaded file
2254
+ * 4. Call create() to save metadata and link the storageId
2255
+ *
2256
+ * @param ctx - Convex mutation context
2257
+ * @param args - Upload configuration options
2258
+ * @returns Upload URL and constraints
2259
+ *
2260
+ * @example
2261
+ * ```typescript
2262
+ * // Generate URL for image uploads
2263
+ * const { uploadUrl, expiresAt, maxFileSize } = await cms.mediaAssets.generateUploadUrl(ctx, {
2264
+ * maxFileSize: 10 * 1024 * 1024, // 10 MB
2265
+ * allowedMimeTypes: ["image/jpeg", "image/png", "image/webp"],
2266
+ * });
2267
+ *
2268
+ * // Client-side upload:
2269
+ * const response = await fetch(uploadUrl, {
2270
+ * method: "POST",
2271
+ * headers: { "Content-Type": file.type },
2272
+ * body: file,
2273
+ * });
2274
+ * const { storageId } = await response.json();
2275
+ *
2276
+ * // Then save metadata
2277
+ * const asset = await cms.mediaAssets.create(ctx, {
2278
+ * storageId,
2279
+ * filename: file.name,
2280
+ * mimeType: file.type,
2281
+ * size: file.size,
2282
+ * type: "image",
2283
+ * });
2284
+ * ```
2285
+ */
2286
+ async generateUploadUrl(
2287
+ ctx: ConvexContext,
2288
+ args: GenerateUploadUrlArgs = {}
2289
+ ): Promise<GenerateUploadUrlResult> {
2290
+ if (!this.config.features.mediaManagement) {
2291
+ throw new Error("Media management feature is not enabled");
2292
+ }
2293
+ // Rate limit check - mediaAssets.create (upload URL generation precedes asset creation)
2294
+ await this.rateLimit(ctx, "mediaItems.create", args.requestedBy);
2295
+ return ctx.runMutation(
2296
+ this.api.mediaUploadMutations.generateUploadUrl,
2297
+ args
2298
+ );
2299
+ }
2300
+
2301
+ /**
2302
+ * Restore a soft-deleted media asset.
2303
+ *
2304
+ * @param ctx - Convex mutation context
2305
+ * @param args - Restore arguments
2306
+ * @returns The restored asset
2307
+ *
2308
+ * @example
2309
+ * ```typescript
2310
+ * // Restore a previously deleted asset
2311
+ * const restoredAsset = await cms.mediaAssets.restore(ctx, {
2312
+ * id: assetId,
2313
+ * });
2314
+ * ```
2315
+ */
2316
+ async restore(
2317
+ ctx: ConvexContext,
2318
+ args: RestoreMediaAssetArgs
2319
+ ): Promise<MediaAsset> {
2320
+ if (!this.config.features.mediaManagement) {
2321
+ throw new Error("Media management feature is not enabled");
2322
+ }
2323
+ // Cast safe: restoreMediaAsset always returns kind="asset"
2324
+ return ctx.runMutation(
2325
+ this.api.mediaAssetMutations.restoreMediaAsset,
2326
+ args
2327
+ ) as Promise<MediaAsset>;
2328
+ }
2329
+
2330
+ /**
2331
+ * Find content entries that reference a media asset.
2332
+ *
2333
+ * Useful for checking references before deletion or for understanding asset usage.
2334
+ *
2335
+ * @param ctx - Convex query context
2336
+ * @param args - Query arguments
2337
+ * @returns Array of references with entry and field information
2338
+ *
2339
+ * @example
2340
+ * ```typescript
2341
+ * // Check if asset is used before deleting
2342
+ * const references = await cms.mediaAssets.findReferences(ctx, {
2343
+ * id: assetId,
2344
+ * });
2345
+ *
2346
+ * if (references.length > 0) {
2347
+ * console.log(`Asset is used in ${references.length} entries`);
2348
+ * // Maybe show a warning to the user
2349
+ * }
2350
+ * ```
2351
+ */
2352
+ async findReferences(
2353
+ ctx: ConvexContext,
2354
+ args: FindMediaAssetReferencesArgs
2355
+ ): Promise<MediaAssetReference[]> {
2356
+ if (!this.config.features.mediaManagement) {
2357
+ throw new Error("Media management feature is not enabled");
2358
+ }
2359
+ // Map the wrapper's args structure to the generated API's expected structure
2360
+ return callQuery(
2361
+ ctx,
2362
+ this.api.mediaAssetMutations.findMediaAssetReferences,
2363
+ { mediaAssetId: args.id, limit: args.limit }
2364
+ );
2365
+ }
2366
+
2367
+ // ===========================================================================
2368
+ // Taxonomy Methods
2369
+ // ===========================================================================
2370
+
2371
+ /**
2372
+ * Get taxonomy terms associated with a media asset.
2373
+ *
2374
+ * @param ctx - Convex query context
2375
+ * @param args - Query arguments
2376
+ * @returns Array of terms associated with the media asset
2377
+ *
2378
+ * @example
2379
+ * ```typescript
2380
+ * const tags = await cms.mediaAssets.getTerms(ctx, {
2381
+ * mediaId: imageId,
2382
+ * });
2383
+ * ```
2384
+ */
2385
+ async getTerms(
2386
+ ctx: ConvexContext,
2387
+ args: { mediaId: string; taxonomyId?: string }
2388
+ ): Promise<unknown[]> {
2389
+ if (!this.config.features.mediaManagement) {
2390
+ throw new Error("Media management feature is not enabled");
2391
+ }
2392
+ return callQuery(ctx, this.api.taxonomies.getTermsByMedia, args);
2393
+ }
2394
+
2395
+ /**
2396
+ * Set terms for a media asset in a taxonomy (replaces existing terms).
2397
+ *
2398
+ * @param ctx - Convex mutation context
2399
+ * @param args - Mutation arguments
2400
+ *
2401
+ * @example
2402
+ * ```typescript
2403
+ * await cms.mediaAssets.setTerms(ctx, {
2404
+ * mediaId: imageId,
2405
+ * taxonomyId: categoriesTaxonomyId,
2406
+ * termIds: [landscapeId, natureId],
2407
+ * userId: currentUserId,
2408
+ * });
2409
+ * ```
2410
+ */
2411
+ async setTerms(
2412
+ ctx: ConvexContext,
2413
+ args: { mediaId: string; taxonomyId: string; termIds: string[]; userId?: string }
2414
+ ): Promise<void> {
2415
+ if (!this.config.features.mediaManagement) {
2416
+ throw new Error("Media management feature is not enabled");
2417
+ }
2418
+ await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
2419
+ await ctx.runMutation(this.api.taxonomyMutations.setMediaTerms, {
2420
+ mediaId: args.mediaId,
2421
+ taxonomyId: args.taxonomyId,
2422
+ termIds: args.termIds,
2423
+ });
2424
+ }
2425
+
2426
+ /**
2427
+ * Add a single term to a media asset.
2428
+ *
2429
+ * @param ctx - Convex mutation context
2430
+ * @param args - Mutation arguments
2431
+ *
2432
+ * @example
2433
+ * ```typescript
2434
+ * await cms.mediaAssets.addTerm(ctx, {
2435
+ * mediaId: imageId,
2436
+ * termId: landscapeId,
2437
+ * userId: currentUserId,
2438
+ * });
2439
+ * ```
2440
+ */
2441
+ async addTerm(
2442
+ ctx: ConvexContext,
2443
+ args: { mediaId: string; termId: string; userId?: string }
2444
+ ): Promise<void> {
2445
+ if (!this.config.features.mediaManagement) {
2446
+ throw new Error("Media management feature is not enabled");
2447
+ }
2448
+ await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
2449
+ await ctx.runMutation(this.api.taxonomyMutations.addTermToMedia, {
2450
+ mediaId: args.mediaId,
2451
+ termId: args.termId,
2452
+ });
2453
+ }
2454
+
2455
+ /**
2456
+ * Remove a term from a media asset.
2457
+ *
2458
+ * @param ctx - Convex mutation context
2459
+ * @param args - Mutation arguments
2460
+ *
2461
+ * @example
2462
+ * ```typescript
2463
+ * await cms.mediaAssets.removeTerm(ctx, {
2464
+ * mediaId: imageId,
2465
+ * termId: landscapeId,
2466
+ * userId: currentUserId,
2467
+ * });
2468
+ * ```
2469
+ */
2470
+ async removeTerm(
2471
+ ctx: ConvexContext,
2472
+ args: { mediaId: string; termId: string; userId?: string }
2473
+ ): Promise<void> {
2474
+ if (!this.config.features.mediaManagement) {
2475
+ throw new Error("Media management feature is not enabled");
2476
+ }
2477
+ await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
2478
+ await ctx.runMutation(this.api.taxonomyMutations.removeTermFromMedia, {
2479
+ mediaId: args.mediaId,
2480
+ termId: args.termId,
2481
+ });
2482
+ }
2483
+
2484
+ /**
2485
+ * Create a term inline and add it to a media asset.
2486
+ *
2487
+ * @param ctx - Convex mutation context
2488
+ * @param args - Mutation arguments
2489
+ * @returns The created or existing term ID
2490
+ *
2491
+ * @example
2492
+ * ```typescript
2493
+ * const termId = await cms.mediaAssets.createAndAddTerm(ctx, {
2494
+ * taxonomyId: tagsTaxonomyId,
2495
+ * name: "Nature",
2496
+ * mediaId: imageId,
2497
+ * userId: currentUserId,
2498
+ * });
2499
+ * ```
2500
+ */
2501
+ async createAndAddTerm(
2502
+ ctx: ConvexContext,
2503
+ args: { taxonomyId: string; name: string; mediaId: string; userId?: string }
2504
+ ): Promise<string> {
2505
+ if (!this.config.features.mediaManagement) {
2506
+ throw new Error("Media management feature is not enabled");
2507
+ }
2508
+ await this.authorize(ctx, "mediaItems.update", args.userId, args.mediaId);
2509
+ return ctx.runMutation(this.api.taxonomyMutations.createTermAndAddToMedia, args);
2510
+ }
2511
+ }
2512
+
2513
+ // =============================================================================
2514
+ // Media Folders API Wrapper
2515
+ // =============================================================================
2516
+
2517
+ /**
2518
+ * Wrapper for media folder operations.
2519
+ */
2520
+ export class MediaFoldersApi {
2521
+ constructor(
2522
+ private readonly api: TypedComponentApi,
2523
+ private readonly config: ResolvedComponentConfig,
2524
+ private readonly authHelper?: AuthorizationHelper,
2525
+ private readonly rateLimitHelper?: RateLimitHelper
2526
+ ) {}
2527
+
2528
+ /**
2529
+ * Perform authorization check for media folder operations.
2530
+ * @param ctx - The Convex context (passed to authorization hooks for database access)
2531
+ * @param operation - The CMS operation being performed
2532
+ * @param userId - The user performing the operation
2533
+ * @param resourceId - Optional resource ID (for update/delete operations)
2534
+ * @param resourceOwnerId - Optional owner ID for ownership-based permissions
2535
+ */
2536
+ private async authorize(
2537
+ ctx: ConvexContext,
2538
+ operation: CmsOperation,
2539
+ userId: string | undefined,
2540
+ resourceId?: string,
2541
+ resourceOwnerId?: string
2542
+ ): Promise<void> {
2543
+ if (!this.authHelper) {
2544
+ if (this.config.permissiveMode) {
2545
+ console.warn(
2546
+ `[ConvexCMS] Authorization not configured for "${operation}". ` +
2547
+ "Operations are allowed in permissiveMode, but this should NOT be used in production."
2548
+ );
2549
+ return;
2550
+ }
2551
+ throw new AuthorizationNotConfiguredError(operation);
2552
+ }
2553
+
2554
+ if (this.authHelper.skipRbac) {
2555
+ return;
2556
+ }
2557
+
2558
+ if (!userId) {
2559
+ if (this.config.permissiveMode) {
2560
+ console.warn(
2561
+ `[ConvexCMS] Anonymous operation attempted for "${operation}".`
2562
+ );
2563
+ return;
2564
+ }
2565
+ throw new AuthorizationNotConfiguredError(
2566
+ `${operation} (no userId provided - anonymous operations require permissiveMode)`
2567
+ );
2568
+ }
2569
+
2570
+ const role = await this.authHelper.getUserRole(ctx, userId);
2571
+
2572
+ await this.authHelper.requireAuthorization(ctx, {
2573
+ operation,
2574
+ userId,
2575
+ role,
2576
+ resourceId,
2577
+ resourceOwnerId,
2578
+ });
2579
+ }
2580
+
2581
+ /**
2582
+ * Enforce rate limit for media folder operations.
2583
+ * @param ctx - The Convex context (for database access)
2584
+ * @param operation - The CMS operation being performed
2585
+ * @param userId - The user performing the operation
2586
+ */
2587
+ private async rateLimit(
2588
+ ctx: ConvexContext,
2589
+ operation: CmsOperation,
2590
+ userId: string | undefined
2591
+ ): Promise<void> {
2592
+ // Skip if no rate limit helper configured
2593
+ if (!this.rateLimitHelper) {
2594
+ return;
2595
+ }
2596
+
2597
+ const role = userId ? await this.rateLimitHelper.getUserRole(ctx, userId) : null;
2598
+
2599
+ await this.rateLimitHelper.requireRateLimit(operation, {
2600
+ userId,
2601
+ role,
2602
+ });
2603
+ }
2604
+
2605
+ /**
2606
+ * Create a new media folder.
2607
+ *
2608
+ * @param ctx - Convex mutation context
2609
+ * @param args - Folder creation arguments
2610
+ * @returns The created folder
2611
+ */
2612
+ async create(
2613
+ ctx: ConvexContext,
2614
+ args: CreateMediaFolderArgs
2615
+ ): Promise<MediaFolder> {
2616
+ if (!this.config.features.mediaManagement) {
2617
+ throw new Error("Media management feature is not enabled");
2618
+ }
2619
+ // Authorization check - mediaFolders.create
2620
+ await this.authorize(ctx, "mediaItems.create", args.createdBy);
2621
+ // Rate limit check - mediaFolders.create
2622
+ await this.rateLimit(ctx, "mediaItems.create", args.createdBy);
2623
+ // Cast safe: createMediaFolder always returns kind="folder"
2624
+ return ctx.runMutation(this.api.mediaFolderMutations.createMediaFolder, args) as Promise<MediaFolder>;
2625
+ }
2626
+
2627
+ /**
2628
+ * Update a media folder.
2629
+ *
2630
+ * @param ctx - Convex mutation context
2631
+ * @param args - Folder update arguments
2632
+ * @returns The updated folder
2633
+ */
2634
+ async update(
2635
+ ctx: ConvexContext,
2636
+ args: UpdateMediaFolderArgs
2637
+ ): Promise<MediaFolder> {
2638
+ if (!this.config.features.mediaManagement) {
2639
+ throw new Error("Media management feature is not enabled");
2640
+ }
2641
+
2642
+ // Fetch folder for ownership-based authorization
2643
+ const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
2644
+ if (!folder) {
2645
+ throw new Error(`Media folder not found: ${args.id}`);
2646
+ }
2647
+
2648
+ // Authorization check - mediaFolders.update (with ownership info)
2649
+ await this.authorize(ctx, "mediaItems.update", args.updatedBy, args.id, folder.createdBy);
2650
+ // Rate limit check - mediaFolders.update
2651
+ await this.rateLimit(ctx, "mediaItems.update", args.updatedBy);
2652
+ // Cast safe: updateMediaFolder always returns kind="folder"
2653
+ return ctx.runMutation(this.api.mediaFolderMutations.updateMediaFolder, args) as Promise<MediaFolder>;
2654
+ }
2655
+
2656
+ /**
2657
+ * Soft delete a media folder.
2658
+ *
2659
+ * @param ctx - Convex mutation context
2660
+ * @param args - Delete arguments
2661
+ * @returns The deleted folder
2662
+ */
2663
+ async delete(
2664
+ ctx: ConvexContext,
2665
+ args: DeleteMediaFolderArgs
2666
+ ): Promise<MediaFolder> {
2667
+ if (!this.config.features.mediaManagement) {
2668
+ throw new Error("Media management feature is not enabled");
2669
+ }
2670
+
2671
+ // Fetch folder for ownership-based authorization
2672
+ const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
2673
+ if (!folder) {
2674
+ throw new Error(`Media folder not found: ${args.id}`);
2675
+ }
2676
+
2677
+ // Authorization check - mediaFolders.delete (with ownership info)
2678
+ await this.authorize(ctx, "mediaItems.delete", args.deletedBy, args.id, folder.createdBy);
2679
+ // Rate limit check - mediaFolders.delete
2680
+ await this.rateLimit(ctx, "mediaItems.delete", args.deletedBy);
2681
+ // Cast safe: deleteMediaFolder always returns kind="folder"
2682
+ return ctx.runMutation(this.api.mediaFolderMutations.deleteMediaFolder, args) as Promise<MediaFolder>;
2683
+ }
2684
+
2685
+ /**
2686
+ * Get a media folder by ID.
2687
+ *
2688
+ * @param ctx - Convex query context
2689
+ * @param args - Get arguments
2690
+ * @returns The folder or null if not found
2691
+ */
2692
+ async get(
2693
+ ctx: ConvexContext,
2694
+ args: GetMediaFolderArgs
2695
+ ): Promise<MediaFolder | null> {
2696
+ if (!this.config.features.mediaManagement) {
2697
+ throw new Error("Media management feature is not enabled");
2698
+ }
2699
+ // Cast safe: getMediaFolder filters for kind="folder"
2700
+ return ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, args) as Promise<MediaFolder | null>;
2701
+ }
2702
+
2703
+ /**
2704
+ * List media folders.
2705
+ *
2706
+ * @param ctx - Convex query context
2707
+ * @param args - Optional filter arguments
2708
+ * @returns Array of folders
2709
+ */
2710
+ async list(
2711
+ ctx: ConvexContext,
2712
+ args: ListMediaFoldersArgs = {}
2713
+ ): Promise<MediaFolder[]> {
2714
+ if (!this.config.features.mediaManagement) {
2715
+ throw new Error("Media management feature is not enabled");
2716
+ }
2717
+ // Cast safe: listMediaFolders filters for kind="folder"
2718
+ return ctx.runQuery(this.api.mediaFolderMutations.listMediaFolders, args) as Promise<MediaFolder[]>;
2719
+ }
2720
+
2721
+ /**
2722
+ * Move a folder to a new parent.
2723
+ *
2724
+ * @param ctx - Convex mutation context
2725
+ * @param args - Move arguments
2726
+ * @returns The moved folder with updated path
2727
+ */
2728
+ async move(
2729
+ ctx: ConvexContext,
2730
+ args: MoveFolderArgs
2731
+ ): Promise<MediaFolder> {
2732
+ if (!this.config.features.mediaManagement) {
2733
+ throw new Error("Media management feature is not enabled");
2734
+ }
2735
+
2736
+ // Fetch folder for ownership-based authorization
2737
+ const folder = await ctx.runQuery(this.api.mediaFolderMutations.getMediaFolder, { id: args.id });
2738
+ if (!folder) {
2739
+ throw new Error(`Media folder not found: ${args.id}`);
2740
+ }
2741
+
2742
+ // Authorization check - mediaFolders.move (with ownership info)
2743
+ await this.authorize(ctx, "mediaItems.move", args.updatedBy, args.id, folder.createdBy);
2744
+ // Rate limit check - mediaFolders.move
2745
+ await this.rateLimit(ctx, "mediaItems.move", args.updatedBy);
2746
+ // Cast safe: moveMediaFolder always returns kind="folder"
2747
+ return ctx.runMutation(this.api.mediaFolderMutations.moveMediaFolder, args) as Promise<MediaFolder>;
2748
+ }
2749
+
2750
+ /**
2751
+ * Restore a soft-deleted media folder.
2752
+ *
2753
+ * @param ctx - Convex mutation context
2754
+ * @param args - Restore arguments
2755
+ * @returns The restored folder
2756
+ *
2757
+ * @example
2758
+ * ```typescript
2759
+ * // Restore a folder and all its contents
2760
+ * const restoredFolder = await cms.mediaFolders.restore(ctx, {
2761
+ * id: folderId,
2762
+ * recursive: true,
2763
+ * });
2764
+ * ```
2765
+ */
2766
+ async restore(
2767
+ ctx: ConvexContext,
2768
+ args: RestoreMediaFolderArgs
2769
+ ): Promise<MediaFolder> {
2770
+ if (!this.config.features.mediaManagement) {
2771
+ throw new Error("Media management feature is not enabled");
2772
+ }
2773
+ // Cast safe: restoreMediaFolder always returns kind="folder"
2774
+ return ctx.runMutation(
2775
+ this.api.mediaFolderMutations.restoreMediaFolder,
2776
+ args
2777
+ ) as Promise<MediaFolder>;
2778
+ }
2779
+
2780
+ /**
2781
+ * Get a folder by its path.
2782
+ *
2783
+ * @param ctx - Convex query context
2784
+ * @param args - Query arguments with path
2785
+ * @returns The folder or null if not found
2786
+ *
2787
+ * @example
2788
+ * ```typescript
2789
+ * // Find folder by path
2790
+ * const folder = await cms.mediaFolders.getByPath(ctx, {
2791
+ * path: "/Images/Blog/2026",
2792
+ * });
2793
+ * ```
2794
+ */
2795
+ async getByPath(
2796
+ ctx: ConvexContext,
2797
+ args: GetMediaFolderByPathArgs
2798
+ ): Promise<MediaFolder | null> {
2799
+ if (!this.config.features.mediaManagement) {
2800
+ throw new Error("Media management feature is not enabled");
2801
+ }
2802
+ // Cast safe: getMediaFolderByPath filters for kind="folder"
2803
+ return ctx.runQuery(
2804
+ this.api.mediaFolderMutations.getMediaFolderByPath,
2805
+ args
2806
+ ) as Promise<MediaFolder | null>;
2807
+ }
2808
+
2809
+ /**
2810
+ * Get the entire folder tree as a flat list sorted by path.
2811
+ *
2812
+ * Useful for building folder navigation or selectors.
2813
+ *
2814
+ * @param ctx - Convex query context
2815
+ * @param args - Optional filter arguments
2816
+ * @returns Array of all folders sorted hierarchically by path
2817
+ *
2818
+ * @example
2819
+ * ```typescript
2820
+ * // Get all folders for a tree view
2821
+ * const folders = await cms.mediaFolders.getTree(ctx, {});
2822
+ *
2823
+ * // Build a nested structure
2824
+ * const rootFolders = folders.filter(f => !f.parentId);
2825
+ * ```
2826
+ */
2827
+ async getTree(
2828
+ ctx: ConvexContext,
2829
+ args: GetFolderTreeArgs = {}
2830
+ ): Promise<MediaFolder[]> {
2831
+ if (!this.config.features.mediaManagement) {
2832
+ throw new Error("Media management feature is not enabled");
2833
+ }
2834
+ // Cast safe: getFolderTree filters for kind="folder"
2835
+ return ctx.runQuery(
2836
+ this.api.mediaFolderMutations.getFolderTree,
2837
+ args
2838
+ ) as Promise<MediaFolder[]>;
2839
+ }
2840
+ }
2841
+
2842
+ // =============================================================================
2843
+ // Media Variants API Wrapper
2844
+ // =============================================================================
2845
+
2846
+ /**
2847
+ * Wrapper for media variant operations.
2848
+ *
2849
+ * Media variants are optimized versions of media assets (thumbnails, responsive
2850
+ * sizes, format conversions). This API provides methods for creating, listing,
2851
+ * and managing variants.
2852
+ *
2853
+ * @example
2854
+ * ```typescript
2855
+ * // Get all variants for an asset
2856
+ * const variants = await cms.mediaVariants.list(ctx, {
2857
+ * assetId: assetId,
2858
+ * status: "completed",
2859
+ * });
2860
+ *
2861
+ * // Get responsive srcset for an image
2862
+ * const srcset = await cms.mediaVariants.getResponsiveSrcset(ctx, {
2863
+ * assetId: assetId,
2864
+ * format: "webp",
2865
+ * });
2866
+ * // Use: <img src={srcset.src} srcset={srcset.srcset} sizes="100vw" />
2867
+ * ```
2868
+ */
2869
+ export class MediaVariantsApi {
2870
+ constructor(
2871
+ private readonly api: TypedComponentApi,
2872
+ private readonly config: ResolvedComponentConfig
2873
+ ) {}
2874
+
2875
+ /**
2876
+ * Create a media variant after external processing.
2877
+ *
2878
+ * Use this when variant processing happens externally (e.g., in a serverless
2879
+ * function or image processing service) and you need to register the
2880
+ * completed variant.
2881
+ *
2882
+ * @param ctx - Convex mutation context
2883
+ * @param args - Variant creation arguments
2884
+ * @returns The created variant with URL
2885
+ *
2886
+ * @example
2887
+ * ```typescript
2888
+ * // After processing image externally and uploading result
2889
+ * const variant = await cms.mediaVariants.create(ctx, {
2890
+ * assetId: assetId,
2891
+ * storageId: processedStorageId,
2892
+ * variantType: "responsive",
2893
+ * width: 480,
2894
+ * height: 320,
2895
+ * format: "webp",
2896
+ * mimeType: "image/webp",
2897
+ * size: 25600,
2898
+ * quality: 80,
2899
+ * preset: "small",
2900
+ * });
2901
+ * ```
2902
+ */
2903
+ async create(
2904
+ ctx: ConvexContext,
2905
+ args: CreateMediaVariantArgs
2906
+ ): Promise<MediaVariantWithUrl> {
2907
+ if (!this.config.features.mediaManagement) {
2908
+ throw new Error("Media management feature is not enabled");
2909
+ }
2910
+ return ctx.runMutation(
2911
+ this.api.mediaVariantMutations.createMediaVariant,
2912
+ args
2913
+ );
2914
+ }
2915
+
2916
+ /**
2917
+ * Request async generation of a variant.
2918
+ *
2919
+ * Creates a variant record with "pending" status. An external processing
2920
+ * system should pick up pending variants, process them, and update the status.
2921
+ *
2922
+ * @param ctx - Convex mutation context
2923
+ * @param args - Generation request arguments
2924
+ * @returns The pending variant
2925
+ */
2926
+ async requestGeneration(
2927
+ ctx: ConvexContext,
2928
+ args: RequestVariantGenerationArgs
2929
+ ): Promise<MediaVariant> {
2930
+ if (!this.config.features.mediaManagement) {
2931
+ throw new Error("Media management feature is not enabled");
2932
+ }
2933
+ return ctx.runMutation(
2934
+ this.api.mediaVariantMutations.requestVariantGeneration,
2935
+ args
2936
+ );
2937
+ }
2938
+
2939
+ /**
2940
+ * Get a variant by ID.
2941
+ *
2942
+ * @param ctx - Convex query context
2943
+ * @param args - Query arguments
2944
+ * @returns The variant with URL or null
2945
+ */
2946
+ async get(
2947
+ ctx: ConvexContext,
2948
+ args: { id: string; includeDeleted?: boolean }
2949
+ ): Promise<MediaVariantWithUrl | null> {
2950
+ if (!this.config.features.mediaManagement) {
2951
+ throw new Error("Media management feature is not enabled");
2952
+ }
2953
+ return ctx.runQuery(
2954
+ this.api.mediaVariants.get,
2955
+ args
2956
+ );
2957
+ }
2958
+
2959
+ /**
2960
+ * List variants for an asset.
2961
+ *
2962
+ * @param ctx - Convex query context
2963
+ * @param args - Query arguments with filters
2964
+ * @returns Array of variants with URLs
2965
+ *
2966
+ * @example
2967
+ * ```typescript
2968
+ * // Get all completed responsive variants
2969
+ * const variants = await cms.mediaVariants.list(ctx, {
2970
+ * assetId: assetId,
2971
+ * variantType: "responsive",
2972
+ * status: "completed",
2973
+ * });
2974
+ * ```
2975
+ */
2976
+ async list(
2977
+ ctx: ConvexContext,
2978
+ args: ListMediaVariantsArgs
2979
+ ): Promise<MediaVariantWithUrl[]> {
2980
+ if (!this.config.features.mediaManagement) {
2981
+ throw new Error("Media management feature is not enabled");
2982
+ }
2983
+ return ctx.runQuery(
2984
+ this.api.mediaVariants.list,
2985
+ args
2986
+ );
2987
+ }
2988
+
2989
+ /**
2990
+ * Find the best matching variant for target dimensions.
2991
+ *
2992
+ * @param ctx - Convex query context
2993
+ * @param args - Target size and preferences
2994
+ * @returns Best matching variant or null
2995
+ *
2996
+ * @example
2997
+ * ```typescript
2998
+ * // Get best variant for 400px wide container
2999
+ * const variant = await cms.mediaVariants.getBestVariant(ctx, {
3000
+ * assetId: assetId,
3001
+ * targetWidth: 400,
3002
+ * preferredFormat: "webp",
3003
+ * });
3004
+ * ```
3005
+ */
3006
+ async getBestVariant(
3007
+ ctx: ConvexContext,
3008
+ args: GetBestVariantArgs
3009
+ ): Promise<(MediaVariantWithUrl & { isOriginal: boolean }) | null> {
3010
+ if (!this.config.features.mediaManagement) {
3011
+ throw new Error("Media management feature is not enabled");
3012
+ }
3013
+ return ctx.runQuery(
3014
+ this.api.mediaVariants.getBestVariant,
3015
+ args
3016
+ );
3017
+ }
3018
+
3019
+ /**
3020
+ * Get responsive srcset data for HTML img/picture tags.
3021
+ *
3022
+ * @param ctx - Convex query context
3023
+ * @param args - Asset ID and optional format filter
3024
+ * @returns Srcset data for responsive images
3025
+ *
3026
+ * @example
3027
+ * ```typescript
3028
+ * const srcset = await cms.mediaVariants.getResponsiveSrcset(ctx, {
3029
+ * assetId: assetId,
3030
+ * format: "webp",
3031
+ * });
3032
+ *
3033
+ * // In React:
3034
+ * <img src={srcset.src} srcset={srcset.srcset} sizes="100vw" />
3035
+ * ```
3036
+ */
3037
+ async getResponsiveSrcset(
3038
+ ctx: ConvexContext,
3039
+ args: { assetId: string; format?: string }
3040
+ ): Promise<ResponsiveSrcsetResult> {
3041
+ if (!this.config.features.mediaManagement) {
3042
+ throw new Error("Media management feature is not enabled");
3043
+ }
3044
+ return ctx.runQuery(
3045
+ this.api.mediaVariants.getResponsiveSrcset,
3046
+ args
3047
+ );
3048
+ }
3049
+
3050
+ /**
3051
+ * Get an asset with all its variants organized by type.
3052
+ *
3053
+ * @param ctx - Convex query context
3054
+ * @param args - Asset ID
3055
+ * @returns Asset with variants or null
3056
+ */
3057
+ async getAssetWithVariants(
3058
+ ctx: ConvexContext,
3059
+ args: { assetId: string }
3060
+ ): Promise<AssetWithVariants | null> {
3061
+ if (!this.config.features.mediaManagement) {
3062
+ throw new Error("Media management feature is not enabled");
3063
+ }
3064
+ return ctx.runQuery(
3065
+ this.api.mediaVariants.getAssetWithVariants,
3066
+ args
3067
+ );
3068
+ }
3069
+
3070
+ /**
3071
+ * Get available variant presets.
3072
+ *
3073
+ * @param ctx - Convex query context
3074
+ * @returns Array of preset configurations
3075
+ */
3076
+ async getPresets(ctx: ConvexContext): Promise<VariantPreset[]> {
3077
+ if (!this.config.features.mediaManagement) {
3078
+ throw new Error("Media management feature is not enabled");
3079
+ }
3080
+ return ctx.runQuery(
3081
+ this.api.mediaVariants.getPresets,
3082
+ {}
3083
+ );
3084
+ }
3085
+
3086
+ /**
3087
+ * Generate variants from preset configurations.
3088
+ *
3089
+ * Queues multiple variants for async processing.
3090
+ *
3091
+ * @param ctx - Convex mutation context
3092
+ * @param args - Asset ID and preset names
3093
+ * @returns Summary of created variant requests
3094
+ *
3095
+ * @example
3096
+ * ```typescript
3097
+ * // Generate standard responsive set
3098
+ * const result = await cms.mediaVariants.generateFromPresets(ctx, {
3099
+ * assetId: assetId,
3100
+ * presets: ["thumbnail", "small", "medium", "large"],
3101
+ * });
3102
+ * console.log(`Queued ${result.succeeded} variants`);
3103
+ * ```
3104
+ */
3105
+ async generateFromPresets(
3106
+ ctx: ConvexContext,
3107
+ args: GenerateFromPresetsArgs
3108
+ ): Promise<GenerateVariantsResult> {
3109
+ if (!this.config.features.mediaManagement) {
3110
+ throw new Error("Media management feature is not enabled");
3111
+ }
3112
+ return ctx.runMutation(
3113
+ this.api.mediaVariantMutations.generateFromPresets,
3114
+ args
3115
+ );
3116
+ }
3117
+
3118
+ /**
3119
+ * Delete a variant.
3120
+ *
3121
+ * @param ctx - Convex mutation context
3122
+ * @param args - Delete arguments
3123
+ * @returns The deleted variant
3124
+ */
3125
+ async delete(
3126
+ ctx: ConvexContext,
3127
+ args: DeleteMediaVariantArgs
3128
+ ): Promise<MediaVariant> {
3129
+ if (!this.config.features.mediaManagement) {
3130
+ throw new Error("Media management feature is not enabled");
3131
+ }
3132
+ return ctx.runMutation(
3133
+ this.api.mediaVariantMutations.deleteMediaVariant,
3134
+ args
3135
+ );
3136
+ }
3137
+
3138
+ /**
3139
+ * Delete all variants for an asset.
3140
+ *
3141
+ * @param ctx - Convex mutation context
3142
+ * @param args - Asset ID and delete options
3143
+ * @returns Summary of deleted variants
3144
+ */
3145
+ async deleteAllForAsset(
3146
+ ctx: ConvexContext,
3147
+ args: DeleteAssetVariantsArgs
3148
+ ): Promise<{ deleted: number; assetId: string }> {
3149
+ if (!this.config.features.mediaManagement) {
3150
+ throw new Error("Media management feature is not enabled");
3151
+ }
3152
+ return ctx.runMutation(
3153
+ this.api.mediaVariantMutations.deleteAssetVariants,
3154
+ args
3155
+ );
3156
+ }
3157
+
3158
+ /**
3159
+ * Restore a soft-deleted variant.
3160
+ *
3161
+ * @param ctx - Convex mutation context
3162
+ * @param args - Variant ID to restore
3163
+ * @returns The restored variant
3164
+ */
3165
+ async restore(
3166
+ ctx: ConvexContext,
3167
+ args: { id: string; restoredBy?: string }
3168
+ ): Promise<MediaVariant> {
3169
+ if (!this.config.features.mediaManagement) {
3170
+ throw new Error("Media management feature is not enabled");
3171
+ }
3172
+ return ctx.runMutation(
3173
+ this.api.mediaVariantMutations.restoreMediaVariant,
3174
+ args
3175
+ );
3176
+ }
3177
+ }
3178
+
3179
+ // =============================================================================
3180
+ // Enhanced CMS Client
3181
+ // =============================================================================
3182
+
3183
+ /**
3184
+ * Enhanced CMS client with typed method wrappers for all component operations.
3185
+ *
3186
+ * This client provides an ergonomic, type-safe API for interacting with the
3187
+ * Convex CMS component. All methods accept a Convex context and return
3188
+ * properly typed results.
3189
+ *
3190
+ * @example
3191
+ * ```typescript
3192
+ * import { createCmsClient } from "@convex-cms/core";
3193
+ * import { components } from "./_generated/api";
3194
+ *
3195
+ * export const cms = createCmsClient(components.convexCms, {
3196
+ * defaultLocale: "en-US",
3197
+ * features: {
3198
+ * versioning: true,
3199
+ * localization: true,
3200
+ * },
3201
+ * });
3202
+ *
3203
+ * // In a mutation:
3204
+ * export const createBlogPost = mutation({
3205
+ * args: { title: v.string(), content: v.string() },
3206
+ * handler: async (ctx, args) => {
3207
+ * return await cms.contentEntries.create(ctx, {
3208
+ * contentTypeId: "blog_post_type_id",
3209
+ * data: { title: args.title, content: args.content },
3210
+ * });
3211
+ * },
3212
+ * });
3213
+ * ```
3214
+ */
3215
+ /**
3216
+ * Options for permission checks.
3217
+ */
3218
+ export interface PermissionCheckOptions {
3219
+ /**
3220
+ * Custom role definitions to check in addition to built-in roles.
3221
+ * Use this when you have defined custom roles beyond the defaults.
3222
+ */
3223
+ customRoles?: Record<string, RoleDefinition>;
3224
+ }
3225
+
3226
+ /**
3227
+ * Result from checking user permissions.
3228
+ */
3229
+ export interface UserPermissionResult {
3230
+ /**
3231
+ * Whether the user has the requested permission.
3232
+ */
3233
+ allowed: boolean;
3234
+
3235
+ /**
3236
+ * The role that was resolved for the user.
3237
+ * Null if the getUserRole hook returned null.
3238
+ */
3239
+ role: string | null;
3240
+
3241
+ /**
3242
+ * The permission that was checked.
3243
+ */
3244
+ permission: {
3245
+ resource: Resource;
3246
+ action: Action;
3247
+ scope?: OwnershipScope;
3248
+ };
3249
+ }
3250
+
3251
+ export interface CmsClient {
3252
+ /**
3253
+ * The resolved configuration for this client instance.
3254
+ */
3255
+ readonly config: ResolvedComponentConfig;
3256
+
3257
+ /**
3258
+ * The underlying component API reference.
3259
+ */
3260
+ readonly api: TypedComponentApi;
3261
+
3262
+ /**
3263
+ * Content type management operations.
3264
+ */
3265
+ readonly contentTypes: ContentTypesApi;
3266
+
3267
+ /**
3268
+ * Content entry CRUD and workflow operations.
3269
+ */
3270
+ readonly contentEntries: ContentEntriesApi;
3271
+
3272
+ /**
3273
+ * Content version history operations.
3274
+ */
3275
+ readonly versions: VersionsApi;
3276
+
3277
+ /**
3278
+ * Media asset management operations.
3279
+ */
3280
+ readonly mediaAssets: MediaAssetsApi;
3281
+
3282
+ /**
3283
+ * Media folder organization operations.
3284
+ */
3285
+ readonly mediaFolders: MediaFoldersApi;
3286
+
3287
+ /**
3288
+ * Media variant operations (thumbnails, responsive sizes, format conversions).
3289
+ */
3290
+ readonly mediaVariants: MediaVariantsApi;
3291
+
3292
+ /**
3293
+ * Check if a specific feature is enabled.
3294
+ * @param feature - The feature flag to check
3295
+ * @returns true if the feature is enabled
3296
+ */
3297
+ isFeatureEnabled(feature: keyof FeatureFlags): boolean;
3298
+
3299
+ /**
3300
+ * Check if a locale is supported by this configuration.
3301
+ * @param locale - The locale code to check
3302
+ * @returns true if the locale is in the supported locales list
3303
+ */
3304
+ isLocaleSupported(locale: LocaleCode): boolean;
3305
+
3306
+ /**
3307
+ * Get the CMS role for a user.
3308
+ *
3309
+ * Uses the getUserRole hook configured in ComponentConfig to map
3310
+ * user IDs from your auth system to CMS roles.
3311
+ *
3312
+ * @param ctx - Convex context (passed to getUserRole hook for database access)
3313
+ * @param userId - The user ID to look up
3314
+ * @returns The role name or null if the user has no CMS role
3315
+ * @throws Error if no getUserRole hook is configured
3316
+ *
3317
+ * @example
3318
+ * ```typescript
3319
+ * const role = await cms.getUserRole(ctx, "user_123");
3320
+ * if (role === "admin") {
3321
+ * // Allow admin-only operations
3322
+ * }
3323
+ * ```
3324
+ */
3325
+ getUserRole(ctx: ConvexContext, userId: string): Promise<GetUserRoleResult>;
3326
+
3327
+ /**
3328
+ * Check if a user has a specific permission.
3329
+ *
3330
+ * This is a convenience method that combines getUserRole + hasPermission
3331
+ * into a single call. It first resolves the user's role using the
3332
+ * configured getUserRole hook, then checks if that role has the
3333
+ * requested permission.
3334
+ *
3335
+ * @param ctx - Convex context (passed to getUserRole hook for database access)
3336
+ * @param userId - The user ID to check
3337
+ * @param permission - The permission to check (resource + action + optional scope)
3338
+ * @param options - Optional configuration like custom roles
3339
+ * @returns UserPermissionResult with allowed status and resolved role
3340
+ * @throws Error if no getUserRole hook is configured
3341
+ *
3342
+ * @example
3343
+ * ```typescript
3344
+ * // Check if user can create content entries
3345
+ * const result = await cms.hasPermissionForUser(ctx, "user_123", {
3346
+ * resource: "contentEntries",
3347
+ * action: "create",
3348
+ * });
3349
+ *
3350
+ * if (!result.allowed) {
3351
+ * throw new Error(`User with role ${result.role} cannot create content entries`);
3352
+ * }
3353
+ * ```
3354
+ *
3355
+ * @example
3356
+ * ```typescript
3357
+ * // Check with ownership scope
3358
+ * const canUpdateOwn = await cms.hasPermissionForUser(ctx, "user_123", {
3359
+ * resource: "contentEntries",
3360
+ * action: "update",
3361
+ * scope: "own",
3362
+ * });
3363
+ * ```
3364
+ */
3365
+ hasPermissionForUser(
3366
+ ctx: ConvexContext,
3367
+ userId: string,
3368
+ permission: { resource: Resource; action: Action; scope?: OwnershipScope },
3369
+ options?: PermissionCheckOptions
3370
+ ): Promise<UserPermissionResult>;
3371
+
3372
+ /**
3373
+ * Check if the getUserRole hook is configured.
3374
+ *
3375
+ * Use this to conditionally enable RBAC features in your application.
3376
+ *
3377
+ * @returns true if a getUserRole hook is configured
3378
+ *
3379
+ * @example
3380
+ * ```typescript
3381
+ * if (cms.hasUserRoleHook()) {
3382
+ * const allowed = await cms.hasPermissionForUser(userId, permission);
3383
+ * if (!allowed.allowed) throw new Error("Unauthorized");
3384
+ * }
3385
+ * ```
3386
+ */
3387
+ hasUserRoleHook(): boolean;
3388
+
3389
+ /**
3390
+ * Check if authorization hooks are configured.
3391
+ *
3392
+ * @returns true if any authorization hooks are configured
3393
+ */
3394
+ hasAuthorizationHooks(): boolean;
3395
+
3396
+ /**
3397
+ * Execute authorization for a CMS operation.
3398
+ *
3399
+ * This method runs the full authorization chain including:
3400
+ * 1. beforeRbac hook (if configured)
3401
+ * 2. Built-in RBAC checks (unless skipRbac is true)
3402
+ * 3. afterRbac hook (if configured)
3403
+ * 4. Operation-specific hooks (if configured)
3404
+ * 5. onDeny hook for denied operations (if configured)
3405
+ *
3406
+ * Use this method to check authorization before performing operations,
3407
+ * especially when you need custom authorization logic beyond RBAC.
3408
+ *
3409
+ * @param context - The authorization context (operation, user, resource info)
3410
+ * @returns AuthorizationResult with allowed status and any modified data
3411
+ *
3412
+ * @example
3413
+ * ```typescript
3414
+ * // Check authorization before publishing
3415
+ * const authResult = await cms.authorize({
3416
+ * operation: "contentEntries.publish",
3417
+ * userId: currentUser,
3418
+ * role: await cms.getUserRole(currentUser),
3419
+ * resourceId: entryId,
3420
+ * resourceOwnerId: entry.createdBy,
3421
+ * contentTypeId: entry.contentTypeId,
3422
+ * operationData: { id: entryId },
3423
+ * });
3424
+ *
3425
+ * if (!authResult.allowed) {
3426
+ * throw new Error(authResult.reason ?? "Not authorized to publish");
3427
+ * }
3428
+ *
3429
+ * // Proceed with operation
3430
+ * await cms.contentEntries.publish(ctx, { id: entryId });
3431
+ * ```
3432
+ *
3433
+ * @example
3434
+ * ```typescript
3435
+ * // With modified data from hooks
3436
+ * const authResult = await cms.authorize({
3437
+ * operation: "contentEntries.create",
3438
+ * userId: currentUser,
3439
+ * role: userRole,
3440
+ * operationData: entryData,
3441
+ * });
3442
+ *
3443
+ * if (authResult.allowed && authResult.modifiedData) {
3444
+ * // Use the modified data from hooks
3445
+ * await cms.contentEntries.create(ctx, authResult.modifiedData);
3446
+ * }
3447
+ * ```
3448
+ */
3449
+ authorize(context: AuthorizationHookContext): Promise<AuthorizationResult>;
3450
+
3451
+ /**
3452
+ * Execute authorization and throw if denied.
3453
+ *
3454
+ * Convenience method that calls `authorize()` and throws an UnauthorizedError
3455
+ * if the operation is not allowed.
3456
+ *
3457
+ * @param context - The authorization context
3458
+ * @throws UnauthorizedError if the operation is denied
3459
+ * @returns The authorization result (if allowed)
3460
+ *
3461
+ * @example
3462
+ * ```typescript
3463
+ * // Will throw if not authorized
3464
+ * await cms.requireAuthorization({
3465
+ * operation: "contentEntries.delete",
3466
+ * userId: currentUser,
3467
+ * role: userRole,
3468
+ * resourceId: entryId,
3469
+ * resourceOwnerId: entry.createdBy,
3470
+ * });
3471
+ *
3472
+ * // Only reached if authorized
3473
+ * await cms.contentEntries.delete(ctx, { id: entryId });
3474
+ * ```
3475
+ */
3476
+ requireAuthorization(context: AuthorizationHookContext): Promise<AuthorizationResult>;
3477
+
3478
+ // =============================================================================
3479
+ // Consolidated Locale API
3480
+ // =============================================================================
3481
+
3482
+ /**
3483
+ * Consolidated locale API with simplified methods.
3484
+ *
3485
+ * @example
3486
+ * ```typescript
3487
+ * // Get locale configuration
3488
+ * const config = cms.locale.getConfig();
3489
+ *
3490
+ * // Get fallback chain for a locale
3491
+ * const chain = cms.locale.getFallbackChain("es-MX");
3492
+ *
3493
+ * // Resolve locale with full metadata
3494
+ * const resolved = cms.locale.resolve("es-MX");
3495
+ * ```
3496
+ */
3497
+ readonly locale: {
3498
+ /**
3499
+ * Get the full locale configuration.
3500
+ */
3501
+ getConfig(): LocaleFallbackConfig;
3502
+
3503
+ /**
3504
+ * Get the fallback chain for a locale.
3505
+ */
3506
+ getFallbackChain(locale: LocaleCode): LocaleCode[];
3507
+
3508
+ /**
3509
+ * Resolve a locale with full metadata.
3510
+ */
3511
+ resolve(locale: LocaleCode): ResolvedFallbackChain;
3512
+ };
3513
+
3514
+ // =============================================================================
3515
+ // Locale Fallback Chain Methods (Legacy)
3516
+ // =============================================================================
3517
+
3518
+ // =============================================================================
3519
+ // Custom Roles Methods
3520
+ // =============================================================================
3521
+
3522
+ /**
3523
+ * Get all configured custom roles.
3524
+ *
3525
+ * Returns a record of custom role definitions that were configured when
3526
+ * creating the CMS client. Does not include built-in roles.
3527
+ *
3528
+ * @returns Record of custom role name to definition
3529
+ *
3530
+ * @example
3531
+ * ```typescript
3532
+ * const customRoles = cms.getCustomRoles();
3533
+ * for (const [name, role] of Object.entries(customRoles)) {
3534
+ * console.log(`${name}: ${role.displayName}`);
3535
+ * }
3536
+ * ```
3537
+ */
3538
+ getCustomRoles(): Record<string, import("./types.js").CustomRoleDefinition>;
3539
+
3540
+ /**
3541
+ * Get a specific custom role by name.
3542
+ *
3543
+ * @param roleName - The name of the custom role to get
3544
+ * @returns The custom role definition, or undefined if not found
3545
+ *
3546
+ * @example
3547
+ * ```typescript
3548
+ * const blogAuthor = cms.getCustomRole("blog-author");
3549
+ * if (blogAuthor) {
3550
+ * console.log(blogAuthor.displayName); // "Blog Author"
3551
+ * }
3552
+ * ```
3553
+ */
3554
+ getCustomRole(roleName: string): import("./types.js").CustomRoleDefinition | undefined;
3555
+
3556
+ /**
3557
+ * Check if a custom role exists.
3558
+ *
3559
+ * @param roleName - The name of the role to check
3560
+ * @returns True if the role is a custom role (not built-in)
3561
+ *
3562
+ * @example
3563
+ * ```typescript
3564
+ * cms.isCustomRole("blog-author"); // true (if configured)
3565
+ * cms.isCustomRole("admin"); // false (built-in)
3566
+ * cms.isCustomRole("unknown"); // false
3567
+ * ```
3568
+ */
3569
+ isCustomRole(roleName: string): boolean;
3570
+
3571
+ /**
3572
+ * Check if a user can perform an action on a specific content type.
3573
+ *
3574
+ * This is similar to `hasPermissionForUser` but additionally checks
3575
+ * content-type-specific permission restrictions that may be configured
3576
+ * on custom roles.
3577
+ *
3578
+ * @param userId - The user ID to check
3579
+ * @param permission - The permission to check
3580
+ * @param contentTypeName - The content type to check permissions for
3581
+ * @returns UserPermissionResult with allowed status
3582
+ *
3583
+ * @example
3584
+ * ```typescript
3585
+ * // Check if user can create blog posts (may be restricted by custom role)
3586
+ * const result = await cms.hasContentTypePermissionForUser(
3587
+ * ctx,
3588
+ * "user_123",
3589
+ * { resource: "contentEntries", action: "create" },
3590
+ * "blog_post"
3591
+ * );
3592
+ *
3593
+ * if (result.allowed) {
3594
+ * // User can create blog posts
3595
+ * }
3596
+ * ```
3597
+ */
3598
+ hasContentTypePermissionForUser(
3599
+ ctx: ConvexContext,
3600
+ userId: string,
3601
+ permission: { resource: Resource; action: Action; scope?: OwnershipScope },
3602
+ contentTypeName: string
3603
+ ): Promise<UserPermissionResult>;
3604
+
3605
+ /**
3606
+ * Get all content types a user can perform an action on.
3607
+ *
3608
+ * Returns an array of content type names that the user has permission to
3609
+ * perform the specified action on, based on their role's permissions.
3610
+ *
3611
+ * @param userId - The user ID to check
3612
+ * @param action - The action to check (e.g., "create", "update", "publish")
3613
+ * @returns Array of content type names, ["*"] if unrestricted, or [] if no permission
3614
+ *
3615
+ * @example
3616
+ * ```typescript
3617
+ * // Get content types the user can create
3618
+ * const types = await cms.getPermittedContentTypesForUser(ctx, "user_123", "create");
3619
+ *
3620
+ * if (types.includes("*")) {
3621
+ * // User can create any content type
3622
+ * } else if (types.includes("blog_post")) {
3623
+ * // User can create blog posts
3624
+ * }
3625
+ * ```
3626
+ */
3627
+ getPermittedContentTypesForUser(
3628
+ ctx: ConvexContext,
3629
+ userId: string,
3630
+ action: Action
3631
+ ): Promise<string[]>;
3632
+
3633
+ /**
3634
+ * Get all roles (built-in and custom) merged together.
3635
+ *
3636
+ * Returns a record containing both the default built-in roles and
3637
+ * any custom roles configured on this client. Useful for UI rendering
3638
+ * or iterating over all available roles.
3639
+ *
3640
+ * @returns Record of all role names to definitions
3641
+ *
3642
+ * @example
3643
+ * ```typescript
3644
+ * const allRoles = cms.getAllRoles();
3645
+ * // Includes: admin, editor, author, viewer, blog-author, etc.
3646
+ *
3647
+ * // Render role selector
3648
+ * Object.entries(allRoles).map(([name, role]) => (
3649
+ * <option key={name} value={name}>{role.displayName}</option>
3650
+ * ));
3651
+ * ```
3652
+ */
3653
+ getAllRoles(): Record<string, RoleDefinition | import("./types.js").CustomRoleDefinition>;
3654
+
3655
+ // =============================================================================
3656
+ // Resource Ownership Methods
3657
+ // =============================================================================
3658
+
3659
+ /**
3660
+ * Check if a user can perform an action on a specific resource, with ownership verification.
3661
+ *
3662
+ * This is the most comprehensive permission check method. It:
3663
+ * 1. Resolves the user's role via the getUserRole hook
3664
+ * 2. Checks if the role has the required permission
3665
+ * 3. For "own" scope permissions, verifies that the user owns the resource
3666
+ *
3667
+ * Use this when you need to check authorization for a specific resource that may
3668
+ * have ownership-based restrictions (e.g., "can this author update THIS entry?").
3669
+ *
3670
+ * @param userId - The user ID performing the action
3671
+ * @param resource - The resource type (e.g., "contentEntries", "mediaAssets")
3672
+ * @param action - The action being performed (e.g., "update", "delete", "publish")
3673
+ * @param resourceOwnerId - The ID of the user who created/owns the resource
3674
+ * @returns Permission result with ownership verification details
3675
+ *
3676
+ * @example
3677
+ * ```typescript
3678
+ * // Check if an author can update a specific content entry
3679
+ * const entry = await ctx.db.get(entryId);
3680
+ * const result = await cms.canUserPerformOnResource(
3681
+ * ctx,
3682
+ * currentUserId,
3683
+ * "contentEntries",
3684
+ * "update",
3685
+ * entry.createdBy // The owner's user ID
3686
+ * );
3687
+ *
3688
+ * if (!result.allowed) {
3689
+ * if (result.ownershipRequired) {
3690
+ * throw new Error("You can only update your own entries");
3691
+ * }
3692
+ * throw new Error(`Role '${result.role}' cannot update content entries`);
3693
+ * }
3694
+ *
3695
+ * // Proceed with update...
3696
+ * ```
3697
+ *
3698
+ * @example
3699
+ * ```typescript
3700
+ * // Check if user can delete a media asset they uploaded
3701
+ * const asset = await ctx.db.get(assetId);
3702
+ * const result = await cms.canUserPerformOnResource(
3703
+ * ctx,
3704
+ * userId,
3705
+ * "mediaAssets",
3706
+ * "delete",
3707
+ * asset.createdBy
3708
+ * );
3709
+ *
3710
+ * if (result.allowed && result.grantedScope === "own") {
3711
+ * console.log("User can delete only because they own this asset");
3712
+ * }
3713
+ * ```
3714
+ */
3715
+ canUserPerformOnResource(
3716
+ ctx: ConvexContext,
3717
+ userId: string,
3718
+ resource: Resource,
3719
+ action: Action,
3720
+ resourceOwnerId?: string
3721
+ ): Promise<ResourcePermissionResult>;
3722
+
3723
+ /**
3724
+ * Require that a user can perform an action on a specific resource.
3725
+ *
3726
+ * This is the throwing version of `canUserPerformOnResource`. If the permission
3727
+ * check fails, it throws an UnauthorizedError with detailed context.
3728
+ *
3729
+ * Use this at the start of mutation handlers to enforce ownership-based access control.
3730
+ *
3731
+ * @param userId - The user ID performing the action
3732
+ * @param resource - The resource type
3733
+ * @param action - The action being performed
3734
+ * @param resourceOwnerId - The ID of the resource owner
3735
+ * @throws UnauthorizedError if permission is denied or ownership verification fails
3736
+ * @returns Permission granted details
3737
+ *
3738
+ * @example
3739
+ * ```typescript
3740
+ * // In a mutation handler - will throw if not authorized
3741
+ * export const deleteEntry = mutation({
3742
+ * args: { id: v.id("contentEntries"), userId: v.string() },
3743
+ * handler: async (ctx, args) => {
3744
+ * const entry = await ctx.db.get(args.id);
3745
+ * if (!entry) throw new Error("Entry not found");
3746
+ *
3747
+ * // Throws UnauthorizedError if user can't delete this entry
3748
+ * await cms.requireUserCanPerformOnResource(
3749
+ * ctx,
3750
+ * args.userId,
3751
+ * "contentEntries",
3752
+ * "delete",
3753
+ * entry.createdBy
3754
+ * );
3755
+ *
3756
+ * // Safe to proceed - user is authorized
3757
+ * await ctx.db.delete(args.id);
3758
+ * },
3759
+ * });
3760
+ * ```
3761
+ */
3762
+ requireUserCanPerformOnResource(
3763
+ ctx: ConvexContext,
3764
+ userId: string,
3765
+ resource: Resource,
3766
+ action: Action,
3767
+ resourceOwnerId?: string
3768
+ ): Promise<ResourcePermissionGranted>;
3769
+
3770
+ /**
3771
+ * Check if a user owns a specific resource.
3772
+ *
3773
+ * Simple helper that compares user ID with resource owner ID.
3774
+ * Does not check permissions - just ownership.
3775
+ *
3776
+ * @param userId - The user ID to check
3777
+ * @param resourceOwnerId - The ID of the resource owner
3778
+ * @returns true if the user owns the resource
3779
+ *
3780
+ * @example
3781
+ * ```typescript
3782
+ * const entry = await ctx.db.get(entryId);
3783
+ * if (cms.isOwner(currentUserId, entry.createdBy)) {
3784
+ * // User owns this entry
3785
+ * }
3786
+ * ```
3787
+ */
3788
+ isOwner(userId: string | undefined, resourceOwnerId: string | undefined): boolean;
3789
+ }
3790
+
3791
+ /**
3792
+ * Result from checking resource permission with ownership verification.
3793
+ */
3794
+ export interface ResourcePermissionResult {
3795
+ /**
3796
+ * Whether the user is allowed to perform the action.
3797
+ */
3798
+ allowed: boolean;
3799
+
3800
+ /**
3801
+ * The user's role (null if no role assigned).
3802
+ */
3803
+ role: string | null;
3804
+
3805
+ /**
3806
+ * The scope that was granted (if allowed).
3807
+ * "all" means the user can access any resource.
3808
+ * "own" means the user can only access resources they created.
3809
+ */
3810
+ grantedScope?: OwnershipScope;
3811
+
3812
+ /**
3813
+ * Whether ownership was verified (true if resourceOwnerId was provided and matched userId).
3814
+ */
3815
+ ownershipVerified?: boolean;
3816
+
3817
+ /**
3818
+ * If denied, indicates whether the denial was due to ownership requirements.
3819
+ * true when the user has "own" scope but doesn't own the resource.
3820
+ */
3821
+ ownershipRequired?: boolean;
3822
+
3823
+ /**
3824
+ * The reason for denial (if not allowed).
3825
+ */
3826
+ reason?: string;
3827
+
3828
+ /**
3829
+ * Error code for programmatic handling (if not allowed).
3830
+ */
3831
+ code?: string;
3832
+ }
3833
+
3834
+ /**
3835
+ * Result from a successful resource permission check.
3836
+ */
3837
+ export interface ResourcePermissionGranted {
3838
+ /**
3839
+ * Always true for granted permissions.
3840
+ */
3841
+ allowed: true;
3842
+
3843
+ /**
3844
+ * The user's role.
3845
+ */
3846
+ role: string;
3847
+
3848
+ /**
3849
+ * The scope that was granted.
3850
+ */
3851
+ grantedScope: OwnershipScope;
3852
+
3853
+ /**
3854
+ * Whether ownership was verified.
3855
+ */
3856
+ ownershipVerified: boolean;
3857
+ }
3858
+
3859
+ /**
3860
+ * Creates an enhanced CMS client with typed method wrappers.
3861
+ *
3862
+ * This is the main entry point for using the Convex CMS component.
3863
+ * The returned client provides typed methods for all CMS operations.
3864
+ *
3865
+ * @param componentApi - The component API from `components.convexCms`
3866
+ * @param config - Optional configuration options
3867
+ * @returns An enhanced CMS client instance
3868
+ *
3869
+ * @example
3870
+ * ```typescript
3871
+ * import { createCmsClient } from "@convex-cms/core";
3872
+ * import { components } from "./_generated/api";
3873
+ *
3874
+ * // Create with default configuration
3875
+ * export const cms = createCmsClient(components.convexCms);
3876
+ *
3877
+ * // Create with custom configuration
3878
+ * export const cms = createCmsClient(components.convexCms, {
3879
+ * defaultLocale: "en-US",
3880
+ * supportedLocales: ["en-US", "es-ES", "fr-FR"],
3881
+ * features: {
3882
+ * versioning: true,
3883
+ * localization: true,
3884
+ * scheduling: true,
3885
+ * },
3886
+ * maxVersionsPerEntry: 100,
3887
+ * });
3888
+ * ```
3889
+ */
3890
+ export function createCmsClient(
3891
+ componentApi: TypedComponentApi,
3892
+ config?: ComponentConfig
3893
+ ): CmsClient {
3894
+ const resolvedConfig = resolveConfig(config);
3895
+ // Store the getUserRole hook from the original config (not resolved)
3896
+ const getUserRoleHook = config?.getUserRole;
3897
+ // Store authorization hooks from config
3898
+ const authHooks = config?.authorizationHooks;
3899
+ // Store rate limit hooks from config
3900
+ const rateLimitHooks = config?.rateLimitHooks;
3901
+
3902
+ // Create rate limit helper for API classes (only if rateLimitHooks are configured)
3903
+ const rateLimitHelper: RateLimitHelper | undefined = rateLimitHooks
3904
+ ? {
3905
+ async getUserRole(ctx: ConvexContext, userId: string): Promise<string | null> {
3906
+ if (!getUserRoleHook) return null;
3907
+ return getUserRoleHook(ctx, { userId });
3908
+ },
3909
+ async requireRateLimit(
3910
+ operation: CmsOperation,
3911
+ options: {
3912
+ userId?: string;
3913
+ role?: string | null;
3914
+ contentTypeId?: string;
3915
+ contentTypeName?: string;
3916
+ metadata?: Record<string, unknown>;
3917
+ }
3918
+ ): Promise<RateLimitResult> {
3919
+ const context = createRateLimitContext(operation, options);
3920
+ return requireRateLimit({
3921
+ hooks: rateLimitHooks,
3922
+ context,
3923
+ });
3924
+ },
3925
+ }
3926
+ : undefined;
3927
+
3928
+ // Create authorization helper for API classes (only if getUserRole is configured)
3929
+ const authHelper: AuthorizationHelper | undefined = getUserRoleHook
3930
+ ? {
3931
+ async getUserRole(ctx: ConvexContext, userId: string): Promise<string | null> {
3932
+ return getUserRoleHook(ctx, { userId });
3933
+ },
3934
+ async requireAuthorization(ctx: ConvexContext, context: Omit<AuthorizationHookContext, 'ctx'>): Promise<AuthorizationResult> {
3935
+ const fullContext: AuthorizationHookContext = {
3936
+ ...context,
3937
+ ctx: ctx,
3938
+ };
3939
+ const rbacOptions = contextToRbacOptions(fullContext);
3940
+
3941
+ const result = await executeAuthorizationHooks({
3942
+ hooks: authHooks,
3943
+ context: fullContext,
3944
+ rbacOptions: rbacOptions ?? undefined,
3945
+ skipRbac: resolvedConfig.skipRbac,
3946
+ });
3947
+
3948
+ if (!result.allowed) {
3949
+ const rbacMapping = operationToRbac(fullContext.operation);
3950
+
3951
+ // Import UnauthorizedError dynamically to avoid circular dependency
3952
+ const { UnauthorizedError } = await import("../component/authorization.js");
3953
+
3954
+ throw new UnauthorizedError(
3955
+ result.reason ?? "Operation not allowed",
3956
+ {
3957
+ code: result.rbacResult?.allowed === false
3958
+ ? result.rbacResult.code
3959
+ : "PERMISSION_DENIED",
3960
+ resource: rbacMapping?.resource,
3961
+ action: rbacMapping?.action,
3962
+ role: fullContext.role ?? undefined,
3963
+ userId: fullContext.userId,
3964
+ }
3965
+ );
3966
+ }
3967
+
3968
+ return result;
3969
+ },
3970
+ skipRbac: resolvedConfig.skipRbac ?? false,
3971
+ }
3972
+ : undefined;
3973
+
3974
+ return {
3975
+ config: resolvedConfig,
3976
+ api: componentApi,
3977
+ contentTypes: new ContentTypesApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
3978
+ contentEntries: new ContentEntriesApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
3979
+ versions: new VersionsApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
3980
+ mediaAssets: new MediaAssetsApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
3981
+ mediaFolders: new MediaFoldersApi(componentApi, resolvedConfig, authHelper, rateLimitHelper),
3982
+ mediaVariants: new MediaVariantsApi(componentApi, resolvedConfig),
3983
+
3984
+ // Locale fallback chain helpers
3985
+ locale: {
3986
+ getConfig(): LocaleFallbackConfig {
3987
+ return {
3988
+ defaultLocale: resolvedConfig.defaultLocale,
3989
+ fallbackChains: resolvedConfig.localeFallbackChains,
3990
+ autoGenerateFallbacks: resolvedConfig.autoGenerateLocaleFallbacks,
3991
+ supportedLocales: resolvedConfig.supportedLocales,
3992
+ };
3993
+ },
3994
+ getFallbackChain(locale: LocaleCode): LocaleCode[] {
3995
+ const fallbackConfig = this.getConfig();
3996
+ return getFallbackChain(locale, fallbackConfig);
3997
+ },
3998
+ resolve(locale: LocaleCode): ResolvedFallbackChain {
3999
+ const fallbackConfig = this.getConfig();
4000
+ return resolveFallbackChain(locale, fallbackConfig);
4001
+ },
4002
+ },
4003
+
4004
+ isFeatureEnabled(feature: keyof FeatureFlags): boolean {
4005
+ return resolvedConfig.features[feature] ?? false;
4006
+ },
4007
+
4008
+ isLocaleSupported(locale: LocaleCode): boolean {
4009
+ return resolvedConfig.supportedLocales.includes(locale);
4010
+ },
4011
+
4012
+ hasUserRoleHook(): boolean {
4013
+ return getUserRoleHook !== undefined;
4014
+ },
4015
+
4016
+ hasAuthorizationHooks(): boolean {
4017
+ if (!authHooks) return false;
4018
+ return !!(
4019
+ authHooks.beforeRbac ||
4020
+ authHooks.afterRbac ||
4021
+ authHooks.onDeny ||
4022
+ (authHooks.operationHooks && Object.keys(authHooks.operationHooks).length > 0)
4023
+ );
4024
+ },
4025
+
4026
+ async getUserRole(ctx: ConvexContext, userId: string): Promise<GetUserRoleResult> {
4027
+ if (!getUserRoleHook) {
4028
+ throw new Error(
4029
+ "No getUserRole hook configured. " +
4030
+ "Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles."
4031
+ );
4032
+ }
4033
+ return await getUserRoleHook(ctx, { userId });
4034
+ },
4035
+
4036
+ async hasPermissionForUser(
4037
+ ctx: ConvexContext,
4038
+ userId: string,
4039
+ permission: { resource: Resource; action: Action; scope?: OwnershipScope },
4040
+ options?: PermissionCheckOptions
4041
+ ): Promise<UserPermissionResult> {
4042
+ if (!getUserRoleHook) {
4043
+ throw new Error(
4044
+ "No getUserRole hook configured. " +
4045
+ "Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles."
4046
+ );
4047
+ }
4048
+
4049
+ const role = await getUserRoleHook(ctx, { userId });
4050
+
4051
+ // If user has no role, they have no permissions
4052
+ if (role === null) {
4053
+ return {
4054
+ allowed: false,
4055
+ role: null,
4056
+ permission,
4057
+ };
4058
+ }
4059
+
4060
+ // Check if the role has the requested permission
4061
+ const allowed = hasPermission(role, permission, options?.customRoles);
4062
+
4063
+ return {
4064
+ allowed,
4065
+ role,
4066
+ permission,
4067
+ };
4068
+ },
4069
+
4070
+ async authorize(context: AuthorizationHookContext): Promise<AuthorizationResult> {
4071
+ // Build RBAC options from context
4072
+ const rbacOptions = contextToRbacOptions(context);
4073
+
4074
+ return executeAuthorizationHooks({
4075
+ hooks: authHooks,
4076
+ context,
4077
+ rbacOptions: rbacOptions ?? undefined,
4078
+ skipRbac: resolvedConfig.skipRbac,
4079
+ });
4080
+ },
4081
+
4082
+ async requireAuthorization(context: AuthorizationHookContext): Promise<AuthorizationResult> {
4083
+ const result = await this.authorize(context);
4084
+
4085
+ if (!result.allowed) {
4086
+ const rbacMapping = operationToRbac(context.operation);
4087
+
4088
+ // Import UnauthorizedError dynamically to avoid circular dependency
4089
+ const { UnauthorizedError } = await import("../component/authorization.js");
4090
+
4091
+ throw new UnauthorizedError(
4092
+ result.reason ?? "Operation not allowed",
4093
+ {
4094
+ code: result.rbacResult?.allowed === false
4095
+ ? result.rbacResult.code
4096
+ : "PERMISSION_DENIED",
4097
+ resource: rbacMapping?.resource,
4098
+ action: rbacMapping?.action,
4099
+ role: context.role ?? undefined,
4100
+ userId: context.userId,
4101
+ }
4102
+ );
4103
+ }
4104
+
4105
+ return result;
4106
+ },
4107
+
4108
+ // ==========================================================================
4109
+ // Custom Roles Methods
4110
+ // ==========================================================================
4111
+
4112
+ getCustomRoles() {
4113
+ return resolvedConfig.customRoles;
4114
+ },
4115
+
4116
+ getCustomRole(roleName: string) {
4117
+ return resolvedConfig.customRoles[roleName];
4118
+ },
4119
+
4120
+ isCustomRole(roleName: string): boolean {
4121
+ return roleName in resolvedConfig.customRoles;
4122
+ },
4123
+
4124
+ async hasContentTypePermissionForUser(
4125
+ ctx: ConvexContext,
4126
+ userId: string,
4127
+ permission: { resource: Resource; action: Action; scope?: OwnershipScope },
4128
+ contentTypeName: string
4129
+ ): Promise<UserPermissionResult> {
4130
+ if (!getUserRoleHook) {
4131
+ throw new Error(
4132
+ "No getUserRole hook configured. " +
4133
+ "Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles."
4134
+ );
4135
+ }
4136
+
4137
+ const role = await getUserRoleHook(ctx, { userId });
4138
+
4139
+ if (role === null) {
4140
+ return {
4141
+ allowed: false,
4142
+ role: null,
4143
+ permission,
4144
+ };
4145
+ }
4146
+
4147
+ // Use the content-type-aware permission check
4148
+ const allowed = hasContentTypePermission(role, permission, {
4149
+ customRoles: resolvedConfig.customRoles,
4150
+ contentTypeName,
4151
+ });
4152
+
4153
+ return {
4154
+ allowed,
4155
+ role,
4156
+ permission,
4157
+ };
4158
+ },
4159
+
4160
+ async getPermittedContentTypesForUser(
4161
+ ctx: ConvexContext,
4162
+ userId: string,
4163
+ action: Action
4164
+ ): Promise<string[]> {
4165
+ if (!getUserRoleHook) {
4166
+ throw new Error(
4167
+ "No getUserRole hook configured. " +
4168
+ "Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles."
4169
+ );
4170
+ }
4171
+
4172
+ const role = await getUserRoleHook(ctx, { userId });
4173
+
4174
+ if (role === null) {
4175
+ return [];
4176
+ }
4177
+
4178
+ return getPermittedContentTypes(role, action, {
4179
+ customRoles: resolvedConfig.customRoles,
4180
+ });
4181
+ },
4182
+
4183
+ getAllRoles() {
4184
+ return {
4185
+ ...DEFAULT_ROLES,
4186
+ ...resolvedConfig.customRoles,
4187
+ };
4188
+ },
4189
+
4190
+ // ==========================================================================
4191
+ // Resource Ownership Methods
4192
+ // ==========================================================================
4193
+
4194
+ async canUserPerformOnResource(
4195
+ ctx: ConvexContext,
4196
+ userId: string,
4197
+ resource: Resource,
4198
+ action: Action,
4199
+ resourceOwnerId?: string
4200
+ ): Promise<ResourcePermissionResult> {
4201
+ if (!getUserRoleHook) {
4202
+ throw new Error(
4203
+ "No getUserRole hook configured. " +
4204
+ "Configure a getUserRole function in createCmsClient options to map user IDs to CMS roles."
4205
+ );
4206
+ }
4207
+
4208
+ const role = await getUserRoleHook(ctx, { userId });
4209
+
4210
+ // If user has no role, they have no permissions
4211
+ if (role === null) {
4212
+ return {
4213
+ allowed: false,
4214
+ role: null,
4215
+ reason: "No role assigned to user",
4216
+ code: "NO_ROLE",
4217
+ };
4218
+ }
4219
+
4220
+ // Use the core checkPermission function for comprehensive RBAC check
4221
+ const { checkPermission } = await import("../component/authorization.js");
4222
+
4223
+ const result = checkPermission({
4224
+ userId,
4225
+ role,
4226
+ resource,
4227
+ action,
4228
+ resourceOwnerId,
4229
+ customRoles: resolvedConfig.customRoles,
4230
+ });
4231
+
4232
+ if (result.allowed === true) {
4233
+ return {
4234
+ allowed: true,
4235
+ role,
4236
+ grantedScope: result.grantedScope,
4237
+ ownershipVerified: result.ownershipVerified,
4238
+ };
4239
+ } else {
4240
+ // TypeScript narrows result to PermissionDenied when allowed === false
4241
+ const denied = result as { allowed: false; reason: string; code: string };
4242
+ return {
4243
+ allowed: false,
4244
+ role,
4245
+ reason: denied.reason,
4246
+ code: denied.code,
4247
+ ownershipRequired: denied.code === "OWNERSHIP_REQUIRED",
4248
+ };
4249
+ }
4250
+ },
4251
+
4252
+ async requireUserCanPerformOnResource(
4253
+ ctx: ConvexContext,
4254
+ userId: string,
4255
+ resource: Resource,
4256
+ action: Action,
4257
+ resourceOwnerId?: string
4258
+ ): Promise<ResourcePermissionGranted> {
4259
+ const result = await this.canUserPerformOnResource(
4260
+ ctx,
4261
+ userId,
4262
+ resource,
4263
+ action,
4264
+ resourceOwnerId
4265
+ );
4266
+
4267
+ if (!result.allowed) {
4268
+ // Import UnauthorizedError dynamically to avoid circular dependency
4269
+ const { UnauthorizedError } = await import("../component/authorization.js");
4270
+
4271
+ throw new UnauthorizedError(
4272
+ result.reason ?? "Operation not allowed",
4273
+ {
4274
+ code: (result.code ?? "PERMISSION_DENIED") as
4275
+ | "NO_ROLE"
4276
+ | "UNKNOWN_ROLE"
4277
+ | "PERMISSION_DENIED"
4278
+ | "OWNERSHIP_REQUIRED",
4279
+ resource,
4280
+ action,
4281
+ role: result.role ?? undefined,
4282
+ userId,
4283
+ requiredScope: result.ownershipRequired ? "own" : undefined,
4284
+ }
4285
+ );
4286
+ }
4287
+
4288
+ return {
4289
+ allowed: true,
4290
+ role: result.role!,
4291
+ grantedScope: result.grantedScope!,
4292
+ ownershipVerified: result.ownershipVerified ?? false,
4293
+ };
4294
+ },
4295
+
4296
+ isOwner(userId: string | undefined, resourceOwnerId: string | undefined): boolean {
4297
+ // Import the helper synchronously (it's a simple comparison)
4298
+ if (userId === undefined || resourceOwnerId === undefined) {
4299
+ return false;
4300
+ }
4301
+ return userId === resourceOwnerId;
4302
+ },
4303
+ };
4304
+ }