Member Junction
    Preparing search index...

    Class RealtimeClientSessionResolver

    Resolver for the client-direct realtime voice topology. A single SessionManager and a single RealtimeClientSessionService are shared across requests — neither holds per-user or per-provider state; every method is passed the request contextUser + provider explicitly.

    Hierarchy (View Summary)

    Index

    Constructors

    Accessors

    Methods

    • Applies an inbound RestoreContext___ blob to a server-side BaseEntity. Mirrors the OldValues___ pattern — the client-side BaseEntity's _restoreContext doesn't traverse the network, so the server must reconstruct it from the mutation input before calling Save().

      Returns true when context was applied; false when no context was on the input.

      Parameters

      • entityObject: BaseEntity
      • input: { RestoreContext___?: { Reason?: string; SourceChangeID?: string } }

      Returns boolean

    • SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.

      ValidateUserProvidedSQLClause still blocks stacked statements, DML, comments, UNION and WAITFOR, and still permits SELECT (server-internal engines pass richer filters). A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that use IN (SELECT … FROM <entity base view>) — that is a legitimate ExtraFilter.

      This screen uses @memberjunction/sql-parser (same wrap as EDS assertReadOnlyClause): wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then allow a FROM only when it is an entity BaseView. Base tables (Meeting, __mj.User) and catalogs are rejected. Server-internal RunView callers get only the provider's statement-separator check, which cannot refuse a legitimate filter.

      The screen is ClientClauseScreen in @memberjunction/generic-database-provider, shared with the REST view routes, ad-hoc list sources, and the provider's saved-view checks.

      Before that parse, the fragment must be balanced on its own (IsBalancedSQLFragment). The provider and the tenant hook splice it inside parentheses and AND their own filters after it, so a fragment that closes those parentheses (1=1) OR (1=1) would leave row-level security and tenant scope on one side of an OR the caller controls. The parse wraps the fragment in parentheses too, which is why it cannot catch this alone. A backslash inside a quoted string or identifier is refused as well: the parser reads \' inside a plain literal as an escaped quote, but SQL Server and PostgreSQL end the literal there, so the text after it could be code that the parse-based checks never saw.

      RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled into the view, and RunView removes denied fields from its own result only. A subquery reads a base view directly with neither, so the screen refuses one over any entity the user reads under a row filter or with denied fields (ClientClauseScreen.AssertUnscopedRead), the queried entity included.

      Parameters

      • clause: string
      • label: string
      • Optionalprovider: IMetadataProvider
      • Optionaluser: UserInfo
      • Optionaloptions: { LeaveUnparseableToProvider?: boolean }

      Returns void

    • SECURITY — screen for a FULL client-supplied SQL statement (ad-hoc query path). In addition to the SELECT-only validation the caller performs, every table reference must resolve to an entity BaseView the acting user can read UNSCOPED. A reference is excluded only when it reads a CTE that is in scope there (StatementRelation.IsCTEReference): unqualified, declared by its own SELECT or an enclosing one, and matched by the dialect's identifier rules. A name reserved for the SQL parser is refused before parsing (ClientClauseScreen.AssertNoIdentifierAliasNames). Fails closed: no user, or an unresolvable reference, refuses.

      The references come from a walk of the whole parse tree (ClientClauseScreen.CollectStatementReads). The statement is refused when that walk does not account for every SELECT in the text, or when a FROM item names no table (a table function or a row list).

      Entity CanRead is not unscoped base-view read authority: RunView narrows a granted read with row-level security, and raw SQL does not apply it. Rather than try to rewrite arbitrary SQL (joins, CTEs) to compose that policy, this refuses any entity that carries a row filter for the caller — those reads belong on RunView. For the remaining entities CanRead genuinely means "every row and field of the view".

      Parameters

      Returns void

    • Cancel in-flight relayed tool delegations for a session — the CLIENT-DIRECT cancel channel.

      The browser calls this on an EXPLICIT user cancel (the call overlay's per-card ✕ — a deliberate host policy: true barge-in alone never aborts delegations, since the narration design expects the user to keep talking while delegated work runs). The service-side in-flight registry aborts the matching AbortController(s), which fires the delegated run's cancellationToken; the original ExecuteRealtimeSessionTool mutation then resolves with the run's cancelled/failed result through its normal path.

      Ownership-gated like the sibling mutations. A Closed session is accepted — a cancel can legitimately race teardown, and aborting stragglers is exactly what the caller wants then.

      Parameters

      • agentSessionId: string
      • __namedParameters: AppContext
      • OptionalcallId: string

        When supplied, only the delegation for that provider call id is aborted; omit to abort ALL in-flight delegations for the session.

      Returns Promise<CancelRealtimeSessionToolResult>

      A structured CancelRealtimeSessionToolResult. Tolerant: an unknown call id or a session with nothing in flight is Success: true, AbortedCount: 0 (the work may simply have finished already); an unexpected registry error is a structured failure.

    • Checks API key scope authorization. Only performs check if request was authenticated via API key (apiKeyHash present in userPayload). For OAuth/JWT auth, this is a no-op.

      Parameters

      • scopePath: string

        The scope path (e.g., 'entity:read', 'agent:execute')

      • resource: string

        The resource name (e.g., entity name, agent name)

      • userPayload: UserPayload

        The user payload from context

      Returns Promise<void>

      AuthorizationError if API key lacks required scope

    • Execute a single tool call the browser relayed from its provider socket and return the serialized result for the browser to relay back to the model.

      Ownership-gated. The target agent id is read from the session config and CanRun on it is checked for the caller on every call, because the owner can edit the config. A direct-action call from a scope-limited caller is refused before any elevation. Heartbeats the session on success.

      Parameters

      • agentSessionId: string
      • callId: string
      • toolName: string
      • argsJson: string
      • __namedParameters: AppContext
      • pubSub: PubSubEngine

      Returns Promise<string>

      The serialized tool result JSON.

    • Filters encrypted field values before sending to the API client.

      For each encrypted field in the entity:

      • If AllowDecryptInAPI is true: value passes through unchanged (already decrypted by data provider)
      • If AllowDecryptInAPI is false and SendEncryptedValue is true: re-encrypt and send ciphertext
      • If AllowDecryptInAPI is false and SendEncryptedValue is false: replace with sentinel value

      Parameters

      • entityName: string

        Name of the entity

      • dataObject: Record<string, unknown>

        The data object containing field values

      • contextUser: UserInfo

        User context for encryption operations

      • Optionalprovider: IMetadataProvider
      • fieldKey: "Name" | "CodeName" = 'CodeName'

        What the object's keys are: field CodeNames (GraphQL transport rows, the default) or field Names (raw provider rows, such as dataset item results)

      Returns Promise<Record<string, unknown>>

      The filtered data object

    • Loads a single external-data-source-backed entity record by primary key and returns it in GraphQL field-name (CodeName) shape, or null if not found.

      External entities (Entity.ExternalDataSourceID set) have no MJ base view or sproc — their data is proxied live from a remote system — so the generated single-record resolver cannot run SELECT * FROM <baseView>. Instead it loads through a BaseEntity object, whose InnerLoad the data provider dispatches to the external read router's LoadExternalRecord (a composite-key aware, quoted, parameter-bound single-record lookup), applying the same RLS gate and field post-processing (decryption / datetime normalization) as the MJ-DB path. The caller is responsible for the CheckUserReadPermissions gate beforehand.

      Type Parameters

      • T

      Parameters

      Returns Promise<T>

    • Maps field names to their GraphQL-safe CodeNames and handles encryption for API responses.

      For encrypted fields coming from raw SQL queries (not entity objects):

      • AllowDecryptInAPI=true: Decrypt the value before sending to client
      • AllowDecryptInAPI=false + SendEncryptedValue=true: Keep encrypted ciphertext
      • AllowDecryptInAPI=false + SendEncryptedValue=false: Replace with sentinel

      Returns a COPY — dataObject is never written to. Callers routinely pass rows straight from findBy/RunView, which are the server cache's own objects held by reference under a reference-sharing storage provider. Renaming in place therefore rewrote the cached row's keys, so later readers were served transport-shaped rows that BaseEntity.SetMany rejects — and because that cache is process-wide, one response corrupted every subsequent request across all workers. It bit every UserByEmail / UserByID / UserByEmployeeID call and every generated single-record resolver whose entity has caching enabled. Copying here fixes every call site at once and makes the hazard unreachable for future ones.

      Parameters

      • entityName: string

        The entity name

      • dataObject: any

        The data object with field values. Not modified.

      • OptionalcontextUser: UserInfo

        Optional user context for decryption (required for encrypted fields)

      • Optionalprovider: IMetadataProvider

      Returns Promise<any>

      A new object in transport shape, or null when there is nothing to map

    • Publishes a CACHE_INVALIDATION event to connected browser clients after a successful entity save or delete. Includes the originSessionId so the originating browser can skip redundant re-fetches (it already handled the event locally).

      The row itself rides along ONLY for entities opted in via cacheSettings.recordDataBroadcastEntities — this event reaches every connected client unfiltered, so the row would otherwise be readable by sessions that could not read the record.

      Parameters

      Returns void

    • Publishes a push-status update to the client on PUSH_STATUS_UPDATES_TOPIC, stamping the authenticated owner's user ID from userPayload so the subscription filter can bind delivery to identity (see B49 / statusUpdatesFilter). The ergonomic wrapper every resolver should use instead of calling pubSub.publish on the topic directly — it makes omitting identity impossible. Non-resolver publishers (services, the liveness heartbeat) call the shared publishStatusUpdate() function directly with an explicit ownerUserId.

      Parameters

      Returns void

    • Relays a co-agent CHANNEL tool-call (browser_ / Whiteboard_ etc.) onto the session's co-agent AIPromptRun.Messages — run-only observability so the run captures what the co-agent DID, not just what it said. Deliberately NOT a ConversationDetail turn (the chat thread stays speech-only). Ownership-gated; best-effort (a missing prompt run / save failure simply returns false).

      Parameters

      • agentSessionId: string
      • toolName: string
      • __namedParameters: AppContext
      • OptionalargsJson: string
      • OptionalresultJson: string

      Returns Promise<boolean>

      true when the tool turn was recorded on the run.

    • Persist a transcript turn (user or assistant) as a Conversation Detail on the session's conversation. Ownership-gated; heartbeats the session on success.

      The co-agent observability AIAgentRun/AIPromptRun are created at session start (see RealtimeClientSessionResolver.StartRealtimeClientSession) and finalized on close; incremental usage telemetry lands on the AIPromptRun via RealtimeClientSessionResolver.RelayRealtimeUsage. Each persisted turn is ALSO appended to the co-agent AIPromptRun.Messages (best-effort), so the run captures the full conversation — observability parity with every other MJ agent run, not just token totals.

      Parameters

      • agentSessionId: string
      • role: string
      • text: string
      • __namedParameters: AppContext
      • OptionalreplacesPrevious: boolean
      • OptionalutteranceStartMs: number
      • OptionalutteranceEndMs: number

      Returns Promise<boolean>

      true when the transcript turn was persisted.

    • Relay incremental usage telemetry from the browser's provider socket onto the co-agent's observability AIPromptRun (the run created at session start).

      The browser accumulates the realtime client's OnUsage token DELTAS and flushes them here debounced (~10s) plus once at teardown; this mutation ACCUMULATES the deltas onto the prompt run's TokensPrompt / TokensCompletion (and recomputes TokensUsed). Status is deliberately untouched — FinalizeCoAgentRun keeps stamping Success/Completed at close, and a post-close flush still lands (accumulation has no Status gate).

      Ownership-gated like the sibling mutations; a Closed session is accepted (the final teardown flush can land after CloseAgentSession). Everything PAST the ownership gate is best-effort and tolerant: a missing/malformed session config, an absent promptRunID (observability creation was skipped), a failed load, or a failed save all log and return false — usage relay must never break a live call.

      Parameters

      • agentSessionId: string
      • inputTokens: number

        Input-token DELTA to add (negative/non-finite values are clamped to 0).

      • outputTokens: number

        Output-token DELTA to add (negative/non-finite values are clamped to 0).

      • __namedParameters: AppContext

      Returns Promise<boolean>

      true when the accumulated usage was persisted; false on any tolerated failure.

    • Reverse-maps GraphQL-safe field names back to entity CodeNames in a mutation input object. For example, _mj__integration_SyncStatus is mapped back to __mj_integration_SyncStatus. Also reverse-maps keys inside the OldValues___ array if present. This is the inverse of MapFieldNamesToCodeNames and must be called before passing GraphQL input to entity SetMany() or field lookups.

      Parameters

      • input: Record<string, unknown>

      Returns Record<string, unknown>

    • Optimized RunViewGenericInternal implementation with:

      • Field filtering at source (Fix #7)
      • Improved error handling (Fix #9)

      Parameters

      • provider: DatabaseProviderBase
      • viewInfo: MJUserViewEntityExtended
      • extraFilter: string
      • orderBy: string
      • userSearchString: string
      • excludeUserViewRunID: string
      • overrideExcludeFilter: string
      • saveViewResults: boolean
      • fields: string[]
      • ignoreMaxRows: boolean
      • excludeDataFromAllPriorViewRuns: boolean
      • forceAuditLog: boolean
      • auditLogDescription: string
      • resultType: string
      • userPayload: UserPayload
      • maxRows: number
      • startRow: number
      • Optionalaggregates: AggregateExpression[]
      • OptionalafterKey: CompositeKey
      • OptionalbypassCache: boolean

      Returns Promise<RunViewResult<any>>

    • Persist an interactive channel's current state (e.g. the live whiteboard's board JSON) as a durable, user-owned artifact — distinct from SaveSessionChannelState, which only stores the session-scoped state of record. The artifact survives the session and shows up in the user's artifact library (and, when possible, in the conversation's history).

      Flow:

      1. Ownership gate — like the sibling mutations (UserID === contextUser.ID). A Closed session is accepted: "save my board" legitimately arrives as/after the call ends.
      2. Size cap — contentJson beyond MAX_CHANNEL_STATE_CHARS is a structured failure.
      3. Resolve the WHITEBOARD_ARTIFACT_TYPE_NAME artifact type by name; when the seed is absent/disabled in this deployment, return a structured failure (never throw).
      4. Create the MJ: Artifacts header (user-owned, Visibility Always) + its MJ: Artifact Versions v1 row carrying contentJson.
      5. Best-effort extras that never fail the save:
        • when the session has a conversation AND a Conversation Detail stamped with this session id exists (the transcript relay stamps AgentSessionID), link the version into history via a MJ: Conversation Detail Artifacts junction row (Direction Output);
        • stamp LastActiveAt on the session-channel row when one exists.

      Parameters

      • agentSessionId: string
      • channelName: string
      • name: string
      • contentJson: string
      • __namedParameters: AppContext

      Returns Promise<SaveSessionChannelArtifactResult>

      A structured SaveSessionChannelArtifactResult — graceful failures (missing type seed, oversized content, save failure) come back as Success: false, while authn/ownership violations throw like the sibling mutations.

    • Persist an interactive channel's state of record (e.g. the live whiteboard's serialized scene) onto the session's MJ: AI Agent Session Channels row.

      Flow:

      1. Ownership gate — like the sibling mutations, the session's UserID must equal the caller's. Unlike Execute/Relay, a Closed session is accepted: the client's final on-end flush legitimately lands after CloseAgentSession has run.
      2. Resolve the channel definition (MJ: AI Agent Channels) by channelName. When no active definition row exists (the deployment hasn't synced the channel seed yet), return false gracefully and log — never throw for a missing definition.
      3. Offer the payload to the session's SERVER-SIDE channel plugin (the registry row's ServerPluginClass, held per-session by RealtimeChannelServerHost) for validation/normalization — strictly best-effort: no plugin, a plugin failure, or an oversized replacement all fall back to persisting the original payload.
      4. Upsert the session-channel row: create it (Status Connected) when missing, store the (possibly normalized) state in its Config field, and stamp LastActiveAt.

      Parameters

      • agentSessionId: string
      • channelName: string
      • stateJson: string
      • __namedParameters: AppContext

      Returns Promise<boolean>

      true when the state was persisted; false on any tolerated failure (missing channel definition, oversized state, save failure) — all logged.

    • Applies assertClientClauseUsesEntityBaseViews to every client-supplied clause a view request can carry that is actually a SQL fragment. GraphQL entry points (RunViewByName, RunViewByID, RunDynamicView, RunViews) funnel through RunViewGenericInternal / RunViewsGenericInternal.

      🚨 UserSearchString is deliberately NOT screened here (#4392). It is not a clause — it is the free text a person typed into a search box, and it never reaches SQL as a fragment. GenericDatabaseProvider.createViewUserSearchSQL builds the predicate itself from IncludeInUserSearchAPI metadata and lands the text only as a literal, doubling single quotes and escaping LIKE metacharacters with an explicit ESCAPE. Running it through a screen that parses its argument as SQL and fails closed rejected every term that is not coincidentally valid SQL: Marcus Chen parses as nothing, O'Leary as an unterminated literal — so essentially every real name search returned 0 rows.

      The provider-level denylist does NOT back this up — it was removed from UserSearchString in the same change, for the same reason (it refused Union Pacific). What protects the value is that it is never SQL: it reaches the database only as a quoted, quote-doubled literal. The single place that is not true is a field carrying UserSearchParamFormatAPI, whose admin-authored format may splice the term in unquoted; createViewUserSearchSQL re-applies ValidateUserProvidedSQLClause for exactly those entities.

      Each Aggregates[].expression gets the same screen, except that text the parser cannot read goes on to the provider, which refuses it per aggregate. The provider never runs the caller's aggregate text: it runs the SQL that CheckAggregateExpression rebuilds from a single aggregate call over the entity's columns, and refuses everything else per aggregate.

      Parameters

      • clauses: {
            aggregates?: AggregateExpression[];
            extraFilter?: string;
            orderBy?: string;
            overrideExcludeFilter?: string;
            userSearchString?: string;
        }
      • Optionalprovider: IMetadataProvider
      • Optionaluser: UserInfo

      Returns void

    • Start a client-direct realtime voice session targeting targetAgentId.

      Flow:

      1. Authorize — the caller must have CanRun on the target agent; denial throws and no session is created.
      2. Resolve the co-agent id via the metadata-driven resolution chain (runtime coAgentId → agent's DefaultCoAgentID → type-level AIAgentCoAgent default row → global Realtime Co-Agent) — see RealtimeClientSessionResolver.resolveCoAgentID for the full contract.
      3. Create the durable AIAgentSession (run by the co-agent), storing targetAgentID in its config — the target later relays use, re-authorized for the caller on each relay.
      4. Mint the import('@memberjunction/ai').ClientRealtimeSessionConfig via the service. On failure the just-created session is closed so no half-open session leaks.

      SECURITY NOTE — clientToolsJson: these are CLIENT-EXECUTED UI tools (e.g. the live whiteboard's Whiteboard.* surface). The server only declares them to the realtime model so it can call them — the server NEVER executes them, and a relayed call for one of these names falls into the standard "not available" path on the server side. They grant no server-side capability whatsoever; server-executed tools remain exclusively server-declared (invoke-target-agent + future action wiring). The declarations are still validated (count cap, size cap, per-tool shape) so a hostile client can't bloat the session config.

      Parameters

      • targetAgentId: string

        The agent the co-agent voices. OPTIONAL when the resolved co-agent has pairing rows with an IsDefault target (MJ: AI Agent Co Agents) — the default pairing stands in. Required for a universal co-agent (zero pairing rows). When the co-agent HAS pairing rows, a supplied target must be in that list (clear error otherwise).

      • __namedParameters: AppContext
      • OptionalconversationId: string
      • OptionallastSessionId: string
      • OptionalpreferredModelId: string
      • OptionalclientToolsJson: string
      • OptionalcoAgentId: string

        Optional EXPLICIT co-agent choice (MJ: AI Agents.ID of an Active, Realtime-type agent). When set, the server uses exactly that co-agent and FAILS with a clear reason if it can't (no silent fallback — mirroring preferredModelId's contract). Omit to let metadata drive the choice: the target agent's DefaultCoAgentID, then the type-level AIAgentCoAgent default row for its agent type, then the global Realtime Co-Agent.

      • OptionalconfigOverridesJson: string

        Optional RUNTIME configuration-override layer (the most-specific layer of the effective-config merge: type DefaultConfiguration ← agent TypeConfiguration ← this). Authorization-gated: requires the Realtime: Advanced Session Controls authorization — unauthorized callers receive a structured rejection (never a silent ignore). Must be a JSON object.

      • OptionalrecordingStartedAt: string
      • OptionalrecordingConsent: boolean
      • OptionalmediaCollectionId: string
      • OptionalapplicationId: string
      • OptionalappContextJson: string

      Returns Promise<StartRealtimeClientSessionResult>

      The ephemeral config + session linkage the browser needs to open its socket.

    • This routine compares the OldValues property in the input object to the values in the DB that we just loaded. If there are differences, we need to check to see if the client is trying to update any of those fields (e.g. overlap). If there is overlap, we throw an error. If there is no overlap, we can proceed with the update even if the DB Values and the ClientOldValues are not 100% the same, so long as there is no overlap in the specific FIELDS that are different.

      ASSUMES: input object has an OldValues___ property that is an array of Key/Value pairs that represent the old values of the record that the client is trying to update.

      Parameters

      Returns Promise<void>

    • Upload a CLIENT-DIRECT session audio recording, store it in MJStorage, link it to the owning AIAgentSession, and stamp the session's recording fields. The browser records locally during the call and uploads the assembled blob (base64) once the session ends.

      Hard gates (in order):

      1. Ownership — loadOwnedSession enforces UserID === contextUser.ID (throws on violation).
      2. Consent — consent !== true is a hard refusal: no audio is ever stored without it.
      3. Storage configuration — the session's agent must resolve a recording storage account (the agent's RecordingStorageProviderID, else AttachmentStorageProviderID).

      Storage failures (and any unexpected throw) come back as Success: false with a reason — they NEVER throw to the browser, mirroring the shared storeRealtimeRecording contract.

      Parameters

      • agentSessionId: string

        The session the recording belongs to (ownership-gated).

      • audioBase64: string

        The base64-encoded recording bytes (client-direct: seekable WAV/PCM).

      • mimeType: string

        The audio MIME type (audio/wav for client-direct; legacy audio/webm/ogg/mp4).

      • ctx: AppContext
      • OptionaldurationMs: number

        Optional client-measured recording duration (ms) — informational.

      • Optionalconsent: boolean

        Whether the user consented to recording. MUST be true or the upload is refused.

      • Optionalpeaks: number[]

        Optional capture-time waveform peaks (max-abs per bucket, normalized 0..1). When present, persisted as a peaks.json sidecar beside the recording for fast waveform rendering without re-decoding the audio.

      Returns Promise<UploadRealtimeRecordingResult>

      A structured UploadRealtimeRecordingResult with the created MJ: Files id.

    • Uploads ONE crash-recovery audio shard (~15s) for an IN-PROGRESS recording into the session's folder (realtime-recordings/<sessionId>/seg-NNNN.<ext>) as a raw storage object. Durability insurance during a live call so a browser/tab death loses at most the last window; the shards are deleted once the canonical consolidated file lands via UploadRealtimeRecording. Ownership- gated; consent was already established at session start. Best-effort — returns false (never throws) on any problem so a failed shard never disrupts the live call.

      Parameters

      • agentSessionId: string

        The in-progress session (ownership-gated).

      • segmentIndex: number

        0-based shard index within the session.

      • audioBase64: string

        The base64-encoded shard bytes.

      • mimeType: string

        The audio MIME type.

      • ctx: AppContext

      Returns Promise<boolean>

      true when the shard was stored.