@memberjunction/server 5.51.3 → 5.51.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -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 +136 -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/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +34 -1
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +6 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +24 -34
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +3262 -3273
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +124 -4
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
- package/dist/generic/CacheInvalidationResolver.js +170 -15
- package/dist/generic/CacheInvalidationResolver.js.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.d.ts +5 -0
- package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -1
- package/dist/generic/FireAndForgetHeartbeat.js +5 -4
- package/dist/generic/FireAndForgetHeartbeat.js.map +1 -1
- package/dist/generic/PushStatusResolver.d.ts +58 -0
- package/dist/generic/PushStatusResolver.d.ts.map +1 -1
- package/dist/generic/PushStatusResolver.js +42 -3
- package/dist/generic/PushStatusResolver.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +148 -3
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +287 -30
- 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.map +1 -1
- package/dist/index.js +56 -48
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts.map +1 -1
- package/dist/multiTenancy/index.js +22 -2
- package/dist/multiTenancy/index.js.map +1 -1
- package/dist/postgresqlCompat.d.ts +8 -0
- package/dist/postgresqlCompat.d.ts.map +1 -0
- package/dist/postgresqlCompat.js +19 -0
- package/dist/postgresqlCompat.js.map +1 -0
- package/dist/postgresqlPoolSettings.d.ts +108 -0
- package/dist/postgresqlPoolSettings.d.ts.map +1 -0
- package/dist/postgresqlPoolSettings.js +134 -0
- package/dist/postgresqlPoolSettings.js.map +1 -0
- package/dist/realtimeWidget/widgetGuestElevation.d.ts +2 -2
- package/dist/realtimeWidget/widgetGuestElevation.js +2 -2
- 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 +39 -17
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +97 -157
- 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 +14 -4
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +18 -6
- package/dist/resolvers/EntityRecordNameResolver.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/MCPResolver.d.ts.map +1 -1
- package/dist/resolvers/MCPResolver.js +13 -16
- package/dist/resolvers/MCPResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +26 -14
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +63 -46
- 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 +24 -27
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +30 -40
- package/dist/resolvers/RunAIAgentResolver.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/RunTestResolver.d.ts.map +1 -1
- package/dist/resolvers/RunTestResolver.js +90 -112
- package/dist/resolvers/RunTestResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.d.ts.map +1 -1
- package/dist/resolvers/VersionHistoryResolver.js +8 -12
- package/dist/resolvers/VersionHistoryResolver.js.map +1 -1
- package/dist/resolvers/adhocTimeout.d.ts +7 -0
- package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
- package/dist/resolvers/adhocTimeout.js +12 -0
- package/dist/resolvers/adhocTimeout.js.map +1 -0
- package/dist/rest/ViewOperationsHandler.d.ts +3 -1
- package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
- package/dist/rest/ViewOperationsHandler.js +29 -17
- package/dist/rest/ViewOperationsHandler.js.map +1 -1
- package/dist/services/TaskOrchestrator.d.ts.map +1 -1
- package/dist/services/TaskOrchestrator.js +17 -15
- package/dist/services/TaskOrchestrator.js.map +1 -1
- package/package.json +90 -89
- package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
- package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
- package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
- package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
- 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 +136 -0
- package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +318 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +66 -27
- package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +134 -0
- package/src/__tests__/ResolverBase.adhocReadScope.test.ts +221 -0
- package/src/__tests__/ResolverBase.aggregateScreen.test.ts +173 -0
- package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
- package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +116 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +351 -0
- package/src/__tests__/RunTemplateResolver.test.ts +132 -0
- package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +164 -0
- package/src/__tests__/SessionManager.test.ts +88 -13
- package/src/__tests__/SessionRunIDVerifier.test.ts +255 -0
- package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
- package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +155 -0
- package/src/__tests__/actionAuthorization.test.ts +130 -0
- package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
- package/src/__tests__/cacheInvalidationRecordData.test.ts +241 -0
- package/src/__tests__/multiTenancy.postgresRewrite.test.ts +180 -0
- package/src/__tests__/multiTenancy.security.test.ts +51 -0
- package/src/__tests__/multiTenancy.test.ts +1 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
- package/src/__tests__/pushStatusResolver.filter.test.ts +56 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/resolverBase.rls.test.ts +2 -0
- package/src/__tests__/translateBracketsToPG.test.ts +20 -0
- package/src/agentSessions/SessionManager.ts +24 -25
- package/src/agentSessions/SessionRunIDVerifier.ts +179 -0
- package/src/agentSessions/index.ts +1 -0
- package/src/auth/actionAuthorization.ts +119 -0
- package/src/auth/index.ts +1 -0
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/config.ts +34 -1
- package/src/context.ts +25 -34
- package/src/generated/generated.ts +3262 -3273
- package/src/generic/CacheInvalidationResolver.ts +206 -15
- package/src/generic/FireAndForgetHeartbeat.ts +10 -4
- package/src/generic/PushStatusResolver.ts +79 -4
- package/src/generic/ResolverBase.ts +339 -31
- package/src/generic/RunViewResolver.ts +17 -0
- package/src/index.ts +63 -50
- package/src/multiTenancy/index.ts +24 -2
- package/src/postgresqlCompat.ts +21 -0
- package/src/postgresqlPoolSettings.ts +213 -0
- package/src/realtimeWidget/widgetGuestElevation.ts +2 -2
- package/src/resolvers/ActionResolver.ts +85 -22
- package/src/resolvers/AdhocQueryResolver.ts +110 -177
- package/src/resolvers/DatasetResolver.ts +70 -10
- package/src/resolvers/EntityRecordNameResolver.ts +24 -7
- package/src/resolvers/GetDataContextDataResolver.ts +139 -41
- package/src/resolvers/GetDataResolver.ts +14 -1
- package/src/resolvers/MCPResolver.ts +13 -15
- package/src/resolvers/RealtimeClientSessionResolver.ts +82 -50
- package/src/resolvers/RemoteBrowserActionResolver.ts +30 -26
- package/src/resolvers/RunAIAgentResolver.ts +30 -40
- package/src/resolvers/RunTemplateResolver.ts +13 -12
- package/src/resolvers/RunTestResolver.ts +90 -112
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/VersionHistoryResolver.ts +10 -13
- package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
- package/src/resolvers/__tests__/ReportResolver.test.ts +2 -0
- package/src/resolvers/adhocTimeout.ts +12 -0
- package/src/rest/ViewOperationsHandler.ts +37 -18
- package/src/services/TaskOrchestrator.ts +17 -17
- package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
- package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
- package/dist/resolvers/adhoc-query-helpers.js +0 -42
- package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
- package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
- package/src/resolvers/adhoc-query-helpers.ts +0 -50
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-contract regression test for the shipped `userHandling.newUserRoles` default (issue #4260).
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT. `DEFAULT_SERVER_CONFIG` shipped `newUserRoles: ['UI', 'Developer']`. On the baseline
|
|
5
|
+
* seed the `Developer` role holds `CanUpdate` on 439 of the database's 446 entities with no row-level filter —
|
|
6
|
+
* `MJ: Users` among them, where `AllowUpdateAPI` is true and both `Type` and `Name` are
|
|
7
|
+
* `AllowUpdateAPI` fields. Every gate in the chain passes:
|
|
8
|
+
*
|
|
9
|
+
* config default -> newUsers.ts assigns the role -> EntityPermission grants Update
|
|
10
|
+
* -> Entity/Field AllowUpdateAPI allow it -> BaseEntity.CheckPermissions passes
|
|
11
|
+
* -> GRANT EXECUTE ON __mj.spUpdateUser TO cdp_Developer
|
|
12
|
+
*
|
|
13
|
+
* So anyone who obtained an account through JWT auto-provisioning or a magic-link redeem could
|
|
14
|
+
* write any row of `MJ: Users` — including setting their own `Type` to `'Owner'`, the column MJ's
|
|
15
|
+
* superuser checks read (`SqlLoggingConfigResolver`, `canIssueInvites`, the backup-system-user
|
|
16
|
+
* fallback). That is privilege escalation from "can obtain a token" to "platform Owner".
|
|
17
|
+
*
|
|
18
|
+
* WHY THE DEFAULT IS THE FIX. The value is not merely the fallback for a host with no config file.
|
|
19
|
+
* `loadConfig()` deep-merges through `mergeConfigs`, so a host writing a PARTIAL `userHandling`
|
|
20
|
+
* block (`{ autoCreateNewUsers: true }`, say) inherits this array — the Zod `.default([])` on the
|
|
21
|
+
* schema never fires, because the key is never absent after the merge. The shipped default is
|
|
22
|
+
* therefore the effective posture of every deployment that does not name `newUserRoles` explicitly.
|
|
23
|
+
*
|
|
24
|
+
* These assertions pin the CONTRACT, not just the literal: the shipped default must grant an
|
|
25
|
+
* auto-provisioned user no more than the end-user surface.
|
|
26
|
+
*/
|
|
27
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
28
|
+
|
|
29
|
+
// config.ts evaluates `configInfo = loadConfig()` at module load, and `loadConfig` rejects a
|
|
30
|
+
// configuration carrying no database credentials. Stubbing the cosmiconfig search — and ONLY that —
|
|
31
|
+
// supplies them so the module imports, while leaving `DEFAULT_SERVER_CONFIG` itself completely
|
|
32
|
+
// untouched. These assertions therefore run against the constant the server actually ships rather
|
|
33
|
+
// than against a copy of it, which is the whole point of a shipped-default contract test.
|
|
34
|
+
vi.mock('cosmiconfig', () => ({
|
|
35
|
+
cosmiconfigSync: () => ({
|
|
36
|
+
search: () => ({
|
|
37
|
+
filepath: '/test/mj.config.cjs',
|
|
38
|
+
isEmpty: false,
|
|
39
|
+
config: { dbUsername: 'test-user', dbPassword: 'test-password', dbDatabase: 'test-db' },
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
import { DEFAULT_SERVER_CONFIG } from '../config.js';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Seeded roles that carry platform-wide write on the baseline database, and so must never appear
|
|
48
|
+
* in a default handed to a party the host has not vetted. `Integration`'s own seed Description
|
|
49
|
+
* says it "shouldn't be applied to end-users"; `Developer` is the role this issue was filed about.
|
|
50
|
+
*/
|
|
51
|
+
const PLATFORM_WRITE_ROLES = ['Developer', 'Integration'];
|
|
52
|
+
|
|
53
|
+
describe('DEFAULT_SERVER_CONFIG.userHandling.newUserRoles (issue #4260)', () => {
|
|
54
|
+
const newUserRoles = DEFAULT_SERVER_CONFIG.userHandling?.newUserRoles;
|
|
55
|
+
|
|
56
|
+
it('grants auto-provisioned users the end-user role only', () => {
|
|
57
|
+
expect(newUserRoles).toEqual(['UI']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each(PLATFORM_WRITE_ROLES)('does not include the platform-write role %j', role => {
|
|
61
|
+
// The regression itself. Before the fix, 'Developer' was present here and every
|
|
62
|
+
// auto-provisioned user could UPDATE MJ: Users, Type and Name included.
|
|
63
|
+
expect(newUserRoles).not.toContain(role);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('is not empty — an auto-provisioned user must still be able to use the product', () => {
|
|
67
|
+
// The opposite failure mode: locking the default down to [] would provision users who
|
|
68
|
+
// cannot read their own conversations, views or dashboards. 'UI' is the seeded role that
|
|
69
|
+
// carries exactly that end-user surface (48 update grants, none of them on MJ: Users).
|
|
70
|
+
expect(newUserRoles?.length).toBeGreaterThan(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('stays minimal because auto-provisioning is ON by default with no domain restriction', () => {
|
|
74
|
+
// This is the linkage that makes the role list security-relevant rather than a convenience
|
|
75
|
+
// setting. A stock server auto-creates an account for ANY identity its configured IdP will
|
|
76
|
+
// issue a token for. If either of these defaults is ever tightened, the role list may be
|
|
77
|
+
// reconsidered — but not before, and never in the other order.
|
|
78
|
+
expect(DEFAULT_SERVER_CONFIG.userHandling?.autoCreateNewUsers).toBe(true);
|
|
79
|
+
expect(DEFAULT_SERVER_CONFIG.userHandling?.newUserLimitedToAuthorizedDomains).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL pools read the same databaseSettings values as SQL Server, with the same defaults.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest';
|
|
5
|
+
import {
|
|
6
|
+
BuildPostgreSQLConnectionConfig,
|
|
7
|
+
CODEGEN_STATEMENT_TIMEOUT_MS,
|
|
8
|
+
ResolvePostgreSQLEndpoint,
|
|
9
|
+
ResolvePostgreSQLReadOnlyCredentials,
|
|
10
|
+
ToPGPoolConfig
|
|
11
|
+
} from '../postgresqlPoolSettings.js';
|
|
12
|
+
import type { DatabaseSettingsInfo } from '../config.js';
|
|
13
|
+
import { DescribeReadOnlyLoginOverreach } from '../postgresqlPoolSettings.js';
|
|
14
|
+
import type { ReadOnlyLoginCheckClient, ReadOnlyLoginGrants } from '../postgresqlPoolSettings.js';
|
|
15
|
+
|
|
16
|
+
const defaults: DatabaseSettingsInfo = {
|
|
17
|
+
connectionTimeout: 45000,
|
|
18
|
+
requestTimeout: 30000,
|
|
19
|
+
metadataCacheRefreshInterval: 180000,
|
|
20
|
+
connectionPool: { max: 50, min: 5, idleTimeoutMillis: 30000, acquireTimeoutMillis: 30000 }
|
|
21
|
+
};
|
|
22
|
+
const endpoint = { Host: 'db', Port: 5432, Database: 'mj', User: 'api', Password: 'secret' };
|
|
23
|
+
|
|
24
|
+
describe('BuildPostgreSQLConnectionConfig', () => {
|
|
25
|
+
it('gives API pools requestTimeout as the statement timeout and the pool settings SQL Server uses', () => {
|
|
26
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api');
|
|
27
|
+
expect(config).toMatchObject({
|
|
28
|
+
...endpoint,
|
|
29
|
+
MaxConnections: 50,
|
|
30
|
+
MinConnections: 5,
|
|
31
|
+
IdleTimeoutMillis: 30000,
|
|
32
|
+
ConnectionTimeoutMillis: 30000,
|
|
33
|
+
StatementTimeoutMs: 30000,
|
|
34
|
+
IdleInTransactionSessionTimeoutMs: 60000
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('follows a configured requestTimeout and pool', () => {
|
|
39
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, {
|
|
40
|
+
...defaults,
|
|
41
|
+
requestTimeout: 5000,
|
|
42
|
+
connectionPool: { max: 12, min: 1, idleTimeoutMillis: 7000, acquireTimeoutMillis: 9000 }
|
|
43
|
+
}, 'api');
|
|
44
|
+
expect(config).toMatchObject({ MaxConnections: 12, MinConnections: 1, IdleTimeoutMillis: 7000, ConnectionTimeoutMillis: 9000, StatementTimeoutMs: 5000, IdleInTransactionSessionTimeoutMs: 10000 });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('sets no timeouts when requestTimeout is 0', () => {
|
|
48
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api');
|
|
49
|
+
expect(config.StatementTimeoutMs).toBe(0);
|
|
50
|
+
expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps the read-only pool small but gives it the API timeouts', () => {
|
|
54
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'read-only');
|
|
55
|
+
expect(config).toMatchObject({ MaxConnections: 10, MinConnections: 0, StatementTimeoutMs: 30000, IdleInTransactionSessionTimeoutMs: 60000 });
|
|
56
|
+
expect(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, connectionPool: { max: 4, min: 1, idleTimeoutMillis: 1, acquireTimeoutMillis: 1 } }, 'read-only').MaxConnections).toBe(4);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('gives the CodeGen pool the long timeout the SQL Server CodeGen pool has, and no idle-in-transaction limit', () => {
|
|
60
|
+
const config = BuildPostgreSQLConnectionConfig(endpoint, defaults, 'codegen');
|
|
61
|
+
expect(config.StatementTimeoutMs).toBe(CODEGEN_STATEMENT_TIMEOUT_MS);
|
|
62
|
+
expect(CODEGEN_STATEMENT_TIMEOUT_MS).toBe(600000);
|
|
63
|
+
expect(config.IdleInTransactionSessionTimeoutMs).toBe(0);
|
|
64
|
+
expect(config.MaxConnections).toBe(10);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('ToPGPoolConfig', () => {
|
|
69
|
+
it('maps the connection config onto pg.Pool option names', () => {
|
|
70
|
+
const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, defaults, 'api'));
|
|
71
|
+
expect(pool).toEqual({
|
|
72
|
+
host: 'db', port: 5432, database: 'mj', user: 'api', password: 'secret',
|
|
73
|
+
max: 50, min: 5, idleTimeoutMillis: 30000, connectionTimeoutMillis: 30000,
|
|
74
|
+
statement_timeout: 30000, idle_in_transaction_session_timeout: 60000
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('leaves the timeouts out when they are 0', () => {
|
|
79
|
+
const pool = ToPGPoolConfig(BuildPostgreSQLConnectionConfig(endpoint, { ...defaults, requestTimeout: 0 }, 'api'));
|
|
80
|
+
expect(pool).not.toHaveProperty('statement_timeout');
|
|
81
|
+
expect(pool).not.toHaveProperty('idle_in_transaction_session_timeout');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('ResolvePostgreSQLEndpoint', () => {
|
|
86
|
+
it('prefers PG_* variables over DB_* ones', () => {
|
|
87
|
+
expect(ResolvePostgreSQLEndpoint({ PG_HOST: 'pg', DB_HOST: 'db', DB_PORT: '6000', DB_DATABASE: 'd', DB_USERNAME: 'u', DB_PASSWORD: 'p' }))
|
|
88
|
+
.toEqual({ Host: 'pg', Port: 6000, Database: 'd', User: 'u', Password: 'p' });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('falls back to localhost:5432 and the postgres user', () => {
|
|
92
|
+
expect(ResolvePostgreSQLEndpoint({})).toEqual({ Host: 'localhost', Port: 5432, Database: '', User: 'postgres', Password: '' });
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('ResolvePostgreSQLReadOnlyCredentials', () => {
|
|
97
|
+
it('uses the dbReadOnlyUsername / dbReadOnlyPassword settings SQL Server uses', () => {
|
|
98
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro', dbReadOnlyPassword: 'pw' }, {}))
|
|
99
|
+
.toEqual({ User: 'ro', Password: 'pw' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('falls back to PG_READ_ONLY_* when the settings are absent', () => {
|
|
103
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({}, { PG_READ_ONLY_USERNAME: 'pgro', PG_READ_ONLY_PASSWORD: 'x' }))
|
|
104
|
+
.toEqual({ User: 'pgro', Password: 'x' });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('returns null when no complete pair is configured', () => {
|
|
108
|
+
expect(ResolvePostgreSQLReadOnlyCredentials({ dbReadOnlyUsername: 'ro' }, { PG_READ_ONLY_USERNAME: 'pgro' })).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('DescribeReadOnlyLoginOverreach', () => {
|
|
113
|
+
const clientAnswering = (row: ReadOnlyLoginGrants): ReadOnlyLoginCheckClient => ({
|
|
114
|
+
query: async () => ({ rows: [row] }),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('says nothing for a login that reads only views', async () => {
|
|
118
|
+
expect(await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 0 }), '__mj')).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('warns when the login can read base tables', async () => {
|
|
122
|
+
const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: false, ReadableBaseTables: 412 }), '__mj');
|
|
123
|
+
expect(warnings).toHaveLength(1);
|
|
124
|
+
expect(warnings[0]).toMatch(/can read 412 base table\(s\) in schema "__mj"/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('warns when the login can read server files', async () => {
|
|
128
|
+
const warnings = await DescribeReadOnlyLoginOverreach(clientAnswering({ ReadsServerFiles: true, ReadableBaseTables: 0 }), '__mj');
|
|
129
|
+
expect(warnings[0]).toMatch(/pg_read_server_files/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pushStatusResolver.filter.test.ts — locks the security-critical subscription filter for B49.
|
|
3
|
+
*
|
|
4
|
+
* `statusUpdatesFilter` is the gate that fixes the `statusUpdates` session-hijack: a push is
|
|
5
|
+
* delivered only when the push's `sessionId` matches the subscriber's requested session AND the
|
|
6
|
+
* push's `ownerUserId` matches the subscriber CONNECTION's server-authenticated identity. Knowing
|
|
7
|
+
* a victim's client-chosen `sessionId` must no longer be sufficient to receive their pushes.
|
|
8
|
+
*
|
|
9
|
+
* The filter is a pure function (no DB / config / transport), so these are plain unit tests.
|
|
10
|
+
*/
|
|
11
|
+
import 'reflect-metadata';
|
|
12
|
+
import { describe, it, expect } from 'vitest';
|
|
13
|
+
import { statusUpdatesFilter, type PushStatusNotificationPayload, type StatusUpdatesFilterContext } from '../generic/PushStatusResolver';
|
|
14
|
+
import type { UserPayload } from '../types';
|
|
15
|
+
|
|
16
|
+
const OWNER = 'AA11BB22-0000-4000-8000-000000000001';
|
|
17
|
+
const ATTACKER = 'CC33DD44-0000-4000-8000-000000000002';
|
|
18
|
+
const SESSION = 'session-abc-123';
|
|
19
|
+
|
|
20
|
+
function ctxFor(userId: string | undefined): StatusUpdatesFilterContext {
|
|
21
|
+
// userRecord is `any` in UserPayload; a minimal shape with ID is all the filter reads.
|
|
22
|
+
return { userPayload: (userId ? { userRecord: { ID: userId } } : {}) as unknown as UserPayload };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function push(overrides: Partial<PushStatusNotificationPayload> = {}): PushStatusNotificationPayload {
|
|
26
|
+
return { sessionId: SESSION, ownerUserId: OWNER, message: 'progress', ...overrides };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('statusUpdatesFilter (B49 session-hijack gate)', () => {
|
|
30
|
+
it('delivers when sessionId matches AND the connection identity owns the push', () => {
|
|
31
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('THE FIX: rejects a matching sessionId when the connection identity is a DIFFERENT user (hijack)', () => {
|
|
35
|
+
// Attacker lifted the victim's sessionId and subscribes with it — but their authenticated
|
|
36
|
+
// connection identity is their own, so delivery must be denied.
|
|
37
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER }), args: { sessionId: SESSION }, context: ctxFor(ATTACKER) })).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('rejects when the requested sessionId does not match the push', () => {
|
|
41
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: 'some-other-session' }, context: ctxFor(OWNER) })).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('fails closed when the push carries no ownerUserId', () => {
|
|
45
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: '' }), args: { sessionId: SESSION }, context: ctxFor(OWNER) })).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('fails closed when the connection has no authenticated identity', () => {
|
|
49
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: ctxFor(undefined) })).toBe(false);
|
|
50
|
+
expect(statusUpdatesFilter({ payload: push(), args: { sessionId: SESSION }, context: undefined })).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('matches identity case-insensitively (SQL Server upper vs PostgreSQL lower UUIDs)', () => {
|
|
54
|
+
expect(statusUpdatesFilter({ payload: push({ ownerUserId: OWNER.toUpperCase() }), args: { sessionId: SESSION }, context: ctxFor(OWNER.toLowerCase()) })).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the API says when a save or a delete is REFUSED.
|
|
3
|
+
*
|
|
4
|
+
* The three write resolvers all end the same way — `throw new GraphQLError(<a string off
|
|
5
|
+
* LatestResult> ?? 'Unknown error')` — and they did not agree on which string. `CreateRecord` read
|
|
6
|
+
* `CompleteMessage`; `UpdateRecord` and `DeleteRecord` read `Message`. A validation refusal puts its
|
|
7
|
+
* reasons in `Errors` and leaves `Message` null, so the same rule on the same entity explained itself
|
|
8
|
+
* on a create and produced the literal text "Unknown error" on an update.
|
|
9
|
+
*
|
|
10
|
+
* Nothing could catch it: both properties exist, both are strings, and the `??` fallback renders the
|
|
11
|
+
* failure as a plausible-looking error rather than a crash. It is visible only by refusing an update
|
|
12
|
+
* and reading what comes back.
|
|
13
|
+
*
|
|
14
|
+
* The first two cases below guard the direction of RISK in the change — that swapping the property
|
|
15
|
+
* cannot lose a plain `Message`, and cannot blank out the `'Unknown error'` fallback. The last case
|
|
16
|
+
* is the only one that fails if a resolver is switched back, and it has to read the source to do it:
|
|
17
|
+
* any test that restates what a resolver reads stays green through the regression, which is exactly
|
|
18
|
+
* how this survived (a create-path test proved nothing about update).
|
|
19
|
+
*/
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
import { BaseEntityResult } from '@memberjunction/core';
|
|
24
|
+
|
|
25
|
+
/** The shape a validation refusal leaves: `Message` untouched, reasons in `Errors`. */
|
|
26
|
+
function refusedByValidation(...messages: string[]): BaseEntityResult {
|
|
27
|
+
const result = new BaseEntityResult();
|
|
28
|
+
result.Success = false;
|
|
29
|
+
result.Errors = messages.map((Message) => ({
|
|
30
|
+
Source: 'SomeField',
|
|
31
|
+
Message,
|
|
32
|
+
Value: null,
|
|
33
|
+
Type: 'Failure',
|
|
34
|
+
})) as unknown as BaseEntityResult['Errors'];
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Exactly what the update/delete resolvers hand to `GraphQLError`, fallback included. */
|
|
39
|
+
const asReported = (r: BaseEntityResult) => r.CompleteMessage ?? 'Unknown error';
|
|
40
|
+
|
|
41
|
+
describe('switching the update/delete throws to CompleteMessage', () => {
|
|
42
|
+
it('does NOT lose a plain Message — it is a superset, not a replacement', () => {
|
|
43
|
+
// The risk in the change: a non-validation failure (a provider/SQL error) sets `Message` and
|
|
44
|
+
// no `Errors`. The new property must still report it.
|
|
45
|
+
const result = new BaseEntityResult();
|
|
46
|
+
result.Success = false;
|
|
47
|
+
result.Message = 'Timeout expired while saving.';
|
|
48
|
+
|
|
49
|
+
expect(asReported(result)).toBe('Timeout expired while saving.');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('still falls back to "Unknown error" when there is genuinely nothing to say', () => {
|
|
53
|
+
// The other direction: if CompleteMessage returned '' rather than undefined for an empty
|
|
54
|
+
// result, `??` would not fire and the user would get a blank error.
|
|
55
|
+
expect(asReported(new BaseEntityResult())).toBe('Unknown error');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('carries the validation prose that "Unknown error" used to replace', () => {
|
|
59
|
+
const prose = 'Record what this contract says instead of the standard clause.';
|
|
60
|
+
const result = refusedByValidation(prose);
|
|
61
|
+
|
|
62
|
+
expect(result.Message, 'a refusal leaves Message unset — this is why reading it failed').toBeFalsy();
|
|
63
|
+
expect(asReported(result)).toBe(prose);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('ResolverBase selects CompleteMessage on every write-refusal throw', () => {
|
|
68
|
+
const source = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8');
|
|
69
|
+
|
|
70
|
+
it('no refusal throw reads the bare LatestResult.Message', () => {
|
|
71
|
+
// `\b` so the longer `CompleteMessage` does not match.
|
|
72
|
+
const offenders = source
|
|
73
|
+
.split('\n')
|
|
74
|
+
.map((line, i) => ({ line: line.trim(), n: i + 1 }))
|
|
75
|
+
.filter(({ line }) => /LatestResult\??\.Message\b/.test(line));
|
|
76
|
+
|
|
77
|
+
expect(
|
|
78
|
+
offenders,
|
|
79
|
+
`these lines still read the bare Message:\n${offenders.map((o) => ` ${o.n}: ${o.line}`).join('\n')}`,
|
|
80
|
+
).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('keeps the three error codes distinct — the fix must not have collapsed the paths', () => {
|
|
84
|
+
for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
|
|
85
|
+
expect(source, code).toContain(code);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -103,6 +103,8 @@ vi.mock('@memberjunction/core', async () => {
|
|
|
103
103
|
});
|
|
104
104
|
|
|
105
105
|
vi.mock('@memberjunction/core-entities', () => ({}));
|
|
106
|
+
// ResolverBase imports the client clause screen from generic-database-provider; these tests do not reach it.
|
|
107
|
+
vi.mock('@memberjunction/generic-database-provider', () => ({}));
|
|
106
108
|
|
|
107
109
|
// ─── Import after mocks ──────────────────────────────────────────────────
|
|
108
110
|
import { ResolverBase } from '../generic/ResolverBase';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generated resolvers emit SQL Server bracket-quoted identifiers; on PostgreSQL they become
|
|
3
|
+
* double-quoted. Only identifiers change: brackets inside a string literal are data.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, it, expect } from 'vitest';
|
|
6
|
+
import { TranslateBracketsToPG } from '../postgresqlCompat.js';
|
|
7
|
+
|
|
8
|
+
describe('TranslateBracketsToPG', () => {
|
|
9
|
+
it('turns bracket-quoted identifiers into double-quoted ones', () => {
|
|
10
|
+
expect(TranslateBracketsToPG('SELECT [ID], [Name] FROM [__mj].[vwUsers]')).toBe('SELECT "ID", "Name" FROM "__mj"."vwUsers"');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('leaves brackets inside a string literal alone', () => {
|
|
14
|
+
expect(TranslateBracketsToPG("SELECT [ID] FROM [t] WHERE [Note] = '[draft] copy'")).toBe(`SELECT "ID" FROM "t" WHERE "Note" = '[draft] copy'`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('unescapes ]] and escapes a double quote in the name', () => {
|
|
18
|
+
expect(TranslateBracketsToPG('SELECT [a]]b], [say "hi"] FROM t')).toBe('SELECT "a]b", "say ""hi""" FROM t');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -8,6 +8,7 @@ import { AIAgentPermissionHelper } from '@memberjunction/ai-engine-base';
|
|
|
8
8
|
import { RealtimeClientSessionService, RealtimeChannelServerHost } from '@memberjunction/ai-agents';
|
|
9
9
|
import { GetHostInstanceID } from './HostInstance.js';
|
|
10
10
|
import { writeReturningVisitorRecap } from './ReturningVisitorRecap.js';
|
|
11
|
+
import { SessionRunIDVerifier, SessionRunIDs } from './SessionRunIDVerifier.js';
|
|
11
12
|
import { ResolveScopedAnonymousRunUser } from '../realtimeWidget/widgetGuestElevation.js';
|
|
12
13
|
|
|
13
14
|
/** Entity names — centralised so the `MJ:`-prefix convention is applied in exactly one place. */
|
|
@@ -15,18 +16,6 @@ const SESSION_ENTITY = 'MJ: AI Agent Sessions';
|
|
|
15
16
|
const SESSION_CHANNEL_ENTITY = 'MJ: AI Agent Session Channels';
|
|
16
17
|
const CONVERSATION_ENTITY = 'MJ: Conversations';
|
|
17
18
|
|
|
18
|
-
/**
|
|
19
|
-
* Shape of the observability run ids a realtime voice session persists in `AIAgentSession.Config_`.
|
|
20
|
-
* Read on {@link SessionManager.CloseSession} to finalize the co-agent runs. All fields optional —
|
|
21
|
-
* a non-voice session (or one whose run creation was skipped) carries none of them.
|
|
22
|
-
*/
|
|
23
|
-
interface SessionRunConfig {
|
|
24
|
-
coAgentRunID?: string;
|
|
25
|
-
promptRunID?: string;
|
|
26
|
-
/** The co-agent run's single system-prompt `MJ: AI Agent Run Steps` row id (its Timeline entry). */
|
|
27
|
-
coAgentRunStepID?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
/**
|
|
31
20
|
* Inputs for {@link SessionManager.CreateSession}. `conversationID`/`lastSessionID`/`config`
|
|
32
21
|
* are optional — when no `conversationID` is supplied a fresh Conversation is created and linked.
|
|
@@ -96,6 +85,9 @@ export class SessionManager {
|
|
|
96
85
|
/** session ID (lowercased) → epoch ms of its last persisted `LastActiveAt` write. */
|
|
97
86
|
private heartbeatLastWrite = new Map<string, number>();
|
|
98
87
|
|
|
88
|
+
/** Checks the run ids read from a session's owner-editable `Config_` before they are used. */
|
|
89
|
+
private readonly runIDVerifier = new SessionRunIDVerifier();
|
|
90
|
+
|
|
99
91
|
/**
|
|
100
92
|
* Authorize and create a new session. Flow:
|
|
101
93
|
* 1. `CanRun` check on the agent — denied → {@link SessionAuthorizationError}, no row written.
|
|
@@ -218,7 +210,8 @@ export class SessionManager {
|
|
|
218
210
|
|
|
219
211
|
/**
|
|
220
212
|
* Finalizes the co-agent observability runs a realtime voice session stored in its `Config_`
|
|
221
|
-
* (see `RealtimeClientSessionResolver`).
|
|
213
|
+
* (see `RealtimeClientSessionResolver`). Only run ids that are records of the session are
|
|
214
|
+
* finalized, because the owner can edit `Config_`. No-op when none remain. Tolerant: a
|
|
222
215
|
* malformed config or a finalize failure is swallowed so it can never break session close.
|
|
223
216
|
*/
|
|
224
217
|
private async finalizeObservabilityRuns(
|
|
@@ -226,8 +219,8 @@ export class SessionManager {
|
|
|
226
219
|
contextUser: UserInfo,
|
|
227
220
|
provider: IMetadataProvider,
|
|
228
221
|
): Promise<void> {
|
|
229
|
-
const
|
|
230
|
-
if (!
|
|
222
|
+
const claimed = this.parseSessionRunConfig(session.Config_);
|
|
223
|
+
if (!claimed.CoAgentRunID && !claimed.PromptRunID && !claimed.CoAgentRunStepID) {
|
|
231
224
|
return;
|
|
232
225
|
}
|
|
233
226
|
try {
|
|
@@ -235,36 +228,42 @@ export class SessionManager {
|
|
|
235
228
|
// for a scoped anonymous session, so an owner-initiated (or error) close must finalize
|
|
236
229
|
// under it too — the caller's role holds no grants on the AI run entities. Janitor and
|
|
237
230
|
// shutdown sweeps already close as the system user and pass through unchanged.
|
|
231
|
+
const runUser = ResolveScopedAnonymousRunUser(contextUser);
|
|
232
|
+
const runIDs = await this.runIDVerifier.Verify(session.ID, claimed, runUser, provider);
|
|
233
|
+
if (!runIDs.CoAgentRunID && !runIDs.PromptRunID && !runIDs.CoAgentRunStepID) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
238
236
|
await new RealtimeClientSessionService().FinalizeCoAgentRun(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
237
|
+
runIDs.CoAgentRunID ?? null,
|
|
238
|
+
runIDs.PromptRunID ?? null,
|
|
239
|
+
runUser,
|
|
242
240
|
provider,
|
|
243
241
|
true,
|
|
244
|
-
|
|
242
|
+
runIDs.CoAgentRunStepID ?? null,
|
|
245
243
|
);
|
|
246
244
|
} catch (e) {
|
|
247
245
|
LogError(`SessionManager.finalizeObservabilityRuns failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
248
246
|
}
|
|
249
247
|
}
|
|
250
248
|
|
|
251
|
-
/** Parses the session's `Config_` for observability run ids; returns
|
|
252
|
-
private parseSessionRunConfig(raw: string | null):
|
|
249
|
+
/** Parses the session's `Config_` for observability run ids; returns none on any error. */
|
|
250
|
+
private parseSessionRunConfig(raw: string | null): SessionRunIDs {
|
|
253
251
|
if (!raw) {
|
|
254
252
|
return {};
|
|
255
253
|
}
|
|
256
254
|
try {
|
|
257
|
-
const parsed = JSON.parse(raw) as
|
|
255
|
+
const parsed = JSON.parse(raw) as { coAgentRunID?: unknown; promptRunID?: unknown; coAgentRunStepID?: unknown };
|
|
258
256
|
return {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
257
|
+
CoAgentRunID: typeof parsed.coAgentRunID === 'string' ? parsed.coAgentRunID : undefined,
|
|
258
|
+
PromptRunID: typeof parsed.promptRunID === 'string' ? parsed.promptRunID : undefined,
|
|
259
|
+
CoAgentRunStepID: typeof parsed.coAgentRunStepID === 'string' ? parsed.coAgentRunStepID : undefined,
|
|
262
260
|
};
|
|
263
261
|
} catch {
|
|
264
262
|
return {};
|
|
265
263
|
}
|
|
266
264
|
}
|
|
267
265
|
|
|
266
|
+
|
|
268
267
|
/**
|
|
269
268
|
* Record activity on a session. Coalesces persisted writes so at most one `LastActiveAt` update
|
|
270
269
|
* lands per session per {@link HEARTBEAT_MIN_WRITE_INTERVAL_MS}; intervening calls return `true`
|