@memberjunction/server 6.1.5 → 6.1.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/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionManager.d.ts +5 -2
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +17 -8
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
- package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
- package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
- package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
- package/dist/agentSessions/index.d.ts +1 -0
- package/dist/agentSessions/index.d.ts.map +1 -1
- package/dist/agentSessions/index.js +1 -0
- package/dist/agentSessions/index.js.map +1 -1
- package/dist/auth/actionAuthorization.d.ts +40 -0
- package/dist/auth/actionAuthorization.d.ts.map +1 -0
- package/dist/auth/actionAuthorization.js +94 -0
- package/dist/auth/actionAuthorization.js.map +1 -0
- package/dist/auth/exampleNewUserSubClass.js +2 -1
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +6 -3
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +15 -11
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +13 -11
- package/dist/context.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +3417 -3428
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +44 -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/ResolverBase.d.ts +95 -11
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +186 -76
- 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/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/isolatedProvider.d.ts +20 -0
- package/dist/isolatedProvider.d.ts.map +1 -0
- package/dist/isolatedProvider.js +22 -0
- package/dist/isolatedProvider.js.map +1 -0
- 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/realtimeWidget/widgetGuestElevation.d.ts +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
- package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
- package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
- package/dist/resolvers/ActionResolver.d.ts +31 -5
- package/dist/resolvers/ActionResolver.d.ts.map +1 -1
- package/dist/resolvers/ActionResolver.js +75 -14
- package/dist/resolvers/ActionResolver.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +28 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +46 -0
- package/dist/resolvers/AdhocQueryResolver.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 +8 -23
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +11 -48
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
- package/dist/resolvers/FetchEntityVectorsResolver.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 +13 -1
- package/dist/resolvers/GetDataResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +2 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +39 -16
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +84 -38
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTemplateResolver.js +12 -11
- package/dist/resolvers/RunTemplateResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
- package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
- package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
- package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
- package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncDataResolver.js +6 -2
- package/dist/resolvers/SyncDataResolver.js.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
- package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
- package/dist/resolvers/VectorizeEntityResolver.js +14 -10
- package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskGraphActionRunner.d.ts +8 -0
- package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphActionRunner.js +27 -1
- package/dist/services/TaskGraphActionRunner.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +4 -2
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
- package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
- package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
- package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +106 -41
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
- package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
- package/src/__tests__/SessionManager.test.ts +94 -17
- package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
- package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +2 -0
- package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
- package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
- package/src/__tests__/magicLinkService.consume.test.ts +6 -0
- package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -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__/newUsers.test.ts +62 -5
- package/src/agentSessions/SessionManager.ts +23 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/exampleNewUserSubClass.ts +2 -2
- package/src/auth/magicLink/MagicLinkService.ts +6 -3
- package/src/auth/newUsers.ts +15 -11
- package/src/context.ts +14 -10
- package/src/generated/graphql-schemas/__mj.ts +3417 -3428
- package/src/generic/CacheInvalidationResolver.ts +74 -19
- package/src/generic/ResolverBase.ts +207 -80
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +9 -1
- package/src/isolatedProvider.ts +22 -0
- package/src/multiTenancy/index.ts +24 -2
- package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
- package/src/resolvers/ActionResolver.ts +84 -21
- package/src/resolvers/AdhocQueryResolver.ts +49 -1
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +11 -52
- package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/QuerySystemUserResolver.ts +2 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +103 -42
- package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
- package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
- package/src/resolvers/SyncDataResolver.ts +6 -2
- package/src/resolvers/VectorizeEntityResolver.ts +16 -11
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskGraphActionRunner.ts +29 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, ObjectType, Query, Resolver } from "type-graphql";
|
|
2
|
+
import type { DatabaseProviderBase, UserInfo } from "@memberjunction/core";
|
|
3
|
+
import { IsValidUUID, UUIDsEqual } from "@memberjunction/global";
|
|
4
|
+
import { DataContext, DataContextItem } from "@memberjunction/data-context";
|
|
5
|
+
import { MJDataContextEntity, MJDataContextItemEntity } from "@memberjunction/core-entities";
|
|
6
|
+
import { RenderPipeline } from "@memberjunction/generic-database-provider";
|
|
2
7
|
import { AppContext } from "../types.js";
|
|
3
|
-
import {
|
|
4
|
-
import { GetReadOnlyDataSource, GetReadOnlyProvider } from "../util.js";
|
|
5
|
-
import { MJDataContextItemEntity } from "@memberjunction/core-entities";
|
|
8
|
+
import { GetReadOnlyProvider } from "../util.js";
|
|
6
9
|
import { ResolverBase } from "../generic/ResolverBase.js";
|
|
10
|
+
import { IsScopeLimitedPrincipal } from "../auth/scopeLimitedPrincipal.js";
|
|
7
11
|
|
|
8
12
|
@ObjectType()
|
|
9
13
|
export class GetDataContextItemDataOutputType {
|
|
@@ -39,6 +43,28 @@ export class GetDataContextDataOutputType {
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
|
|
46
|
+
/** The outcome of loading one data context item: its data as JSON, or why it has none. */
|
|
47
|
+
interface ItemOutcome {
|
|
48
|
+
Result: string | null;
|
|
49
|
+
Error: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Which `sql` items of a data context may run: the read-only provider, and each refused item's reason. */
|
|
53
|
+
interface SQLItemGate {
|
|
54
|
+
Provider: DatabaseProviderBase | null;
|
|
55
|
+
Refusals: Map<DataContextItem, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Loads the data of data contexts and their items.
|
|
60
|
+
*
|
|
61
|
+
* Items of type `sql` hold raw SQL, which is held to the rules ExecuteAdhocQuery applies to
|
|
62
|
+
* caller-supplied SQL. It runs only for the data context's owner or an administrator (Owner-type
|
|
63
|
+
* user), never for a scope-limited session, only on the read-only provider, and only as a single read
|
|
64
|
+
* statement over entity base views the caller may read in full. A SQL item that may not run is
|
|
65
|
+
* refused on its own, and the other items still load. Items of the other types load through entity
|
|
66
|
+
* permissions for the calling user.
|
|
67
|
+
*/
|
|
42
68
|
@Resolver()
|
|
43
69
|
export class GetDataContextDataResolver extends ResolverBase {
|
|
44
70
|
/**
|
|
@@ -52,30 +78,25 @@ export class GetDataContextDataResolver extends ResolverBase {
|
|
|
52
78
|
) {
|
|
53
79
|
// Check API key scope authorization for data context read
|
|
54
80
|
await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextItemID, appCtx.userPayload);
|
|
81
|
+
if (!IsValidUUID(DataContextItemID)) {
|
|
82
|
+
return { Success: false, ErrorMessage: 'DataContextItemID must be a UUID', Result: null };
|
|
83
|
+
}
|
|
55
84
|
|
|
56
85
|
try {
|
|
57
|
-
const
|
|
58
|
-
allowFallbackToReadWrite: true,
|
|
59
|
-
})
|
|
86
|
+
const user: UserInfo = appCtx.userPayload.userRecord;
|
|
60
87
|
const md = GetReadOnlyProvider(appCtx.providers, {allowFallbackToReadWrite: true});
|
|
61
|
-
const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items",
|
|
88
|
+
const dciData = await md.GetEntityObject<MJDataContextItemEntity>("MJ: Data Context Items", user);
|
|
62
89
|
if (await dciData.Load(DataContextItemID)) {
|
|
63
90
|
const dci = DataContext.CreateDataContextItem(); // use class factory to get whatever lowest level sub-class is registered
|
|
64
|
-
await dci.LoadMetadataFromEntityRecord(dciData, md,
|
|
91
|
+
await dci.LoadMetadataFromEntityRecord(dciData, md, user);
|
|
92
|
+
const ownerID = dci.Type === 'sql' ? await this.loadDataContextOwnerID(dciData.DataContextID, md, user) : null;
|
|
93
|
+
const gate = this.authorizeSQLItems([dci], ownerID, appCtx);
|
|
65
94
|
// now the metadata is loaded so we can call the regular load function
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
else {
|
|
74
|
-
return {
|
|
75
|
-
Success: false,
|
|
76
|
-
ErrorMessage: 'Error loading data context item data',
|
|
77
|
-
Result: null,
|
|
78
|
-
}
|
|
95
|
+
const outcome = await this.loadItem(dci, gate, user, false);
|
|
96
|
+
return {
|
|
97
|
+
Success: outcome.Error === null,
|
|
98
|
+
ErrorMessage: outcome.Error,
|
|
99
|
+
Result: outcome.Result,
|
|
79
100
|
}
|
|
80
101
|
}
|
|
81
102
|
else {
|
|
@@ -89,14 +110,15 @@ export class GetDataContextDataResolver extends ResolverBase {
|
|
|
89
110
|
catch (e) {
|
|
90
111
|
return {
|
|
91
112
|
Success: false,
|
|
92
|
-
ErrorMessage: e,
|
|
113
|
+
ErrorMessage: e instanceof Error ? e.message : String(e),
|
|
93
114
|
Result: null,
|
|
94
115
|
}
|
|
95
116
|
}
|
|
96
117
|
}
|
|
97
118
|
|
|
98
119
|
/**
|
|
99
|
-
* Returns data for a given data context.
|
|
120
|
+
* Returns data for a given data context. `Results` and `ErrorMessages` hold one entry per item, in
|
|
121
|
+
* item order. `Success` is true, and `ErrorMessages` null, only when every item loaded.
|
|
100
122
|
* @param DataContextID
|
|
101
123
|
*/
|
|
102
124
|
@Query(() => GetDataContextDataOutputType)
|
|
@@ -106,38 +128,114 @@ export class GetDataContextDataResolver extends ResolverBase {
|
|
|
106
128
|
) {
|
|
107
129
|
// Check API key scope authorization for data context read
|
|
108
130
|
await this.CheckAPIKeyScopeAuthorization('datacontext:read', DataContextID, appCtx.userPayload);
|
|
131
|
+
// DataContext.LoadMetadata places this ID in the item filter, so it must be a plain UUID.
|
|
132
|
+
if (!IsValidUUID(DataContextID)) {
|
|
133
|
+
return { Success: false, ErrorMessages: ['DataContextID must be a UUID'], Results: null };
|
|
134
|
+
}
|
|
109
135
|
|
|
110
136
|
try {
|
|
111
137
|
// our job here is to load the entire data context, so we do that with the Data Context object
|
|
138
|
+
const user: UserInfo = appCtx.userPayload.userRecord;
|
|
139
|
+
const md = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: true });
|
|
112
140
|
const dc = new DataContext();
|
|
113
|
-
|
|
114
|
-
allowFallbackToReadWrite: true,
|
|
115
|
-
})
|
|
116
|
-
const success = await dc.Load(DataContextID, ds, true, false, 0, appCtx.userPayload.userRecord);
|
|
117
|
-
if (success) {
|
|
118
|
-
const retVal = {
|
|
119
|
-
Success: true,
|
|
120
|
-
ErrorMessages: null,
|
|
121
|
-
Results: dc.Items.map((item) => {
|
|
122
|
-
return JSON.stringify(item.Data);
|
|
123
|
-
}),
|
|
124
|
-
}
|
|
125
|
-
return retVal;
|
|
126
|
-
}
|
|
127
|
-
else {
|
|
141
|
+
if (!(await dc.LoadMetadata(DataContextID, user, md))) {
|
|
128
142
|
return {
|
|
129
143
|
Success: false,
|
|
130
144
|
ErrorMessages: ['Error loading data context'],
|
|
131
|
-
Results: null,
|
|
145
|
+
Results: null,
|
|
132
146
|
}
|
|
133
147
|
}
|
|
148
|
+
const gate = this.authorizeSQLItems(dc.Items, dc.MJDataContextEntity.UserID, appCtx);
|
|
149
|
+
const outcomes = await Promise.all(dc.Items.map((item) => this.loadItem(item, gate, user, true)));
|
|
150
|
+
const allLoaded = outcomes.every((outcome) => outcome.Error === null);
|
|
151
|
+
return {
|
|
152
|
+
Success: allLoaded,
|
|
153
|
+
ErrorMessages: allLoaded ? null : outcomes.map((outcome) => outcome.Error),
|
|
154
|
+
Results: outcomes.map((outcome) => outcome.Result),
|
|
155
|
+
}
|
|
134
156
|
}
|
|
135
157
|
catch (e) {
|
|
136
158
|
return {
|
|
137
159
|
Success: false,
|
|
138
|
-
ErrorMessages: [e],
|
|
160
|
+
ErrorMessages: [e instanceof Error ? e.message : String(e)],
|
|
139
161
|
Results: null,
|
|
140
162
|
}
|
|
141
163
|
}
|
|
142
164
|
}
|
|
143
|
-
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Decides which `sql` items among `items` may run. Returns the read-only provider they run on, and
|
|
168
|
+
* the reason for each SQL item that may not run.
|
|
169
|
+
*/
|
|
170
|
+
private authorizeSQLItems(items: DataContextItem[], ownerID: string | null, appCtx: AppContext): SQLItemGate {
|
|
171
|
+
const refusals = new Map<DataContextItem, string>();
|
|
172
|
+
const sqlItems = items.filter((item) => item.Type === 'sql');
|
|
173
|
+
if (sqlItems.length === 0) {
|
|
174
|
+
return { Provider: null, Refusals: refusals };
|
|
175
|
+
}
|
|
176
|
+
const user: UserInfo | undefined = appCtx.userPayload?.userRecord;
|
|
177
|
+
const provider = GetReadOnlyProvider(appCtx.providers, { allowFallbackToReadWrite: false });
|
|
178
|
+
for (const item of sqlItems) {
|
|
179
|
+
const refusal = this.sqlItemRefusal(item, ownerID, user, provider);
|
|
180
|
+
if (refusal !== null) {
|
|
181
|
+
refusals.set(item, refusal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { Provider: provider, Refusals: refusals };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Returns why `item` may not run for `user`, or null when it may. */
|
|
188
|
+
private sqlItemRefusal(item: DataContextItem, ownerID: string | null, user: UserInfo | undefined, provider: DatabaseProviderBase | null): string | null {
|
|
189
|
+
if (!user || IsScopeLimitedPrincipal(user)) {
|
|
190
|
+
return 'SQL data context items cannot be run by a scope-limited or unidentified session.';
|
|
191
|
+
}
|
|
192
|
+
if (!this.mayRunDataContextSQL(ownerID, user)) {
|
|
193
|
+
return 'Only the owner of this data context, or an administrator, may run its SQL items.';
|
|
194
|
+
}
|
|
195
|
+
if (!provider) {
|
|
196
|
+
return 'No read-only data source is configured, so SQL data context items cannot run.';
|
|
197
|
+
}
|
|
198
|
+
return this.screenItemSQL(item.SQL ?? '', provider, user);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** True when `user` owns the data context or is an administrator (Owner-type user). */
|
|
202
|
+
private mayRunDataContextSQL(ownerID: string | null, user: UserInfo): boolean {
|
|
203
|
+
const isOwner = !!ownerID && UUIDsEqual(ownerID, user.ID);
|
|
204
|
+
return isOwner || user.Type?.trim().toLowerCase() === 'owner';
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Returns why `sqlText` may not run, or null when it is a single read statement whose every table
|
|
209
|
+
* reference is an entity base view `user` may read without row-level or field-level narrowing. It is
|
|
210
|
+
* the check ExecuteAdhocQuery makes on the rendered SQL.
|
|
211
|
+
*/
|
|
212
|
+
private screenItemSQL(sqlText: string, provider: DatabaseProviderBase, user: UserInfo): string | null {
|
|
213
|
+
try {
|
|
214
|
+
const rendered = RenderPipeline.Run(sqlText, { Platform: provider.PlatformKey, ContextUser: user, RequireReadStatement: true });
|
|
215
|
+
this.assertFullQueryUsesReadableEntityViews(rendered.Trace.AfterTemplates, provider, user, 'data context SQL');
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
220
|
+
return reason || 'The SQL of this item did not pass the read-only check.';
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Loads one item's data unless `gate` refused it. Returns the data as JSON, or why there is none. */
|
|
225
|
+
private async loadItem(item: DataContextItem, gate: SQLItemGate, user: UserInfo, forceRefresh: boolean): Promise<ItemOutcome> {
|
|
226
|
+
const refusal = gate.Refusals.get(item);
|
|
227
|
+
if (refusal !== undefined) {
|
|
228
|
+
return { Result: null, Error: refusal };
|
|
229
|
+
}
|
|
230
|
+
if (await item.LoadData(gate.Provider, forceRefresh, false, 0, user)) {
|
|
231
|
+
return { Result: JSON.stringify(item.Data), Error: null };
|
|
232
|
+
}
|
|
233
|
+
return { Result: null, Error: item.DataLoadingError || 'Error loading data context item data' };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Returns the owner (`UserID`) of a data context, or null when the caller cannot load it. */
|
|
237
|
+
private async loadDataContextOwnerID(dataContextID: string, provider: DatabaseProviderBase, user: UserInfo): Promise<string | null> {
|
|
238
|
+
const dataContext = await provider.GetEntityObject<MJDataContextEntity>('MJ: Data Contexts', user);
|
|
239
|
+
return (await dataContext.Load(dataContextID)) ? dataContext.UserID : null;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Arg, Ctx, Field, InputType, ObjectType, Query } from 'type-graphql';
|
|
2
|
+
import { SQLExpressionValidator } from '@memberjunction/global';
|
|
2
3
|
import { AppContext } from '../types.js';
|
|
3
4
|
import { LogError, LogStatus, LogStatusEx, Metadata } from '@memberjunction/core';
|
|
4
5
|
import { QueryCompositionEngine } from '@memberjunction/generic-database-provider';
|
|
@@ -154,8 +155,20 @@ export class GetDataResolver {
|
|
|
154
155
|
resolvedSQL = compositionResult.ResolvedSQL;
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
// Defense-in-depth: even system-token holders get SELECT-only SQL here.
|
|
159
|
+
// The read-only pool is the primary control, but if it is ever
|
|
160
|
+
// misconfigured to a read-write login this validator still refuses DML/DDL
|
|
161
|
+
// (mirrors AdhocQueryResolver). The validator reads exactly the text that
|
|
162
|
+
// runs: it is trimmed here as validate() trims it, and literal \n sequences
|
|
163
|
+
// are not rewritten as validateFullQuery would.
|
|
164
|
+
const sqlToRun = resolvedSQL.trim();
|
|
165
|
+
const validation = SQLExpressionValidator.Instance.validate(sqlToRun, { context: 'full_query', Dialect: platform });
|
|
166
|
+
if (!validation.valid) {
|
|
167
|
+
return { result: null, error: validation.error || 'SQL validation failed' };
|
|
168
|
+
}
|
|
169
|
+
|
|
157
170
|
const request = new sql.Request(readOnlyDataSource);
|
|
158
|
-
const result = await request.query(
|
|
171
|
+
const result = await request.query(sqlToRun);
|
|
159
172
|
return { result: result.recordset, error: null };
|
|
160
173
|
} catch (err) {
|
|
161
174
|
// Extract clean SQL error message
|
|
@@ -6,6 +6,7 @@ import { MJQueryResolver, MJQuery_, MJQueryField_, MJQueryParameter_, MJQueryEnt
|
|
|
6
6
|
import { GetReadWriteProvider } from '../util.js';
|
|
7
7
|
import { DeleteOptionsInput } from '../generic/DeleteOptionsInput.js';
|
|
8
8
|
import { MJQueryEntityServer } from '@memberjunction/core-entities-server';
|
|
9
|
+
import { EscapeSQLString } from '@memberjunction/global';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Minimal shape of a query row returned by RunView lookups (plain object, not entity instance).
|
|
@@ -493,7 +494,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
493
494
|
// now make sure there is NO existing query by the same name in the specified category
|
|
494
495
|
const existingQueryResult = await provider.RunView({
|
|
495
496
|
EntityName: 'MJ: Queries',
|
|
496
|
-
ExtraFilter: `Name='${input.Name}' AND CategoryID='${finalCategoryID}'`
|
|
497
|
+
ExtraFilter: `Name='${EscapeSQLString(input.Name)}' AND CategoryID='${EscapeSQLString(finalCategoryID)}'`
|
|
497
498
|
}, context.userPayload.userRecord);
|
|
498
499
|
if (existingQueryResult.Success && existingQueryResult.Results?.length > 0) {
|
|
499
500
|
// we have a match! Let's return an error
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* and **executes** every tool call the browser relays back. The three mutations here cover the MVP:
|
|
12
12
|
*
|
|
13
13
|
* 1. {@link RealtimeClientSessionResolver.StartRealtimeClientSession} — authorize, create the
|
|
14
|
-
* session (storing the target agent id
|
|
15
|
-
* 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
|
|
16
|
-
*
|
|
14
|
+
* session (storing the target agent id on it), mint the ephemeral config.
|
|
15
|
+
* 2. {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} — execute a relayed tool call
|
|
16
|
+
* for the target agent stored on the session, re-authorized for the caller on every call.
|
|
17
17
|
* 3. {@link RealtimeClientSessionResolver.RelayRealtimeTranscript} — persist a transcript turn as a
|
|
18
18
|
* `Conversation Detail`.
|
|
19
19
|
*
|
|
@@ -21,6 +21,13 @@
|
|
|
21
21
|
* - **Start**: the caller must have `CanRun` on the *target* agent (the Realtime Co-Agent is an internal
|
|
22
22
|
* orchestration agent, so the meaningful gate is the agent doing the real work).
|
|
23
23
|
* - **Execute / Relay**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
|
|
24
|
+
* - **Session `Config`**: the owner can edit it through the generic entity API, so nothing in it is
|
|
25
|
+
* trusted. Execute re-checks `CanRun` on its target, direct actions never come from it, and its
|
|
26
|
+
* run ids are used only when {@link SessionRunIDVerifier} finds them to be records of the session.
|
|
27
|
+
* - **Scope-limited callers** (anonymous and resource-scoped magic-link sessions, including public
|
|
28
|
+
* web-widget guests): no direct actions. Their scope exists only as row-level filters, which an
|
|
29
|
+
* action can read around, and a scoped anonymous caller's relay runs as the system user. They
|
|
30
|
+
* reach the target agent only through `invoke-target-agent`.
|
|
24
31
|
*
|
|
25
32
|
* @module @memberjunction/server
|
|
26
33
|
*/
|
|
@@ -48,6 +55,7 @@ import {
|
|
|
48
55
|
FindIgnoredRealtimeConfigKeys,
|
|
49
56
|
ParseRealtimeTypeConfiguration,
|
|
50
57
|
ResolveEffectiveRealtimeConfig,
|
|
58
|
+
INVOKE_TARGET_AGENT_TOOL_NAME,
|
|
51
59
|
RealtimeAllowedAgent,
|
|
52
60
|
RealtimeDirectActionsConfig,
|
|
53
61
|
REALTIME_ADVANCED_SESSION_CONTROLS_AUTHORIZATION,
|
|
@@ -62,6 +70,8 @@ import { ResolverBase } from '../generic/ResolverBase.js';
|
|
|
62
70
|
import { PUSH_STATUS_UPDATES_TOPIC } from '../generic/PushStatusResolver.js';
|
|
63
71
|
import { GetReadWriteProvider } from '../util.js';
|
|
64
72
|
import { SessionManager } from '../agentSessions/index.js';
|
|
73
|
+
import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
|
|
74
|
+
import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
|
|
65
75
|
import { resolveWidgetGuestRunContext, ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
|
|
66
76
|
|
|
67
77
|
/**
|
|
@@ -137,9 +147,9 @@ const MAX_PRIOR_TRANSCRIPT_CHARS = 8_000;
|
|
|
137
147
|
const MAX_PRIOR_TRANSCRIPT_LEGS = 5;
|
|
138
148
|
|
|
139
149
|
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
150
|
+
* Shape persisted in `AIAgentSession.Config_` for a client-direct voice session. The server writes
|
|
151
|
+
* it, but the session owner can also edit it through the generic entity API, so relays re-check
|
|
152
|
+
* what they read from it (see the authorization model above).
|
|
143
153
|
*/
|
|
144
154
|
interface RealtimeSessionConfig {
|
|
145
155
|
/** The top-level target agent the co-agent voices on behalf of. */
|
|
@@ -375,6 +385,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
375
385
|
// state on — otherwise that cleanup silently no-ops on a freshly-constructed instance.
|
|
376
386
|
private readonly clientSessionService = new RealtimeClientSessionService();
|
|
377
387
|
private readonly sessionManager = new SessionManager(this.clientSessionService);
|
|
388
|
+
private readonly runIDVerifier = new SessionRunIDVerifier();
|
|
378
389
|
|
|
379
390
|
/**
|
|
380
391
|
* The reason a `Save()`/`Delete()` returned false, for a log line. `CompleteMessage` renders
|
|
@@ -411,7 +422,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
411
422
|
* agent's `DefaultCoAgentID` → type-level `AIAgentCoAgent` default row → global Realtime Co-Agent) —
|
|
412
423
|
* see {@link RealtimeClientSessionResolver.resolveCoAgentID} for the full contract.
|
|
413
424
|
* 3. Create the durable `AIAgentSession` (run by the co-agent), storing `targetAgentID` in its
|
|
414
|
-
* config
|
|
425
|
+
* config — the target later relays use, re-authorized for the caller on each relay.
|
|
415
426
|
* 4. Mint the {@link import('@memberjunction/ai').ClientRealtimeSessionConfig} via the service.
|
|
416
427
|
* On failure the just-created session is closed so no half-open session leaks.
|
|
417
428
|
*
|
|
@@ -526,8 +537,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
526
537
|
* Execute a single tool call the browser relayed from its provider socket and return the
|
|
527
538
|
* serialized result for the browser to relay back to the model.
|
|
528
539
|
*
|
|
529
|
-
* Ownership-gated
|
|
530
|
-
*
|
|
540
|
+
* Ownership-gated. The target agent id is read from the session config and `CanRun` on it is
|
|
541
|
+
* checked for the caller on every call, because the owner can edit the config. A direct-action
|
|
542
|
+
* call from a scope-limited caller is refused before any elevation. Heartbeats the session on
|
|
543
|
+
* success.
|
|
531
544
|
*
|
|
532
545
|
* @returns The serialized tool result JSON.
|
|
533
546
|
*/
|
|
@@ -543,13 +556,23 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
543
556
|
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
544
557
|
const session = await this.loadOwnedActiveSession(agentSessionId, contextUser, provider);
|
|
545
558
|
const config = this.readSessionConfig(session);
|
|
559
|
+
await this.assertCanRunTarget(config.targetAgentID, contextUser, provider);
|
|
560
|
+
if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME && IsScopeLimitedPrincipal(contextUser)) {
|
|
561
|
+
return this.refuseDirectAction(agentSessionId, toolName);
|
|
562
|
+
}
|
|
546
563
|
|
|
547
564
|
// SCOPED-ANONYMOUS ELEVATION (issue #3371): once ownership is proven above, the delegated
|
|
548
565
|
// run + its AI-run-entity writes execute as the system user for a scoped anonymous caller
|
|
549
566
|
// (the caller's role deliberately holds no grants on the run entities). The lead
|
|
550
|
-
// targetAgentID
|
|
551
|
-
//
|
|
567
|
+
// targetAgentID is CanRun-gated against the CALLER just above, and the colleague union
|
|
568
|
+
// just below, so elevation never widens agent authority.
|
|
552
569
|
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
570
|
+
const runIDs = await this.runIDVerifier.Verify(
|
|
571
|
+
session.ID,
|
|
572
|
+
{ CoAgentRunID: config.coAgentRunID, PendingFeedbackRunID: config.pendingFeedbackRunID },
|
|
573
|
+
runUser,
|
|
574
|
+
provider,
|
|
575
|
+
);
|
|
553
576
|
if (runUser !== contextUser) {
|
|
554
577
|
LogStatus(
|
|
555
578
|
`ExecuteRealtimeSessionTool: dispatching relayed tool '${toolName}' for session ${agentSessionId} ` +
|
|
@@ -567,16 +590,17 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
567
590
|
// Multi-target (Move 4): the session's persisted allowed-agent union — a model-named
|
|
568
591
|
// colleague in the call is validated against this; absent ⇒ single-target behavior.
|
|
569
592
|
AllowedAgents: await this.filterAllowedAgentsByCanRun(config.allowedAgents, contextUser),
|
|
570
|
-
DirectActions:
|
|
593
|
+
// No DirectActions: the service takes them from the session's start-time projection
|
|
594
|
+
// or the target agent's metadata, never from the owner-editable config.
|
|
571
595
|
// Attribution follows the VISITOR even when `runUser` is elevated: the delegated run
|
|
572
596
|
// row and its context-memory scope must stay the person's, not the system user's.
|
|
573
597
|
AttributionUserID: contextUser.ID,
|
|
574
598
|
// Nest the delegated target-agent run under the co-agent observability run (when present).
|
|
575
|
-
ParentRunID:
|
|
599
|
+
ParentRunID: runIDs.CoAgentRunID,
|
|
576
600
|
Call: { CallID: callId, ToolName: toolName, Arguments: argsJson },
|
|
577
601
|
OnProgress: this.buildDelegationProgressCallback(pubSub, userPayload, agentSessionId, callId),
|
|
578
602
|
// Resume a previously-paused delegated run (if any) with the user's answer.
|
|
579
|
-
ResumeRunID:
|
|
603
|
+
ResumeRunID: runIDs.PendingFeedbackRunID,
|
|
580
604
|
},
|
|
581
605
|
runUser,
|
|
582
606
|
provider,
|
|
@@ -586,7 +610,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
586
610
|
`ExecuteRealtimeSessionTool: completed relayed tool '${toolName}' (callId: ${callId}) in ${durationMs}ms`,
|
|
587
611
|
);
|
|
588
612
|
|
|
589
|
-
if (toolName !==
|
|
613
|
+
if (toolName !== INVOKE_TARGET_AGENT_TOOL_NAME) {
|
|
590
614
|
await this.persistDirectActionTurn(
|
|
591
615
|
session,
|
|
592
616
|
callId,
|
|
@@ -643,6 +667,19 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
643
667
|
};
|
|
644
668
|
}
|
|
645
669
|
|
|
670
|
+
/**
|
|
671
|
+
* The tool result for a direct-action call from a scope-limited caller (see the authorization
|
|
672
|
+
* model above). It has the broker's result shape, so the model hears that the tool is not
|
|
673
|
+
* available and can ask the target agent through `invoke-target-agent` instead.
|
|
674
|
+
*/
|
|
675
|
+
private refuseDirectAction(agentSessionId: string, toolName: string): string {
|
|
676
|
+
LogStatus(`ExecuteRealtimeSessionTool: refused direct action '${toolName}' for a scope-limited caller on session ${agentSessionId}.`);
|
|
677
|
+
return JSON.stringify({
|
|
678
|
+
success: false,
|
|
679
|
+
output: `The tool '${toolName}' is not available in this session. Ask the agent to do it with ${INVOKE_TARGET_AGENT_TOOL_NAME}.`,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
646
683
|
/**
|
|
647
684
|
* Rolls the session's `pendingFeedbackRunID` forward after a relayed tool call. The id we just
|
|
648
685
|
* consumed (resumed or none) is dropped; a new paused run id is stored only when the run paused
|
|
@@ -713,14 +750,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
713
750
|
// conversation (run-viewer observability parity). Best-effort — never fails the transcript relay.
|
|
714
751
|
// The prompt-run write runs as the scoped-anonymous elevated user (issue #3371) — the visible
|
|
715
752
|
// Conversation Detail above deliberately stays on the caller.
|
|
716
|
-
const
|
|
753
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
754
|
+
const promptRunID = await this.readPromptRunID(session, runUser, provider);
|
|
717
755
|
if (promptRunID) {
|
|
718
756
|
await this.clientSessionService.AppendPromptRunMessage(
|
|
719
757
|
promptRunID,
|
|
720
758
|
this.mapTranscriptRoleToChatRole(role),
|
|
721
759
|
text,
|
|
722
760
|
replacesPrevious ?? false,
|
|
723
|
-
|
|
761
|
+
runUser,
|
|
724
762
|
provider,
|
|
725
763
|
);
|
|
726
764
|
}
|
|
@@ -918,7 +956,8 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
918
956
|
provider,
|
|
919
957
|
);
|
|
920
958
|
|
|
921
|
-
const
|
|
959
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
960
|
+
const promptRunID = await this.readPromptRunID(session, runUser, provider);
|
|
922
961
|
if (!promptRunID) {
|
|
923
962
|
return false;
|
|
924
963
|
}
|
|
@@ -927,7 +966,7 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
927
966
|
'assistant',
|
|
928
967
|
this.formatToolTurn(toolName, argsJson, resultJson),
|
|
929
968
|
false,
|
|
930
|
-
|
|
969
|
+
runUser,
|
|
931
970
|
provider,
|
|
932
971
|
);
|
|
933
972
|
}
|
|
@@ -1019,16 +1058,15 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1019
1058
|
return true; // nothing to add — a no-op flush is a success, not a failure
|
|
1020
1059
|
}
|
|
1021
1060
|
|
|
1022
|
-
|
|
1061
|
+
// Runs as the scoped-anonymous elevated user (issue #3371) — the caller's role holds no prompt-run grants.
|
|
1062
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
1063
|
+
const promptRunID = await this.readPromptRunID(session, runUser, provider);
|
|
1023
1064
|
if (!promptRunID) {
|
|
1024
1065
|
return false; // no observability prompt run for this session — logged in the helper
|
|
1025
1066
|
}
|
|
1026
1067
|
// Delegate to the service so usage writes share the per-run serialization with transcript-message
|
|
1027
1068
|
// appends — otherwise the frequent usage save clobbers freshly-appended Messages (and vice-versa).
|
|
1028
|
-
|
|
1029
|
-
return this.clientSessionService.AccumulatePromptRunUsage(
|
|
1030
|
-
promptRunID, inputDelta, outputDelta, ResolveScopedAnonymousRunUser(contextUser), provider,
|
|
1031
|
-
);
|
|
1069
|
+
return this.clientSessionService.AccumulatePromptRunUsage(promptRunID, inputDelta, outputDelta, runUser, provider);
|
|
1032
1070
|
}
|
|
1033
1071
|
|
|
1034
1072
|
/** Clamps a relayed token delta: negative / non-finite values become 0. */
|
|
@@ -1055,24 +1093,40 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1055
1093
|
|
|
1056
1094
|
/**
|
|
1057
1095
|
* Reads the co-agent `AIPromptRun` id from the session config WITHOUT the relay path's
|
|
1058
|
-
* throw-on-missing-target semantics
|
|
1059
|
-
*
|
|
1096
|
+
* throw-on-missing-target semantics, and keeps it only when it is the prompt run of the
|
|
1097
|
+
* session's co-agent run (the owner can edit the config). Usage relay is best-effort, so a
|
|
1098
|
+
* missing/malformed config, an absent id, or an id of another session just logs and returns `null`.
|
|
1099
|
+
*
|
|
1100
|
+
* @param runUser The user the prompt-run write runs as; the check runs as the same user.
|
|
1060
1101
|
*/
|
|
1061
|
-
private readPromptRunID(
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1102
|
+
private async readPromptRunID(
|
|
1103
|
+
session: MJAIAgentSessionEntity,
|
|
1104
|
+
runUser: UserInfo,
|
|
1105
|
+
provider: IMetadataProvider,
|
|
1106
|
+
): Promise<string | null> {
|
|
1107
|
+
const config = this.tryReadSessionConfig(session);
|
|
1108
|
+
if (!config) {
|
|
1109
|
+
LogError(`RelayRealtimeUsage: session ${session.ID} has no parseable config — usage delta dropped.`);
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
if (!config.promptRunID) {
|
|
1067
1113
|
LogStatus(
|
|
1068
1114
|
`RelayRealtimeUsage: session ${session.ID} has no co-agent promptRunID (observability run ` +
|
|
1069
1115
|
'creation was skipped or failed) — usage delta dropped.',
|
|
1070
1116
|
);
|
|
1071
1117
|
return null;
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1118
|
+
}
|
|
1119
|
+
const verified = await this.runIDVerifier.Verify(
|
|
1120
|
+
session.ID,
|
|
1121
|
+
{ CoAgentRunID: config.coAgentRunID, PromptRunID: config.promptRunID },
|
|
1122
|
+
runUser,
|
|
1123
|
+
provider,
|
|
1124
|
+
);
|
|
1125
|
+
if (!verified.PromptRunID) {
|
|
1126
|
+
LogError(`RelayRealtimeUsage: session ${session.ID} names a promptRunID that is not its co-agent prompt run — ignored.`);
|
|
1074
1127
|
return null;
|
|
1075
1128
|
}
|
|
1129
|
+
return verified.PromptRunID;
|
|
1076
1130
|
}
|
|
1077
1131
|
|
|
1078
1132
|
/**
|
|
@@ -1222,8 +1276,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1222
1276
|
}
|
|
1223
1277
|
|
|
1224
1278
|
/**
|
|
1225
|
-
* Authorization gate for
|
|
1226
|
-
*
|
|
1279
|
+
* Authorization gate for the **target** agent: the caller must be able to run it. Denial throws.
|
|
1280
|
+
* {@link RealtimeClientSessionResolver.StartRealtimeClientSession} runs it before any session is
|
|
1281
|
+
* created, and {@link RealtimeClientSessionResolver.ExecuteRealtimeSessionTool} runs it on every
|
|
1282
|
+
* relayed call, because the owner can edit the target in the session config.
|
|
1227
1283
|
*/
|
|
1228
1284
|
private async assertCanRunTarget(
|
|
1229
1285
|
targetAgentId: string,
|
|
@@ -1752,6 +1808,9 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1752
1808
|
// (allowed-agent union); the snapshot is injected into the system prompt at mint.
|
|
1753
1809
|
ApplicationID: applicationId,
|
|
1754
1810
|
AppContext: appContext,
|
|
1811
|
+
// Scope-limited callers get no direct actions (see the authorization model above), so
|
|
1812
|
+
// the model is not given tools that the relay refuses.
|
|
1813
|
+
ExcludeDirectActions: IsScopeLimitedPrincipal(contextUser),
|
|
1755
1814
|
},
|
|
1756
1815
|
// SCOPED-ANONYMOUS ELEVATION (issue #3371): the prepare creates the co-agent
|
|
1757
1816
|
// observability AIAgentRun/AIPromptRun/run-step, which a scoped anonymous caller's role
|
|
@@ -1793,9 +1852,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
1793
1852
|
|
|
1794
1853
|
/**
|
|
1795
1854
|
* Writes the co-agent observability run ids into the session's `Config_` alongside the
|
|
1796
|
-
*
|
|
1797
|
-
* finalize the runs (and on relay to nest delegated runs).
|
|
1798
|
-
* not thrown — the voice session still proceeds,
|
|
1855
|
+
* `targetAgentID`, then saves the session. These ids are read back on close to
|
|
1856
|
+
* finalize the runs (and on relay to nest delegated runs). Relays never read the direct actions
|
|
1857
|
+
* from it. Best-effort: a save failure is logged, not thrown — the voice session still proceeds,
|
|
1858
|
+
* it just won't carry the run ids.
|
|
1799
1859
|
*/
|
|
1800
1860
|
private async persistObservabilityRunIDs(
|
|
1801
1861
|
session: MJAIAgentSessionEntity,
|
|
@@ -2537,9 +2597,10 @@ export class RealtimeClientSessionResolver extends ResolverBase {
|
|
|
2537
2597
|
}
|
|
2538
2598
|
|
|
2539
2599
|
/**
|
|
2540
|
-
* Parses the session's persisted config, returning
|
|
2541
|
-
*
|
|
2542
|
-
*
|
|
2600
|
+
* Parses the session's persisted config, returning its `targetAgentID` plus any observability run
|
|
2601
|
+
* ids (`coAgentRunID`/`promptRunID`). Throws when the config is missing/malformed or carries no
|
|
2602
|
+
* target — a relay cannot proceed without a target. The owner can edit the config, so callers
|
|
2603
|
+
* re-check what they act on.
|
|
2543
2604
|
*/
|
|
2544
2605
|
private readSessionConfig(session: MJAIAgentSessionEntity): RealtimeSessionConfig {
|
|
2545
2606
|
const raw = session.Config_;
|
|
@@ -24,6 +24,7 @@ import { MJAIAgentSessionEntity, MJAIAgentEntity } from '@memberjunction/core-en
|
|
|
24
24
|
import { NormalizeInstanceKey, RemoteBrowserEngine } from '@memberjunction/remote-browser-server';
|
|
25
25
|
import { beginBrowserGoalStep, finalizeBrowserGoalStep, extractCoAgentRunID } from '../agentSessions/remoteBrowserGoalEngine.js';
|
|
26
26
|
import { RemoteBrowserGoalRegistry } from '../agentSessions/remoteBrowserGoalRegistry.js';
|
|
27
|
+
import { SessionRunIDVerifier } from '../agentSessions/SessionRunIDVerifier.js';
|
|
27
28
|
import { randomUUID } from 'node:crypto';
|
|
28
29
|
import {
|
|
29
30
|
RemoteBrowserAction,
|
|
@@ -261,6 +262,9 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
261
262
|
*/
|
|
262
263
|
private startedScreencasts = new Map<string, number>();
|
|
263
264
|
|
|
265
|
+
/** Checks the co-agent run id read from a session's owner-editable `Config_` before a step is attached to it. */
|
|
266
|
+
private readonly runIDVerifier = new SessionRunIDVerifier();
|
|
267
|
+
|
|
264
268
|
/**
|
|
265
269
|
* Surfaces whose live tab-audio stream this resolver has already started, keyed by
|
|
266
270
|
* {@link RemoteBrowserActionResolver.streamKey} for the same reason the screencast map is. Entries
|
|
@@ -400,7 +404,13 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
400
404
|
const providerName = await this.resolveProviderName(session, contextUser, provider);
|
|
401
405
|
// Observability: nest this goal's many prompt runs under ONE "Browser goal" step on the realtime
|
|
402
406
|
// co-agent run (when the session has one). Best-effort — a null step just means the goal runs unlinked.
|
|
403
|
-
|
|
407
|
+
// The owner can edit Config_, so its run id is used only when it is a run of this session.
|
|
408
|
+
const { CoAgentRunID: coAgentRunID } = await this.runIDVerifier.Verify(
|
|
409
|
+
session.ID,
|
|
410
|
+
{ CoAgentRunID: extractCoAgentRunID(session.Config_) },
|
|
411
|
+
contextUser,
|
|
412
|
+
provider,
|
|
413
|
+
);
|
|
404
414
|
const goalStep = await beginBrowserGoalStep(provider, contextUser, coAgentRunID, goal);
|
|
405
415
|
|
|
406
416
|
// ASYNC START: a goal loop can run for minutes; do NOT hold this request open for it (browser
|