@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.6
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/README.md +47 -1
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.d.ts +91 -30
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -1
- package/dist/agentSessions/SessionJanitor.js +311 -81
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/agentSessions/SessionManager.d.ts +17 -3
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +44 -10
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/apiRoot.d.ts +32 -0
- package/dist/apiRoot.d.ts.map +1 -0
- package/dist/apiRoot.js +67 -0
- package/dist/apiRoot.js.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -1
- package/dist/auth/AuthProviderCatalogRouter.js +27 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -1
- package/dist/auth/LoginScreenEngine.d.ts +22 -0
- package/dist/auth/LoginScreenEngine.d.ts.map +1 -0
- package/dist/auth/LoginScreenEngine.js +57 -0
- package/dist/auth/LoginScreenEngine.js.map +1 -0
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +52 -10
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +10 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +37 -1
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/auth/localDevAuth.d.ts +79 -0
- package/dist/auth/localDevAuth.d.ts.map +1 -0
- package/dist/auth/localDevAuth.js +439 -0
- package/dist/auth/localDevAuth.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts +2 -2
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkRouter.js +3 -2
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
- package/dist/auth/magicLink/types.d.ts +12 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -1
- package/dist/auth/sessionUserClone.d.ts +1 -1
- package/dist/auth/sessionUserClone.d.ts.map +1 -1
- package/dist/auth/sessionUserClone.js +3 -2
- package/dist/auth/sessionUserClone.js.map +1 -1
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts +292 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js +442 -0
- package/dist/broadcast/BaseCacheInvalidationBroadcastProvider.js.map +1 -0
- package/dist/broadcast/index.d.ts +6 -0
- package/dist/broadcast/index.d.ts.map +1 -0
- package/dist/broadcast/index.js +6 -0
- package/dist/broadcast/index.js.map +1 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts +43 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js +144 -0
- package/dist/broadcast/providers/AWSIoTCoreBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts +46 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js +149 -0
- package/dist/broadcast/providers/AzureWebPubSubBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts +49 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js +131 -0
- package/dist/broadcast/providers/GCPPubSubBroadcastProvider.js.map +1 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts +24 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.d.ts.map +1 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js +51 -0
- package/dist/broadcast/providers/SequencedRoleBucketBroadcastProvider.js.map +1 -0
- package/dist/cache.d.ts +3 -2
- package/dist/cache.d.ts.map +1 -1
- package/dist/cache.js.map +1 -1
- package/dist/config.d.ts +632 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +104 -4
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +3 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +29 -8
- package/dist/context.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +156 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +1016 -29
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +5125 -434
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +85 -30
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +175 -60
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.d.ts.map +1 -1
- package/dist/generic/OrphanedConversationDetailReconciler.js +2 -0
- package/dist/generic/OrphanedConversationDetailReconciler.js.map +1 -1
- package/dist/generic/PubSubManager.d.ts +10 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -1
- package/dist/generic/PubSubManager.js +44 -1
- package/dist/generic/PubSubManager.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +39 -6
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +94 -12
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +168 -69
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +464 -405
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +11 -3
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +91 -13
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/actionOutputFieldSecurity.d.ts +26 -0
- package/dist/generic/actionOutputFieldSecurity.d.ts.map +1 -0
- package/dist/generic/actionOutputFieldSecurity.js +47 -0
- package/dist/generic/actionOutputFieldSecurity.js.map +1 -0
- package/dist/generic/fieldSecurityTransport.d.ts +30 -0
- package/dist/generic/fieldSecurityTransport.d.ts.map +1 -0
- package/dist/generic/fieldSecurityTransport.js +30 -0
- package/dist/generic/fieldSecurityTransport.js.map +1 -0
- package/dist/globalCors.d.ts +33 -0
- package/dist/globalCors.d.ts.map +1 -0
- package/dist/globalCors.js +58 -0
- package/dist/globalCors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +230 -222
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +6 -1
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.d.ts +12 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +50 -1
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js +2 -1
- package/dist/realtimeSessions/RealtimeSessionVerificationService.js.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.d.ts.map +1 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js +2 -1
- package/dist/realtimeSessions/RealtimeVerifyRouter.js.map +1 -1
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts +100 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.d.ts.map +1 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js +173 -0
- package/dist/realtimeSessions/RecordingAssemblyScheduler.js.map +1 -0
- package/dist/realtimeSessions/SessionVerificationPortsBase.d.ts.map +1 -1
- package/dist/realtimeSessions/SessionVerificationPortsBase.js +1 -0
- package/dist/realtimeSessions/SessionVerificationPortsBase.js.map +1 -1
- package/dist/realtimeSessions/index.d.ts +2 -0
- package/dist/realtimeSessions/index.d.ts.map +1 -1
- package/dist/realtimeSessions/index.js +2 -0
- package/dist/realtimeSessions/index.js.map +1 -1
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts +32 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +56 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +91 -8
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +127 -20
- package/dist/realtimeSessions/sessionAccess.js.map +1 -1
- package/dist/realtimeSessions/sessionConfigStamps.js +1 -1
- package/dist/realtimeSessions/sessionConfigStamps.js.map +1 -1
- package/dist/realtimeSessions/subscriptionAuthorization.d.ts.map +1 -1
- package/dist/realtimeSessions/subscriptionAuthorization.js +5 -2
- package/dist/realtimeSessions/subscriptionAuthorization.js.map +1 -1
- package/dist/realtimeSessions/verificationWorkflow.d.ts +2 -0
- package/dist/realtimeSessions/verificationWorkflow.d.ts.map +1 -1
- package/dist/realtimeSessions/verificationWorkflow.js +1 -1
- package/dist/realtimeSessions/verificationWorkflow.js.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetRouter.js +19 -3
- package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts +67 -7
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +201 -19
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/host-identity.d.ts +1 -1
- package/dist/realtimeWidget/host-identity.js +1 -1
- package/dist/realtimeWidget/widgetCore.d.ts +20 -1
- package/dist/realtimeWidget/widgetCore.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetCore.js +17 -1
- package/dist/realtimeWidget/widgetCore.js.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -1
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +19 -1
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +63 -12
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +1 -0
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.d.ts +18 -4
- package/dist/resolvers/AgentRunStatusPublisher.d.ts.map +1 -1
- package/dist/resolvers/AgentRunStatusPublisher.js +32 -8
- package/dist/resolvers/AgentRunStatusPublisher.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +10 -4
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/AgentSessionResolver.js +40 -17
- package/dist/resolvers/AgentSessionResolver.js.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts +5 -1
- package/dist/resolvers/ArtifactFileResolver.d.ts.map +1 -1
- package/dist/resolvers/ArtifactFileResolver.js +9 -7
- package/dist/resolvers/ArtifactFileResolver.js.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.d.ts +8 -4
- package/dist/resolvers/ConversationRunEventsResolver.d.ts.map +1 -1
- package/dist/resolvers/ConversationRunEventsResolver.js +6 -6
- package/dist/resolvers/ConversationRunEventsResolver.js.map +1 -1
- package/dist/resolvers/DatasetResolver.d.ts +24 -3
- package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
- package/dist/resolvers/DatasetResolver.js +57 -8
- package/dist/resolvers/DatasetResolver.js.map +1 -1
- package/dist/resolvers/EntityCommunicationsResolver.d.ts +13 -1
- package/dist/resolvers/EntityCommunicationsResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityCommunicationsResolver.js +50 -14
- package/dist/resolvers/EntityCommunicationsResolver.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +2 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +3 -2
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts +46 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +211 -50
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -0
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +87 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +292 -105
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
- package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataContextDataResolver.js +117 -38
- package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
- package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
- package/dist/resolvers/GetDataResolver.js +6 -3
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +49 -9
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts +8 -1
- package/dist/resolvers/MergeRecordsResolver.d.ts.map +1 -1
- package/dist/resolvers/MergeRecordsResolver.js +31 -3
- package/dist/resolvers/MergeRecordsResolver.js.map +1 -1
- package/dist/resolvers/PipelineProgressResolver.d.ts +6 -0
- package/dist/resolvers/PipelineProgressResolver.d.ts.map +1 -1
- package/dist/resolvers/PipelineProgressResolver.js +5 -3
- package/dist/resolvers/PipelineProgressResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +2 -0
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +33 -6
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +2 -0
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +15 -6
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RSUResolver.d.ts.map +1 -1
- package/dist/resolvers/RSUResolver.js +12 -0
- package/dist/resolvers/RSUResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +18 -10
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +92 -54
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +11 -9
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +56 -8
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +241 -67
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +4 -4
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +3 -1
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts +12 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeStreamResolver.js +15 -2
- package/dist/resolvers/SearchKnowledgeStreamResolver.js.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
- package/dist/resolvers/SqlLoggingConfigResolver.js +2 -3
- package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +4 -0
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +2 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +28 -1
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.d.ts +24 -0
- package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
- package/dist/resolvers/TestQuerySQLResolver.js +66 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +34 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +150 -27
- package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +2 -1
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/resolvers/agent-run-data-guard.d.ts +31 -0
- package/dist/resolvers/agent-run-data-guard.d.ts.map +1 -0
- package/dist/resolvers/agent-run-data-guard.js +72 -0
- package/dist/resolvers/agent-run-data-guard.js.map +1 -0
- package/dist/rest/EntityCRUDHandler.d.ts +6 -0
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +19 -5
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +8 -5
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/rest/RealtimeRecordingHandler.d.ts +49 -0
- package/dist/rest/RealtimeRecordingHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeRecordingHandler.js +383 -0
- package/dist/rest/RealtimeRecordingHandler.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +9 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +43 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/rest/setupRESTEndpoints.d.ts +6 -0
- package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
- package/dist/rest/setupRESTEndpoints.js +6 -0
- package/dist/rest/setupRESTEndpoints.js.map +1 -1
- package/dist/serverExtensionReservedRoots.d.ts +12 -10
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +14 -17
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/dist/serverRoleConfigUnits.d.ts +29 -0
- package/dist/serverRoleConfigUnits.d.ts.map +1 -0
- package/dist/serverRoleConfigUnits.js +33 -0
- package/dist/serverRoleConfigUnits.js.map +1 -0
- package/dist/services/MJStorageBlobStore.d.ts +8 -1
- package/dist/services/MJStorageBlobStore.d.ts.map +1 -1
- package/dist/services/MJStorageBlobStore.js +25 -19
- package/dist/services/MJStorageBlobStore.js.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +2 -2
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +5 -3
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -5
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +14 -2
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +157 -9
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts +59 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +626 -16
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/sharedCache.d.ts.map +1 -1
- package/dist/sharedCache.js +3 -0
- package/dist/sharedCache.js.map +1 -1
- package/dist/types.d.ts +23 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +99 -98
- package/src/__tests__/ActionResolver.fieldSecurity.test.ts +237 -0
- package/src/__tests__/AdhocQueryResolver.dialect.test.ts +137 -0
- package/src/__tests__/AgentRunStatusPublisher.test.ts +49 -0
- package/src/__tests__/AgentSessionResolver.ownership.test.ts +137 -0
- package/src/__tests__/CustomColumnPromoterOnePass.test.ts +15 -1
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityCommunicationsResolver.actingUser.test.ts +151 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/EvolutionActsOnWhatItDetects.test.ts +113 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +369 -18
- package/src/__tests__/FileResolver.fieldSecurity.test.ts +82 -0
- package/src/__tests__/FileResolver.storageAccess.test.ts +562 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/LoginScreenEngine.test.ts +86 -0
- package/src/__tests__/MJStorageBlobStore.storageGate.test.ts +63 -0
- package/src/__tests__/PipelineProgressResolver.filter.test.ts +58 -0
- package/src/__tests__/QuerySystemUserResolver.fieldSecurity.test.ts +49 -0
- package/src/__tests__/RESTEndpointHandler.test.ts +25 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +23 -3
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +324 -46
- package/src/__tests__/RecordingAssemblyScheduler.test.ts +178 -0
- package/src/__tests__/RemoteBrowserActionResolver.ownership.test.ts +98 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +133 -2
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.clientOldValues.test.ts +243 -7
- package/src/__tests__/ResolverBase.deleteReturnProjection.test.ts +52 -0
- package/src/__tests__/ResolverBase.entityMessageRelay.test.ts +176 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/ResolverBase.includeBinaryFields.test.ts +23 -1
- package/src/__tests__/ResolverBase.publishStatusUpdate.test.ts +68 -0
- package/src/__tests__/ResolverBase.userViewCacheAndDedup.test.ts +130 -0
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +69 -13
- package/src/__tests__/RunAIAgentResolver.conversationWriter.test.ts +210 -0
- package/src/__tests__/RunAIAgentResolver.fireAndForgetCompletion.test.ts +209 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +20 -28
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +264 -0
- package/src/__tests__/RunAIAgentResolver.serverReply.test.ts +358 -0
- package/src/__tests__/RunAIAgentResolver.stoppedPredecessor.test.ts +4 -1
- package/src/__tests__/RunTemplateResolver.test.ts +35 -2
- package/src/__tests__/RunViewResolver.cacheCheckForwarding.test.ts +11 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SearchKnowledgeStreamResolver.test.ts +161 -0
- package/src/__tests__/SessionJanitor.test.ts +365 -10
- package/src/__tests__/SessionManager.test.ts +122 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +468 -23
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.fieldSecurity.test.ts +199 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +15 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +7 -2
- package/src/__tests__/WidgetSessionService.test.ts +583 -4
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/buildMagicLinkSessionUser.test.ts +96 -0
- package/src/__tests__/cacheBroadcastProviders.test.ts +403 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +109 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/fieldSecurityFixtures.ts +91 -0
- package/src/__tests__/globalCors.test.ts +217 -0
- package/src/__tests__/localDevAuth.test.ts +196 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/orphanedConversationDetailReconciler.test.ts +10 -0
- package/src/__tests__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +64 -1
- package/src/__tests__/realtimeRecordingHandler.test.ts +669 -0
- package/src/__tests__/realtimeSessions/RealtimeSessionEventsResolver.test.ts +29 -1
- package/src/__tests__/realtimeSessions/ownedRealtimeSession.anonymous.test.ts +31 -0
- package/src/__tests__/realtimeSessions/ownedRealtimeSession.namedScoped.test.ts +37 -0
- package/src/__tests__/realtimeSessions/sessionAccess.test.ts +146 -7
- package/src/__tests__/realtimeSessions/sessionConfigStamps.test.ts +1 -1
- package/src/__tests__/realtimeSessions/verificationWorkflow.test.ts +25 -4
- package/src/__tests__/resolverBase.cloudEventChangedFields.test.ts +157 -0
- package/src/__tests__/resolverBase.fieldSecurityTransport.test.ts +222 -0
- package/src/__tests__/resolverBase.fls.test.ts +146 -97
- package/src/__tests__/restTransport.fieldSecurity.test.ts +148 -0
- package/src/__tests__/runParamsTimeZone.test.ts +179 -0
- package/src/__tests__/runStreams.fieldSecurity.test.ts +100 -0
- package/src/__tests__/runViewProperties.test.ts +114 -0
- package/src/__tests__/scalabilityBenchmarks.test.ts +765 -0
- package/src/__tests__/scalabilityDatabaseIntegration.test.ts +207 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sessionUserClone.test.ts +5 -1
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/__tests__/startupSweepOrder.test.ts +40 -0
- package/src/__tests__/unifiedAuth.test.ts +28 -0
- package/src/__tests__/widgetGuestElevation.test.ts +2 -0
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +45 -10
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +29 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +57 -12
- package/src/auth/initializeProviders.ts +43 -1
- package/src/auth/localDevAuth.ts +528 -0
- package/src/auth/magicLink/MagicLinkKeys.ts +2 -2
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/auth/magicLink/types.ts +10 -0
- package/src/auth/sessionUserClone.ts +3 -2
- package/src/broadcast/BaseCacheInvalidationBroadcastProvider.ts +674 -0
- package/src/broadcast/index.ts +5 -0
- package/src/broadcast/providers/AWSIoTCoreBroadcastProvider.ts +180 -0
- package/src/broadcast/providers/AzureWebPubSubBroadcastProvider.ts +182 -0
- package/src/broadcast/providers/GCPPubSubBroadcastProvider.ts +169 -0
- package/src/broadcast/providers/SequencedRoleBucketBroadcastProvider.ts +57 -0
- package/src/cache.ts +2 -1
- package/src/config.ts +115 -5
- package/src/context.ts +32 -8
- package/src/coreRoutes.ts +181 -0
- package/src/generated/graphql-schemas/__mj.ts +3684 -284
- package/src/generic/CacheInvalidationResolver.ts +172 -60
- package/src/generic/OrphanedConversationDetailReconciler.ts +4 -2
- package/src/generic/PubSubManager.ts +54 -1
- package/src/generic/PushStatusResolver.ts +109 -12
- package/src/generic/ResolverBase.ts +540 -431
- package/src/generic/RunViewResolver.ts +97 -15
- package/src/generic/actionOutputFieldSecurity.ts +75 -0
- package/src/generic/fieldSecurityTransport.ts +62 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +239 -238
- package/src/integration/CustomColumnPromoter.ts +6 -1
- package/src/logging/variablesLoggingMiddleware.ts +59 -3
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/README.md +7 -3
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/SessionVerificationPortsBase.ts +1 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +69 -0
- package/src/realtimeSessions/sessionAccess.ts +183 -23
- package/src/realtimeSessions/sessionConfigStamps.ts +1 -1
- package/src/realtimeSessions/subscriptionAuthorization.ts +4 -2
- package/src/realtimeSessions/verificationWorkflow.ts +3 -1
- package/src/realtimeWidget/WidgetRouter.ts +22 -4
- package/src/realtimeWidget/WidgetSessionService.ts +252 -17
- package/src/realtimeWidget/host-identity.ts +1 -1
- package/src/realtimeWidget/widgetCore.ts +30 -1
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -1
- package/src/resolvers/ActionResolver.ts +78 -15
- package/src/resolvers/AdhocQueryResolver.ts +1 -0
- package/src/resolvers/AgentRunStatusPublisher.ts +36 -8
- package/src/resolvers/AgentSessionResolver.ts +46 -18
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityCommunicationsResolver.ts +56 -15
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +248 -55
- package/src/resolvers/FileCategoryResolver.ts +1 -0
- package/src/resolvers/FileResolver.ts +334 -109
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +53 -9
- package/src/resolvers/MergeRecordsResolver.ts +23 -2
- package/src/resolvers/PipelineProgressResolver.ts +12 -3
- package/src/resolvers/QueryResolver.ts +31 -6
- package/src/resolvers/QuerySystemUserResolver.ts +15 -6
- package/src/resolvers/RSUResolver.ts +12 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +114 -66
- package/src/resolvers/RemoteBrowserActionResolver.ts +15 -10
- package/src/resolvers/RunAIAgentResolver.ts +296 -85
- package/src/resolvers/RunTemplateResolver.ts +6 -4
- package/src/resolvers/RunTestResolver.ts +3 -1
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/SyncDataResolver.ts +4 -0
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +69 -13
- package/src/resolvers/TransactionGroupResolver.ts +185 -30
- package/src/resolvers/UserViewResolver.ts +2 -1
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/MergeRecordsResolver.test.ts +43 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/EntityCRUDHandler.ts +22 -5
- package/src/rest/RESTEndpointHandler.ts +8 -5
- package/src/rest/RealtimeRecordingHandler.ts +467 -0
- package/src/rest/ViewOperationsHandler.ts +52 -18
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- package/src/serverRoleConfigUnits.ts +48 -0
- package/src/services/MJStorageBlobStore.ts +31 -30
- package/src/services/StartTaskGraphDispatcher.ts +2 -2
- package/src/services/TaskGraphActionRunner.ts +25 -5
- package/src/services/TaskGraphAgentRunner.ts +188 -11
- package/src/services/TaskGraphContinuationDeliverer.ts +738 -16
- package/src/sharedCache.ts +3 -0
- package/src/types.ts +23 -1
|
@@ -1,19 +1,36 @@
|
|
|
1
|
-
import { BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, EntityPermissionType, KeyValuePair, LogDebug, LogError, LogStatus, Metadata, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
|
|
2
|
-
import {
|
|
1
|
+
import { AssertClientInputWritableForUser, BaseEntity, BaseEntityEvent, CompositeKey, EntityFieldTSType, EntityPermissionType, KeyValuePair, LogDebug, LogError, LogErrorEx, LogStatus, Metadata, ProjectEntityValuesForUser, ReadableFieldsTransportKey, RecordChangeFieldSecurityProjector, RecordChangePayloadFields, } from '@memberjunction/core';
|
|
2
|
+
import { UserViewEngine } from '@memberjunction/core-entities';
|
|
3
|
+
import { ClientClauseScreen, UserCache } from '@memberjunction/generic-database-provider';
|
|
3
4
|
import { AuthorizationError } from 'type-graphql';
|
|
4
5
|
import { GraphQLError } from 'graphql';
|
|
5
6
|
import { RefusalExtensions } from './refusalExtensions.js';
|
|
6
7
|
import { GetAPIKeyEngine } from '@memberjunction/api-keys';
|
|
7
8
|
import { httpTransport, CloudEvent, emitterFor } from 'cloudevents';
|
|
8
9
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
9
|
-
import { MJEventType, MJGlobal, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
10
|
-
import { SQLParser } from '@memberjunction/sql-parser';
|
|
11
|
-
import { PostgreSQLDialect, SQLServerDialect } from '@memberjunction/sql-dialect';
|
|
10
|
+
import { MJEventType, MJGlobal, UUIDsEqual, ENCRYPTED_SENTINEL, ReplaceByteArraysWithBase64, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
11
|
+
import { SQLParser, IsBalancedSQLFragment, LexSQL } from '@memberjunction/sql-parser';
|
|
12
12
|
import { EncryptionEngine, IsEncryptionKeyUnavailable, ReportEncryptionKeyUnavailable } from '@memberjunction/encryption';
|
|
13
|
-
import { PublishStatusUpdate } from './PushStatusResolver.js';
|
|
13
|
+
import { PublishStatusUpdate, StatusUpdateOwnerID } from './PushStatusResolver.js';
|
|
14
14
|
import { CACHE_INVALIDATION_TOPIC, MayBroadcastRecordData } from './CacheInvalidationResolver.js';
|
|
15
15
|
import { PubSubManager } from './PubSubManager.js';
|
|
16
16
|
import { FieldMapper } from '@memberjunction/graphql-dataprovider';
|
|
17
|
+
/**
|
|
18
|
+
* Entities whose update is always hydrated from the stored row, never from the client's `OldValues___`, though they
|
|
19
|
+
* do not track record changes: their server-side write rules compare against the prior state, which a client could
|
|
20
|
+
* otherwise supply itself (lower-case names). See `ResolverBase.MustLoadTruthFromDatabase`.
|
|
21
|
+
*
|
|
22
|
+
* `mj: conversations` and `mj: ai agent sessions` carry the anonymous realtime-session ownership key
|
|
23
|
+
* (`ExternalID` / `ConversationID`), which their server entities refuse an anonymous visitor to change — a rule
|
|
24
|
+
* that decides on `Dirty`, so the baseline must be the stored row, not a client-supplied one (#4931).
|
|
25
|
+
*/
|
|
26
|
+
const ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW = new Set([
|
|
27
|
+
'mj: record changes',
|
|
28
|
+
'mj: conversation details',
|
|
29
|
+
'mj: data context items',
|
|
30
|
+
'mj: conversations',
|
|
31
|
+
'mj: ai agent sessions',
|
|
32
|
+
'mj: ai agent runs',
|
|
33
|
+
]);
|
|
17
34
|
export class ResolverBase {
|
|
18
35
|
static { this._emit = process.env.CLOUDEVENTS_HTTP_TRANSPORT ? emitterFor(httpTransport(process.env.CLOUDEVENTS_HTTP_TRANSPORT)) : null; }
|
|
19
36
|
static { this._cloudeventsHeaders = process.env.CLOUDEVENTS_HTTP_HEADERS ? JSON.parse(process.env.CLOUDEVENTS_HTTP_HEADERS) : {}; }
|
|
@@ -51,131 +68,123 @@ export class ResolverBase {
|
|
|
51
68
|
* @param contextUser - Optional user context for decryption (required for encrypted fields)
|
|
52
69
|
* @returns A new object in transport shape, or null when there is nothing to map
|
|
53
70
|
*/
|
|
54
|
-
|
|
55
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Precomputed per-entity / per-request field-mapping plan shared across rows in
|
|
73
|
+
* {@link MapFieldNamesToCodeNames} and {@link ArrayMapFieldNamesToCodeNames}.
|
|
74
|
+
*/
|
|
75
|
+
buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector) {
|
|
76
|
+
const md = provider ?? new Metadata();
|
|
77
|
+
const entityInfo = md.EntityByName(entityName);
|
|
78
|
+
if (!entityInfo)
|
|
79
|
+
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
80
|
+
const denied = deniedReadFields ?? (entityInfo.FieldLevelSecurityApplies && contextUser
|
|
81
|
+
? entityInfo.GetDeniedReadFields(contextUser)
|
|
82
|
+
: null);
|
|
83
|
+
const mapper = new FieldMapper();
|
|
84
|
+
const fieldsToProcess = [];
|
|
85
|
+
for (const f of entityInfo.Fields) {
|
|
86
|
+
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
87
|
+
const isDenied = Boolean(denied?.has(f.Name.trim().toLowerCase()));
|
|
88
|
+
if (isDenied || mappedFieldName !== f.Name) {
|
|
89
|
+
fieldsToProcess.push({ name: f.Name, mappedFieldName, isDenied });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
93
|
+
const projector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
94
|
+
? (recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser))
|
|
95
|
+
: undefined;
|
|
96
|
+
const encryptedFields = entityInfo.EncryptedFields ?? [];
|
|
97
|
+
const checkBinary = entityInfo.HasBinaryFields !== false;
|
|
98
|
+
return {
|
|
99
|
+
entityInfo,
|
|
100
|
+
fieldsToProcess,
|
|
101
|
+
readableFields,
|
|
102
|
+
projector,
|
|
103
|
+
encryptedFields,
|
|
104
|
+
checkBinary,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Applies synchronous field renaming, FLS stripping, readable-fields transport stamping, and
|
|
109
|
+
* Record Changes payload projection for a single row using a precomputed mapping plan.
|
|
110
|
+
*/
|
|
111
|
+
mapRowSynchronously(dataObject, plan) {
|
|
56
112
|
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
57
113
|
return null;
|
|
58
114
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!entityInfo)
|
|
73
|
-
throw new Error(`Entity ${entityName} not found in metadata`);
|
|
74
|
-
// const fields = entityInfo.Fields.filter((f) => f.Name !== f.CodeName || f.Name.startsWith('__mj_'));
|
|
75
|
-
// FIELD-LEVEL SECURITY — this is the authoritative read boundary for every GraphQL
|
|
76
|
-
// return path (single-record resolvers, external-data-source loads, and anything else
|
|
77
|
-
// routed through here), sitting exactly where encryption masking already lives.
|
|
78
|
-
//
|
|
79
|
-
// The denied set is normally computed ONCE per (entity, user) by the caller and passed
|
|
80
|
-
// in: this method runs once per ROW, so resolving it here would cost fields x rows
|
|
81
|
-
// aggregations. We fall back to computing it when a caller doesn't supply it (the
|
|
82
|
-
// single-record resolvers, where "once per row" and "once per request" are the same
|
|
83
|
-
// thing) rather than failing open — a missing argument must never mean missing security.
|
|
84
|
-
const denied = deniedReadFields ?? (entityInfo.EnableFieldLevelSecurity && contextUser
|
|
85
|
-
? entityInfo.GetDeniedReadFields(contextUser)
|
|
86
|
-
: null);
|
|
87
|
-
const mapper = new FieldMapper();
|
|
88
|
-
entityInfo.Fields.forEach((f) => {
|
|
89
|
-
if (dataObject.hasOwnProperty(f.Name)) {
|
|
90
|
-
// GraphQL doesn't allow us to pass back fields with __ so we are mapping our special field cases that start with __mj_ to _mj__ for transport - they are converted back on the other side automatically
|
|
91
|
-
const mappedFieldName = mapper.MapFieldName(f.CodeName);
|
|
92
|
-
if (denied?.has(f.Name.trim().toLowerCase())) {
|
|
93
|
-
// Omit entirely rather than nulling — a null is indistinguishable from a real
|
|
94
|
-
// null value, and the client should see the field as absent, not as empty.
|
|
95
|
-
// Both key shapes are removed because callers reach this method with rows keyed
|
|
96
|
-
// either way depending on whether mapping has already run.
|
|
97
|
-
delete dataObject[f.Name];
|
|
98
|
-
delete dataObject[mappedFieldName];
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
if (mappedFieldName !== f.Name) {
|
|
102
|
-
dataObject[mappedFieldName] = dataObject[f.Name];
|
|
103
|
-
delete dataObject[f.Name];
|
|
104
|
-
}
|
|
115
|
+
let row = plan.checkBinary
|
|
116
|
+
? ReplaceByteArraysWithBase64({ ...dataObject })
|
|
117
|
+
: { ...dataObject };
|
|
118
|
+
for (let i = 0; i < plan.fieldsToProcess.length; i++) {
|
|
119
|
+
const fp = plan.fieldsToProcess[i];
|
|
120
|
+
if (Object.prototype.hasOwnProperty.call(row, fp.name)) {
|
|
121
|
+
if (fp.isDenied) {
|
|
122
|
+
delete row[fp.name];
|
|
123
|
+
delete row[fp.mappedFieldName];
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
row[fp.mappedFieldName] = row[fp.name];
|
|
127
|
+
delete row[fp.name];
|
|
105
128
|
}
|
|
106
|
-
});
|
|
107
|
-
// Field security, second half: say IN-BAND which fields this caller may read. Deleting the
|
|
108
|
-
// key above is not enough on its own — GraphQL emits every SELECTED field regardless, so a
|
|
109
|
-
// denied field the client asked for arrives as an explicit null it cannot tell apart from a
|
|
110
|
-
// genuine one. The client must not settle that from its own metadata: during the window
|
|
111
|
-
// after a permission change its copy disagrees with ours, and once metadata filtering lands
|
|
112
|
-
// (issue #3485) it may not hold the permission rows at all. Null for unrestricted callers,
|
|
113
|
-
// which is nearly every request.
|
|
114
|
-
const readableFields = this.BuildReadableFieldsTransportValue(entityInfo, denied);
|
|
115
|
-
if (readableFields) {
|
|
116
|
-
dataObject[ReadableFieldsTransportKey] = readableFields;
|
|
117
|
-
}
|
|
118
|
-
// Field security, third part — the audit trail. A 'MJ: Record Changes' row carries the old
|
|
119
|
-
// and new values of ANOTHER entity's fields, and its own EnableFieldLevelSecurity flag is
|
|
120
|
-
// off, so everything above is a no-op for it and the payload would flow out untouched. The
|
|
121
|
-
// projector recomputes the denied set against the entity each row is about; see
|
|
122
|
-
// RecordChangeFieldSecurityProjector for what each payload column gets.
|
|
123
|
-
//
|
|
124
|
-
// Same "a missing argument must never mean missing security" rule as the denied set above:
|
|
125
|
-
// the projector is normally built ONCE per request by the array caller and passed in, and
|
|
126
|
-
// we build one here when it wasn't — the single-record resolvers, where once-per-row and
|
|
127
|
-
// once-per-request are the same thing.
|
|
128
|
-
if (contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
129
|
-
const projector = recordChangeProjector ?? new RecordChangeFieldSecurityProjector(md, contextUser);
|
|
130
|
-
dataObject = projector.ProjectRow(dataObject);
|
|
131
129
|
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
130
|
+
}
|
|
131
|
+
if (plan.readableFields) {
|
|
132
|
+
row[ReadableFieldsTransportKey] = plan.readableFields;
|
|
133
|
+
}
|
|
134
|
+
if (plan.projector) {
|
|
135
|
+
row = plan.projector.ProjectRow(row);
|
|
136
|
+
}
|
|
137
|
+
return row;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Decrypts or masks encrypted fields on a mapped row when `plan.encryptedFields.length > 0`.
|
|
141
|
+
*/
|
|
142
|
+
async decryptRowEncryptedFields(row, encryptedFields, contextUser) {
|
|
143
|
+
for (const field of encryptedFields) {
|
|
144
|
+
const fieldName = field.CodeName;
|
|
145
|
+
const value = row[fieldName];
|
|
146
|
+
if (value === null || value === undefined)
|
|
147
|
+
continue;
|
|
148
|
+
const engine = EncryptionEngine.Instance;
|
|
149
|
+
await engine.Config(false, contextUser);
|
|
150
|
+
const keyMarker = field.EncryptionKeyID ? engine.GetKeyByID(field.EncryptionKeyID)?.Marker : undefined;
|
|
151
|
+
if (typeof value === 'string' && IsValueEncrypted(value, keyMarker)) {
|
|
152
|
+
if (field.AllowDecryptInAPI) {
|
|
153
|
+
if (contextUser) {
|
|
154
|
+
try {
|
|
155
|
+
const decryptedValue = await engine.Decrypt(value, contextUser);
|
|
156
|
+
row[fieldName] = decryptedValue;
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
if (IsEncryptionKeyUnavailable(err)) {
|
|
160
|
+
ReportEncryptionKeyUnavailable(err);
|
|
163
161
|
}
|
|
164
162
|
else {
|
|
165
|
-
|
|
166
|
-
dataObject[fieldName] = ENCRYPTED_SENTINEL;
|
|
163
|
+
LogError(`Failed to decrypt field ${fieldName} for API response: ${err}`);
|
|
167
164
|
}
|
|
165
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
168
166
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
}
|
|
173
|
-
// else: AllowDecryptInAPI=false and SendEncryptedValue=true - keep encrypted value as-is
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
174
170
|
}
|
|
175
171
|
}
|
|
172
|
+
else if (!field.SendEncryptedValue) {
|
|
173
|
+
row[fieldName] = ENCRYPTED_SENTINEL;
|
|
174
|
+
}
|
|
176
175
|
}
|
|
177
176
|
}
|
|
178
|
-
|
|
177
|
+
}
|
|
178
|
+
async MapFieldNamesToCodeNames(entityName, dataObject, contextUser, provider, deniedReadFields, recordChangeProjector) {
|
|
179
|
+
if (!dataObject || Object.keys(dataObject).length === 0) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider, deniedReadFields, recordChangeProjector);
|
|
183
|
+
const mapped = this.mapRowSynchronously(dataObject, plan);
|
|
184
|
+
if (mapped && plan.encryptedFields.length > 0) {
|
|
185
|
+
await this.decryptRowEncryptedFields(mapped, plan.encryptedFields, contextUser);
|
|
186
|
+
}
|
|
187
|
+
return mapped;
|
|
179
188
|
}
|
|
180
189
|
/**
|
|
181
190
|
* Reverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object.
|
|
@@ -232,26 +241,17 @@ export class ResolverBase {
|
|
|
232
241
|
if (!dataObjectArray || dataObjectArray.length === 0) {
|
|
233
242
|
return dataObjectArray;
|
|
234
243
|
}
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
// span many — so the projector, which owns that memo, must outlive the row loop. Built once
|
|
247
|
-
// here for the same reason the denied set above is: GetDeniedReadFields walks every field on
|
|
248
|
-
// an entity and must never be called per row.
|
|
249
|
-
const recordChangeProjector = contextUser && RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)
|
|
250
|
-
? new RecordChangeFieldSecurityProjector(md, contextUser)
|
|
251
|
-
: undefined;
|
|
252
|
-
const mapped = [];
|
|
253
|
-
for (const element of dataObjectArray) {
|
|
254
|
-
mapped.push(await this.MapFieldNamesToCodeNames(entityName, element, contextUser, provider, deniedReadFields, recordChangeProjector));
|
|
244
|
+
// Precompute the entity lookup, FieldMapper plan, denied-fields set, readable-fields list,
|
|
245
|
+
// and Record Changes projector ONCE for the whole array rather than per row.
|
|
246
|
+
const plan = this.buildFieldMappingPlan(entityName, contextUser, provider);
|
|
247
|
+
const hasEncryptedFields = plan.encryptedFields.length > 0;
|
|
248
|
+
const mapped = new Array(dataObjectArray.length);
|
|
249
|
+
for (let i = 0; i < dataObjectArray.length; i++) {
|
|
250
|
+
const row = this.mapRowSynchronously(dataObjectArray[i], plan);
|
|
251
|
+
if (row && hasEncryptedFields) {
|
|
252
|
+
await this.decryptRowEncryptedFields(row, plan.encryptedFields, contextUser);
|
|
253
|
+
}
|
|
254
|
+
mapped[i] = row;
|
|
255
255
|
}
|
|
256
256
|
return mapped;
|
|
257
257
|
}
|
|
@@ -292,6 +292,7 @@ export class ResolverBase {
|
|
|
292
292
|
if (!loaded) {
|
|
293
293
|
return null;
|
|
294
294
|
}
|
|
295
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
295
296
|
const mapped = await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
296
297
|
return mapped;
|
|
297
298
|
}
|
|
@@ -307,9 +308,11 @@ export class ResolverBase {
|
|
|
307
308
|
* @param dataObject - The data object containing field values
|
|
308
309
|
* @param encryptionEngine - Optional encryption engine for re-encryption (lazy loaded if needed)
|
|
309
310
|
* @param contextUser - User context for encryption operations
|
|
311
|
+
* @param fieldKey - What the object's keys are: field CodeNames (GraphQL transport rows, the
|
|
312
|
+
* default) or field Names (raw provider rows, such as dataset item results)
|
|
310
313
|
* @returns The filtered data object
|
|
311
314
|
*/
|
|
312
|
-
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider) {
|
|
315
|
+
async FilterEncryptedFieldsForAPI(entityName, dataObject, contextUser, provider, fieldKey = 'CodeName') {
|
|
313
316
|
if (!dataObject)
|
|
314
317
|
return dataObject;
|
|
315
318
|
const md = provider ?? new Metadata();
|
|
@@ -322,7 +325,7 @@ export class ResolverBase {
|
|
|
322
325
|
return dataObject;
|
|
323
326
|
// Process each encrypted field
|
|
324
327
|
for (const field of encryptedFields) {
|
|
325
|
-
const fieldName = field.CodeName;
|
|
328
|
+
const fieldName = fieldKey === 'Name' ? field.Name : field.CodeName;
|
|
326
329
|
const value = dataObject[fieldName];
|
|
327
330
|
// Skip null/undefined values
|
|
328
331
|
if (value === null || value === undefined)
|
|
@@ -358,9 +361,10 @@ export class ResolverBase {
|
|
|
358
361
|
return dataObject;
|
|
359
362
|
}
|
|
360
363
|
/**
|
|
361
|
-
* Filters encrypted fields for an array of data objects
|
|
364
|
+
* Filters encrypted fields for an array of data objects. `fieldKey` is as for
|
|
365
|
+
* {@link FilterEncryptedFieldsForAPI}.
|
|
362
366
|
*/
|
|
363
|
-
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider) {
|
|
367
|
+
async ArrayFilterEncryptedFieldsForAPI(entityName, dataObjectArray, contextUser, provider, fieldKey = 'CodeName') {
|
|
364
368
|
if (!dataObjectArray || dataObjectArray.length === 0)
|
|
365
369
|
return dataObjectArray;
|
|
366
370
|
// Check if entity has any encrypted fields first to avoid unnecessary processing
|
|
@@ -373,7 +377,7 @@ export class ResolverBase {
|
|
|
373
377
|
return dataObjectArray;
|
|
374
378
|
// Process each element
|
|
375
379
|
for (const element of dataObjectArray) {
|
|
376
|
-
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider);
|
|
380
|
+
await this.FilterEncryptedFieldsForAPI(entityName, element, contextUser, provider, fieldKey);
|
|
377
381
|
}
|
|
378
382
|
return dataObjectArray;
|
|
379
383
|
}
|
|
@@ -454,23 +458,54 @@ export class ResolverBase {
|
|
|
454
458
|
return [];
|
|
455
459
|
}
|
|
456
460
|
}
|
|
461
|
+
/**
|
|
462
|
+
* Resolves a User View by name, consulting the in-memory {@link UserViewEngine} cache first
|
|
463
|
+
* and falling back to a database `RunView` only on a cache miss.
|
|
464
|
+
*/
|
|
465
|
+
async resolveUserViewByName(viewName, provider, contextUser) {
|
|
466
|
+
if (UserViewEngine.Instance.Loaded) {
|
|
467
|
+
const cached = UserViewEngine.Instance.GetViewByName(viewName);
|
|
468
|
+
if (cached) {
|
|
469
|
+
return cached;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const rv = provider;
|
|
473
|
+
const result = await rv.RunView({
|
|
474
|
+
EntityName: 'MJ: User Views',
|
|
475
|
+
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
476
|
+
ExtraFilter: "Name='" + EscapeSQLString(viewName) + "'",
|
|
477
|
+
}, contextUser);
|
|
478
|
+
if (result && result.Success && result.Results.length > 0) {
|
|
479
|
+
return result.Results[0];
|
|
480
|
+
}
|
|
481
|
+
return null;
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Resolves a User View by ID, consulting the in-memory {@link UserViewEngine} cache first
|
|
485
|
+
* and falling back to an entity `Load()` only on a cache miss.
|
|
486
|
+
*/
|
|
487
|
+
async resolveUserViewByID(viewID, provider, contextUser) {
|
|
488
|
+
if (UserViewEngine.Instance.Loaded) {
|
|
489
|
+
const cached = UserViewEngine.Instance.GetViewById(viewID);
|
|
490
|
+
if (cached) {
|
|
491
|
+
return cached;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const viewInfo = await provider.GetEntityObject('MJ: User Views', contextUser);
|
|
495
|
+
await viewInfo.Load(viewID);
|
|
496
|
+
return viewInfo;
|
|
497
|
+
}
|
|
457
498
|
async RunViewByNameGeneric(viewInput, provider, userPayload, pubSub) {
|
|
458
499
|
try {
|
|
459
500
|
// Log aggregate input for debugging
|
|
460
501
|
if (viewInput.Aggregates?.length) {
|
|
461
502
|
LogStatus(`[ResolverBase] RunViewByNameGeneric received aggregates: viewName=${viewInput.ViewName}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
|
|
462
503
|
}
|
|
463
|
-
const
|
|
464
|
-
|
|
465
|
-
EntityName: 'MJ: User Views',
|
|
466
|
-
// SECURITY: the view name is client-supplied and lands inside a SQL literal.
|
|
467
|
-
ExtraFilter: "Name='" + EscapeSQLString(viewInput.ViewName) + "'",
|
|
468
|
-
}, userPayload.userRecord);
|
|
469
|
-
if (result && result.Success && result.Results.length > 0) {
|
|
470
|
-
const viewInfo = result.Results[0];
|
|
504
|
+
const viewInfo = await this.resolveUserViewByName(viewInput.ViewName, provider, userPayload.userRecord);
|
|
505
|
+
if (viewInfo) {
|
|
471
506
|
return this.RunViewGenericInternal(provider, viewInfo, viewInput.ExtraFilter, viewInput.OrderBy, viewInput.UserSearchString, viewInput.ExcludeUserViewRunID, viewInput.OverrideExcludeFilter, viewInput.SaveViewResults, viewInput.Fields, viewInput.IgnoreMaxRows, viewInput.ExcludeDataFromAllPriorViewRuns, viewInput.ForceAuditLog, viewInput.AuditLogDescription, viewInput.ResultType, userPayload, viewInput.MaxRows, viewInput.StartRow, viewInput.Aggregates, viewInput.AfterKey
|
|
472
507
|
? CompositeKey.FromKeyValuePairs(viewInput.AfterKey.KeyValuePairs)
|
|
473
|
-
: undefined, viewInput.BypassCache, viewInput.DataSource, viewInput.IncludeBinaryFields);
|
|
508
|
+
: undefined, viewInput.BypassCache, viewInput.DataSource, viewInput.IncludeBinaryFields, viewInput.AlternateBaseView, viewInput.Properties);
|
|
474
509
|
}
|
|
475
510
|
else {
|
|
476
511
|
LogError(`RunViewByNameGeneric: View ${viewInput.ViewName} not found or no results returned`);
|
|
@@ -489,9 +524,11 @@ export class ResolverBase {
|
|
|
489
524
|
LogStatus(`[ResolverBase] RunViewByIDGeneric received aggregates: viewID=${viewInput.ViewID}, aggregateCount=${viewInput.Aggregates.length}, aggregates=${JSON.stringify(viewInput.Aggregates.map(a => ({ expression: a.expression, alias: a.alias })))}`);
|
|
490
525
|
}
|
|
491
526
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
492
|
-
const viewInfo = await
|
|
493
|
-
|
|
494
|
-
|
|
527
|
+
const viewInfo = await this.resolveUserViewByID(viewInput.ViewID, provider, contextUser);
|
|
528
|
+
if (!viewInfo) {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
return this.RunViewGenericInternal(provider, viewInfo, viewInput.ExtraFilter, viewInput.OrderBy, viewInput.UserSearchString, viewInput.ExcludeUserViewRunID, viewInput.OverrideExcludeFilter, viewInput.SaveViewResults, viewInput.Fields, viewInput.IgnoreMaxRows, viewInput.ExcludeDataFromAllPriorViewRuns, viewInput.ForceAuditLog, viewInput.AuditLogDescription, viewInput.ResultType, userPayload, viewInput.MaxRows, viewInput.StartRow, viewInput.Aggregates, undefined, viewInput.BypassCache, viewInput.DataSource, viewInput.IncludeBinaryFields, viewInput.AlternateBaseView, viewInput.Properties);
|
|
495
532
|
}
|
|
496
533
|
catch (err) {
|
|
497
534
|
console.log(err);
|
|
@@ -514,7 +551,7 @@ export class ResolverBase {
|
|
|
514
551
|
EntityID: entity.ID,
|
|
515
552
|
EntityBaseView: entity.BaseView,
|
|
516
553
|
}; // only providing a few bits of data here, but it's enough to get the view to run
|
|
517
|
-
return this.RunViewGenericInternal(provider, viewInfo, viewInput.ExtraFilter, viewInput.OrderBy, viewInput.UserSearchString, viewInput.ExcludeUserViewRunID, viewInput.OverrideExcludeFilter, false, viewInput.Fields, viewInput.IgnoreMaxRows, false, viewInput.ForceAuditLog, viewInput.AuditLogDescription, viewInput.ResultType, userPayload, viewInput.MaxRows, viewInput.StartRow, viewInput.Aggregates, undefined, viewInput.BypassCache, viewInput.DataSource, viewInput.IncludeBinaryFields);
|
|
554
|
+
return this.RunViewGenericInternal(provider, viewInfo, viewInput.ExtraFilter, viewInput.OrderBy, viewInput.UserSearchString, viewInput.ExcludeUserViewRunID, viewInput.OverrideExcludeFilter, false, viewInput.Fields, viewInput.IgnoreMaxRows, false, viewInput.ForceAuditLog, viewInput.AuditLogDescription, viewInput.ResultType, userPayload, viewInput.MaxRows, viewInput.StartRow, viewInput.Aggregates, undefined, viewInput.BypassCache, viewInput.DataSource, viewInput.IncludeBinaryFields, viewInput.AlternateBaseView);
|
|
518
555
|
}
|
|
519
556
|
catch (err) {
|
|
520
557
|
console.log(err);
|
|
@@ -529,7 +566,7 @@ export class ResolverBase {
|
|
|
529
566
|
try {
|
|
530
567
|
let viewInfo = null;
|
|
531
568
|
if (viewInput.ViewName) {
|
|
532
|
-
viewInfo =
|
|
569
|
+
viewInfo = await this.resolveUserViewByName(viewInput.ViewName, provider, userPayload.userRecord);
|
|
533
570
|
// Populate EntityName on the input so callers (e.g. RunViews resolver) can
|
|
534
571
|
// look up the entity without re-querying the view
|
|
535
572
|
if (viewInfo && !viewInput.EntityName) {
|
|
@@ -537,8 +574,7 @@ export class ResolverBase {
|
|
|
537
574
|
}
|
|
538
575
|
}
|
|
539
576
|
else if (viewInput.ViewID) {
|
|
540
|
-
viewInfo = await
|
|
541
|
-
await viewInfo.Load(viewInput.ViewID);
|
|
577
|
+
viewInfo = await this.resolveUserViewByID(viewInput.ViewID, provider, contextUser);
|
|
542
578
|
// Populate EntityName on the input so callers can look up the entity
|
|
543
579
|
if (viewInfo && !viewInput.EntityName) {
|
|
544
580
|
viewInput.EntityName = viewInfo.Entity;
|
|
@@ -585,6 +621,8 @@ export class ResolverBase {
|
|
|
585
621
|
bypassCache: viewInput.BypassCache,
|
|
586
622
|
dataSource: viewInput.DataSource,
|
|
587
623
|
includeBinaryFields: viewInput.IncludeBinaryFields,
|
|
624
|
+
alternateBaseView: viewInput.AlternateBaseView,
|
|
625
|
+
properties: viewInput.Properties,
|
|
588
626
|
});
|
|
589
627
|
}
|
|
590
628
|
catch (err) {
|
|
@@ -595,6 +633,30 @@ export class ResolverBase {
|
|
|
595
633
|
let results = await this.RunViewsGenericInternal(params);
|
|
596
634
|
return results;
|
|
597
635
|
}
|
|
636
|
+
/**
|
|
637
|
+
* What an entity event may carry to a CloudEvents sink or a log line.
|
|
638
|
+
*
|
|
639
|
+
* The whole row for an entity without field security. For one where it applies (its own flag
|
|
640
|
+
* or an IS-A ancestor's), and for `MJ: Record Changes` (whose payload columns hold other
|
|
641
|
+
* entities' values), only the primary key and the names of the fields that changed: the sink
|
|
642
|
+
* is outside MemberJunction with no reader to project the row for, and these events fire for
|
|
643
|
+
* server-internal saves too, whose objects hold every column.
|
|
644
|
+
*/
|
|
645
|
+
EntityEventPayload(baseEntity, changedFieldNames) {
|
|
646
|
+
const entityInfo = baseEntity.EntityInfo;
|
|
647
|
+
if (!entityInfo?.FieldLevelSecurityApplies && !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo)) {
|
|
648
|
+
// fls-guard: entity without field security and not the audit trail; whole row is the documented payload
|
|
649
|
+
return baseEntity.GetAll();
|
|
650
|
+
}
|
|
651
|
+
return {
|
|
652
|
+
// Read through Get() rather than PrimaryKey, which builds every field object of a raw-mode record.
|
|
653
|
+
PrimaryKey: (entityInfo.PrimaryKeys ?? []).map((pk) => ({ FieldName: pk.Name, Value: baseEntity.Get(pk.Name) })),
|
|
654
|
+
// A completed save has already rebased the object, so nothing on it is dirty: the event
|
|
655
|
+
// carries the names the save wrote. Other events (save_started) still read them off the object.
|
|
656
|
+
// GetDirtyFieldNames answers without building field objects for a record still in raw mode.
|
|
657
|
+
ChangedFields: changedFieldNames ?? baseEntity.GetDirtyFieldNames(),
|
|
658
|
+
};
|
|
659
|
+
}
|
|
598
660
|
static { this._priorEmittedData = []; }
|
|
599
661
|
async EmitCloudEvent({ component, event, eventCode, args }) {
|
|
600
662
|
if (ResolverBase._emit && event === MJEventType.ComponentEvent && eventCode === BaseEntity.BaseEventCode) {
|
|
@@ -602,7 +664,7 @@ export class ResolverBase {
|
|
|
602
664
|
const type = `MemberJunction.${event}${extendedType}`;
|
|
603
665
|
const source = `${process.env.CLOUDEVENTS_SOURCE ?? 'MemberJunction'}`;
|
|
604
666
|
const subject = args instanceof BaseEntityEvent ? args.baseEntity.EntityInfo.CodeName : undefined;
|
|
605
|
-
const data = args?.baseEntity
|
|
667
|
+
const data = args?.baseEntity ? this.EntityEventPayload(args.baseEntity, args.ChangedFieldNames) : {};
|
|
606
668
|
const cloudEvent = new CloudEvent({ type, source, subject, data });
|
|
607
669
|
try {
|
|
608
670
|
// check to see if the combination of Entity and pkey was already emitted, if so, Log that condtion next
|
|
@@ -732,48 +794,59 @@ export class ResolverBase {
|
|
|
732
794
|
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
733
795
|
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
734
796
|
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
735
|
-
* and catalogs are rejected. Server-internal RunView callers
|
|
797
|
+
* and catalogs are rejected. Server-internal RunView callers get only the provider's
|
|
798
|
+
* statement-separator check, which cannot refuse a legitimate filter.
|
|
799
|
+
*
|
|
800
|
+
* The screen is `ClientClauseScreen` in `@memberjunction/generic-database-provider`, shared
|
|
801
|
+
* with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.
|
|
802
|
+
*
|
|
803
|
+
* Before that parse, the fragment must be balanced on its own (`IsBalancedSQLFragment`). The
|
|
804
|
+
* provider and the tenant hook splice it inside parentheses and AND their own filters after
|
|
805
|
+
* it, so a fragment that closes those parentheses (`1=1) OR (1=1`) would leave row-level
|
|
806
|
+
* security, API-key row filters and tenant scope on one side of an OR the caller controls.
|
|
807
|
+
* The parse wraps the fragment in parentheses too, which is why it cannot catch this alone.
|
|
808
|
+
* A backslash inside a quoted string or identifier is refused as well: the parser reads `\'`
|
|
809
|
+
* inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal
|
|
810
|
+
* there, so the text after it could be code that the parse-based checks never saw.
|
|
736
811
|
*
|
|
737
812
|
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
738
|
-
* into the view
|
|
739
|
-
*
|
|
813
|
+
* into the view, and RunView removes denied fields from its own result only. A subquery reads a
|
|
814
|
+
* base view directly with neither, so the screen refuses one over any entity the user reads under
|
|
815
|
+
* a row filter or with denied fields (`ClientClauseScreen.AssertUnscopedRead`), the queried
|
|
816
|
+
* entity included.
|
|
740
817
|
*/
|
|
741
818
|
assertClientClauseUsesEntityBaseViews(clause, label, provider, user, options) {
|
|
742
819
|
if (!clause?.trim())
|
|
743
820
|
return;
|
|
821
|
+
// The balance and backslash checks run before the shared screen: it parses the fragment
|
|
822
|
+
// inside its own parentheses, so it cannot see a fragment that closes the parentheses the
|
|
823
|
+
// provider and the tenant hook splice it into.
|
|
744
824
|
const dialect = this.dialectForProvider(provider);
|
|
745
825
|
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
746
826
|
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
747
827
|
}
|
|
748
|
-
const
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
const parser = new SQLParser(wrapped, dialect);
|
|
752
|
-
// A clause the provider checks again on its own (an aggregate expression) leaves text the parser
|
|
753
|
-
// cannot read to that per-item check instead of failing the whole request.
|
|
754
|
-
if (!parser.IsValid && options?.LeaveUnparseableToProvider)
|
|
755
|
-
return;
|
|
756
|
-
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
757
|
-
throw new Error(`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`);
|
|
758
|
-
}
|
|
759
|
-
if (this.astContainsWriteNode(parser.AST)) {
|
|
760
|
-
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
828
|
+
const balance = IsBalancedSQLFragment(clause, dialect);
|
|
829
|
+
if (!balance.IsBalanced) {
|
|
830
|
+
throw new Error(`Invalid ${label}: ${balance.Reason}`);
|
|
761
831
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
for (const t of tables) {
|
|
765
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
766
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
767
|
-
if (table.toLowerCase() === '__mj_clause_screen')
|
|
768
|
-
continue;
|
|
769
|
-
this.assertTableRefReadable(allowed, schema, table, label, user);
|
|
832
|
+
if (this.hasBackslashInQuotes(clause, dialect)) {
|
|
833
|
+
throw new Error(`Invalid ${label}: a backslash inside a quoted string or identifier is not permitted in client-supplied filters`);
|
|
770
834
|
}
|
|
835
|
+
ClientClauseScreen.AssertClauseUsesEntityBaseViews(clause, label, provider?.Entities ?? [], dialect, user, options);
|
|
771
836
|
}
|
|
772
837
|
/**
|
|
773
838
|
* SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition
|
|
774
839
|
* to the SELECT-only validation the caller performs, every table reference must resolve to an
|
|
775
|
-
* entity BaseView the acting user can read UNSCOPED.
|
|
776
|
-
*
|
|
840
|
+
* entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a
|
|
841
|
+
* CTE that is in scope there (`StatementRelation.IsCTEReference`): unqualified, declared by its
|
|
842
|
+
* own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved
|
|
843
|
+
* for the SQL parser is refused before parsing (`ClientClauseScreen.AssertNoIdentifierAliasNames`).
|
|
844
|
+
* Fails closed: no user, or an unresolvable reference, refuses.
|
|
845
|
+
*
|
|
846
|
+
* The references come from a walk of the whole parse tree
|
|
847
|
+
* (`ClientClauseScreen.CollectStatementReads`). The statement is refused when that walk does not
|
|
848
|
+
* account for every SELECT in the text, or when a FROM item names no table (a table function or
|
|
849
|
+
* a row list).
|
|
777
850
|
*
|
|
778
851
|
* Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with
|
|
779
852
|
* row-level security and denied-field projection, and raw SQL applies neither. Rather than try
|
|
@@ -786,6 +859,7 @@ export class ResolverBase {
|
|
|
786
859
|
throw new Error(`Invalid ${label}: no acting user resolved for ad-hoc SQL — refusing`);
|
|
787
860
|
}
|
|
788
861
|
const dialect = this.dialectForProvider(provider);
|
|
862
|
+
ClientClauseScreen.AssertNoIdentifierAliasNames(sqlText, dialect, label);
|
|
789
863
|
const parser = new SQLParser(sqlText, dialect);
|
|
790
864
|
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
791
865
|
throw new Error(`Invalid ${label}: not a safe read-only statement — refusing under uncertainty`);
|
|
@@ -793,51 +867,20 @@ export class ResolverBase {
|
|
|
793
867
|
if (this.astContainsWriteNode(parser.AST)) {
|
|
794
868
|
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
795
869
|
}
|
|
796
|
-
const
|
|
797
|
-
|
|
870
|
+
const reads = ClientClauseScreen.CollectStatementReads(parser);
|
|
871
|
+
ClientClauseScreen.AssertEverySelectAccountedFor(sqlText, dialect, reads, label);
|
|
798
872
|
const allowed = this.entityBaseViewAllowList(provider);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
const table = this.stripSqlIdent(t.TableName);
|
|
802
|
-
const schema = this.stripSqlIdent(t.SchemaName);
|
|
803
|
-
if (this.isUnqualifiedSchema(schema) && cteNames.has(table.toLowerCase()))
|
|
873
|
+
for (const relation of reads.Relations) {
|
|
874
|
+
if (relation.IsCTEReference)
|
|
804
875
|
continue;
|
|
805
|
-
const entity =
|
|
806
|
-
|
|
876
|
+
const entity = ClientClauseScreen.AssertRelationReadable(allowed, relation, label, user);
|
|
877
|
+
if (entity)
|
|
878
|
+
this.assertUnscopedRead(entity, user, label);
|
|
807
879
|
}
|
|
808
880
|
}
|
|
809
|
-
/** Refuses an entity whose read is narrowed for this user
|
|
881
|
+
/** Refuses an entity whose read is narrowed for this user; see `ClientClauseScreen.AssertUnscopedRead`. */
|
|
810
882
|
assertUnscopedRead(entity, user, label) {
|
|
811
|
-
|
|
812
|
-
throw new Error(`Invalid ${label}: entity '${entity.Name}' is row-level-security filtered for you — ad-hoc SQL cannot apply that filter; use RunView`);
|
|
813
|
-
}
|
|
814
|
-
if (entity.GetDeniedReadFields(user).size > 0) {
|
|
815
|
-
throw new Error(`Invalid ${label}: entity '${entity.Name}' has fields you are not permitted to read — ad-hoc SQL cannot project them away; use RunView`);
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
/** Walks a parsed AST collecting the names of CTEs the statement itself defines. */
|
|
819
|
-
collectCTENames(node, names) {
|
|
820
|
-
if (!node || typeof node !== 'object')
|
|
821
|
-
return;
|
|
822
|
-
if (Array.isArray(node)) {
|
|
823
|
-
for (const n of node)
|
|
824
|
-
this.collectCTENames(n, names);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
const obj = node;
|
|
828
|
-
const withClause = obj.with;
|
|
829
|
-
if (Array.isArray(withClause)) {
|
|
830
|
-
for (const cte of withClause) {
|
|
831
|
-
const name = cte?.name;
|
|
832
|
-
if (typeof name === 'string')
|
|
833
|
-
names.add(name.toLowerCase());
|
|
834
|
-
else if (name && typeof name === 'object' && typeof name.value === 'string') {
|
|
835
|
-
names.add(name.value.toLowerCase());
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
for (const v of Object.values(obj))
|
|
840
|
-
this.collectCTENames(v, names);
|
|
883
|
+
ClientClauseScreen.AssertUnscopedRead(entity, user, label);
|
|
841
884
|
}
|
|
842
885
|
/**
|
|
843
886
|
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
@@ -874,97 +917,30 @@ export class ResolverBase {
|
|
|
874
917
|
this.assertClientClauseUsesEntityBaseViews(aggregate?.expression, 'Aggregate', provider, user, { LeaveUnparseableToProvider: true });
|
|
875
918
|
}
|
|
876
919
|
}
|
|
877
|
-
/**
|
|
920
|
+
/**
|
|
921
|
+
* True when a string literal or quoted identifier in `clause`, read the way the database reads
|
|
922
|
+
* it, contains a backslash (see {@link assertClientClauseUsesEntityBaseViews}).
|
|
923
|
+
*/
|
|
924
|
+
hasBackslashInQuotes(clause, dialect) {
|
|
925
|
+
return LexSQL(clause, dialect).some((t) => (t.Kind === 'string' || t.Kind === 'identifier') && t.Text.includes('\\'));
|
|
926
|
+
}
|
|
927
|
+
/** Write/DDL node walk shared with the clause screen; see `ClientClauseScreen.AstContainsWriteNode`. */
|
|
878
928
|
astContainsWriteNode(node) {
|
|
879
|
-
|
|
880
|
-
return false;
|
|
881
|
-
if (Array.isArray(node))
|
|
882
|
-
return node.some((n) => this.astContainsWriteNode(n));
|
|
883
|
-
const obj = node;
|
|
884
|
-
const type = obj.type;
|
|
885
|
-
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
886
|
-
return true;
|
|
887
|
-
}
|
|
888
|
-
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
929
|
+
return ClientClauseScreen.AstContainsWriteNode(node);
|
|
889
930
|
}
|
|
890
|
-
static { this.WRITE_NODE_TYPES = new Set([
|
|
891
|
-
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
892
|
-
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
893
|
-
]); }
|
|
894
931
|
dialectForProvider(provider) {
|
|
895
|
-
|
|
896
|
-
if (/postgres/i.test(name))
|
|
897
|
-
return new PostgreSQLDialect();
|
|
898
|
-
return new SQLServerDialect();
|
|
899
|
-
}
|
|
900
|
-
/** True for a reference with no schema — which `SQLParser.ExtractTableRefs` reports as `dbo`. */
|
|
901
|
-
isUnqualifiedSchema(schema) {
|
|
902
|
-
return !schema || schema.toLowerCase() === 'dbo';
|
|
932
|
+
return ClientClauseScreen.DialectFor(provider);
|
|
903
933
|
}
|
|
904
|
-
|
|
905
|
-
if (!name)
|
|
906
|
-
return '';
|
|
907
|
-
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
908
|
-
}
|
|
909
|
-
/**
|
|
910
|
-
* `bare` maps an unqualified view name to its entity, or to `null` when the name exists in
|
|
911
|
-
* more than one schema — the database, not this screen, would decide which one runs.
|
|
912
|
-
*/
|
|
934
|
+
/** The entity base-view allow-list for a provider's entities. */
|
|
913
935
|
entityBaseViewAllowList(provider) {
|
|
914
|
-
|
|
915
|
-
const bare = new Map();
|
|
916
|
-
const entities = provider?.Entities ?? [];
|
|
917
|
-
for (const e of entities) {
|
|
918
|
-
const view = this.stripSqlIdent(e.BaseView);
|
|
919
|
-
if (!view)
|
|
920
|
-
continue;
|
|
921
|
-
const schema = this.stripSqlIdent(e.SchemaName);
|
|
922
|
-
const bareKey = view.toLowerCase();
|
|
923
|
-
bare.set(bareKey, bare.has(bareKey) ? null : e);
|
|
924
|
-
if (schema)
|
|
925
|
-
qualified.set(`${schema}.${view}`.toLowerCase(), e);
|
|
926
|
-
}
|
|
927
|
-
return { qualified, bare };
|
|
928
|
-
}
|
|
929
|
-
/**
|
|
930
|
-
* SECURITY — resolves a table reference against the entity BaseView allow-list and, when an
|
|
931
|
-
* acting user is supplied, additionally requires that user to hold CanRead on the referenced
|
|
932
|
-
* entity. Base views do not embed RLS and entity permissions are otherwise checked only on
|
|
933
|
-
* the TOP entity of a request, so without this check a subquery (or ad-hoc query) could read
|
|
934
|
-
* entities the caller has no read grant on.
|
|
935
|
-
*
|
|
936
|
-
* A schema-qualified reference must match that exact schema — `secret.vwFoo` never resolves
|
|
937
|
-
* through another schema's `vwFoo`. An unqualified reference resolves only when the view name
|
|
938
|
-
* is unique across schemas; an ambiguous one must be qualified, because the database's
|
|
939
|
-
* default-schema resolution, not this screen, would pick which view actually runs.
|
|
940
|
-
*
|
|
941
|
-
* `SQLParser.ExtractTableRefs` reports an unqualified reference as schema `dbo`, so `dbo` is
|
|
942
|
-
* indistinguishable from "no schema" here and both take the unqualified path (an exact
|
|
943
|
-
* `dbo.<view>` entity wins first).
|
|
944
|
-
*/
|
|
945
|
-
assertTableRefReadable(allowed, schema, table, label, user) {
|
|
946
|
-
const qualified = allowed.qualified.get(`${schema}.${table}`.toLowerCase());
|
|
947
|
-
const entity = this.isUnqualifiedSchema(schema) ? qualified ?? allowed.bare.get(table.toLowerCase()) : qualified;
|
|
948
|
-
if (entity === null) {
|
|
949
|
-
throw new Error(`Invalid ${label}: '${table}' is a base view in more than one schema — qualify it with its schema`);
|
|
950
|
-
}
|
|
951
|
-
if (!entity) {
|
|
952
|
-
throw new Error(`Invalid ${label}: subquery must use an entity base view, not '${schema ? schema + '.' : ''}${table}'`);
|
|
953
|
-
}
|
|
954
|
-
if (user) {
|
|
955
|
-
const perms = entity.GetUserPermisions(user);
|
|
956
|
-
if (!perms.CanRead) {
|
|
957
|
-
throw new Error(`Invalid ${label}: you do not have read permission on entity '${entity.Name}' referenced by '${schema ? schema + '.' : ''}${table}'`);
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
return entity;
|
|
936
|
+
return ClientClauseScreen.BuildEntityBaseViewAllowList(provider?.Entities ?? []);
|
|
961
937
|
}
|
|
962
938
|
/**
|
|
963
939
|
* Optimized RunViewGenericInternal implementation with:
|
|
964
940
|
* - Field filtering at source (Fix #7)
|
|
965
941
|
* - Improved error handling (Fix #9)
|
|
966
942
|
*/
|
|
967
|
-
async RunViewGenericInternal(provider, viewInfo, extraFilter, orderBy, userSearchString, excludeUserViewRunID, overrideExcludeFilter, saveViewResults, fields, ignoreMaxRows, excludeDataFromAllPriorViewRuns, forceAuditLog, auditLogDescription, resultType, userPayload, maxRows, startRow, aggregates, afterKey, bypassCache, dataSource, includeBinaryFields) {
|
|
943
|
+
async RunViewGenericInternal(provider, viewInfo, extraFilter, orderBy, userSearchString, excludeUserViewRunID, overrideExcludeFilter, saveViewResults, fields, ignoreMaxRows, excludeDataFromAllPriorViewRuns, forceAuditLog, auditLogDescription, resultType, userPayload, maxRows, startRow, aggregates, afterKey, bypassCache, dataSource, includeBinaryFields, alternateBaseView, properties) {
|
|
968
944
|
try {
|
|
969
945
|
if (!viewInfo || !userPayload)
|
|
970
946
|
return null;
|
|
@@ -1010,6 +986,7 @@ export class ResolverBase {
|
|
|
1010
986
|
const result = await rv.RunView({
|
|
1011
987
|
ViewID: viewInfo.ID,
|
|
1012
988
|
ViewName: viewInfo.Name,
|
|
989
|
+
ViewEntity: viewInfo.ID ? viewInfo : undefined,
|
|
1013
990
|
EntityName: viewInfo.Entity,
|
|
1014
991
|
ExtraFilter: extraFilter,
|
|
1015
992
|
OrderBy: orderBy,
|
|
@@ -1030,7 +1007,18 @@ export class ResolverBase {
|
|
|
1030
1007
|
BypassCache: bypassCache,
|
|
1031
1008
|
DataSource: dataSource,
|
|
1032
1009
|
IncludeBinaryFields: includeBinaryFields,
|
|
1010
|
+
AlternateBaseView: alternateBaseView,
|
|
1011
|
+
Properties: properties,
|
|
1012
|
+
TimeZone: userPayload?.TimeZone,
|
|
1033
1013
|
}, user);
|
|
1014
|
+
if (result && typeof result === 'object') {
|
|
1015
|
+
Object.defineProperty(result, '_resolvedViewEntity', {
|
|
1016
|
+
value: viewInfo,
|
|
1017
|
+
enumerable: false,
|
|
1018
|
+
configurable: true,
|
|
1019
|
+
writable: true,
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1034
1022
|
// Log aggregate results for debugging
|
|
1035
1023
|
if (aggregates?.length) {
|
|
1036
1024
|
LogStatus(`[ResolverBase] RunView result aggregate info: entityName=${viewInfo.Entity}, hasAggregateResults=${!!result?.AggregateResults}, aggregateResultCount=${result?.AggregateResults?.length || 0}, aggregateExecutionTime=${result?.AggregateExecutionTime}, aggregateResults=${JSON.stringify(result?.AggregateResults)}`);
|
|
@@ -1057,7 +1045,7 @@ export class ResolverBase {
|
|
|
1057
1045
|
if (result?.Success && result.Results?.length) {
|
|
1058
1046
|
result.Results = result.Results.map(r => mapper.MapFields({ ...r }));
|
|
1059
1047
|
// Filter encrypted fields before sending to API client
|
|
1060
|
-
await this.ArrayFilterEncryptedFieldsForAPI(viewInfo.Entity, result.Results, user);
|
|
1048
|
+
await this.ArrayFilterEncryptedFieldsForAPI(viewInfo.Entity, result.Results, user, md);
|
|
1061
1049
|
}
|
|
1062
1050
|
return result;
|
|
1063
1051
|
}
|
|
@@ -1141,6 +1129,7 @@ export class ResolverBase {
|
|
|
1141
1129
|
runViewParams.push({
|
|
1142
1130
|
ViewID: param.viewInfo.ID,
|
|
1143
1131
|
ViewName: param.viewInfo.Name,
|
|
1132
|
+
ViewEntity: param.viewInfo.ID ? param.viewInfo : undefined,
|
|
1144
1133
|
EntityName: param.viewInfo.Entity,
|
|
1145
1134
|
ExtraFilter: param.extraFilter,
|
|
1146
1135
|
OrderBy: param.orderBy,
|
|
@@ -1161,6 +1150,9 @@ export class ResolverBase {
|
|
|
1161
1150
|
BypassCache: param.bypassCache,
|
|
1162
1151
|
DataSource: param.dataSource,
|
|
1163
1152
|
IncludeBinaryFields: param.includeBinaryFields,
|
|
1153
|
+
AlternateBaseView: param.alternateBaseView,
|
|
1154
|
+
Properties: param.properties,
|
|
1155
|
+
TimeZone: param.userPayload?.TimeZone,
|
|
1164
1156
|
});
|
|
1165
1157
|
}
|
|
1166
1158
|
// Fix #4: Run views in a single batch through RunViews
|
|
@@ -1177,7 +1169,7 @@ export class ResolverBase {
|
|
|
1177
1169
|
// Use the corresponding param's entity name
|
|
1178
1170
|
const entityName = params[i]?.viewInfo?.Entity;
|
|
1179
1171
|
if (entityName && contextUser) {
|
|
1180
|
-
await this.ArrayFilterEncryptedFieldsForAPI(entityName, runViewResult.Results, contextUser);
|
|
1172
|
+
await this.ArrayFilterEncryptedFieldsForAPI(entityName, runViewResult.Results, contextUser, md);
|
|
1181
1173
|
}
|
|
1182
1174
|
}
|
|
1183
1175
|
}
|
|
@@ -1296,8 +1288,9 @@ export class ResolverBase {
|
|
|
1296
1288
|
* skip redundant re-fetches (it already handled the event locally).
|
|
1297
1289
|
*
|
|
1298
1290
|
* The row itself rides along ONLY for entities opted in via
|
|
1299
|
-
* `cacheSettings.recordDataBroadcastEntities
|
|
1300
|
-
*
|
|
1291
|
+
* `cacheSettings.recordDataBroadcastEntities`, and never for one under field-level security —
|
|
1292
|
+
* this event reaches every connected client unprojected, so the row would otherwise be readable
|
|
1293
|
+
* by sessions that could not read the record or its restricted fields.
|
|
1301
1294
|
*/
|
|
1302
1295
|
PublishCacheInvalidation(entityObject, action, userPayload) {
|
|
1303
1296
|
const entityName = entityObject.EntityInfo.Name;
|
|
@@ -1308,23 +1301,25 @@ export class ResolverBase {
|
|
|
1308
1301
|
sourceServerId: MJGlobal.Instance.ProcessUUID,
|
|
1309
1302
|
timestamp: new Date(),
|
|
1310
1303
|
originSessionId: userPayload?.sessionId || null,
|
|
1311
|
-
recordData: action === 'save' && MayBroadcastRecordData(entityName)
|
|
1304
|
+
recordData: action === 'save' && MayBroadcastRecordData(entityName, entityObject.EntityInfo)
|
|
1305
|
+
// fls-guard: MayBroadcastRecordData refuses FLS entities and Record Changes
|
|
1312
1306
|
? JSON.stringify(entityObject.GetAll())
|
|
1313
1307
|
: undefined,
|
|
1314
1308
|
});
|
|
1315
1309
|
}
|
|
1316
1310
|
/**
|
|
1317
1311
|
* Publishes a push-status update to the client on {@link PUSH_STATUS_UPDATES_TOPIC}, stamping the
|
|
1318
|
-
*
|
|
1319
|
-
*
|
|
1320
|
-
*
|
|
1321
|
-
*
|
|
1322
|
-
*
|
|
1312
|
+
* owner key of `userPayload` (`StatusUpdateOwnerID`: the request's user, or the widget guest an
|
|
1313
|
+
* elevated payload stands in for) so the subscription filter can bind delivery to identity (see B49 /
|
|
1314
|
+
* `StatusUpdatesFilter`). The ergonomic wrapper every resolver should use instead of calling
|
|
1315
|
+
* `pubSub.publish` on the topic directly — it makes omitting identity impossible. Non-resolver
|
|
1316
|
+
* publishers (services, the liveness heartbeat) call the shared `PublishStatusUpdate()` function
|
|
1317
|
+
* directly with an explicit `ownerUserId`.
|
|
1323
1318
|
*/
|
|
1324
1319
|
PublishStatusUpdate(pubSub, sessionId, message, userPayload) {
|
|
1325
1320
|
PublishStatusUpdate(pubSub, {
|
|
1326
1321
|
sessionId,
|
|
1327
|
-
ownerUserId: userPayload
|
|
1322
|
+
ownerUserId: StatusUpdateOwnerID(userPayload),
|
|
1328
1323
|
message,
|
|
1329
1324
|
});
|
|
1330
1325
|
}
|
|
@@ -1344,28 +1339,50 @@ export class ResolverBase {
|
|
|
1344
1339
|
if (baseEntity?.EntityInfo?.Name !== uniqueKey) {
|
|
1345
1340
|
return;
|
|
1346
1341
|
}
|
|
1347
|
-
const baseEntityValues = baseEntity ? baseEntity.
|
|
1342
|
+
const baseEntityValues = baseEntity ? this.EntityEventPayload(baseEntity, event.args instanceof BaseEntityEvent ? event.args.ChangedFieldNames : undefined) : null;
|
|
1348
1343
|
const eventToLog = { entityName: entityObject.EntityInfo.Name, baseEntity: baseEntityValues, event: event.event, eventCode: event.eventCode };
|
|
1349
1344
|
LogDebug(`ResolverBase.ListenForEntityMessages: Received Event from within MJGlobal.Instance.GetEventListener() callback. Will call EmitCloudEvent() next\nEvent data:\n${JSON.stringify(eventToLog)}`);
|
|
1350
1345
|
await this.EmitCloudEvent(event);
|
|
1351
1346
|
LogDebug(`ResolverBase.ListenForEntityMessages: EmitCloudEvent() completed successfully`);
|
|
1352
|
-
if (event.args && event.args instanceof BaseEntityEvent) {
|
|
1353
|
-
|
|
1354
|
-
// message from our entity object, relay it to the client
|
|
1347
|
+
if (event.args && event.args instanceof BaseEntityEvent && this.EntityEventBelongsToSession(event.args, userPayload)) {
|
|
1348
|
+
// message from an entity object of this session's user, relay it to the client
|
|
1355
1349
|
LogDebug('ResolverBase.ListenForEntityMessages: About to publish PUSH_STATUS_UPDATES_TOPIC');
|
|
1356
|
-
this.PublishStatusUpdate(pubSub, userPayload.sessionId,
|
|
1357
|
-
status: 'OK',
|
|
1358
|
-
type: 'EntityObjectStatusMessage',
|
|
1359
|
-
entityName: baseEntityEvent.baseEntity.EntityInfo.Name,
|
|
1360
|
-
primaryKey: baseEntityEvent.baseEntity.PrimaryKey,
|
|
1361
|
-
message: event.args.payload,
|
|
1362
|
-
}), userPayload);
|
|
1350
|
+
this.PublishStatusUpdate(pubSub, userPayload.sessionId, this.EntityStatusMessage(event.args), userPayload);
|
|
1363
1351
|
}
|
|
1364
1352
|
}
|
|
1365
1353
|
});
|
|
1366
1354
|
this.eventSubscriptions.set(uniqueKey, theSub);
|
|
1367
1355
|
}
|
|
1368
1356
|
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Whether an entity event may be relayed to the session whose call created the listener. The
|
|
1359
|
+
* listener is shared by every caller in the process for that entity, so an event raised by an
|
|
1360
|
+
* object loaded for another user (or for the system user) is never relayed to this session.
|
|
1361
|
+
*/
|
|
1362
|
+
EntityEventBelongsToSession(event, userPayload) {
|
|
1363
|
+
const sessionUserId = userPayload?.userRecord?.ID;
|
|
1364
|
+
const objectUserId = event.baseEntity?.ContextCurrentUser?.ID;
|
|
1365
|
+
return Boolean(sessionUserId && objectUserId && UUIDsEqual(sessionUserId, objectUserId));
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* The status message relayed for an entity event: the entity, key and event type. The event's
|
|
1369
|
+
* payload is included only when it cannot carry record values: never for a delete (its payload
|
|
1370
|
+
* is the whole deleted row), an entity under field-level security, or `MJ: Record Changes`.
|
|
1371
|
+
*/
|
|
1372
|
+
EntityStatusMessage(event) {
|
|
1373
|
+
const entityInfo = event.baseEntity.EntityInfo;
|
|
1374
|
+
const mayCarryPayload = event.type !== 'delete'
|
|
1375
|
+
&& !entityInfo.FieldLevelSecurityApplies
|
|
1376
|
+
&& !RecordChangeFieldSecurityProjector.IsRecordChangesEntity(entityInfo);
|
|
1377
|
+
return JSON.stringify({
|
|
1378
|
+
status: 'OK',
|
|
1379
|
+
type: 'EntityObjectStatusMessage',
|
|
1380
|
+
entityName: entityInfo.Name,
|
|
1381
|
+
primaryKey: event.baseEntity.PrimaryKey,
|
|
1382
|
+
eventType: event.type,
|
|
1383
|
+
message: mayCarryPayload ? event.payload : undefined,
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1369
1386
|
/**
|
|
1370
1387
|
* IS-A PROMOTION over the wire: an EXISTING parent record gaining a subtype — an Animal that is
|
|
1371
1388
|
* now also a Dog. It arrives as the CHILD's create mutation carrying the existing parent's
|
|
@@ -1439,6 +1456,9 @@ export class ResolverBase {
|
|
|
1439
1456
|
// IS-A promotion: bind the new child to its EXISTING parent row BEFORE the field
|
|
1440
1457
|
// assignments, so values the client sent for parent fields land on the loaded parent
|
|
1441
1458
|
// (as an update) instead of being wiped by the load.
|
|
1459
|
+
// Field-level security: refuse a key the caller may not read, or that names no field, before
|
|
1460
|
+
// anything is applied (the same gate as the update path).
|
|
1461
|
+
AssertClientInputWritableForUser(entityObject.EntityInfo, fieldsForSet, this.GetUserFromPayload(userPayload));
|
|
1442
1462
|
await this.attachToExistingParentIfPromotion(entityObject, fieldsForSet);
|
|
1443
1463
|
entityObject.SetMany(fieldsForSet);
|
|
1444
1464
|
// Reconstruct the client-side restore context, if any, on this server
|
|
@@ -1452,6 +1472,7 @@ export class ResolverBase {
|
|
|
1452
1472
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1453
1473
|
const contextUser = this.GetUserFromPayload(userPayload);
|
|
1454
1474
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1475
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
1455
1476
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), contextUser);
|
|
1456
1477
|
}
|
|
1457
1478
|
// save failed, throw error with message
|
|
@@ -1489,12 +1510,11 @@ export class ResolverBase {
|
|
|
1489
1510
|
}
|
|
1490
1511
|
});
|
|
1491
1512
|
// Field-level security: any value the client sends for a field it cannot READ is
|
|
1492
|
-
// fabricated by construction (the field was
|
|
1493
|
-
// received), so
|
|
1494
|
-
// denied we also force the truth-load branch below
|
|
1495
|
-
//
|
|
1496
|
-
|
|
1497
|
-
const hasDeniedReadFields = this.StripDeniedReadFieldsFromClientInput(entityInfo, userInfo, input, clientNewValues);
|
|
1513
|
+
// fabricated by construction (the field was absent from every payload the client ever
|
|
1514
|
+
// received), so the input is refused before anything applies it. When any field is
|
|
1515
|
+
// denied we also force the truth-load branch below, so the denied columns keep what the
|
|
1516
|
+
// database holds rather than anything derived from the client's OldValues.
|
|
1517
|
+
const hasDeniedReadFields = this.AssertClientInputFieldSecurity(entityInfo, userInfo, input, clientNewValues);
|
|
1498
1518
|
// The audit-trail equivalent. Its `true` matters most on THIS entity: 'MJ: Record Changes'
|
|
1499
1519
|
// has TrackRecordChanges off, so without it a client sending OldValues takes the branch below
|
|
1500
1520
|
// that hydrates from those values — never loading what the database actually holds.
|
|
@@ -1550,6 +1570,7 @@ export class ResolverBase {
|
|
|
1550
1570
|
await this.AfterUpdate(provider, input); // fire event
|
|
1551
1571
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1552
1572
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1573
|
+
// fls-guard: MapFieldNamesToCodeNames drops the fields the caller may not read
|
|
1553
1574
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1554
1575
|
}
|
|
1555
1576
|
else {
|
|
@@ -1571,15 +1592,37 @@ export class ResolverBase {
|
|
|
1571
1592
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
1572
1593
|
});
|
|
1573
1594
|
}
|
|
1595
|
+
/**
|
|
1596
|
+
* Checks whether `oldValues` (`input.OldValues___`) contains every writable in-table field of
|
|
1597
|
+
* `entityInfo` (plus primary keys).
|
|
1598
|
+
*
|
|
1599
|
+
* With the delta-only `Save()` wire protocol, `GraphQLDataProvider.Save()` sends only primary keys
|
|
1600
|
+
* and dirty writable fields in `vars.input` and `OldValues___` on normal updates. Hydrating an
|
|
1601
|
+
* entity directly from a partial-delta `OldValues___` via `entityObject.LoadFromData(oldValues)`
|
|
1602
|
+
* (without first loading the authoritative row via `InnerLoad`) would leave every untouched column
|
|
1603
|
+
* `NotLoaded = true` (or `null`), causing `spUpdate` or custom `Validate()` hooks to see incomplete
|
|
1604
|
+
* state. Returning `false` for partial deltas ensures {@link MustLoadTruthFromDatabase} routes
|
|
1605
|
+
* delta updates through `InnerLoad(cKey)` + {@link TestAndSetClientOldValuesToDBValues}, even on
|
|
1606
|
+
* entities with `TrackRecordChanges === false`.
|
|
1607
|
+
*/
|
|
1608
|
+
hasCompleteOldValues(entityInfo, oldValues) {
|
|
1609
|
+
if (!oldValues || oldValues.length === 0) {
|
|
1610
|
+
return false;
|
|
1611
|
+
}
|
|
1612
|
+
const providedKeys = new Set(oldValues.map((kv) => kv.Key));
|
|
1613
|
+
return entityInfo.Fields.every((f) => (!f.IsPrimaryKey && (f.ReadOnly || f.IsVirtual)) || providedKeys.has(f.Name) || providedKeys.has(f.CodeName));
|
|
1614
|
+
}
|
|
1574
1615
|
/**
|
|
1575
1616
|
* Whether `UpdateRecord` must hydrate the entity from the DATABASE rather than from the
|
|
1576
1617
|
* client's `OldValues___`.
|
|
1577
1618
|
*
|
|
1578
|
-
* The `OldValues___` path exists as an optimization
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1619
|
+
* The `OldValues___` path exists as an optimization when a client supplies the complete pre-save
|
|
1620
|
+
* record (e.g. `IgnoreDirtyState: true`) and nothing else requires the database's authoritative prior
|
|
1621
|
+
* state. Under the delta-only `Save()` wire protocol, normal updates send only primary keys and dirty
|
|
1622
|
+
* writable fields in `OldValues___`, so {@link hasCompleteOldValues} returns `false` and forces the
|
|
1623
|
+
* database load (`InnerLoad`) so untouched columns retain their stored values.
|
|
1581
1624
|
*
|
|
1582
|
-
* `
|
|
1625
|
+
* `FieldLevelSecurityApplies` (own flag or an IS-A ancestor's) is the security-critical one, and it is NOT redundant with
|
|
1583
1626
|
* `hasDeniedReadFields`. That flag reports READ denials, and the canonical FLS configuration is
|
|
1584
1627
|
* Read Allow + Update Deny — which leaves it false. Such a caller would be hydrated from its own
|
|
1585
1628
|
* `OldValues___`, and a value it supplies there for an update-denied field arrives through
|
|
@@ -1603,6 +1646,16 @@ export class ResolverBase {
|
|
|
1603
1646
|
* caller pin forged audit columns as both old and new values. It doesn't track its own changes, so
|
|
1604
1647
|
* nothing else forces the load.
|
|
1605
1648
|
*
|
|
1649
|
+
* `MJ: Data Context Items` always loads from the database for the same reason: its server class
|
|
1650
|
+
* lets only an administrator save an item that holds SQL, and decides that from the item's `Type`
|
|
1651
|
+
* and `SQL`. Hydrated from OldValues, a client could leave both out, and the stored procedure would
|
|
1652
|
+
* keep the stored SQL item while the rule saw none.
|
|
1653
|
+
*
|
|
1654
|
+
* `MJ: Conversation Details` always loads for the same reason: its write gate decides from the stored
|
|
1655
|
+
* row (who wrote a person's message, its role, the owner's rating), and an OldValues-hydrated entity
|
|
1656
|
+
* would let a grantee claim to be a message's author while rewriting it. See
|
|
1657
|
+
* {@link ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW}.
|
|
1658
|
+
*
|
|
1606
1659
|
* A caller under an Update row filter (a role's Update RLS filter or an API-key row filter) always
|
|
1607
1660
|
* loads from the database too (#4919). The save's post-image check skips its query when no column
|
|
1608
1661
|
* the filter reads is dirty, and dirtiness measured against client OldValues is the caller's to
|
|
@@ -1613,64 +1666,47 @@ export class ResolverBase {
|
|
|
1613
1666
|
*/
|
|
1614
1667
|
MustLoadTruthFromDatabase(entityInfo, input, hasDeniedReadFields, hasNarrowedAuditPayload, user) {
|
|
1615
1668
|
return (entityInfo.TrackRecordChanges ||
|
|
1616
|
-
entityInfo.Name.trim().toLowerCase()
|
|
1617
|
-
!input.OldValues___ ||
|
|
1669
|
+
ENTITIES_THAT_ALWAYS_LOAD_STORED_ROW.has(entityInfo.Name.trim().toLowerCase()) ||
|
|
1670
|
+
!this.hasCompleteOldValues(entityInfo, input.OldValues___) ||
|
|
1618
1671
|
hasDeniedReadFields ||
|
|
1619
1672
|
hasNarrowedAuditPayload ||
|
|
1620
|
-
entityInfo.
|
|
1673
|
+
entityInfo.FieldLevelSecurityApplies ||
|
|
1621
1674
|
!user ||
|
|
1622
1675
|
entityInfo.GetEffectiveRowFilterWhereClause(user, EntityPermissionType.Update, '').length > 0);
|
|
1623
1676
|
}
|
|
1624
1677
|
/**
|
|
1625
|
-
* Field-level security
|
|
1678
|
+
* Field-level security gate on the update path's client input.
|
|
1626
1679
|
*
|
|
1627
|
-
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
* save" safe for a restricted user.
|
|
1680
|
+
* For a caller denied at least one field on the entity, refuses (with a `FieldSecurityError`)
|
|
1681
|
+
* any key in the new values or in `OldValues___` that names a field the caller cannot READ, or
|
|
1682
|
+
* that names no field at all, with the same message for both (see
|
|
1683
|
+
* `AssertClientInputWritableForUser`), so the refusal does not reveal which columns exist.
|
|
1632
1684
|
*
|
|
1633
|
-
*
|
|
1634
|
-
*
|
|
1685
|
+
* Refused rather than dropped: a field the caller cannot read was absent from every payload the
|
|
1686
|
+
* client received, so a value for it can only be fabricated. A well-behaved client never sends
|
|
1687
|
+
* one (it omits fields it did not load), so ordinary traffic is unaffected.
|
|
1635
1688
|
*
|
|
1636
|
-
*
|
|
1637
|
-
*
|
|
1638
|
-
* and is restricted).
|
|
1689
|
+
* There is no split by update permission: Read is required for Update, so a user denied read is
|
|
1690
|
+
* denied update too.
|
|
1639
1691
|
*
|
|
1640
|
-
* Returns true when the user has a non-empty denied-read set on this entity. The caller
|
|
1641
|
-
*
|
|
1642
|
-
*
|
|
1692
|
+
* Returns true when the user has a non-empty denied-read set on this entity. The caller must
|
|
1693
|
+
* then hydrate the entity from the DATABASE (never from client OldValues), so denied fields hold
|
|
1694
|
+
* true values the client input never touches.
|
|
1643
1695
|
*/
|
|
1644
|
-
|
|
1645
|
-
if (!entityInfo.
|
|
1696
|
+
AssertClientInputFieldSecurity(entityInfo, userInfo, input, clientNewValues) {
|
|
1697
|
+
if (!entityInfo.FieldLevelSecurityApplies || !userInfo) {
|
|
1646
1698
|
return false;
|
|
1647
1699
|
}
|
|
1648
|
-
|
|
1649
|
-
if (deniedReadNames.size === 0) {
|
|
1700
|
+
if (entityInfo.GetDeniedReadFields(userInfo).size === 0) {
|
|
1650
1701
|
return false;
|
|
1651
1702
|
}
|
|
1652
|
-
|
|
1653
|
-
// lowercased field Names — bridge via the field metadata once.
|
|
1654
|
-
const deniedReadCodeNames = new Set();
|
|
1655
|
-
for (const field of entityInfo.Fields) {
|
|
1656
|
-
if (deniedReadNames.has(field.Name.trim().toLowerCase())) {
|
|
1657
|
-
deniedReadCodeNames.add(field.CodeName.trim().toLowerCase());
|
|
1658
|
-
}
|
|
1659
|
-
}
|
|
1660
|
-
const stripped = [];
|
|
1661
|
-
for (const key of Object.keys(clientNewValues)) {
|
|
1662
|
-
if (deniedReadCodeNames.has(key.trim().toLowerCase())) {
|
|
1663
|
-
delete clientNewValues[key];
|
|
1664
|
-
delete input[key];
|
|
1665
|
-
stripped.push(key);
|
|
1666
|
-
}
|
|
1667
|
-
}
|
|
1703
|
+
AssertClientInputWritableForUser(entityInfo, clientNewValues, userInfo);
|
|
1668
1704
|
if (Array.isArray(input.OldValues___)) {
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1705
|
+
const oldValueKeys = {};
|
|
1706
|
+
for (const item of input.OldValues___) {
|
|
1707
|
+
oldValueKeys[String(item.Key)] = null;
|
|
1708
|
+
}
|
|
1709
|
+
AssertClientInputWritableForUser(entityInfo, oldValueKeys, userInfo);
|
|
1674
1710
|
}
|
|
1675
1711
|
return true;
|
|
1676
1712
|
}
|
|
@@ -1765,41 +1801,59 @@ export class ResolverBase {
|
|
|
1765
1801
|
}
|
|
1766
1802
|
}
|
|
1767
1803
|
/**
|
|
1768
|
-
*
|
|
1769
|
-
*
|
|
1770
|
-
*
|
|
1804
|
+
* Compares the client-supplied `OldValues___` array in `input` against the authoritative database
|
|
1805
|
+
* values just loaded onto `entityObject` via `InnerLoad`, checks for overlapping concurrent field
|
|
1806
|
+
* modifications, and then applies `clientNewValues` onto `entityObject` via `SetMany`.
|
|
1771
1807
|
*
|
|
1772
|
-
*
|
|
1808
|
+
* Supports both full-record and delta-only `OldValues___` payloads: only keys present in
|
|
1809
|
+
* `input.OldValues___` (e.g. primary keys + dirty writable fields) are compared against `dbValues`,
|
|
1810
|
+
* and `entityObject.SetMany(clientNewValues)` mutates only the fields sent in `clientNewValues`
|
|
1811
|
+
* while preserving all untouched DB-loaded fields on `entityObject`.
|
|
1812
|
+
*
|
|
1813
|
+
* ASSUMES: `input` has an `OldValues___` property that is an array of Key/Value pairs representing
|
|
1814
|
+
* the pre-mutation values of the fields the client is updating.
|
|
1773
1815
|
*/
|
|
1816
|
+
/**
|
|
1817
|
+
* Resolves a field on `entityInfo` by `CodeName` or `Name` in $O(1)$ via `FieldByCodeName` /
|
|
1818
|
+
* `FieldByName` when available on `EntityInfo`, falling back to `.Fields.find()` for test stubs.
|
|
1819
|
+
*/
|
|
1820
|
+
findEntityFieldByCodeNameOrName(entityInfo, key) {
|
|
1821
|
+
if (typeof entityInfo.FieldByCodeName === 'function') {
|
|
1822
|
+
return entityInfo.FieldByCodeName(key) ?? entityInfo.FieldByName(key);
|
|
1823
|
+
}
|
|
1824
|
+
return entityInfo.Fields.find((f) => f.CodeName === key || f.Name === key);
|
|
1825
|
+
}
|
|
1774
1826
|
async TestAndSetClientOldValuesToDBValues(input, clientNewValues, entityObject, contextUser) {
|
|
1775
1827
|
// we have OldValues, so we need to compare them to the values we just loaded from the DB
|
|
1776
1828
|
const clientOldValues = {};
|
|
1777
1829
|
// for each item in the oldValues array, add it to the clientOldValues object
|
|
1778
1830
|
input.OldValues___.forEach((item) => {
|
|
1779
1831
|
// we need to do a quick transform on the values to make sure they match the TS Type for the given field because item.Value will always be a string
|
|
1780
|
-
const field = entityObject.EntityInfo
|
|
1832
|
+
const field = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, item.Key);
|
|
1781
1833
|
const val = this.ClientOldValueToFieldValue(field, item.Value);
|
|
1782
1834
|
clientOldValues[item.Key] = val;
|
|
1783
1835
|
});
|
|
1784
1836
|
// clientOldValues now has all of the oldValues the CLIENT passed us. Now we need to build the same kind of object
|
|
1785
1837
|
// with the DB values
|
|
1838
|
+
// fls-guard: compared on the server only, never returned
|
|
1786
1839
|
const dbValues = entityObject.GetAll();
|
|
1787
1840
|
// now we need to compare clientOldValues and dbValues and have a new array that has entries for any differences and have FieldName, clientOldValue and dbValue as properties
|
|
1788
1841
|
const dbDifferences = [];
|
|
1789
1842
|
Object.keys(clientOldValues).forEach((key) => {
|
|
1790
|
-
const f = entityObject.EntityInfo
|
|
1843
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
1844
|
+
const dbVal = f && f.Name in dbValues ? dbValues[f.Name] : dbValues[key];
|
|
1791
1845
|
let different = false;
|
|
1792
1846
|
switch (typeof clientOldValues[key]) {
|
|
1793
1847
|
case 'number':
|
|
1794
|
-
different = clientOldValues[key] !==
|
|
1848
|
+
different = clientOldValues[key] !== dbVal;
|
|
1795
1849
|
break;
|
|
1796
1850
|
case 'boolean':
|
|
1797
|
-
different = clientOldValues[key] !==
|
|
1851
|
+
different = clientOldValues[key] !== dbVal;
|
|
1798
1852
|
break;
|
|
1799
1853
|
case 'object':
|
|
1800
1854
|
if (clientOldValues[key] instanceof Date) {
|
|
1801
1855
|
const clientDate = clientOldValues[key];
|
|
1802
|
-
const dbDate =
|
|
1856
|
+
const dbDate = dbVal;
|
|
1803
1857
|
if (dbDate == null || !(dbDate instanceof Date)) {
|
|
1804
1858
|
different = true;
|
|
1805
1859
|
}
|
|
@@ -1818,11 +1872,11 @@ export class ResolverBase {
|
|
|
1818
1872
|
}
|
|
1819
1873
|
}
|
|
1820
1874
|
else if (clientOldValues[key] === null) {
|
|
1821
|
-
different =
|
|
1875
|
+
different = dbVal !== null;
|
|
1822
1876
|
}
|
|
1823
1877
|
break;
|
|
1824
1878
|
default:
|
|
1825
|
-
different = clientOldValues[key] !==
|
|
1879
|
+
different = clientOldValues[key] !== dbVal;
|
|
1826
1880
|
break;
|
|
1827
1881
|
}
|
|
1828
1882
|
if (different && f && !f.ReadOnly) {
|
|
@@ -1830,7 +1884,7 @@ export class ResolverBase {
|
|
|
1830
1884
|
dbDifferences.push({
|
|
1831
1885
|
FieldName: key,
|
|
1832
1886
|
ClientOldValue: clientOldValues[key],
|
|
1833
|
-
DBValue:
|
|
1887
|
+
DBValue: dbVal,
|
|
1834
1888
|
});
|
|
1835
1889
|
}
|
|
1836
1890
|
});
|
|
@@ -1839,8 +1893,8 @@ export class ResolverBase {
|
|
|
1839
1893
|
// first step is to get clientNewValues into an object that is like clientOldValues, get the diff and then compare that diff to the differences array that shows diff between DB and ClientOld
|
|
1840
1894
|
const clientDifferences = [];
|
|
1841
1895
|
Object.keys(clientOldValues).forEach((key) => {
|
|
1842
|
-
const f = entityObject.EntityInfo
|
|
1843
|
-
if (clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
1896
|
+
const f = this.findEntityFieldByCodeNameOrName(entityObject.EntityInfo, key);
|
|
1897
|
+
if (key in clientNewValues && clientOldValues[key] !== clientNewValues[key] && f && f.AllowUpdateAPI && !f.IsPrimaryKey) {
|
|
1844
1898
|
// only include updateable fields
|
|
1845
1899
|
clientDifferences.push({
|
|
1846
1900
|
FieldName: key,
|
|
@@ -1851,7 +1905,8 @@ export class ResolverBase {
|
|
|
1851
1905
|
});
|
|
1852
1906
|
// now we have clientDifferences which shows what the client thinks they are changing. And, we have the dbDifferences array that shows changes between the clientOldValues and the dbValues
|
|
1853
1907
|
// if there is ANY overlap in the FIELDS that appear in both arrays, we need to log a warning but allow the save to continue
|
|
1854
|
-
const
|
|
1908
|
+
const dbDiffFieldNames = new Set(dbDifferences.map((dd) => dd.FieldName));
|
|
1909
|
+
const overlap = clientDifferences.filter((cd) => dbDiffFieldNames.has(cd.FieldName));
|
|
1855
1910
|
if (overlap.length > 0) {
|
|
1856
1911
|
const msg = {
|
|
1857
1912
|
Message: 'Inconsistency between old values provided for changed fields, and the values of one or more of those fields in the database. Save operation continued with warning.',
|
|
@@ -1861,30 +1916,31 @@ export class ResolverBase {
|
|
|
1861
1916
|
};
|
|
1862
1917
|
// Log as warning to console and ErrorLog table instead of throwing error
|
|
1863
1918
|
console.warn('Entity save inconsistency detected but allowing save to continue:', JSON.stringify(msg));
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1919
|
+
LogErrorEx({
|
|
1920
|
+
message: `Entity save inconsistency detected: ${JSON.stringify(msg)}`,
|
|
1921
|
+
severity: 'warning',
|
|
1922
|
+
category: 'ResolverBase.TestAndSetClientOldValuesToDBValues',
|
|
1923
|
+
metadata: {
|
|
1869
1924
|
entityName: entityObject.EntityInfo.Name,
|
|
1870
1925
|
clientDifferences: clientDifferences,
|
|
1871
1926
|
dbDifferences: dbDifferences,
|
|
1872
|
-
overlap: overlap
|
|
1873
|
-
}
|
|
1927
|
+
overlap: overlap,
|
|
1928
|
+
},
|
|
1874
1929
|
});
|
|
1875
1930
|
// Create ErrorLog record in the database — use the entity's bound provider so the
|
|
1876
1931
|
// ErrorLog write goes to the same connection as the entity that triggered it.
|
|
1877
1932
|
try {
|
|
1878
1933
|
const md = entityObject.ProviderToUse;
|
|
1879
1934
|
const errorLogEntity = await md.GetEntityObject('MJ: Error Logs', contextUser);
|
|
1880
|
-
|
|
1935
|
+
// Code is NCHAR(20); a longer value is rejected at the database
|
|
1936
|
+
errorLogEntity.Code = 'SAVE_INCONSISTENCY';
|
|
1881
1937
|
errorLogEntity.Message = `Entity save inconsistency detected for ${entityObject.EntityInfo.Name}: ${JSON.stringify(msg)}`;
|
|
1882
1938
|
errorLogEntity.Status = 'Warning';
|
|
1883
1939
|
errorLogEntity.Category = 'Entity Save';
|
|
1884
1940
|
errorLogEntity.CreatedBy = contextUser.Email || contextUser.Name;
|
|
1885
1941
|
const saveResult = await errorLogEntity.Save();
|
|
1886
1942
|
if (!saveResult) {
|
|
1887
|
-
console.error(
|
|
1943
|
+
console.error(`Failed to save ErrorLog record for ${entityObject.EntityInfo.Name}: ${errorLogEntity.LatestResult?.CompleteMessage}`);
|
|
1888
1944
|
}
|
|
1889
1945
|
}
|
|
1890
1946
|
catch (errorLogError) {
|
|
@@ -1913,7 +1969,10 @@ export class ResolverBase {
|
|
|
1913
1969
|
extensions: { code: 'LOAD_ENTITY_ERROR', entityName },
|
|
1914
1970
|
});
|
|
1915
1971
|
}
|
|
1916
|
-
|
|
1972
|
+
// Grab the values before we delete so we can return the last state if the delete succeeds,
|
|
1973
|
+
// projected for the caller in case the object holds a column they may not read.
|
|
1974
|
+
// fls-guard: projected by ProjectEntityValuesForUser
|
|
1975
|
+
const returnValue = ProjectEntityValuesForUser(provider, entityObject.EntityInfo, entityObject.GetAll(), this.GetUserFromPayload(userPayload));
|
|
1917
1976
|
this.ListenForEntityMessages(entityObject, pubSub, userPayload);
|
|
1918
1977
|
if (await entityObject.Delete(options)) {
|
|
1919
1978
|
await this.AfterDelete(provider, key); // fire event
|