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.
- package/dist/cli/commands/admin.d.ts +16 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +88 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +18 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/utils/detectConvexUrl.d.ts +13 -0
- package/dist/cli/utils/detectConvexUrl.d.ts.map +1 -0
- package/dist/cli/utils/detectConvexUrl.js +48 -0
- package/dist/cli/utils/detectConvexUrl.js.map +1 -0
- package/dist/cli/utils/openBrowser.d.ts +7 -0
- package/dist/cli/utils/openBrowser.d.ts.map +1 -0
- package/dist/cli/utils/openBrowser.js +17 -0
- package/dist/cli/utils/openBrowser.js.map +1 -0
- package/dist/client/admin-config.d.ts +126 -0
- package/dist/client/admin-config.d.ts.map +1 -0
- package/dist/client/admin-config.js +117 -0
- package/dist/client/admin-config.js.map +1 -0
- package/dist/client/adminApi.d.ts +2273 -0
- package/dist/client/adminApi.d.ts.map +1 -0
- package/dist/client/adminApi.js +716 -0
- package/dist/client/adminApi.js.map +1 -0
- package/dist/client/agentTools.d.ts +933 -0
- package/dist/client/agentTools.d.ts.map +1 -0
- package/dist/client/agentTools.js +1004 -0
- package/dist/client/agentTools.js.map +1 -0
- package/dist/client/argTypes.d.ts +212 -0
- package/dist/client/argTypes.d.ts.map +1 -0
- package/dist/client/argTypes.js +5 -0
- package/dist/client/argTypes.js.map +1 -0
- package/dist/client/field-types.d.ts +55 -0
- package/dist/client/field-types.d.ts.map +1 -0
- package/dist/client/field-types.js +152 -0
- package/dist/client/field-types.js.map +1 -0
- package/dist/client/index.d.ts +189 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +668 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/queryBuilder.d.ts +765 -0
- package/dist/client/queryBuilder.d.ts.map +1 -0
- package/dist/client/queryBuilder.js +970 -0
- package/dist/client/queryBuilder.js.map +1 -0
- package/dist/client/schema/codegen.d.ts +128 -0
- package/dist/client/schema/codegen.d.ts.map +1 -0
- package/dist/client/schema/codegen.js +318 -0
- package/dist/client/schema/codegen.js.map +1 -0
- package/dist/client/schema/defineContentType.d.ts +221 -0
- package/dist/client/schema/defineContentType.d.ts.map +1 -0
- package/dist/client/schema/defineContentType.js +380 -0
- package/dist/client/schema/defineContentType.js.map +1 -0
- package/dist/client/schema/index.d.ts +85 -0
- package/dist/client/schema/index.d.ts.map +1 -0
- package/dist/client/schema/index.js +92 -0
- package/dist/client/schema/index.js.map +1 -0
- package/dist/client/schema/schemaDrift.d.ts +199 -0
- package/dist/client/schema/schemaDrift.d.ts.map +1 -0
- package/dist/client/schema/schemaDrift.js +340 -0
- package/dist/client/schema/schemaDrift.js.map +1 -0
- package/dist/client/schema/typedClient.d.ts +401 -0
- package/dist/client/schema/typedClient.d.ts.map +1 -0
- package/dist/client/schema/typedClient.js +269 -0
- package/dist/client/schema/typedClient.js.map +1 -0
- package/dist/client/schema/types.d.ts +477 -0
- package/dist/client/schema/types.d.ts.map +1 -0
- package/dist/client/schema/types.js +39 -0
- package/dist/client/schema/types.js.map +1 -0
- package/dist/client/types.d.ts +449 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +149 -0
- package/dist/client/types.js.map +1 -0
- package/dist/client/workflows.d.ts +51 -0
- package/dist/client/workflows.d.ts.map +1 -0
- package/dist/client/workflows.js +103 -0
- package/dist/client/workflows.js.map +1 -0
- package/dist/client/wrapper.d.ts +2198 -0
- package/dist/client/wrapper.d.ts.map +1 -0
- package/dist/client/wrapper.js +2651 -0
- package/dist/client/wrapper.js.map +1 -0
- package/dist/component/_generated/api.d.ts +124 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +4321 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/auditLog.d.ts +410 -0
- package/dist/component/auditLog.d.ts.map +1 -0
- package/dist/component/auditLog.js +607 -0
- package/dist/component/auditLog.js.map +1 -0
- package/dist/component/authorization.d.ts +323 -0
- package/dist/component/authorization.d.ts.map +1 -0
- package/dist/component/authorization.js +464 -0
- package/dist/component/authorization.js.map +1 -0
- package/dist/component/authorizationHooks.d.ts +184 -0
- package/dist/component/authorizationHooks.d.ts.map +1 -0
- package/dist/component/authorizationHooks.js +521 -0
- package/dist/component/authorizationHooks.js.map +1 -0
- package/dist/component/bulkOperations.d.ts +200 -0
- package/dist/component/bulkOperations.d.ts.map +1 -0
- package/dist/component/bulkOperations.js +568 -0
- package/dist/component/bulkOperations.js.map +1 -0
- package/dist/component/contentEntries.d.ts +719 -0
- package/dist/component/contentEntries.d.ts.map +1 -0
- package/dist/component/contentEntries.js +1617 -0
- package/dist/component/contentEntries.js.map +1 -0
- package/dist/component/contentEntryMutations.d.ts +505 -0
- package/dist/component/contentEntryMutations.d.ts.map +1 -0
- package/dist/component/contentEntryMutations.js +1009 -0
- package/dist/component/contentEntryMutations.js.map +1 -0
- package/dist/component/contentEntryValidation.d.ts +115 -0
- package/dist/component/contentEntryValidation.d.ts.map +1 -0
- package/dist/component/contentEntryValidation.js +546 -0
- package/dist/component/contentEntryValidation.js.map +1 -0
- package/dist/component/contentLock.d.ts +328 -0
- package/dist/component/contentLock.d.ts.map +1 -0
- package/dist/component/contentLock.js +471 -0
- package/dist/component/contentLock.js.map +1 -0
- package/dist/component/contentTypeMigration.d.ts +411 -0
- package/dist/component/contentTypeMigration.d.ts.map +1 -0
- package/dist/component/contentTypeMigration.js +805 -0
- package/dist/component/contentTypeMigration.js.map +1 -0
- package/dist/component/contentTypeMutations.d.ts +975 -0
- package/dist/component/contentTypeMutations.d.ts.map +1 -0
- package/dist/component/contentTypeMutations.js +768 -0
- package/dist/component/contentTypeMutations.js.map +1 -0
- package/dist/component/contentTypes.d.ts +538 -0
- package/dist/component/contentTypes.d.ts.map +1 -0
- package/dist/component/contentTypes.js +304 -0
- package/dist/component/contentTypes.js.map +1 -0
- package/dist/component/convex.config.d.ts +42 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +43 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/documentTypes.d.ts +186 -0
- package/dist/component/documentTypes.d.ts.map +1 -0
- package/dist/component/documentTypes.js +23 -0
- package/dist/component/documentTypes.js.map +1 -0
- package/dist/component/eventEmitter.d.ts +281 -0
- package/dist/component/eventEmitter.d.ts.map +1 -0
- package/dist/component/eventEmitter.js +300 -0
- package/dist/component/eventEmitter.js.map +1 -0
- package/dist/component/exportImport.d.ts +1120 -0
- package/dist/component/exportImport.d.ts.map +1 -0
- package/dist/component/exportImport.js +931 -0
- package/dist/component/exportImport.js.map +1 -0
- package/dist/component/index.d.ts +28 -0
- package/dist/component/index.d.ts.map +1 -0
- package/dist/component/index.js +142 -0
- package/dist/component/index.js.map +1 -0
- package/dist/component/lib/deepReferenceResolver.d.ts +252 -0
- package/dist/component/lib/deepReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/deepReferenceResolver.js +601 -0
- package/dist/component/lib/deepReferenceResolver.js.map +1 -0
- package/dist/component/lib/errors.d.ts +306 -0
- package/dist/component/lib/errors.d.ts.map +1 -0
- package/dist/component/lib/errors.js +407 -0
- package/dist/component/lib/errors.js.map +1 -0
- package/dist/component/lib/index.d.ts +10 -0
- package/dist/component/lib/index.d.ts.map +1 -0
- package/dist/component/lib/index.js +33 -0
- package/dist/component/lib/index.js.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts +217 -0
- package/dist/component/lib/mediaReferenceResolver.d.ts.map +1 -0
- package/dist/component/lib/mediaReferenceResolver.js +326 -0
- package/dist/component/lib/mediaReferenceResolver.js.map +1 -0
- package/dist/component/lib/metadataExtractor.d.ts +245 -0
- package/dist/component/lib/metadataExtractor.d.ts.map +1 -0
- package/dist/component/lib/metadataExtractor.js +548 -0
- package/dist/component/lib/metadataExtractor.js.map +1 -0
- package/dist/component/lib/mutationAuth.d.ts +95 -0
- package/dist/component/lib/mutationAuth.d.ts.map +1 -0
- package/dist/component/lib/mutationAuth.js +146 -0
- package/dist/component/lib/mutationAuth.js.map +1 -0
- package/dist/component/lib/queries.d.ts +17 -0
- package/dist/component/lib/queries.d.ts.map +1 -0
- package/dist/component/lib/queries.js +49 -0
- package/dist/component/lib/queries.js.map +1 -0
- package/dist/component/lib/ragContentChunker.d.ts +423 -0
- package/dist/component/lib/ragContentChunker.d.ts.map +1 -0
- package/dist/component/lib/ragContentChunker.js +897 -0
- package/dist/component/lib/ragContentChunker.js.map +1 -0
- package/dist/component/lib/referenceResolver.d.ts +175 -0
- package/dist/component/lib/referenceResolver.d.ts.map +1 -0
- package/dist/component/lib/referenceResolver.js +293 -0
- package/dist/component/lib/referenceResolver.js.map +1 -0
- package/dist/component/lib/slugGenerator.d.ts +71 -0
- package/dist/component/lib/slugGenerator.d.ts.map +1 -0
- package/dist/component/lib/slugGenerator.js +207 -0
- package/dist/component/lib/slugGenerator.js.map +1 -0
- package/dist/component/lib/slugUniqueness.d.ts +131 -0
- package/dist/component/lib/slugUniqueness.d.ts.map +1 -0
- package/dist/component/lib/slugUniqueness.js +229 -0
- package/dist/component/lib/slugUniqueness.js.map +1 -0
- package/dist/component/lib/softDelete.d.ts +18 -0
- package/dist/component/lib/softDelete.d.ts.map +1 -0
- package/dist/component/lib/softDelete.js +29 -0
- package/dist/component/lib/softDelete.js.map +1 -0
- package/dist/component/localeFallbackChain.d.ts +410 -0
- package/dist/component/localeFallbackChain.d.ts.map +1 -0
- package/dist/component/localeFallbackChain.js +467 -0
- package/dist/component/localeFallbackChain.js.map +1 -0
- package/dist/component/localeFields.d.ts +508 -0
- package/dist/component/localeFields.d.ts.map +1 -0
- package/dist/component/localeFields.js +592 -0
- package/dist/component/localeFields.js.map +1 -0
- package/dist/component/mediaAssetMutations.d.ts +235 -0
- package/dist/component/mediaAssetMutations.d.ts.map +1 -0
- package/dist/component/mediaAssetMutations.js +558 -0
- package/dist/component/mediaAssetMutations.js.map +1 -0
- package/dist/component/mediaAssets.d.ts +168 -0
- package/dist/component/mediaAssets.d.ts.map +1 -0
- package/dist/component/mediaAssets.js +618 -0
- package/dist/component/mediaAssets.js.map +1 -0
- package/dist/component/mediaFolderMutations.d.ts +642 -0
- package/dist/component/mediaFolderMutations.d.ts.map +1 -0
- package/dist/component/mediaFolderMutations.js +849 -0
- package/dist/component/mediaFolderMutations.js.map +1 -0
- package/dist/component/mediaUploadMutations.d.ts +136 -0
- package/dist/component/mediaUploadMutations.d.ts.map +1 -0
- package/dist/component/mediaUploadMutations.js +205 -0
- package/dist/component/mediaUploadMutations.js.map +1 -0
- package/dist/component/mediaVariantMutations.d.ts +468 -0
- package/dist/component/mediaVariantMutations.d.ts.map +1 -0
- package/dist/component/mediaVariantMutations.js +737 -0
- package/dist/component/mediaVariantMutations.js.map +1 -0
- package/dist/component/mediaVariants.d.ts +525 -0
- package/dist/component/mediaVariants.d.ts.map +1 -0
- package/dist/component/mediaVariants.js +661 -0
- package/dist/component/mediaVariants.js.map +1 -0
- package/dist/component/ragContentIndexer.d.ts +595 -0
- package/dist/component/ragContentIndexer.d.ts.map +1 -0
- package/dist/component/ragContentIndexer.js +794 -0
- package/dist/component/ragContentIndexer.js.map +1 -0
- package/dist/component/rateLimitHooks.d.ts +266 -0
- package/dist/component/rateLimitHooks.d.ts.map +1 -0
- package/dist/component/rateLimitHooks.js +412 -0
- package/dist/component/rateLimitHooks.js.map +1 -0
- package/dist/component/roles.d.ts +649 -0
- package/dist/component/roles.d.ts.map +1 -0
- package/dist/component/roles.js +884 -0
- package/dist/component/roles.js.map +1 -0
- package/dist/component/scheduledPublish.d.ts +182 -0
- package/dist/component/scheduledPublish.d.ts.map +1 -0
- package/dist/component/scheduledPublish.js +304 -0
- package/dist/component/scheduledPublish.js.map +1 -0
- package/dist/component/schema.d.ts +4114 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +469 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/taxonomies.d.ts +476 -0
- package/dist/component/taxonomies.d.ts.map +1 -0
- package/dist/component/taxonomies.js +785 -0
- package/dist/component/taxonomies.js.map +1 -0
- package/dist/component/taxonomyMutations.d.ts +206 -0
- package/dist/component/taxonomyMutations.d.ts.map +1 -0
- package/dist/component/taxonomyMutations.js +1001 -0
- package/dist/component/taxonomyMutations.js.map +1 -0
- package/dist/component/trash.d.ts +265 -0
- package/dist/component/trash.d.ts.map +1 -0
- package/dist/component/trash.js +621 -0
- package/dist/component/trash.js.map +1 -0
- package/dist/component/types.d.ts +4 -0
- package/dist/component/types.d.ts.map +1 -0
- package/dist/component/types.js +2 -0
- package/dist/component/types.js.map +1 -0
- package/dist/component/userContext.d.ts +508 -0
- package/dist/component/userContext.d.ts.map +1 -0
- package/dist/component/userContext.js +615 -0
- package/dist/component/userContext.js.map +1 -0
- package/dist/component/validation.d.ts +387 -0
- package/dist/component/validation.d.ts.map +1 -0
- package/dist/component/validation.js +1052 -0
- package/dist/component/validation.js.map +1 -0
- package/dist/component/validators.d.ts +4645 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +641 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/component/versionMutations.d.ts +216 -0
- package/dist/component/versionMutations.d.ts.map +1 -0
- package/dist/component/versionMutations.js +321 -0
- package/dist/component/versionMutations.js.map +1 -0
- package/dist/component/webhookTrigger.d.ts +770 -0
- package/dist/component/webhookTrigger.d.ts.map +1 -0
- package/dist/component/webhookTrigger.js +1413 -0
- package/dist/component/webhookTrigger.js.map +1 -0
- package/dist/react/index.d.ts +316 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +558 -0
- package/dist/react/index.js.map +1 -0
- package/dist/test.d.ts +2230 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +1107 -0
- package/dist/test.js.map +1 -0
- package/package.json +95 -0
- package/src/cli/commands/admin.ts +104 -0
- package/src/cli/index.ts +21 -0
- package/src/cli/utils/detectConvexUrl.ts +54 -0
- package/src/cli/utils/openBrowser.ts +16 -0
- package/src/client/admin-config.ts +138 -0
- package/src/client/adminApi.ts +942 -0
- package/src/client/agentTools.ts +1311 -0
- package/src/client/argTypes.ts +316 -0
- package/src/client/field-types.ts +187 -0
- package/src/client/index.ts +1301 -0
- package/src/client/queryBuilder.ts +1100 -0
- package/src/client/schema/codegen.ts +500 -0
- package/src/client/schema/defineContentType.ts +501 -0
- package/src/client/schema/index.ts +169 -0
- package/src/client/schema/schemaDrift.ts +574 -0
- package/src/client/schema/typedClient.ts +688 -0
- package/src/client/schema/types.ts +666 -0
- package/src/client/types.ts +723 -0
- package/src/client/workflows.ts +141 -0
- package/src/client/wrapper.ts +4304 -0
- package/src/component/_generated/api.ts +140 -0
- package/src/component/_generated/component.ts +5029 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/authorization.ts +647 -0
- package/src/component/authorizationHooks.ts +668 -0
- package/src/component/bulkOperations.ts +687 -0
- package/src/component/contentEntries.ts +1976 -0
- package/src/component/contentEntryMutations.ts +1223 -0
- package/src/component/contentEntryValidation.ts +707 -0
- package/src/component/contentLock.ts +550 -0
- package/src/component/contentTypeMigration.ts +1064 -0
- package/src/component/contentTypeMutations.ts +969 -0
- package/src/component/contentTypes.ts +346 -0
- package/src/component/convex.config.ts +44 -0
- package/src/component/documentTypes.ts +240 -0
- package/src/component/eventEmitter.ts +485 -0
- package/src/component/exportImport.ts +1169 -0
- package/src/component/index.ts +491 -0
- package/src/component/lib/deepReferenceResolver.ts +999 -0
- package/src/component/lib/errors.ts +816 -0
- package/src/component/lib/index.ts +145 -0
- package/src/component/lib/mediaReferenceResolver.ts +495 -0
- package/src/component/lib/metadataExtractor.ts +792 -0
- package/src/component/lib/mutationAuth.ts +199 -0
- package/src/component/lib/queries.ts +79 -0
- package/src/component/lib/ragContentChunker.ts +1371 -0
- package/src/component/lib/referenceResolver.ts +430 -0
- package/src/component/lib/slugGenerator.ts +262 -0
- package/src/component/lib/slugUniqueness.ts +333 -0
- package/src/component/lib/softDelete.ts +44 -0
- package/src/component/localeFallbackChain.ts +673 -0
- package/src/component/localeFields.ts +896 -0
- package/src/component/mediaAssetMutations.ts +725 -0
- package/src/component/mediaAssets.ts +932 -0
- package/src/component/mediaFolderMutations.ts +1046 -0
- package/src/component/mediaUploadMutations.ts +224 -0
- package/src/component/mediaVariantMutations.ts +900 -0
- package/src/component/mediaVariants.ts +793 -0
- package/src/component/ragContentIndexer.ts +1067 -0
- package/src/component/rateLimitHooks.ts +572 -0
- package/src/component/roles.ts +1360 -0
- package/src/component/scheduledPublish.ts +358 -0
- package/src/component/schema.ts +617 -0
- package/src/component/taxonomies.ts +949 -0
- package/src/component/taxonomyMutations.ts +1210 -0
- package/src/component/trash.ts +724 -0
- package/src/component/userContext.ts +898 -0
- package/src/component/validation.ts +1388 -0
- package/src/component/validators.ts +949 -0
- package/src/component/versionMutations.ts +392 -0
- package/src/component/webhookTrigger.ts +1922 -0
- package/src/react/index.ts +898 -0
- 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
|
+
}
|