@memberjunction/server 6.2.0-edge.4 → 6.2.0-edge.5
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 +20 -0
- 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 +5 -1
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +13 -1
- 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 +25 -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 +1 -0
- package/dist/auth/index.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/config.d.ts +136 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +43 -4
- package/dist/config.js.map +1 -1
- package/dist/coreRoutes.d.ts +29 -0
- package/dist/coreRoutes.d.ts.map +1 -0
- package/dist/coreRoutes.js +142 -0
- package/dist/coreRoutes.js.map +1 -0
- package/dist/generated/graphql-schemas/__mj.d.ts +369 -3
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +1750 -7
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +43 -8
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +59 -19
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/PushStatusResolver.js +1 -1
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +103 -44
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +264 -305
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +12 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- 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 +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +112 -160
- package/dist/index.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/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 +30 -0
- package/dist/realtimeSessions/ownedRealtimeSession.d.ts.map +1 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js +47 -0
- package/dist/realtimeSessions/ownedRealtimeSession.js.map +1 -0
- package/dist/realtimeSessions/sessionAccess.d.ts +6 -0
- package/dist/realtimeSessions/sessionAccess.d.ts.map +1 -1
- package/dist/realtimeSessions/sessionAccess.js +7 -3
- package/dist/realtimeSessions/sessionAccess.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/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 +18 -3
- package/dist/resolvers/FetchEntityVectorsResolver.d.ts.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +100 -35
- package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
- package/dist/resolvers/FileResolver.d.ts +82 -11
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +279 -101
- 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 +4 -2
- package/dist/resolvers/IntegrationDiscoveryResolver.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 +13 -0
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +2 -8
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +10 -26
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +35 -6
- package/dist/resolvers/RunAIAgentResolver.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/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 +65 -13
- package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.d.ts +19 -2
- package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
- package/dist/resolvers/TransactionGroupResolver.js +102 -24
- package/dist/resolvers/TransactionGroupResolver.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/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 +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +21 -12
- 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/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 +54 -2
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +598 -15
- 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/package.json +99 -98
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/FetchEntityVectorsResolver.test.ts +163 -15
- 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__/RecordingAssemblyScheduler.test.ts +178 -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 +209 -6
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +5 -2
- package/src/__tests__/RunAIAgentResolver.branch.test.ts +1 -0
- package/src/__tests__/RunAIAgentResolver.historyFrom.test.ts +5 -3
- package/src/__tests__/RunAIAgentResolver.runDataGuard.test.ts +258 -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 +27 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +25 -0
- package/src/__tests__/TaskGraphAgentRunner.test.ts +266 -0
- package/src/__tests__/TaskGraphContinuationDeliverer.test.ts +356 -22
- package/src/__tests__/TaskGraphFrameResolver.test.ts +22 -0
- package/src/__tests__/TestQuerySQLResolver.test.ts +274 -0
- package/src/__tests__/TransactionGroupResolver.refusals.test.ts +98 -1
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/agent-run-data-guard.test.ts +130 -0
- package/src/__tests__/apiRootBoot.test.ts +202 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/conversationRunEvents.test.ts +56 -14
- package/src/__tests__/globalCors.test.ts +217 -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__/ownedRealtimeSession.test.ts +39 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +7 -0
- package/src/__tests__/realtimeRecordingHandler.test.ts +652 -0
- package/src/__tests__/serverExtensionHostOverlay.test.ts +66 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +11 -98
- package/src/__tests__/sharedCache.test.ts +5 -3
- package/src/agentSessions/SessionJanitor.ts +369 -93
- package/src/agentSessions/SessionManager.ts +13 -1
- package/src/apiRoot.ts +78 -0
- package/src/auth/AuthProviderCatalogRouter.ts +26 -0
- package/src/auth/LoginScreenEngine.ts +50 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +3 -2
- package/src/config.ts +46 -5
- package/src/coreRoutes.ts +166 -0
- package/src/generated/graphql-schemas/__mj.ts +1273 -10
- package/src/generic/CacheInvalidationResolver.ts +73 -18
- package/src/generic/PushStatusResolver.ts +1 -1
- package/src/generic/ResolverBase.ts +304 -325
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/globalCors.ts +71 -0
- package/src/index.ts +116 -174
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeSessions/RealtimeSessionVerificationService.ts +2 -1
- package/src/realtimeSessions/RealtimeVerifyRouter.ts +2 -1
- package/src/realtimeSessions/RecordingAssemblyScheduler.ts +234 -0
- package/src/realtimeSessions/index.ts +2 -0
- package/src/realtimeSessions/ownedRealtimeSession.ts +55 -0
- package/src/realtimeSessions/sessionAccess.ts +7 -3
- package/src/resolvers/ArtifactFileResolver.ts +9 -8
- package/src/resolvers/ConversationRunEventsResolver.ts +15 -11
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +3 -2
- package/src/resolvers/FetchEntityVectorsResolver.ts +121 -39
- package/src/resolvers/FileResolver.ts +321 -106
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +6 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +4 -2
- package/src/resolvers/QueryResolver.ts +11 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +10 -33
- package/src/resolvers/RunAIAgentResolver.ts +39 -4
- package/src/resolvers/SearchKnowledgeStreamResolver.ts +19 -3
- package/src/resolvers/SqlLoggingConfigResolver.ts +2 -4
- package/src/resolvers/TaskGraphFrameResolver.ts +33 -1
- package/src/resolvers/TestQuerySQLResolver.ts +68 -13
- package/src/resolvers/TransactionGroupResolver.ts +131 -27
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/agent-run-data-guard.ts +89 -0
- package/src/rest/RealtimeRecordingHandler.ts +464 -0
- package/src/rest/ViewOperationsHandler.ts +29 -13
- package/src/rest/setupRESTEndpoints.ts +6 -0
- package/src/serverExtensionReservedRoots.ts +14 -17
- 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 +704 -14
- package/src/sharedCache.ts +3 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview REST routes the browser uses to upload a client-direct realtime call's recording as
|
|
3
|
+
* `MJOPUS1` shards, and to send the terminal signal that says how many shards and samples the call
|
|
4
|
+
* captured:
|
|
5
|
+
*
|
|
6
|
+
* - `POST /realtime/recording/:agentSessionId/shard/:index` — one shard as raw `application/octet-stream`.
|
|
7
|
+
* - `POST /realtime/recording/:agentSessionId/end?lastShardIndex=&capturedSamples=[&final=false]` — the
|
|
8
|
+
* terminal signal, with the final partial shard (the tail) as an optional body. The browser sends it with
|
|
9
|
+
* `keepalive` on End, `pagehide` and `visibilitychange→hidden`, so it may arrive more than once, and late.
|
|
10
|
+
* A hidden tab sends `final=false`: the call may well go on, so the counts are stamped but the recording
|
|
11
|
+
* is assembled only after the grace period.
|
|
12
|
+
*
|
|
13
|
+
* Assembly is never scheduled sooner than the grace period while the session is still open, except for a
|
|
14
|
+
* final terminal: a hidden tab's terminal and the shards re-sent after it would otherwise rewrite
|
|
15
|
+
* `recording.wav` on every tab switch. Closing the session (`SessionManager.CloseSession`) schedules it
|
|
16
|
+
* right away.
|
|
17
|
+
*
|
|
18
|
+
* Both run after the unified auth middleware and are owner- and scope-gated (see `loadAuthorizedSession`). A request does only cheap work: parse
|
|
19
|
+
* the shard header and packet bounds, hash, one guarded ledger proc, maybe a count. It never decodes:
|
|
20
|
+
* assembly runs off the request path through the {@link IRecordingAssemblyScheduler}.
|
|
21
|
+
*
|
|
22
|
+
* @module @memberjunction/server
|
|
23
|
+
*/
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import express, { type NextFunction, type Request, type Response, type Router } from 'express';
|
|
26
|
+
import { DatabaseProviderBase, LogError, LogStatus, UserInfo } from '@memberjunction/core';
|
|
27
|
+
import { IsValidUUID } from '@memberjunction/global';
|
|
28
|
+
import type { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
|
|
29
|
+
import {
|
|
30
|
+
ParseRecordingShard, RecordingShardFormatError, RecordingShardHeader, RECORDING_SAMPLE_RATE, RECORDING_SHARD_MAX_BYTES, RECORDING_TERMINAL_MAX_BYTES,
|
|
31
|
+
} from '@memberjunction/ai-core-plus';
|
|
32
|
+
import { MAX_ASSEMBLY_SHARDS, type IRealtimeRecordingLedger, type RecordShardResult } from '@memberjunction/ai-agents';
|
|
33
|
+
import {
|
|
34
|
+
LoadOwnedRealtimeSession, RealtimeSessionNotFoundError, RealtimeSessionOwnershipError,
|
|
35
|
+
} from '../realtimeSessions/ownedRealtimeSession.js';
|
|
36
|
+
import type { IRecordingAssemblyScheduler } from '../realtimeSessions/RecordingAssemblyScheduler.js';
|
|
37
|
+
import { AuthorizeSessionAccess, LoadSessionConversationExternalID } from '../realtimeSessions/sessionAccess.js';
|
|
38
|
+
|
|
39
|
+
/** Where {@link CreateRealtimeRecordingRouter} is mounted (under the reserved `/realtime` root). */
|
|
40
|
+
export const REALTIME_RECORDING_MOUNT_PATH = '/realtime/recording';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long after a session closed it still accepts recording requests (a late gap shard re-assembles).
|
|
44
|
+
* Past it the recording is settled: shards and terminals are refused with 410 and nothing is stored.
|
|
45
|
+
*/
|
|
46
|
+
const LATE_SHARD_WINDOW_MS = 24 * 60 * 60 * 1000;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Allowance on top of a session's lifetime when bounding how much audio a request may claim: the
|
|
50
|
+
* recorder starts after the session row exists, so a real claim never exceeds the lifetime, but a
|
|
51
|
+
* request in flight while the session closes must not be refused.
|
|
52
|
+
*/
|
|
53
|
+
const SESSION_LIFETIME_SLACK_MS = 2 * 60 * 1000;
|
|
54
|
+
|
|
55
|
+
const OCTET_STREAM = 'application/octet-stream';
|
|
56
|
+
/**
|
|
57
|
+
* How soon a final terminal that found shards missing looks again. The browser sends the terminal first
|
|
58
|
+
* and re-sends its unacknowledged shards right after it (all `keepalive`), so they normally land within a
|
|
59
|
+
* second or two; when the tab closed right after End, no close follows to trigger assembly. A re-check
|
|
60
|
+
* that is still incomplete comes back `Deferred`, and the scheduler then arms the forced-`Partial` grace.
|
|
61
|
+
*/
|
|
62
|
+
export const FINAL_TERMINAL_RECHECK_MS = 5000;
|
|
63
|
+
|
|
64
|
+
const NON_NEGATIVE_INT = /^\d+$/;
|
|
65
|
+
const INT = /^-?\d+$/;
|
|
66
|
+
|
|
67
|
+
/** The authenticated caller of a recording request. */
|
|
68
|
+
export interface RealtimeRecordingCaller {
|
|
69
|
+
ContextUser: UserInfo;
|
|
70
|
+
/** The read-write provider the ledger writes through (the ledger needs a SQL-capable provider). */
|
|
71
|
+
Provider: DatabaseProviderBase;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Dependencies of {@link CreateRealtimeRecordingRouter}; injectable so the routes can be tested without a database. */
|
|
75
|
+
export interface RealtimeRecordingRouterDeps {
|
|
76
|
+
/** The authenticated caller, or null (answered 401). */
|
|
77
|
+
ResolveCaller(req: Request): RealtimeRecordingCaller | null;
|
|
78
|
+
LoadOwnedSession: typeof LoadOwnedRealtimeSession;
|
|
79
|
+
/** The session conversation's per-session scope id, for the anonymous-guest half of the access decision. */
|
|
80
|
+
LoadConversationExternalID: typeof LoadSessionConversationExternalID;
|
|
81
|
+
/** The ledger for one request; `contextUser` is the caller (used for logging by the procs). */
|
|
82
|
+
CreateLedger(provider: DatabaseProviderBase, contextUser: UserInfo): IRealtimeRecordingLedger;
|
|
83
|
+
Scheduler: IRecordingAssemblyScheduler;
|
|
84
|
+
/** Grace before an incomplete recording is assembled as `Partial`. */
|
|
85
|
+
GraceMs: () => number;
|
|
86
|
+
/**
|
|
87
|
+
* The server's system user, or null when it is not available. Deciding whether a recording is ready
|
|
88
|
+
* reads the shard entity, which callers (UI users, widget guests) deliberately cannot read; that read
|
|
89
|
+
* is the server's own scheduling decision, made after the caller was authorized, so it runs as this
|
|
90
|
+
* user. With none, nothing is scheduled and the session janitor assembles the recording.
|
|
91
|
+
*/
|
|
92
|
+
SystemUser: () => UserInfo | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A request failure with the HTTP status to answer. */
|
|
96
|
+
class RecordingRequestError extends Error {
|
|
97
|
+
constructor(public readonly Status: number, message: string) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.name = 'RecordingRequestError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What the pre-body middleware validated, handed to the handler through `res.locals`. */
|
|
104
|
+
interface RecordingRequestContext {
|
|
105
|
+
SessionID: string;
|
|
106
|
+
Caller: RealtimeRecordingCaller;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Builds the router for the shard and terminal routes (see the file overview). Status codes: 200 on
|
|
111
|
+
* success (`{ stored, duplicate }` / `{ accepted, ignored }`), 400 bad params or shard format, 401 no
|
|
112
|
+
* caller, 403 not the owner, 404 no such session, 409 same shard index with different bytes, 413 body
|
|
113
|
+
* over the cap, 500 unexpected (logged).
|
|
114
|
+
*/
|
|
115
|
+
export function CreateRealtimeRecordingRouter(deps: RealtimeRecordingRouterDeps): Router {
|
|
116
|
+
const router = express.Router();
|
|
117
|
+
|
|
118
|
+
router.post(
|
|
119
|
+
'/:agentSessionId/shard/:index',
|
|
120
|
+
validateBeforeBody(deps, RECORDING_SHARD_MAX_BYTES),
|
|
121
|
+
express.raw({ type: OCTET_STREAM, limit: RECORDING_SHARD_MAX_BYTES }),
|
|
122
|
+
(req, res) => respond(req, res, 'shard', () => handleShard(deps, req, res)),
|
|
123
|
+
);
|
|
124
|
+
router.post(
|
|
125
|
+
'/:agentSessionId/end',
|
|
126
|
+
validateBeforeBody(deps, RECORDING_TERMINAL_MAX_BYTES),
|
|
127
|
+
express.raw({ type: OCTET_STREAM, limit: RECORDING_TERMINAL_MAX_BYTES }),
|
|
128
|
+
(req, res) => respond(req, res, 'terminal', () => handleTerminal(deps, req, res)),
|
|
129
|
+
);
|
|
130
|
+
router.use(handleBodyError);
|
|
131
|
+
return router;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Rejects what can be rejected before reading the body: a malformed session id, no caller, or a declared
|
|
136
|
+
* `Content-Length` over the cap (`express.raw` enforces the cap on the bytes actually streamed too).
|
|
137
|
+
*/
|
|
138
|
+
function validateBeforeBody(deps: RealtimeRecordingRouterDeps, maxBytes: number) {
|
|
139
|
+
return (req: Request, res: Response, next: NextFunction): void => {
|
|
140
|
+
const sessionID = String(req.params['agentSessionId']);
|
|
141
|
+
if (!IsValidUUID(sessionID)) {
|
|
142
|
+
res.status(400).json({ error: 'agentSessionId must be a UUID' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const caller = deps.ResolveCaller(req);
|
|
146
|
+
if (!caller) {
|
|
147
|
+
res.status(401).json({ error: 'Not authenticated' });
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const declared = Number(req.headers['content-length']);
|
|
151
|
+
if (Number.isFinite(declared) && declared > maxBytes) {
|
|
152
|
+
LogStatus(`RealtimeRecordingHandler: refused a ${declared}-byte body for session ${sessionID} (cap ${maxBytes})`);
|
|
153
|
+
res.status(413).json({ error: `Body exceeds ${maxBytes} bytes` });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const context: RecordingRequestContext = { SessionID: sessionID, Caller: caller };
|
|
157
|
+
res.locals['recording'] = context;
|
|
158
|
+
next();
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Runs a handler, mapping its failures to status codes; unexpected ones are logged and answered 500. */
|
|
163
|
+
async function respond(req: Request, res: Response, kind: string, handler: () => Promise<void>): Promise<void> {
|
|
164
|
+
try {
|
|
165
|
+
await handler();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
const sessionID = String(req.params['agentSessionId']);
|
|
168
|
+
if (error instanceof RecordingRequestError) {
|
|
169
|
+
res.status(error.Status).json({ error: error.message });
|
|
170
|
+
} else if (error instanceof RealtimeSessionNotFoundError) {
|
|
171
|
+
res.status(404).json({ error: 'Realtime session not found' });
|
|
172
|
+
} else if (error instanceof RealtimeSessionOwnershipError) {
|
|
173
|
+
res.status(403).json({ error: 'Not authorized for this realtime session' });
|
|
174
|
+
} else {
|
|
175
|
+
LogError(`RealtimeRecordingHandler: ${kind} request for session ${sessionID} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
176
|
+
res.status(500).json({ error: 'Recording request failed' });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function handleShard(deps: RealtimeRecordingRouterDeps, req: Request, res: Response): Promise<void> {
|
|
182
|
+
const { SessionID, Caller } = res.locals['recording'] as RecordingRequestContext;
|
|
183
|
+
const index = parseInteger(req.params['index'], 'shard index', NON_NEGATIVE_INT);
|
|
184
|
+
if (index >= MAX_ASSEMBLY_SHARDS) {
|
|
185
|
+
throw new RecordingRequestError(400, `shard index ${index} is past the ${MAX_ASSEMBLY_SHARDS} shards one recording can assemble`);
|
|
186
|
+
}
|
|
187
|
+
const body = Buffer.isBuffer(req.body) ? req.body : null;
|
|
188
|
+
if (!body || body.length === 0) {
|
|
189
|
+
throw new RecordingRequestError(400, `The shard must be a non-empty ${OCTET_STREAM} body`);
|
|
190
|
+
}
|
|
191
|
+
const header = parseShard(body, SessionID);
|
|
192
|
+
if (header.ShardIndex !== index) {
|
|
193
|
+
throw new RecordingRequestError(400, `Shard header index ${header.ShardIndex} does not match the path index ${index}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const session = await loadAuthorizedSession(deps, SessionID, Caller);
|
|
197
|
+
assertWithinSessionLifetime(session, header.StartSample + header.SampleCount, `shard ${index}`);
|
|
198
|
+
const ledger = deps.CreateLedger(Caller.Provider, Caller.ContextUser);
|
|
199
|
+
const result = await recordShard(ledger, SessionID, header, body);
|
|
200
|
+
if (result.Outcome === 'Inserted') {
|
|
201
|
+
await scheduleAfterLateShard(deps, Caller.Provider, SessionID, index, result);
|
|
202
|
+
}
|
|
203
|
+
res.status(200).json({ stored: true, duplicate: result.Outcome === 'Duplicate' });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function handleTerminal(deps: RealtimeRecordingRouterDeps, req: Request, res: Response): Promise<void> {
|
|
207
|
+
const { SessionID, Caller } = res.locals['recording'] as RecordingRequestContext;
|
|
208
|
+
const lastShardIndex = parseInteger(req.query['lastShardIndex'], 'lastShardIndex', INT);
|
|
209
|
+
const capturedSamples = parseInteger(req.query['capturedSamples'], 'capturedSamples', NON_NEGATIVE_INT);
|
|
210
|
+
if (lastShardIndex < -1) {
|
|
211
|
+
throw new RecordingRequestError(400, `lastShardIndex must be -1 or more, got ${lastShardIndex}`);
|
|
212
|
+
}
|
|
213
|
+
if (lastShardIndex >= MAX_ASSEMBLY_SHARDS) {
|
|
214
|
+
throw new RecordingRequestError(400, `lastShardIndex ${lastShardIndex} lists more than the ${MAX_ASSEMBLY_SHARDS} shards one recording can assemble`);
|
|
215
|
+
}
|
|
216
|
+
if (lastShardIndex === -1 && capturedSamples !== 0) {
|
|
217
|
+
throw new RecordingRequestError(400, `lastShardIndex -1 (no shards) contradicts capturedSamples ${capturedSamples}`);
|
|
218
|
+
}
|
|
219
|
+
const final = parseFinalFlag(req.query['final']);
|
|
220
|
+
const tail = readOptionalTail(req, SessionID, lastShardIndex);
|
|
221
|
+
|
|
222
|
+
const session = await loadAuthorizedSession(deps, SessionID, Caller);
|
|
223
|
+
assertWithinSessionLifetime(session, capturedSamples, 'captured samples');
|
|
224
|
+
if (tail) {
|
|
225
|
+
assertWithinSessionLifetime(session, tail.Header.StartSample + tail.Header.SampleCount, `tail shard ${tail.Header.ShardIndex}`);
|
|
226
|
+
}
|
|
227
|
+
const ledger = deps.CreateLedger(Caller.Provider, Caller.ContextUser);
|
|
228
|
+
if (tail) {
|
|
229
|
+
const tailResult = await recordShard(ledger, SessionID, tail.Header, tail.Bytes);
|
|
230
|
+
if (tailResult.Outcome === 'Inserted') {
|
|
231
|
+
// A re-sent tail can fill a gap of a recording already assembled, exactly like a late shard.
|
|
232
|
+
await scheduleAfterLateShard(deps, Caller.Provider, SessionID, tail.Header.ShardIndex, tailResult);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (lastShardIndex === -1) {
|
|
236
|
+
// The call captured nothing: there is no recording to stamp or assemble.
|
|
237
|
+
res.status(200).json({ accepted: true, ignored: false });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const expected = lastShardIndex + 1;
|
|
242
|
+
const stamp = await ledger.StampTerminal(SessionID, expected, capturedSamples);
|
|
243
|
+
if (stamp.Outcome === 'Ignored') {
|
|
244
|
+
logIgnoredTerminal(session, expected, capturedSamples, stamp.RecordingStatus);
|
|
245
|
+
res.status(200).json({ accepted: true, ignored: true });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
LogStatus(`RealtimeRecordingHandler: terminal for session ${SessionID}: ${expected} shards, ${capturedSamples} samples; recording ${stamp.RecordingStatus ?? 'not recorded'}`);
|
|
249
|
+
if (stamp.RecordingStatus === 'Pending') {
|
|
250
|
+
if (final) {
|
|
251
|
+
await scheduleWhenReady(deps, Caller.Provider, SessionID, expected);
|
|
252
|
+
} else {
|
|
253
|
+
deps.Scheduler.ScheduleAssembly(SessionID, deps.GraceMs(), true);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
res.status(200).json({ accepted: true, ignored: false });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Loads the session through the shared ownership gate, then applies the per-session scope rule of
|
|
261
|
+
* {@link AuthorizeSessionAccess}. The gate alone checks `UserID`, which every anonymous widget guest shares
|
|
262
|
+
* (they all authenticate as the one Anonymous user); without the scope rule one guest could write shards to
|
|
263
|
+
* another guest's recording, and a different-bytes shard would mark it Failed. The owner half of the
|
|
264
|
+
* decision repeats the gate's check on purpose: the decision is one rule, applied whole. Named users carry
|
|
265
|
+
* no scope and are unaffected.
|
|
266
|
+
*/
|
|
267
|
+
async function loadAuthorizedSession(deps: RealtimeRecordingRouterDeps, sessionID: string, caller: RealtimeRecordingCaller): Promise<MJAIAgentSessionEntity> {
|
|
268
|
+
const session = await deps.LoadOwnedSession(sessionID, caller.ContextUser, caller.Provider);
|
|
269
|
+
const externalID = await deps.LoadConversationExternalID(session, caller.ContextUser, caller.Provider);
|
|
270
|
+
const decision = AuthorizeSessionAccess({ SessionUserID: session.UserID, ConversationExternalID: externalID }, caller.ContextUser);
|
|
271
|
+
if (decision.Allowed === false) {
|
|
272
|
+
LogError(`RealtimeRecordingHandler: refused user ${caller.ContextUser.ID} on session ${sessionID}: ${decision.Reason}`);
|
|
273
|
+
throw new RecordingRequestError(403, 'Not authorized for this realtime session');
|
|
274
|
+
}
|
|
275
|
+
return session;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Refuses a request the session's own lifetime rules out: 410 once the session closed more than
|
|
280
|
+
* {@link LATE_SHARD_WINDOW_MS} ago (the recording is settled), and 400 when `endSample` (a shard's end,
|
|
281
|
+
* or the terminal's captured count) is more audio than the session has existed, from its server-set
|
|
282
|
+
* creation to its close (or now), plus {@link SESSION_LIFETIME_SLACK_MS}. Without the second rule one
|
|
283
|
+
* 5 s shard and a terminal claiming 3 h made the server allocate and store a ~1 GB WAV.
|
|
284
|
+
*/
|
|
285
|
+
function assertWithinSessionLifetime(session: MJAIAgentSessionEntity, endSample: number, what: string): void {
|
|
286
|
+
const now = Date.now();
|
|
287
|
+
const closedAt = session.ClosedAt ? new Date(session.ClosedAt).getTime() : null;
|
|
288
|
+
if (closedAt !== null && now - closedAt > LATE_SHARD_WINDOW_MS) {
|
|
289
|
+
throw new RecordingRequestError(410, `Session ${session.ID} closed over 24 h ago; its recording no longer accepts audio`);
|
|
290
|
+
}
|
|
291
|
+
const lifetimeMs = (closedAt ?? now) - new Date(session.__mj_CreatedAt).getTime() + SESSION_LIFETIME_SLACK_MS;
|
|
292
|
+
const maxSamples = Math.max(0, Math.floor((lifetimeMs * RECORDING_SAMPLE_RATE) / 1000));
|
|
293
|
+
if (endSample > maxSamples) {
|
|
294
|
+
LogError(`RealtimeRecordingHandler: refused ${what} of session ${session.ID}: it reaches sample ${endSample}, past the ${maxSamples} the session's lifetime allows`);
|
|
295
|
+
throw new RecordingRequestError(400, `The ${what} reach sample ${endSample}, more audio than session ${session.ID} has existed for (${maxSamples} samples)`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The tail shard of a terminal request, or null for an empty body. */
|
|
300
|
+
function readOptionalTail(req: Request, sessionID: string, lastShardIndex: number): { Header: RecordingShardHeader; Bytes: Buffer } | null {
|
|
301
|
+
if (!Buffer.isBuffer(req.body)) {
|
|
302
|
+
// express.raw leaves the body unset for an empty body or a non-octet-stream content type; only the former is a valid "no tail".
|
|
303
|
+
if (Number(req.headers['content-length'] ?? 0) > 0 || req.headers['transfer-encoding'] !== undefined) {
|
|
304
|
+
throw new RecordingRequestError(400, `The tail must be an ${OCTET_STREAM} body`);
|
|
305
|
+
}
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
if (req.body.length === 0) {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
const header = parseShard(req.body, sessionID);
|
|
312
|
+
if (header.ShardIndex !== lastShardIndex) {
|
|
313
|
+
throw new RecordingRequestError(400, `Tail shard index ${header.ShardIndex} does not match lastShardIndex ${lastShardIndex}`);
|
|
314
|
+
}
|
|
315
|
+
return { Header: header, Bytes: req.body };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Validates the MJOPUS1 structure (header and packet bounds; no decoding). */
|
|
319
|
+
function parseShard(bytes: Buffer, sessionID: string): RecordingShardHeader {
|
|
320
|
+
try {
|
|
321
|
+
const { ShardIndex, StartSample, SampleCount, PacketCount } = ParseRecordingShard(bytes);
|
|
322
|
+
return { ShardIndex, StartSample, SampleCount, PacketCount };
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof RecordingShardFormatError) {
|
|
325
|
+
LogStatus(`RealtimeRecordingHandler: malformed shard for session ${sessionID}: ${error.message}`);
|
|
326
|
+
throw new RecordingRequestError(400, `Malformed recording shard: ${error.message}`);
|
|
327
|
+
}
|
|
328
|
+
throw error;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Records one shard; a conflict (same index, different bytes) is logged and answered 409. */
|
|
333
|
+
async function recordShard(ledger: IRealtimeRecordingLedger, sessionID: string, header: RecordingShardHeader, bytes: Buffer): Promise<RecordShardResult> {
|
|
334
|
+
const result = await ledger.RecordShard({
|
|
335
|
+
SessionID: sessionID,
|
|
336
|
+
Index: header.ShardIndex,
|
|
337
|
+
StartSample: header.StartSample,
|
|
338
|
+
SampleCount: header.SampleCount,
|
|
339
|
+
Data: bytes,
|
|
340
|
+
Sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
341
|
+
});
|
|
342
|
+
if (result.Outcome === 'Conflict') {
|
|
343
|
+
LogError(
|
|
344
|
+
`RealtimeRecordingHandler: session ${sessionID} sent shard ${header.ShardIndex} again with different bytes; ` +
|
|
345
|
+
`the recording is marked Failed for review`,
|
|
346
|
+
);
|
|
347
|
+
throw new RecordingRequestError(409, `Shard ${header.ShardIndex} was already stored with different bytes`);
|
|
348
|
+
}
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* After a newly inserted shard below the expected count (one that arrived after the terminal signal):
|
|
354
|
+
* - on a `Partial` recording, re-assemble now, unless the session closed more than 24 h ago;
|
|
355
|
+
* - on a `Pending` recording waiting out its grace, assemble now if this was the last missing shard,
|
|
356
|
+
* instead of waiting for the grace timer.
|
|
357
|
+
* Shards at or past the expected count need nothing here: the ledger re-opens the recording (`Pending`)
|
|
358
|
+
* and a terminal signal or the janitor follows.
|
|
359
|
+
*/
|
|
360
|
+
async function scheduleAfterLateShard(
|
|
361
|
+
deps: RealtimeRecordingRouterDeps,
|
|
362
|
+
provider: DatabaseProviderBase,
|
|
363
|
+
sessionID: string,
|
|
364
|
+
index: number,
|
|
365
|
+
result: RecordShardResult,
|
|
366
|
+
): Promise<void> {
|
|
367
|
+
const expected = result.ExpectedShards;
|
|
368
|
+
if (expected === null || index >= expected) {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
if (result.RecordingStatus !== 'Partial' && result.RecordingStatus !== 'Pending') {
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (!result.ClosedAt) {
|
|
375
|
+
// The call is still open (e.g. the tab was hidden and came back): assemble after the grace period,
|
|
376
|
+
// not now. Repeated requests keep the earliest timer.
|
|
377
|
+
deps.Scheduler.ScheduleAssembly(sessionID, deps.GraceMs(), true);
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (result.RecordingStatus === 'Partial') {
|
|
381
|
+
// A session closed over LATE_SHARD_WINDOW_MS ago never gets here: its requests are refused (410).
|
|
382
|
+
deps.Scheduler.ScheduleAssembly(sessionID, 0, false);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
await scheduleWhenReady(deps, provider, sessionID, expected, false);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Schedules a `Pending` recording: now when every expected shard is stored; otherwise, after a final
|
|
390
|
+
* terminal (`recheckWhenIncomplete`), a re-check in {@link FINAL_TERMINAL_RECHECK_MS} that falls back to
|
|
391
|
+
* the forced-`Partial` grace when shards are still missing.
|
|
392
|
+
*/
|
|
393
|
+
async function scheduleWhenReady(
|
|
394
|
+
deps: RealtimeRecordingRouterDeps,
|
|
395
|
+
provider: DatabaseProviderBase,
|
|
396
|
+
sessionID: string,
|
|
397
|
+
expected: number,
|
|
398
|
+
recheckWhenIncomplete = true,
|
|
399
|
+
): Promise<void> {
|
|
400
|
+
const systemUser = deps.SystemUser();
|
|
401
|
+
if (!systemUser) {
|
|
402
|
+
LogError(`RealtimeRecordingHandler: no system user to check whether the recording of session ${sessionID} is complete; the session janitor will assemble it`);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const present = await deps.CreateLedger(provider, systemUser).CountShardsBelow(sessionID, expected);
|
|
406
|
+
if (present === expected) {
|
|
407
|
+
deps.Scheduler.ScheduleAssembly(sessionID, 0, false);
|
|
408
|
+
} else if (recheckWhenIncomplete) {
|
|
409
|
+
deps.Scheduler.ScheduleAssembly(sessionID, FINAL_TERMINAL_RECHECK_MS, false);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function logIgnoredTerminal(session: MJAIAgentSessionEntity, expected: number, captured: number, status: string | null): void {
|
|
414
|
+
LogStatus(
|
|
415
|
+
`RealtimeRecordingHandler: ignored a lower terminal for session ${session.ID}: received ${expected} shards / ${captured} samples, ` +
|
|
416
|
+
`stored ${session.RecordingExpectedShards ?? 'none'} shards / ${session.RecordingCapturedSamples ?? 'none'} samples (recording ${status ?? 'not recorded'})`,
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The terminal's `final` flag: absent or `true` means the call ended; `false` means the tab was hidden. */
|
|
421
|
+
function parseFinalFlag(value: unknown): boolean {
|
|
422
|
+
if (value === undefined || value === 'true') {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
if (value === 'false') {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
throw new RecordingRequestError(400, `final must be true or false, got '${String(value)}'`);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Parses a non-negative (or, with {@link INT}, signed) safe integer route or query value; 400 otherwise. */
|
|
432
|
+
function parseInteger(value: unknown, field: string, pattern: RegExp): number {
|
|
433
|
+
if (typeof value !== 'string' || !pattern.test(value)) {
|
|
434
|
+
throw new RecordingRequestError(400, `${field} must be an integer, got '${String(value ?? '')}'`);
|
|
435
|
+
}
|
|
436
|
+
const parsed = Number(value);
|
|
437
|
+
if (!Number.isSafeInteger(parsed)) {
|
|
438
|
+
throw new RecordingRequestError(400, `${field} is out of range`);
|
|
439
|
+
}
|
|
440
|
+
return parsed;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Error middleware for failures raised before a handler runs, which are body-parsing failures: 413 for an
|
|
445
|
+
* over-cap body, 400 for any other client-side body error, 500 (logged) otherwise.
|
|
446
|
+
*/
|
|
447
|
+
function handleBodyError(error: unknown, req: Request, res: Response, next: NextFunction): void {
|
|
448
|
+
if (res.headersSent) {
|
|
449
|
+
next(error);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const status = typeof error === 'object' && error !== null && 'status' in error ? Number((error as { status: unknown }).status) : 500;
|
|
453
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
454
|
+
if (status === 413) {
|
|
455
|
+
LogStatus(`RealtimeRecordingHandler: ${req.originalUrl} body over the cap: ${message}`);
|
|
456
|
+
res.status(413).json({ error: 'Body too large' });
|
|
457
|
+
} else if (status >= 400 && status < 500) {
|
|
458
|
+
LogStatus(`RealtimeRecordingHandler: ${req.originalUrl} body could not be read: ${message}`);
|
|
459
|
+
res.status(400).json({ error: 'Body could not be read' });
|
|
460
|
+
} else {
|
|
461
|
+
LogError(`RealtimeRecordingHandler: ${req.originalUrl} failed before its handler: ${message}`);
|
|
462
|
+
res.status(500).json({ error: 'Recording request failed' });
|
|
463
|
+
}
|
|
464
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
-
LogError, Metadata, RunView, RunViewParams,
|
|
3
|
-
RunViewResult, UserInfo
|
|
2
|
+
BaseEntity, LogError, Metadata, RunView, RunViewParams,
|
|
3
|
+
RunViewResult, UserInfo, type IRunViewProvider
|
|
4
4
|
} from '@memberjunction/core';
|
|
5
|
+
import { ClientClauseScreen } from '@memberjunction/generic-database-provider';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* View Operations Implementation for REST endpoints
|
|
@@ -32,11 +33,11 @@ export class ViewOperationsHandler {
|
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
// Sanitize and validate parameters
|
|
36
|
-
|
|
36
|
+
// Sanitize and validate parameters against the provider that runs the view
|
|
37
|
+
const runView = new RunView();
|
|
38
|
+
this.sanitizeRunViewParams(params, md, runView.ProviderToUse, user);
|
|
37
39
|
|
|
38
40
|
// Execute the view
|
|
39
|
-
const runView = new RunView();
|
|
40
41
|
const result = await runView.RunView(params, user);
|
|
41
42
|
|
|
42
43
|
return { success: true, result };
|
|
@@ -56,8 +57,9 @@ export class ViewOperationsHandler {
|
|
|
56
57
|
*/
|
|
57
58
|
static async RunViews(paramsArray: RunViewParams[], user: UserInfo): Promise<{ success: boolean, results?: RunViewResult[], error?: string }> {
|
|
58
59
|
try {
|
|
59
|
-
// Validate and sanitize each set of parameters
|
|
60
|
+
// Validate and sanitize each set of parameters against the provider that runs the views
|
|
60
61
|
const md = new Metadata(); // global-provider-ok: REST endpoint — no per-request provider injection in REST middleware yet
|
|
62
|
+
const runView = new RunView();
|
|
61
63
|
for (const params of paramsArray) {
|
|
62
64
|
// Validate entity exists
|
|
63
65
|
const entity = md.Entities.find(e => e.Name === params.EntityName);
|
|
@@ -78,11 +80,10 @@ export class ViewOperationsHandler {
|
|
|
78
80
|
}
|
|
79
81
|
|
|
80
82
|
// Sanitize parameters
|
|
81
|
-
this.sanitizeRunViewParams(params);
|
|
83
|
+
this.sanitizeRunViewParams(params, md, runView.ProviderToUse, user);
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
// Execute the views
|
|
85
|
-
const runView = new RunView();
|
|
86
87
|
const results = await runView.RunViews(paramsArray, user);
|
|
87
88
|
|
|
88
89
|
return { success: true, results };
|
|
@@ -114,11 +115,11 @@ export class ViewOperationsHandler {
|
|
|
114
115
|
throw new Error(`User ${user.Name} does not have permission to read ${params.EntityName} records`);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
// Sanitize and validate parameters
|
|
118
|
-
|
|
118
|
+
// Sanitize and validate parameters against the provider that runs the view
|
|
119
|
+
const runView = new RunView();
|
|
120
|
+
this.sanitizeRunViewParams(params, md, runView.ProviderToUse, user);
|
|
119
121
|
|
|
120
122
|
// Execute the view
|
|
121
|
-
const runView = new RunView();
|
|
122
123
|
return await runView.RunView(params, user);
|
|
123
124
|
} catch (error) {
|
|
124
125
|
LogError(error);
|
|
@@ -193,13 +194,21 @@ export class ViewOperationsHandler {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
/**
|
|
196
|
-
* Sanitize and validate RunViewParams
|
|
197
|
+
* Sanitize and validate RunViewParams. Client filter and sort text passes the same clause
|
|
198
|
+
* screen as the GraphQL RunView resolvers, parsed in the dialect of `provider`, the provider
|
|
199
|
+
* that runs the view.
|
|
197
200
|
*/
|
|
198
|
-
private static sanitizeRunViewParams(params: RunViewParams): void {
|
|
201
|
+
private static sanitizeRunViewParams(params: RunViewParams, md: Metadata, provider: IRunViewProvider, user: UserInfo): void {
|
|
199
202
|
// Ensure EntityName is provided
|
|
200
203
|
if (!params.EntityName) {
|
|
201
204
|
throw new Error('EntityName is required');
|
|
202
205
|
}
|
|
206
|
+
|
|
207
|
+
// A loaded view entity cannot arrive over JSON; the provider would trust a plain object's
|
|
208
|
+
// fields (its ID is spliced into view SQL). REST callers name a saved view by ViewID or ViewName.
|
|
209
|
+
if (params.ViewEntity != null && !(params.ViewEntity instanceof BaseEntity)) {
|
|
210
|
+
throw new Error('ViewEntity is not accepted over REST; use ViewID or ViewName');
|
|
211
|
+
}
|
|
203
212
|
|
|
204
213
|
// Convert string arrays if they came in as comma-separated strings
|
|
205
214
|
if (params.Fields && typeof params.Fields === 'string') {
|
|
@@ -226,5 +235,12 @@ export class ViewOperationsHandler {
|
|
|
226
235
|
if (!params.ResultType) {
|
|
227
236
|
params.ResultType = 'simple';
|
|
228
237
|
}
|
|
238
|
+
|
|
239
|
+
ClientClauseScreen.ScreenViewClauses(
|
|
240
|
+
{ ExtraFilter: params.ExtraFilter, OrderBy: params.OrderBy, OverrideExcludeFilter: params.OverrideExcludeFilter },
|
|
241
|
+
md.Entities,
|
|
242
|
+
ClientClauseScreen.DialectFor(provider),
|
|
243
|
+
user,
|
|
244
|
+
);
|
|
229
245
|
}
|
|
230
246
|
}
|
|
@@ -3,6 +3,12 @@ import BodyParser from 'body-parser';
|
|
|
3
3
|
import { LogStatusEx } from '@memberjunction/core';
|
|
4
4
|
import { RESTEndpointHandler } from './RESTEndpointHandler.js';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Known exception to the API-root rule (#5674): REST stays at the ORIGIN-absolute `/api/v1` and does NOT go
|
|
8
|
+
* through `ApiRoute()`. Prefixed deployments (CloudFront `/api/*`) already reach it at this absolute path;
|
|
9
|
+
* under `MJAPI_BASE_PATH=/api` wrapping it would make it `/api/api/v1` and break
|
|
10
|
+
* every REST client. It is passed as a constant, so `check:api-root` (which flags literals) does not trip.
|
|
11
|
+
*/
|
|
6
12
|
export const REST_API_BASE_PATH = '/api/v1';
|
|
7
13
|
|
|
8
14
|
/** @deprecated Use {@link REST_API_BASE_PATH}. */
|
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
* Core HTTP prefixes MJServer mounts — many of them PRE-AUTH — that an Open App
|
|
3
3
|
* server extension must not claim.
|
|
4
4
|
*
|
|
5
|
+
* Every value is relative to MJAPI's base path (`ApiRoute()` composes the real mount path).
|
|
6
|
+
*
|
|
5
7
|
* Kept as literals (not imported `*_MOUNT_PATH` constants) so this module cannot
|
|
6
8
|
* pull `config.ts` / telephony routers into every importer. Values MUST stay in
|
|
7
9
|
* lockstep with the `export const *_MOUNT_PATH` declarations and the `app.use` /
|
|
8
|
-
* `app.get` calls in `src/
|
|
9
|
-
*
|
|
10
|
+
* `app.get` calls in `src/coreRoutes.ts`. The companion unit tests read the constants'
|
|
11
|
+
* source and boot those mounts (`apiRootBoot.test.ts`), failing if either drifts.
|
|
10
12
|
*
|
|
11
13
|
* `serve()` passes the result into `prepareServerExtensionConfigs({ extraReservedRoots })`.
|
|
12
14
|
*
|
|
13
|
-
* If you add a pre-auth
|
|
15
|
+
* If you add a pre-auth core mount, add its prefix here.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
/** Core mounts that have no exported `*_MOUNT_PATH` constant. */
|
|
@@ -35,21 +37,16 @@ export const CORE_CONSTANT_RESERVED_SERVER_EXTENSION_ROOTS: readonly string[] =
|
|
|
35
37
|
];
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
|
-
* Reserved roots for the running process
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* Reserved roots for the running process, relative to MJAPI's base path like every extension RootPath.
|
|
41
|
+
* `graphqlRootPath` is no longer reserved: it is only Apollo's path, and Apollo mounts last, so an extension
|
|
42
|
+
* route registered before it always wins (`prepareServerExtensionConfigs({ apiRootPath })` composes the
|
|
43
|
+
* mount path).
|
|
42
44
|
*/
|
|
43
|
-
export function CoreReservedServerExtensionRoots(
|
|
44
|
-
|
|
45
|
-
return [
|
|
46
|
-
...(graphql ? [graphql] : []),
|
|
47
|
-
...CORE_CONSTANT_RESERVED_SERVER_EXTENSION_ROOTS,
|
|
48
|
-
...CORE_STATIC_RESERVED_SERVER_EXTENSION_ROOTS,
|
|
49
|
-
];
|
|
45
|
+
export function CoreReservedServerExtensionRoots(): string[] {
|
|
46
|
+
return [...CORE_CONSTANT_RESERVED_SERVER_EXTENSION_ROOTS, ...CORE_STATIC_RESERVED_SERVER_EXTENSION_ROOTS];
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
/** @deprecated Use {@link CoreReservedServerExtensionRoots}. */
|
|
53
|
-
export function coreReservedServerExtensionRoots(
|
|
54
|
-
return CoreReservedServerExtensionRoots(
|
|
49
|
+
/** @deprecated Use {@link CoreReservedServerExtensionRoots}. The argument is ignored. */
|
|
50
|
+
export function coreReservedServerExtensionRoots(_graphqlRootPath?: string): string[] {
|
|
51
|
+
return CoreReservedServerExtensionRoots();
|
|
55
52
|
}
|