@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
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tenant PreRunView hook checks the caller's ExtraFilter and builds
|
|
3
|
+
* `(<ExtraFilter>) AND (<tenant predicate>)`. On PostgreSQL the provider then rewrites the whole
|
|
4
|
+
* filter (identifier quoting, T-SQL date functions, boolean literals), and that rewrite can change
|
|
5
|
+
* the caller's part after the hook checked it. These tests run the real hook, the real PostgreSQL
|
|
6
|
+
* rewrite and the real view query builder, with only ExecuteSQL recorded, and pin that the tenant
|
|
7
|
+
* predicate stays a separate AND term whatever the rewrite does to the caller's term. The last
|
|
8
|
+
* block runs the same path with no hook and pins that the per-term rewrite keeps a SQL Server
|
|
9
|
+
* bracket name whole.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import {
|
|
13
|
+
ClearAllDataHooks,
|
|
14
|
+
Metadata,
|
|
15
|
+
RegisterDataHook,
|
|
16
|
+
UserInfo,
|
|
17
|
+
type EntityInfo,
|
|
18
|
+
type IMetadataProvider,
|
|
19
|
+
} from '@memberjunction/core';
|
|
20
|
+
import { PostgreSQLDataProvider } from '@memberjunction/postgresql-dataprovider';
|
|
21
|
+
import type { SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
22
|
+
import { IsBalancedSQLFragment, SplitTopLevelAndTerms } from '@memberjunction/sql-parser';
|
|
23
|
+
import { createTenantPreRunViewHook } from '../multiTenancy/index.js';
|
|
24
|
+
import type { MultiTenancyConfig } from '../config.js';
|
|
25
|
+
|
|
26
|
+
const TENANT_TERM = `(("OrganizationID" = 't1'))`;
|
|
27
|
+
|
|
28
|
+
/** The real PostgreSQL provider over one entity fixture, with every executed statement recorded. */
|
|
29
|
+
class RecordingPostgreSQLProvider extends PostgreSQLDataProvider {
|
|
30
|
+
public readonly ExecutedSQL: string[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(private readonly testEntities: EntityInfo[]) {
|
|
33
|
+
super();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public override get Entities(): EntityInfo[] {
|
|
37
|
+
return this.testEntities;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
public override async ExecuteSQL<T>(sql: string): Promise<T[]> {
|
|
41
|
+
this.ExecutedSQL.push(sql);
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A tenant-scoped entity with a boolean field, which the PostgreSQL rewrite coerces. */
|
|
47
|
+
function customersEntity(): EntityInfo {
|
|
48
|
+
const field = (name: string, tsType: string) => ({ Name: name, CodeName: name, TSType: tsType, IsVirtual: false });
|
|
49
|
+
const fields = [field('ID', 'string'), field('Name', 'string'), field('OrganizationID', 'string'), field('IsActive', 'boolean')];
|
|
50
|
+
return {
|
|
51
|
+
ID: 'ENT-CUSTOMERS',
|
|
52
|
+
Name: 'Customers',
|
|
53
|
+
SchemaName: 'app',
|
|
54
|
+
BaseView: 'vwCustomers',
|
|
55
|
+
Fields: fields,
|
|
56
|
+
PrimaryKeys: [fields[0]],
|
|
57
|
+
FirstPrimaryKey: fields[0],
|
|
58
|
+
FieldByName: (n: string) => fields.find((f) => f.Name.toLowerCase() === n.trim().toLowerCase()),
|
|
59
|
+
DatetimeFields: [],
|
|
60
|
+
ExternalDataSourceID: null,
|
|
61
|
+
UserViewMaxRows: 0,
|
|
62
|
+
EnableFieldLevelSecurity: false,
|
|
63
|
+
AuditViewRuns: false,
|
|
64
|
+
GetUserPermisions: () => ({ CanRead: true }),
|
|
65
|
+
GetEffectiveRowFilterWhereClause: () => '',
|
|
66
|
+
} as unknown as EntityInfo;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function tenantConfig(): MultiTenancyConfig {
|
|
70
|
+
return {
|
|
71
|
+
enabled: true,
|
|
72
|
+
contextSource: 'header',
|
|
73
|
+
tenantHeader: 'x-tenant-id',
|
|
74
|
+
scopingStrategy: 'allowlist',
|
|
75
|
+
scopedEntities: ['Customers'],
|
|
76
|
+
autoExcludeCoreEntities: true,
|
|
77
|
+
defaultTenantColumn: 'OrganizationID',
|
|
78
|
+
entityColumnMappings: {},
|
|
79
|
+
adminRoles: ['Admin'],
|
|
80
|
+
writeProtection: 'strict',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The WHERE clause of the one statement the provider ran. */
|
|
85
|
+
function whereOf(provider: RecordingPostgreSQLProvider): string {
|
|
86
|
+
expect(provider.ExecutedSQL).toHaveLength(1);
|
|
87
|
+
const sql = provider.ExecutedSQL[0];
|
|
88
|
+
return sql.substring(sql.indexOf(' WHERE ') + ' WHERE '.length);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether `term` is one of the top-level AND terms inside the one pair of parentheses around
|
|
93
|
+
* `where`. Only then does it narrow every row the query returns.
|
|
94
|
+
*/
|
|
95
|
+
function isTopLevelAndTerm(where: string, term: string, dialect: SQLParserDialect): boolean {
|
|
96
|
+
const inner = where.slice(1, -1);
|
|
97
|
+
const wrapped = where.startsWith('(') && where.endsWith(')') && IsBalancedSQLFragment(inner, dialect).IsBalanced;
|
|
98
|
+
return wrapped && (SplitTopLevelAndTerms(inner, dialect) ?? []).some((t) => t.trim() === term);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('Multi-tenancy on PostgreSQL — the provider rewrite cannot reach the tenant predicate', () => {
|
|
102
|
+
let provider: RecordingPostgreSQLProvider;
|
|
103
|
+
let savedProvider: IMetadataProvider;
|
|
104
|
+
let user: UserInfo;
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
108
|
+
savedProvider = Metadata.Provider;
|
|
109
|
+
Metadata.Provider = provider;
|
|
110
|
+
ClearAllDataHooks();
|
|
111
|
+
RegisterDataHook('PreRunView', createTenantPreRunViewHook(tenantConfig()));
|
|
112
|
+
user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
113
|
+
user.TenantContext = { TenantID: 't1', Source: 'header' };
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
ClearAllDataHooks();
|
|
118
|
+
Metadata.Provider = savedProvider;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('keeps the tenant predicate a separate AND term when the rewrite changes the caller term', async () => {
|
|
122
|
+
// The rewrite turns the caller's quoted text into boolean literals and changes where its
|
|
123
|
+
// parentheses close. Each term is rewritten and wrapped on its own, so that change stays
|
|
124
|
+
// inside the caller's term.
|
|
125
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND ("IsActive" = 1'` }, user);
|
|
126
|
+
|
|
127
|
+
expect(result.Success).toBe(true);
|
|
128
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE) AND ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('keeps the tenant predicate its own AND term after a quote the boolean rewrite drops, followed by a top-level OR', async () => {
|
|
132
|
+
// The rewrite drops the quote, so text the hook read inside a string literal becomes code
|
|
133
|
+
// with a top-level OR. The tenant predicate must still narrow every row.
|
|
134
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) OR (1=1) OR ("IsActive" = 1'` }, user);
|
|
135
|
+
|
|
136
|
+
expect(result.Success).toBe(true);
|
|
137
|
+
const where = whereOf(provider);
|
|
138
|
+
expect(isTopLevelAndTerm(where, TENANT_TERM, provider.Dialect), `tenant predicate is not a top-level AND term of ${where}`).toBe(true);
|
|
139
|
+
expect(where).toBe(`((("IsActive" = TRUE) OR (1=1) OR ("IsActive" = TRUE)) AND ${TENANT_TERM})`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('refuses a caller term that the rewrite leaves unbalanced, and runs no SQL', async () => {
|
|
143
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = '1) AND (1 = 1'` }, user);
|
|
144
|
+
|
|
145
|
+
expect(provider.ExecutedSQL).toEqual([]);
|
|
146
|
+
expect(result.Success).toBe(false);
|
|
147
|
+
expect(result.ErrorMessage).toMatch(/ExtraFilter/);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('leaves an ordinary filter and the tenant predicate as written, each in its own term', async () => {
|
|
151
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: `"IsActive" = 1 OR "Name" = 'a'` }, user);
|
|
152
|
+
|
|
153
|
+
expect(result.Success).toBe(true);
|
|
154
|
+
expect(whereOf(provider)).toBe(`((("IsActive" = TRUE OR "Name" = 'a')) AND ${TENANT_TERM})`);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('PostgreSQL ExtraFilter rewrite with no PreRunView hook', () => {
|
|
159
|
+
let provider: RecordingPostgreSQLProvider;
|
|
160
|
+
let savedProvider: IMetadataProvider;
|
|
161
|
+
|
|
162
|
+
beforeEach(() => {
|
|
163
|
+
provider = new RecordingPostgreSQLProvider([customersEntity()]);
|
|
164
|
+
savedProvider = Metadata.Provider;
|
|
165
|
+
Metadata.Provider = provider;
|
|
166
|
+
ClearAllDataHooks();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
Metadata.Provider = savedProvider;
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('keeps a SQL Server bracket name that contains AND whole when it rewrites each AND term', async () => {
|
|
174
|
+
const user = new UserInfo(provider, { ID: 'U1', Email: 'u1@example.com' });
|
|
175
|
+
|
|
176
|
+
const result = await provider.RunView({ EntityName: 'Customers', ExtraFilter: '[Profit and Loss] > 0 AND [IsActive] = 1' }, user);
|
|
177
|
+
|
|
178
|
+
expect(result.Success).toBe(true);
|
|
179
|
+
expect(whereOf(provider)).toBe('(("Profit and Loss" > 0) AND ("IsActive" = TRUE))');
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -518,6 +518,57 @@ describe('Multi-Tenancy Security Edge Cases', () => {
|
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
520
|
|
|
521
|
+
// ─── Client ExtraFilter containment ───────────────────────────────────
|
|
522
|
+
|
|
523
|
+
describe('client ExtraFilter must stay inside its parentheses', () => {
|
|
524
|
+
// The hook builds `(<ExtraFilter>) AND (<tenant>)`. A filter that closes a parenthesis it did
|
|
525
|
+
// not open turns that into `(1=1) OR (1=1) AND (<tenant>)`: AND binds tighter than OR, so the
|
|
526
|
+
// tenant predicate would narrow only one side of an OR the caller controls.
|
|
527
|
+
const escapes = [
|
|
528
|
+
'1=1) OR (1=1',
|
|
529
|
+
"Name = 'a') OR (Name = 'b'",
|
|
530
|
+
'1=1)) OR ((1=1',
|
|
531
|
+
];
|
|
532
|
+
|
|
533
|
+
it.each(escapes)('refuses the ExtraFilter %j instead of ANDing the tenant predicate onto it', (extraFilter) => {
|
|
534
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
535
|
+
const params = { EntityName: 'Customers', ExtraFilter: extraFilter } as RunViewParams;
|
|
536
|
+
|
|
537
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/parenthesis/);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('refuses an ExtraFilter that ends inside an unterminated string literal', () => {
|
|
541
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
542
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a" } as RunViewParams;
|
|
543
|
+
|
|
544
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/unterminated/);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('refuses an ExtraFilter that contains a comment', () => {
|
|
548
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
549
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a' -- note\n" } as RunViewParams;
|
|
550
|
+
|
|
551
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/comment/);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it('applies the PostgreSQL rules too when the provider dialect is unknown', () => {
|
|
555
|
+
// The mocked Metadata.Provider here is not a database provider, so the hook reads the filter
|
|
556
|
+
// with every supported dialect.
|
|
557
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
558
|
+
const params = { EntityName: 'Customers', ExtraFilter: 'Name = $$x$$' } as RunViewParams;
|
|
559
|
+
|
|
560
|
+
expect(() => hook(params, makeUser('tenant-abc'))).toThrow(/dollar/);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('keeps a balanced ExtraFilter and the tenant predicate as separate parenthesized terms', () => {
|
|
564
|
+
const hook = createTenantPreRunViewHook(makeConfig());
|
|
565
|
+
const params = { EntityName: 'Customers', ExtraFilter: "Name = 'a) OR (b' OR Name = 'c'" } as RunViewParams;
|
|
566
|
+
|
|
567
|
+
const result = hook(params, makeUser('tenant-abc')) as RunViewParams;
|
|
568
|
+
expect(result.ExtraFilter).toBe("(Name = 'a) OR (b' OR Name = 'c') AND ([OrganizationID] = 'tenant-abc')");
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
521
572
|
// ─── PreSave security edge cases ──────────────────────────────────────
|
|
522
573
|
|
|
523
574
|
describe('PreSave security', () => {
|
|
@@ -87,7 +87,7 @@ describe('Multi-Tenancy Hooks', () => {
|
|
|
87
87
|
|
|
88
88
|
const result = hook(params, user);
|
|
89
89
|
expect((result as RunViewParams).ExtraFilter).toBe(
|
|
90
|
-
"(Status = 'Active') AND [OrganizationID] = 'tenant-abc'"
|
|
90
|
+
"(Status = 'Active') AND ([OrganizationID] = 'tenant-abc')"
|
|
91
91
|
);
|
|
92
92
|
});
|
|
93
93
|
|
|
@@ -30,6 +30,8 @@ const {
|
|
|
30
30
|
mockBeginTransaction,
|
|
31
31
|
mockCommitTransaction,
|
|
32
32
|
mockRollbackTransaction,
|
|
33
|
+
mockGlobalProviderCalls,
|
|
34
|
+
mockCreateIsolatedProvider,
|
|
33
35
|
mockGetDefaultApplicationsForNewUser,
|
|
34
36
|
mockRolesArray,
|
|
35
37
|
mockApplicationsArray,
|
|
@@ -69,6 +71,10 @@ const {
|
|
|
69
71
|
mockBeginTransaction: vi.fn(),
|
|
70
72
|
mockCommitTransaction: vi.fn(),
|
|
71
73
|
mockRollbackTransaction: vi.fn(),
|
|
74
|
+
// Every call new-user creation makes on the SHARED Metadata.Provider. Must stay empty: a
|
|
75
|
+
// transaction there nests into every concurrent request's (see CreateIsolatedProvider).
|
|
76
|
+
mockGlobalProviderCalls: [] as string[],
|
|
77
|
+
mockCreateIsolatedProvider: vi.fn(),
|
|
72
78
|
mockGetDefaultApplicationsForNewUser: vi.fn(),
|
|
73
79
|
mockRolesArray: [] as Array<{ ID: string; Name: string }>,
|
|
74
80
|
mockApplicationsArray: [] as Array<{ ID: string; Name: string }>,
|
|
@@ -79,6 +85,9 @@ const {
|
|
|
79
85
|
|
|
80
86
|
vi.mock('../config.js', () => ({ configInfo: mockConfig }));
|
|
81
87
|
|
|
88
|
+
// The provider new-user creation must use: its own instance, so its transaction is its own.
|
|
89
|
+
vi.mock('../isolatedProvider.js', () => ({ CreateIsolatedProvider: mockCreateIsolatedProvider }));
|
|
90
|
+
|
|
82
91
|
vi.mock('@memberjunction/generic-database-provider', () => {
|
|
83
92
|
const instance = {
|
|
84
93
|
get Users() {
|
|
@@ -104,10 +113,11 @@ vi.mock('@memberjunction/generic-database-provider', () => {
|
|
|
104
113
|
|
|
105
114
|
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
106
115
|
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
// The shared global provider: records any use instead of doing anything.
|
|
117
|
+
const globalProvider = {
|
|
118
|
+
BeginTransaction: async () => { mockGlobalProviderCalls.push('BeginTransaction'); },
|
|
119
|
+
CommitTransaction: async () => { mockGlobalProviderCalls.push('CommitTransaction'); },
|
|
120
|
+
RollbackTransaction: async () => { mockGlobalProviderCalls.push('RollbackTransaction'); },
|
|
111
121
|
Dialect: { BooleanLiteral: (value: boolean) => (value ? '1' : '0') },
|
|
112
122
|
};
|
|
113
123
|
class MockMetadata {
|
|
@@ -118,9 +128,10 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
|
118
128
|
return mockApplicationsArray;
|
|
119
129
|
}
|
|
120
130
|
public GetEntityObject(entityName: string, contextUser?: UserInfo): Promise<unknown> {
|
|
131
|
+
mockGlobalProviderCalls.push(`GetEntityObject:${entityName}`);
|
|
121
132
|
return mockGetEntityObject(entityName, contextUser);
|
|
122
133
|
}
|
|
123
|
-
static Provider =
|
|
134
|
+
static Provider = globalProvider;
|
|
124
135
|
}
|
|
125
136
|
class MockUserInfo {
|
|
126
137
|
constructor(_provider: unknown, initData: Record<string, unknown>) {
|
|
@@ -128,6 +139,9 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
class MockRunView {
|
|
142
|
+
public static FromMetadataProvider(_provider: unknown): MockRunView {
|
|
143
|
+
return new MockRunView();
|
|
144
|
+
}
|
|
131
145
|
public RunView(params: unknown, contextUser?: UserInfo): Promise<unknown> {
|
|
132
146
|
return mockRunViewFn(params, contextUser);
|
|
133
147
|
}
|
|
@@ -294,6 +308,21 @@ beforeEach(() => {
|
|
|
294
308
|
});
|
|
295
309
|
mockRunViewFn.mockResolvedValue({ Success: true, Results: [] });
|
|
296
310
|
mockGetDefaultApplicationsForNewUser.mockReturnValue([]);
|
|
311
|
+
|
|
312
|
+
mockGlobalProviderCalls.length = 0;
|
|
313
|
+
mockCreateIsolatedProvider.mockImplementation(async () => ({
|
|
314
|
+
BeginTransaction: mockBeginTransaction,
|
|
315
|
+
CommitTransaction: mockCommitTransaction,
|
|
316
|
+
RollbackTransaction: mockRollbackTransaction,
|
|
317
|
+
Dialect: { BooleanLiteral: (value: boolean) => (value ? '1' : '0') },
|
|
318
|
+
get Roles() {
|
|
319
|
+
return mockRolesArray;
|
|
320
|
+
},
|
|
321
|
+
get Applications() {
|
|
322
|
+
return mockApplicationsArray;
|
|
323
|
+
},
|
|
324
|
+
GetEntityObject: (entityName: string, contextUser?: UserInfo) => mockGetEntityObject(entityName, contextUser),
|
|
325
|
+
}));
|
|
297
326
|
});
|
|
298
327
|
|
|
299
328
|
// ─── NewUserBase.createNewUser ──────────────────────────────────────────────
|
|
@@ -302,6 +331,34 @@ describe('NewUserBase.createNewUser', () => {
|
|
|
302
331
|
const create = (first = 'Ada', last = 'Lovelace', email = 'ada@example.com') =>
|
|
303
332
|
new NewUserBase().createNewUser(first, last, email);
|
|
304
333
|
|
|
334
|
+
describe('provider isolation', () => {
|
|
335
|
+
it('runs the whole provisioning on its own provider, never the shared Metadata.Provider', async () => {
|
|
336
|
+
mockConfig.userHandling.CreateUserApplicationRecords = true;
|
|
337
|
+
mockConfig.userHandling.UserApplications = ['CRM'];
|
|
338
|
+
mockRunViewFn.mockResolvedValue({ Success: true, Results: [{ EntityID: 'ent-1', Entity: 'Accounts' }] });
|
|
339
|
+
|
|
340
|
+
const user = await create();
|
|
341
|
+
|
|
342
|
+
expect(user).toBeTruthy();
|
|
343
|
+
expect(mockCreateIsolatedProvider).toHaveBeenCalledTimes(1);
|
|
344
|
+
expect(mockBeginTransaction).toHaveBeenCalledTimes(1);
|
|
345
|
+
expect(mockCommitTransaction).toHaveBeenCalledTimes(1);
|
|
346
|
+
// User, role, application and application-entity records all came from the isolated one.
|
|
347
|
+
expect(getEntityObjectCalls.map((c) => c.entityName)).toEqual([
|
|
348
|
+
'MJ: Users', 'MJ: User Roles', 'MJ: User Applications', 'MJ: User Application Entities',
|
|
349
|
+
]);
|
|
350
|
+
expect(mockGlobalProviderCalls).toEqual([]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('rolls back on the isolated provider too, never the shared one', async () => {
|
|
354
|
+
saveBehavior['MJ: Users'] = () => false;
|
|
355
|
+
|
|
356
|
+
expect(await create()).toBeNull();
|
|
357
|
+
expect(mockRollbackTransaction).toHaveBeenCalledTimes(1);
|
|
358
|
+
expect(mockGlobalProviderCalls).toEqual([]);
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
|
|
305
362
|
describe('context-user resolution', () => {
|
|
306
363
|
it('uses the configured contextUserForNewUserCreation for every entity it creates', async () => {
|
|
307
364
|
const user = await create();
|
|
@@ -9,6 +9,7 @@ import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
|
|
|
9
9
|
import { RealtimeClientSessionService, RealtimeChannelServerHost } from '@memberjunction/ai-agents';
|
|
10
10
|
import { GetHostInstanceID } from './HostInstance.js';
|
|
11
11
|
import { writeReturningVisitorRecap } from './ReturningVisitorRecap.js';
|
|
12
|
+
import { SessionRunIDVerifier, SessionRunIDs } from './SessionRunIDVerifier.js';
|
|
12
13
|
import { ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
|
|
13
14
|
|
|
14
15
|
/** Entity names — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
|
|
@@ -16,18 +17,6 @@ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
|
16
17
|
const SESSION_CHANNEL_ENTITY = 'MJ: AI Agent Session Channels';
|
|
17
18
|
const CONVERSATION_ENTITY = 'MJ: Conversations';
|
|
18
19
|
|
|
19
|
-
/**
|
|
20
|
-
* Shape of the observability run ids a realtime voice session persists in `AIAgentSession.Config_`.
|
|
21
|
-
* Read on {@link SessionManager.CloseSession} to finalize the co-agent runs. All fields optional —
|
|
22
|
-
* a non-voice session (or one whose run creation was skipped) carries none of them.
|
|
23
|
-
*/
|
|
24
|
-
interface SessionRunConfig {
|
|
25
|
-
coAgentRunID?: string;
|
|
26
|
-
promptRunID?: string;
|
|
27
|
-
/** The co-agent run's single system-prompt `MJ: AI Agent Run Steps` row id (its Timeline entry). */
|
|
28
|
-
coAgentRunStepID?: string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
20
|
/**
|
|
32
21
|
* Inputs for {@link SessionManager.CreateSession}. `conversationID`/`lastSessionID`/`config`
|
|
33
22
|
* are optional — when no `conversationID` is supplied a fresh Conversation is created and linked.
|
|
@@ -126,6 +115,9 @@ export class SessionManager {
|
|
|
126
115
|
*/
|
|
127
116
|
private readonly realtimeClientSessionService: RealtimeClientSessionService;
|
|
128
117
|
|
|
118
|
+
/** Checks the run ids read from a session's owner-editable `Config_` before they are used. */
|
|
119
|
+
private readonly runIDVerifier = new SessionRunIDVerifier();
|
|
120
|
+
|
|
129
121
|
/**
|
|
130
122
|
* @param realtimeClientSessionService The shared {@link RealtimeClientSessionService} instance to
|
|
131
123
|
* finalize observability runs through. Pass the SAME instance your resolver/service used to create
|
|
@@ -260,7 +252,8 @@ export class SessionManager {
|
|
|
260
252
|
|
|
261
253
|
/**
|
|
262
254
|
* Finalizes the co-agent observability runs a realtime voice session stored in its `Config_`
|
|
263
|
-
* (see `RealtimeClientSessionResolver`).
|
|
255
|
+
* (see `RealtimeClientSessionResolver`). Only run ids that are records of the session are
|
|
256
|
+
* finalized, because the owner can edit `Config_`. No-op when none remain. Tolerant: a
|
|
264
257
|
* malformed config or a finalize failure is swallowed so it can never break session close.
|
|
265
258
|
*/
|
|
266
259
|
private async finalizeObservabilityRuns(
|
|
@@ -268,8 +261,8 @@ export class SessionManager {
|
|
|
268
261
|
contextUser: UserInfo,
|
|
269
262
|
provider: IMetadataProvider,
|
|
270
263
|
): Promise<void> {
|
|
271
|
-
const
|
|
272
|
-
if (!
|
|
264
|
+
const claimed = this.parseSessionRunConfig(session.Config_);
|
|
265
|
+
if (!claimed.CoAgentRunID && !claimed.PromptRunID && !claimed.CoAgentRunStepID) {
|
|
273
266
|
return;
|
|
274
267
|
}
|
|
275
268
|
try {
|
|
@@ -277,30 +270,35 @@ export class SessionManager {
|
|
|
277
270
|
// for a scoped anonymous session, so an owner-initiated (or error) close must finalize
|
|
278
271
|
// under it too — the caller's role holds no grants on the AI run entities. Janitor and
|
|
279
272
|
// shutdown sweeps already close as the system user and pass through unchanged.
|
|
273
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
274
|
+
const runIDs = await this.runIDVerifier.Verify(session.ID, claimed, runUser, provider);
|
|
275
|
+
if (!runIDs.CoAgentRunID && !runIDs.PromptRunID && !runIDs.CoAgentRunStepID) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
280
278
|
await this.realtimeClientSessionService.FinalizeCoAgentRun(
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
279
|
+
runIDs.CoAgentRunID ?? null,
|
|
280
|
+
runIDs.PromptRunID ?? null,
|
|
281
|
+
runUser,
|
|
284
282
|
provider,
|
|
285
283
|
true,
|
|
286
|
-
|
|
284
|
+
runIDs.CoAgentRunStepID ?? null,
|
|
287
285
|
);
|
|
288
286
|
} catch (e) {
|
|
289
287
|
LogError(`SessionManager.finalizeObservabilityRuns failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
290
288
|
}
|
|
291
289
|
}
|
|
292
290
|
|
|
293
|
-
/** Parses the session's `Config_` for observability run ids; returns
|
|
294
|
-
private parseSessionRunConfig(raw: string | null):
|
|
291
|
+
/** Parses the session's `Config_` for observability run ids; returns none on any error. */
|
|
292
|
+
private parseSessionRunConfig(raw: string | null): SessionRunIDs {
|
|
295
293
|
if (!raw) {
|
|
296
294
|
return {};
|
|
297
295
|
}
|
|
298
296
|
try {
|
|
299
|
-
const parsed = JSON.parse(raw) as
|
|
297
|
+
const parsed = JSON.parse(raw) as { coAgentRunID?: unknown; promptRunID?: unknown; coAgentRunStepID?: unknown };
|
|
300
298
|
return {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
299
|
+
CoAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
|
|
300
|
+
PromptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
|
|
301
|
+
CoAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
|
|
304
302
|
};
|
|
305
303
|
} catch {
|
|
306
304
|
return {};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Checks the run ids a realtime session keeps in its `Config` before server code
|
|
3
|
+
* acts on them.
|
|
4
|
+
*
|
|
5
|
+
* @module @memberjunction/server
|
|
6
|
+
*/
|
|
7
|
+
import { IMetadataProvider, LogError, RunView, RunViewParams, UserInfo } from '@memberjunction/core';
|
|
8
|
+
import { EscapeSQLString, IsValidUUID, UUIDsEqual } from '@memberjunction/global';
|
|
9
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
10
|
+
|
|
11
|
+
const AGENT_RUN_ENTITY = 'MJ: AI Agent Runs';
|
|
12
|
+
const AGENT_RUN_STEP_ENTITY = 'MJ: AI Agent Run Steps';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The run ids a realtime voice session keeps in its `Config` JSON. Server code reads and writes the
|
|
16
|
+
* records they point at, often as an elevated user.
|
|
17
|
+
*/
|
|
18
|
+
export interface SessionRunIDs {
|
|
19
|
+
/** The co-agent observability run (`MJ: AI Agent Runs`). */
|
|
20
|
+
CoAgentRunID?: string;
|
|
21
|
+
/** The co-agent prompt run (`MJ: AI Prompt Runs`), linked through the co-agent run's prompt step. */
|
|
22
|
+
PromptRunID?: string;
|
|
23
|
+
/** The co-agent run's system-prompt step (`MJ: AI Agent Run Steps`). */
|
|
24
|
+
CoAgentRunStepID?: string;
|
|
25
|
+
/** A delegated run (`MJ: AI Agent Runs`) that paused for the user's answer. */
|
|
26
|
+
PendingFeedbackRunID?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A row of the run-step lookup. */
|
|
30
|
+
interface RunStepRow {
|
|
31
|
+
ID: string;
|
|
32
|
+
StepType: string;
|
|
33
|
+
TargetLogID: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A row of either lookup; the run lookup returns only `ID`. */
|
|
37
|
+
type LookupRow = Partial<RunStepRow> & { ID: string };
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keeps the run ids from a session's `Config` that are records of that session.
|
|
41
|
+
*
|
|
42
|
+
* The session owner can edit `Config`, so its run ids are claims. A run id is kept when it is the
|
|
43
|
+
* UUID of an `MJ: AI Agent Runs` row whose `AgentSessionID` is the session. The step id and the
|
|
44
|
+
* prompt run id are kept when they belong to that co-agent run: the step's `AgentRunID` is the
|
|
45
|
+
* co-agent run, and the prompt run is the `TargetLogID` of one of its `Prompt` steps.
|
|
46
|
+
*
|
|
47
|
+
* The lookups run as the system user when one is cached, else as the acting user. They are pinned
|
|
48
|
+
* to the session's id and return nothing to the caller, and row-level security can hide a session's
|
|
49
|
+
* own runs from its owner (a web-widget guest's delegated runs carry no `ConversationID`, which the
|
|
50
|
+
* guest's run filter keys on).
|
|
51
|
+
*
|
|
52
|
+
* Each call reads the database again (`BypassCache`) and keeps no result, because the links can
|
|
53
|
+
* change after a lookup: a run's `AgentSessionID` and a step's `AgentRunID` and `TargetLogID` are
|
|
54
|
+
* updatable.
|
|
55
|
+
*/
|
|
56
|
+
export class SessionRunIDVerifier {
|
|
57
|
+
/**
|
|
58
|
+
* Returns the subset of `claimed` that belongs to the session now. Never throws: when a lookup
|
|
59
|
+
* fails, nothing is kept.
|
|
60
|
+
*
|
|
61
|
+
* @param sessionID The `MJ: AI Agent Sessions` id the claims were read from. The caller must have
|
|
62
|
+
* checked that the session is the caller's own.
|
|
63
|
+
* @param claimed The run ids read from the session's `Config`.
|
|
64
|
+
* @param contextUser The acting user; the lookups run as it only when no system user is cached.
|
|
65
|
+
* @param provider The request-scoped metadata provider.
|
|
66
|
+
* @returns The claims that are records of the session.
|
|
67
|
+
*/
|
|
68
|
+
public async Verify(
|
|
69
|
+
sessionID: string,
|
|
70
|
+
claimed: SessionRunIDs,
|
|
71
|
+
contextUser: UserInfo,
|
|
72
|
+
provider: IMetadataProvider,
|
|
73
|
+
): Promise<SessionRunIDs> {
|
|
74
|
+
const candidates = this.keepUUIDs(claimed);
|
|
75
|
+
if (!candidates.CoAgentRunID && !candidates.PendingFeedbackRunID) {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
return (await this.lookUpOwned(sessionID.trim(), candidates, contextUser, provider)) ?? {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Drops every claim that is not a UUID. The prompt run and step need a co-agent run to hang off. */
|
|
82
|
+
private keepUUIDs(claimed: SessionRunIDs): SessionRunIDs {
|
|
83
|
+
const coAgentRunID = this.uuidOrUndefined(claimed.CoAgentRunID);
|
|
84
|
+
return {
|
|
85
|
+
CoAgentRunID: coAgentRunID,
|
|
86
|
+
PromptRunID: coAgentRunID ? this.uuidOrUndefined(claimed.PromptRunID) : undefined,
|
|
87
|
+
CoAgentRunStepID: coAgentRunID ? this.uuidOrUndefined(claimed.CoAgentRunStepID) : undefined,
|
|
88
|
+
PendingFeedbackRunID: this.uuidOrUndefined(claimed.PendingFeedbackRunID),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private uuidOrUndefined(value: string | undefined): string | undefined {
|
|
93
|
+
return value && IsValidUUID(value) ? value.trim() : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Runs the lookups; `null` when one of them fails. */
|
|
97
|
+
private async lookUpOwned(
|
|
98
|
+
sessionID: string,
|
|
99
|
+
candidates: SessionRunIDs,
|
|
100
|
+
contextUser: UserInfo,
|
|
101
|
+
provider: IMetadataProvider,
|
|
102
|
+
): Promise<SessionRunIDs | null> {
|
|
103
|
+
const stepsQuery = this.stepsQuery(candidates);
|
|
104
|
+
const queries = stepsQuery ? [this.runsQuery(sessionID, candidates), stepsQuery] : [this.runsQuery(sessionID, candidates)];
|
|
105
|
+
try {
|
|
106
|
+
const lookupUser = UserCache.Instance.GetSystemUser() ?? contextUser;
|
|
107
|
+
const [runs, steps] = await RunView.FromMetadataProvider(provider).RunViews<LookupRow>(queries, lookupUser);
|
|
108
|
+
const failed = [runs, steps].find((result) => result && !result.Success);
|
|
109
|
+
if (!runs || failed) {
|
|
110
|
+
LogError(`SessionRunIDVerifier: run lookup failed for session ${sessionID}: ${failed?.ErrorMessage ?? 'no result'}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return this.pickOwned(candidates, runs.Results, steps?.Results ?? []);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
LogError(`SessionRunIDVerifier: run lookup failed for session ${sessionID}: ${error instanceof Error ? error.message : String(error)}`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The claimed runs that carry this session's id. */
|
|
121
|
+
private runsQuery(sessionID: string, candidates: SessionRunIDs): RunViewParams {
|
|
122
|
+
const ids = [candidates.CoAgentRunID, candidates.PendingFeedbackRunID]
|
|
123
|
+
.filter((id): id is string => !!id)
|
|
124
|
+
.map((id) => `'${EscapeSQLString(id)}'`);
|
|
125
|
+
return {
|
|
126
|
+
EntityName: AGENT_RUN_ENTITY,
|
|
127
|
+
ExtraFilter: `AgentSessionID='${EscapeSQLString(sessionID)}' AND ID IN (${ids.join(', ')})`,
|
|
128
|
+
Fields: ['ID'],
|
|
129
|
+
ResultType: 'simple',
|
|
130
|
+
BypassCache: true,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The claimed co-agent run's steps that match the claimed step or prompt run; `null` when neither is claimed. */
|
|
135
|
+
private stepsQuery(candidates: SessionRunIDs): RunViewParams | null {
|
|
136
|
+
const matches: string[] = [];
|
|
137
|
+
if (candidates.CoAgentRunStepID) {
|
|
138
|
+
matches.push(`ID='${EscapeSQLString(candidates.CoAgentRunStepID)}'`);
|
|
139
|
+
}
|
|
140
|
+
if (candidates.PromptRunID) {
|
|
141
|
+
matches.push(`TargetLogID='${EscapeSQLString(candidates.PromptRunID)}'`);
|
|
142
|
+
}
|
|
143
|
+
if (!candidates.CoAgentRunID || matches.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
EntityName: AGENT_RUN_STEP_ENTITY,
|
|
148
|
+
ExtraFilter: `AgentRunID='${EscapeSQLString(candidates.CoAgentRunID)}' AND (${matches.join(' OR ')})`,
|
|
149
|
+
Fields: ['ID', 'StepType', 'TargetLogID'],
|
|
150
|
+
ResultType: 'simple',
|
|
151
|
+
BypassCache: true,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private pickOwned(candidates: SessionRunIDs, runs: LookupRow[], steps: LookupRow[]): SessionRunIDs {
|
|
156
|
+
const isOwnedRun = (id: string | undefined): boolean => !!id && runs.some((row) => UUIDsEqual(row.ID, id));
|
|
157
|
+
const coAgentRunID = isOwnedRun(candidates.CoAgentRunID) ? candidates.CoAgentRunID : undefined;
|
|
158
|
+
const ownedSteps = coAgentRunID ? steps : [];
|
|
159
|
+
const stepID = candidates.CoAgentRunStepID;
|
|
160
|
+
const promptRunID = candidates.PromptRunID;
|
|
161
|
+
return {
|
|
162
|
+
CoAgentRunID: coAgentRunID,
|
|
163
|
+
CoAgentRunStepID: stepID && ownedSteps.some((row) => UUIDsEqual(row.ID, stepID)) ? stepID : undefined,
|
|
164
|
+
PromptRunID:
|
|
165
|
+
promptRunID && ownedSteps.some((row) => row.StepType === 'Prompt' && UUIDsEqual(row.TargetLogID, promptRunID))
|
|
166
|
+
? promptRunID
|
|
167
|
+
: undefined,
|
|
168
|
+
PendingFeedbackRunID: isOwnedRun(candidates.PendingFeedbackRunID) ? candidates.PendingFeedbackRunID : undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|