@memberjunction/server 5.40.2 → 5.41.0
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/agentSessions/HostInstance.d.ts +19 -0
- package/dist/agentSessions/HostInstance.d.ts.map +1 -0
- package/dist/agentSessions/HostInstance.js +48 -0
- package/dist/agentSessions/HostInstance.js.map +1 -0
- package/dist/agentSessions/SessionJanitor.d.ts +97 -0
- package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
- package/dist/agentSessions/SessionJanitor.js +222 -0
- package/dist/agentSessions/SessionJanitor.js.map +1 -0
- package/dist/agentSessions/SessionManager.d.ts +142 -0
- package/dist/agentSessions/SessionManager.d.ts.map +1 -0
- package/dist/agentSessions/SessionManager.js +308 -0
- package/dist/agentSessions/SessionManager.js.map +1 -0
- package/dist/agentSessions/index.d.ts +4 -0
- package/dist/agentSessions/index.d.ts.map +1 -0
- package/dist/agentSessions/index.js +26 -0
- package/dist/agentSessions/index.js.map +1 -0
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +6 -1
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +41 -7
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +659 -3
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +5459 -1759
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.js +1 -1
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.js +9 -10
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +97 -41
- package/dist/index.js.map +1 -1
- package/dist/logging/StartupLogger.d.ts +121 -0
- package/dist/logging/StartupLogger.d.ts.map +1 -0
- package/dist/logging/StartupLogger.js +245 -0
- package/dist/logging/StartupLogger.js.map +1 -0
- package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
- package/dist/logging/variablesLoggingMiddleware.js +21 -2
- package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
- package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
- package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/AgentSessionResolver.js +152 -0
- package/dist/resolvers/AgentSessionResolver.js.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
- package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
- package/dist/resolvers/EntityPermissionResolver.js +95 -0
- package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
- package/dist/services/ScheduledJobsService.d.ts.map +1 -1
- package/dist/services/ScheduledJobsService.js +6 -5
- package/dist/services/ScheduledJobsService.js.map +1 -1
- package/package.json +78 -74
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
- package/src/__tests__/SessionJanitor.test.ts +234 -0
- package/src/__tests__/SessionManager.test.ts +465 -0
- package/src/__tests__/subscriptionRedaction.test.ts +5 -0
- package/src/agentSessions/HostInstance.ts +53 -0
- package/src/agentSessions/SessionJanitor.ts +267 -0
- package/src/agentSessions/SessionManager.ts +446 -0
- package/src/agentSessions/index.ts +27 -0
- package/src/auth/initializeProviders.ts +6 -1
- package/src/context.ts +42 -7
- package/src/generated/generated.ts +3111 -567
- package/src/generic/ResolverBase.ts +1 -1
- package/src/generic/RunViewResolver.ts +9 -9
- package/src/index.ts +98 -41
- package/src/logging/StartupLogger.ts +317 -0
- package/src/logging/variablesLoggingMiddleware.ts +25 -5
- package/src/resolvers/AgentSessionResolver.ts +138 -0
- package/src/resolvers/EntityPermissionResolver.ts +73 -0
- package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
- package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
- package/src/services/ScheduledJobsService.ts +6 -5
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { configInfo } from '../config.js';
|
|
2
|
+
import { LogStatus } from '@memberjunction/core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server log verbosity levels, ordered from least to most chatty.
|
|
6
|
+
*
|
|
7
|
+
* Reuses the SAME enum shape as `telemetry.level` in `config.ts` so operators
|
|
8
|
+
* have a single knob (`telemetry.level` in mj.config.cjs) that governs both
|
|
9
|
+
* telemetry verbosity and server-log verbosity — there is no parallel setting.
|
|
10
|
+
*
|
|
11
|
+
* Ordering (ascending): `minimal` < `standard` < `verbose` < `debug`.
|
|
12
|
+
*/
|
|
13
|
+
export type ServerLogLevel = 'minimal' | 'standard' | 'verbose' | 'debug';
|
|
14
|
+
|
|
15
|
+
/** Numeric rank for each level, used for `>=` threshold comparisons. */
|
|
16
|
+
const LEVEL_RANK: Record<ServerLogLevel, number> = {
|
|
17
|
+
minimal: 0,
|
|
18
|
+
standard: 1,
|
|
19
|
+
verbose: 2,
|
|
20
|
+
debug: 3,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single captured startup phase timing (e.g. "DB Pool Connect: 412ms").
|
|
25
|
+
* Buffered during startup and either printed individually (verbose+) or
|
|
26
|
+
* collapsed into the one-line `Startup` summary (standard).
|
|
27
|
+
*/
|
|
28
|
+
type PhaseTiming = {
|
|
29
|
+
/** Human-readable phase label. */
|
|
30
|
+
Label: string;
|
|
31
|
+
/** Elapsed milliseconds for the phase. */
|
|
32
|
+
Ms: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Fields gathered during startup that are rendered into the compact
|
|
37
|
+
* `standard`-level summary block. All optional — the renderer degrades
|
|
38
|
+
* gracefully when a field was never set.
|
|
39
|
+
*/
|
|
40
|
+
type StartupSummaryData = {
|
|
41
|
+
/** Server package version (e.g. "5.40.2"); omitted from the header if absent. */
|
|
42
|
+
Version?: string;
|
|
43
|
+
/** Database platform label, e.g. "SQL Server" or "PostgreSQL". */
|
|
44
|
+
DbPlatform?: string;
|
|
45
|
+
/** Database connection string fragment, e.g. "localhost:1433/MJ_DB". */
|
|
46
|
+
DbConnection?: string;
|
|
47
|
+
/** Count of entities loaded into metadata. */
|
|
48
|
+
EntityCount?: number;
|
|
49
|
+
/** Names of registered auth providers. */
|
|
50
|
+
AuthProviders: string[];
|
|
51
|
+
/** Whether the REST API is enabled. */
|
|
52
|
+
RestEnabled?: boolean;
|
|
53
|
+
/** Count of active scheduled jobs (or 0 / suspended). */
|
|
54
|
+
ScheduledJobCount?: number;
|
|
55
|
+
/** Public URL the server is ready at. */
|
|
56
|
+
ReadyUrl?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* MJServer-local startup + per-request log gating helper.
|
|
61
|
+
*
|
|
62
|
+
* Holds the resolved {@link ServerLogLevel} (from `telemetry.level`) and exposes
|
|
63
|
+
* a small surface for level-gated logging, startup phase-timing capture, and the
|
|
64
|
+
* compact summary block printed once at the end of boot.
|
|
65
|
+
*
|
|
66
|
+
* This is deliberately NOT a `BaseSingleton` and does NOT touch
|
|
67
|
+
* `@memberjunction/core` logging internals — it is a thin, process-local gate
|
|
68
|
+
* that wraps `console.*` / `LogStatus`. The point is the *gating*, not a new
|
|
69
|
+
* logging framework.
|
|
70
|
+
*
|
|
71
|
+
* Usage:
|
|
72
|
+
* ```ts
|
|
73
|
+
* const logger = new StartupLogger();
|
|
74
|
+
* logger.LogIf('verbose', 'Registered auth provider: azure');
|
|
75
|
+
* const t = logger.StartPhase();
|
|
76
|
+
* // ... work ...
|
|
77
|
+
* logger.EndPhase('DB Pool Connect', t);
|
|
78
|
+
* logger.PrintSummary();
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export class StartupLogger {
|
|
82
|
+
private readonly resolvedLevel: ServerLogLevel;
|
|
83
|
+
private readonly phaseTimings: PhaseTiming[] = [];
|
|
84
|
+
private readonly summary: StartupSummaryData = { AuthProviders: [] };
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param level Optional explicit level override (primarily for tests).
|
|
88
|
+
* When omitted, the level is resolved from config.
|
|
89
|
+
*/
|
|
90
|
+
constructor(level?: ServerLogLevel) {
|
|
91
|
+
this.resolvedLevel = level ?? StartupLogger.resolveLevelFromConfig();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The resolved log level in effect for this logger instance.
|
|
96
|
+
*/
|
|
97
|
+
public get Level(): ServerLogLevel {
|
|
98
|
+
return this.resolvedLevel;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Resolves the active server log level from configuration.
|
|
103
|
+
*
|
|
104
|
+
* Source of truth is `telemetry.level` (the pre-existing enum), so a single
|
|
105
|
+
* operator knob governs both telemetry and server logging. Defaults to
|
|
106
|
+
* `standard` when unset or unrecognized.
|
|
107
|
+
*/
|
|
108
|
+
public static resolveLevelFromConfig(): ServerLogLevel {
|
|
109
|
+
const raw = configInfo.telemetry?.level;
|
|
110
|
+
if (raw === 'minimal' || raw === 'standard' || raw === 'verbose' || raw === 'debug') {
|
|
111
|
+
return raw;
|
|
112
|
+
}
|
|
113
|
+
return 'standard';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* True when the active level is at least `minLevel` (using the ascending
|
|
118
|
+
* `minimal < standard < verbose < debug` ordering).
|
|
119
|
+
*/
|
|
120
|
+
public IsAtLeast(minLevel: ServerLogLevel): boolean {
|
|
121
|
+
return LEVEL_RANK[this.resolvedLevel] >= LEVEL_RANK[minLevel];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Logs `message` only when the active level is at least `minLevel`.
|
|
126
|
+
* Routes through MJ's `LogStatus` so the line participates in MJ's
|
|
127
|
+
* standard status-logging pipeline.
|
|
128
|
+
*/
|
|
129
|
+
public LogIf(minLevel: ServerLogLevel, message: string): void {
|
|
130
|
+
if (this.IsAtLeast(minLevel)) {
|
|
131
|
+
LogStatus(message);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Starts a phase timer. Returns an opaque start timestamp to pass to
|
|
137
|
+
* {@link EndPhase}.
|
|
138
|
+
*/
|
|
139
|
+
public StartPhase(): number {
|
|
140
|
+
return performance.now();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Records the elapsed time for a phase since `startedAt`, buffering it for the
|
|
145
|
+
* summary. At `verbose`+ the phase is also printed immediately (matching the
|
|
146
|
+
* legacy `⏱️ [Startup] X: Yms` lines); at `standard` it is collapsed into the
|
|
147
|
+
* one-line `Startup` summary printed by {@link PrintSummary}.
|
|
148
|
+
*
|
|
149
|
+
* @returns A fresh timestamp, so callers can chain phases ergonomically.
|
|
150
|
+
*/
|
|
151
|
+
public EndPhase(label: string, startedAt: number): number {
|
|
152
|
+
const ms = performance.now() - startedAt;
|
|
153
|
+
this.phaseTimings.push({ Label: label, Ms: ms });
|
|
154
|
+
if (this.IsAtLeast('verbose')) {
|
|
155
|
+
// eslint-disable-next-line no-console
|
|
156
|
+
console.log(`⏱️ [Startup] ${label}: ${ms.toFixed(0)}ms`);
|
|
157
|
+
}
|
|
158
|
+
return performance.now();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Records the server package version for the summary header. */
|
|
162
|
+
public SetVersion(version: string | undefined): void {
|
|
163
|
+
this.summary.Version = version;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Records DB platform + connection + entity count for the summary `DB` line. */
|
|
167
|
+
public SetDatabaseInfo(platform: string, connection: string, entityCount: number): void {
|
|
168
|
+
this.summary.DbPlatform = platform;
|
|
169
|
+
this.summary.DbConnection = connection;
|
|
170
|
+
this.summary.EntityCount = entityCount;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Adds a registered auth-provider name to the summary `Auth` line. */
|
|
174
|
+
public AddAuthProvider(name: string): void {
|
|
175
|
+
this.summary.AuthProviders.push(name);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Records whether the REST API is enabled for the summary `Auth` line. */
|
|
179
|
+
public SetRestEnabled(enabled: boolean): void {
|
|
180
|
+
this.summary.RestEnabled = enabled;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Records the active scheduled-job count for the summary `Auth` line. */
|
|
184
|
+
public SetScheduledJobCount(count: number): void {
|
|
185
|
+
this.summary.ScheduledJobCount = count;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Records the ready URL for the summary `Ready` line. */
|
|
189
|
+
public SetReadyUrl(url: string): void {
|
|
190
|
+
this.summary.ReadyUrl = url;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Prints the boot summary appropriate for the active level:
|
|
195
|
+
* - `minimal`: only the `Ready <url>` line.
|
|
196
|
+
* - `standard`: the compact ~8-line summary block (with phases collapsed).
|
|
197
|
+
* - `verbose`/`debug`: the same compact block (individual phase lines and
|
|
198
|
+
* all detail lines were already printed inline during boot).
|
|
199
|
+
*/
|
|
200
|
+
public PrintSummary(): void {
|
|
201
|
+
if (this.resolvedLevel === 'minimal') {
|
|
202
|
+
this.printReadyLineOnly();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
this.printSummaryBlock();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Prints just the ready URL (minimal level). */
|
|
209
|
+
private printReadyLineOnly(): void {
|
|
210
|
+
if (this.summary.ReadyUrl) {
|
|
211
|
+
// eslint-disable-next-line no-console
|
|
212
|
+
console.log(`🚀 Ready ${this.summary.ReadyUrl}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Renders and prints the multi-line compact summary block. */
|
|
217
|
+
private printSummaryBlock(): void {
|
|
218
|
+
const lines = this.buildSummaryLines();
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
console.log('\n' + lines.join('\n') + '\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Builds the array of summary lines (header + indented detail rows). */
|
|
224
|
+
private buildSummaryLines(): string[] {
|
|
225
|
+
const lines: string[] = [];
|
|
226
|
+
lines.push(this.buildHeaderLine());
|
|
227
|
+
lines.push(this.buildDbLine());
|
|
228
|
+
lines.push(this.buildAuthLine());
|
|
229
|
+
lines.push(this.buildStartupLine());
|
|
230
|
+
lines.push(this.buildReadyLine());
|
|
231
|
+
return lines.filter((l) => l.length > 0);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** `🚀 MemberJunction Server · v<version>` (version omitted if unknown). */
|
|
235
|
+
private buildHeaderLine(): string {
|
|
236
|
+
const version = this.summary.Version ? ` · v${this.summary.Version}` : '';
|
|
237
|
+
return `🚀 MemberJunction Server${version}`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** ` DB <platform> · <connection> · <N> entities`. */
|
|
241
|
+
private buildDbLine(): string {
|
|
242
|
+
if (!this.summary.DbPlatform) {
|
|
243
|
+
return '';
|
|
244
|
+
}
|
|
245
|
+
const parts = [this.summary.DbPlatform];
|
|
246
|
+
if (this.summary.DbConnection) {
|
|
247
|
+
parts.push(this.summary.DbConnection);
|
|
248
|
+
}
|
|
249
|
+
if (this.summary.EntityCount != null) {
|
|
250
|
+
parts.push(`${this.summary.EntityCount} entities`);
|
|
251
|
+
}
|
|
252
|
+
return ` DB ${parts.join(' · ')}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** ` Auth <providers> REST <on/off> · <N> scheduled jobs`. */
|
|
256
|
+
private buildAuthLine(): string {
|
|
257
|
+
const providers = this.summary.AuthProviders.length > 0
|
|
258
|
+
? this.summary.AuthProviders.join(', ')
|
|
259
|
+
: 'none';
|
|
260
|
+
const rest = this.summary.RestEnabled == null
|
|
261
|
+
? ''
|
|
262
|
+
: `REST ${this.summary.RestEnabled ? 'on' : 'off'}`;
|
|
263
|
+
const jobs = this.summary.ScheduledJobCount == null
|
|
264
|
+
? ''
|
|
265
|
+
: `${this.summary.ScheduledJobCount} scheduled jobs`;
|
|
266
|
+
const trailer = [rest, jobs].filter((s) => s.length > 0).join(' · ');
|
|
267
|
+
const trailerPart = trailer ? ` ${trailer}` : '';
|
|
268
|
+
return ` Auth ${providers}${trailerPart}`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** ` Startup <total>s (<phase> <x>s · <phase> <y>s …)`. */
|
|
272
|
+
private buildStartupLine(): string {
|
|
273
|
+
if (this.phaseTimings.length === 0) {
|
|
274
|
+
return '';
|
|
275
|
+
}
|
|
276
|
+
const totalMs = this.phaseTimings.reduce((sum, p) => sum + p.Ms, 0);
|
|
277
|
+
const phaseParts = this.phaseTimings
|
|
278
|
+
.map((p) => `${this.shortPhaseLabel(p.Label)} ${(p.Ms / 1000).toFixed(1)}s`)
|
|
279
|
+
.join(' · ');
|
|
280
|
+
return ` Startup ${(totalMs / 1000).toFixed(1)}s (${phaseParts})`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Abbreviates a verbose phase label into a compact token for the collapsed
|
|
285
|
+
* summary line (e.g. "Metadata + Provider Setup" → "metadata").
|
|
286
|
+
*/
|
|
287
|
+
private shortPhaseLabel(label: string): string {
|
|
288
|
+
const lower = label.toLowerCase();
|
|
289
|
+
if (lower.includes('metadata') || lower.includes('provider')) {
|
|
290
|
+
return 'metadata';
|
|
291
|
+
}
|
|
292
|
+
if (lower.includes('schema')) {
|
|
293
|
+
return 'schema';
|
|
294
|
+
}
|
|
295
|
+
if (lower.includes('db') || lower.includes('pool')) {
|
|
296
|
+
return 'db';
|
|
297
|
+
}
|
|
298
|
+
if (lower.includes('resolver') || lower.includes('middleware')) {
|
|
299
|
+
return 'resolvers';
|
|
300
|
+
}
|
|
301
|
+
if (lower.includes('apollo') || lower.includes('express')) {
|
|
302
|
+
return 'http';
|
|
303
|
+
}
|
|
304
|
+
if (lower.includes('telemetry') || lower.includes('cache')) {
|
|
305
|
+
return 'cache';
|
|
306
|
+
}
|
|
307
|
+
return lower.split(' ')[0];
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** ` Ready <url>`. */
|
|
311
|
+
private buildReadyLine(): string {
|
|
312
|
+
if (!this.summary.ReadyUrl) {
|
|
313
|
+
return '';
|
|
314
|
+
}
|
|
315
|
+
return ` Ready ${this.summary.ReadyUrl}`;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
@@ -6,6 +6,22 @@ import { configInfo } from '../config.js';
|
|
|
6
6
|
import type { AppContext } from '../types.js';
|
|
7
7
|
import { redactArg } from './secretRedactor.js';
|
|
8
8
|
import { hasNoLogParameter, getNoLogFields } from './NoLog.js';
|
|
9
|
+
import { StartupLogger } from './StartupLogger.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Memoized "is the server log level `debug`?" check. The per-resolver
|
|
13
|
+
* variables echo is gated behind BOTH `logVariables` (opt-in redaction path)
|
|
14
|
+
* AND `level === 'debug'` — it is a raw, constantly-on debug line, so it should
|
|
15
|
+
* never fire unless an operator explicitly opted into debug verbosity. Config
|
|
16
|
+
* is immutable post-boot, so caching the resolution is safe.
|
|
17
|
+
*/
|
|
18
|
+
let isDebugLevelCache: boolean | undefined;
|
|
19
|
+
function isDebugLogLevel(): boolean {
|
|
20
|
+
if (isDebugLevelCache === undefined) {
|
|
21
|
+
isDebugLevelCache = StartupLogger.resolveLevelFromConfig() === 'debug';
|
|
22
|
+
}
|
|
23
|
+
return isDebugLevelCache;
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
/**
|
|
11
27
|
* Structural shape of a type-graphql `@Arg` parameter metadata entry. Mirrors the
|
|
@@ -147,11 +163,15 @@ export const variablesLoggingMiddleware: MiddlewareFn<AppContext> = async (actio
|
|
|
147
163
|
}
|
|
148
164
|
}
|
|
149
165
|
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
166
|
+
// Raw per-resolver variables echo — gated to debug-only so it never streams
|
|
167
|
+
// constantly even when an operator turns on logVariables at a lower level.
|
|
168
|
+
if (isDebugLogLevel()) {
|
|
169
|
+
// eslint-disable-next-line no-console
|
|
170
|
+
console.dir(
|
|
171
|
+
{ operation: action.info.fieldName, args: redactedArgs },
|
|
172
|
+
{ depth: null, breakLength: 200 },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
155
175
|
|
|
156
176
|
if (hasUndecoratedCustomArg && resolverEntry) {
|
|
157
177
|
const signature = `${resolverEntry.target.name}.${resolverEntry.methodName}`;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview GraphQL resolver for the AI Agent Session record lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Exposes start / close / heartbeat mutations over {@link SessionManager}, each running under the
|
|
5
|
+
* request's `contextUser` + request-scoped `IMetadataProvider`. Enforces two authorization gates:
|
|
6
|
+
* - **Open**: `CanRun` on the target agent (delegated to {@link SessionManager.CreateSession}).
|
|
7
|
+
* - **Close / Heartbeat**: inbound ownership — the session's `UserID` must equal `contextUser.ID`.
|
|
8
|
+
*
|
|
9
|
+
* The audio/media transport and the long-lived realtime run are out of scope here (P5); this
|
|
10
|
+
* resolver only manipulates the durable session record.
|
|
11
|
+
*
|
|
12
|
+
* @module @memberjunction/server
|
|
13
|
+
*/
|
|
14
|
+
import { Resolver, Mutation, Arg, Ctx, ObjectType, Field } from 'type-graphql';
|
|
15
|
+
import { AppContext } from '../types.js';
|
|
16
|
+
import { UserInfo, IMetadataProvider, LogError } from '@memberjunction/core';
|
|
17
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
18
|
+
import { MJAIAgentSessionEntity } from '@memberjunction/core-entities';
|
|
19
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
20
|
+
import { GetReadWriteProvider } from '../util.js';
|
|
21
|
+
import { SessionManager, SessionAuthorizationError } from '../agentSessions/index.js';
|
|
22
|
+
|
|
23
|
+
/** Result of {@link AgentSessionResolver.StartAgentSession}. */
|
|
24
|
+
@ObjectType()
|
|
25
|
+
export class StartAgentSessionResult {
|
|
26
|
+
/** ID of the newly created session. */
|
|
27
|
+
@Field()
|
|
28
|
+
agentSessionId: string;
|
|
29
|
+
|
|
30
|
+
/** Lifecycle status of the session (always `Active` on a successful start). */
|
|
31
|
+
@Field()
|
|
32
|
+
status: string;
|
|
33
|
+
|
|
34
|
+
/** ID of the conversation the session is attached to (supplied or freshly created). */
|
|
35
|
+
@Field()
|
|
36
|
+
conversationId: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolver for the AI Agent Session record lifecycle. A single {@link SessionManager} instance is
|
|
41
|
+
* shared across requests — it holds no per-user/provider state (only heartbeat-coalescing
|
|
42
|
+
* timestamps), and every method is passed the request `contextUser` + provider explicitly.
|
|
43
|
+
*/
|
|
44
|
+
@Resolver()
|
|
45
|
+
export class AgentSessionResolver extends ResolverBase {
|
|
46
|
+
private readonly sessionManager = new SessionManager();
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Open a new session for an agent. Authorization (`CanRun`) is enforced inside
|
|
50
|
+
* {@link SessionManager.CreateSession}; a denial surfaces as a thrown error and no row is written.
|
|
51
|
+
*/
|
|
52
|
+
@Mutation(() => StartAgentSessionResult)
|
|
53
|
+
async StartAgentSession(
|
|
54
|
+
@Arg('agentId') agentId: string,
|
|
55
|
+
@Ctx() { userPayload, providers }: AppContext,
|
|
56
|
+
@Arg('conversationId', { nullable: true }) conversationId?: string,
|
|
57
|
+
@Arg('lastSessionId', { nullable: true }) lastSessionId?: string,
|
|
58
|
+
@Arg('configJson', { nullable: true }) configJson?: string,
|
|
59
|
+
): Promise<StartAgentSessionResult> {
|
|
60
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const session = await this.sessionManager.CreateSession(
|
|
64
|
+
{ agentID: agentId, userID: contextUser.ID, conversationID: conversationId, lastSessionID: lastSessionId, config: configJson },
|
|
65
|
+
contextUser,
|
|
66
|
+
provider,
|
|
67
|
+
);
|
|
68
|
+
return { agentSessionId: session.ID, status: session.Status, conversationId: session.ConversationID ?? '' };
|
|
69
|
+
} catch (err) {
|
|
70
|
+
if (err instanceof SessionAuthorizationError) {
|
|
71
|
+
throw new Error(err.message); // authorization denial — no session created
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Close a session. Rejects unless the caller owns it. Returns `true` on a successful (or
|
|
79
|
+
* idempotent) close, `false` if the session can't be loaded.
|
|
80
|
+
*/
|
|
81
|
+
@Mutation(() => Boolean)
|
|
82
|
+
async CloseAgentSession(
|
|
83
|
+
@Arg('agentSessionId') agentSessionId: string,
|
|
84
|
+
@Ctx() { userPayload, providers }: AppContext,
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
87
|
+
await this.assertOwnership(agentSessionId, contextUser, provider);
|
|
88
|
+
return this.sessionManager.CloseSession(agentSessionId, contextUser, provider);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Record activity on a session (coalesced). Rejects unless the caller owns it. Returns `true`
|
|
93
|
+
* when the heartbeat was accepted (written or coalesced), `false` if the session is gone/closed.
|
|
94
|
+
*/
|
|
95
|
+
@Mutation(() => Boolean)
|
|
96
|
+
async AgentSessionHeartbeat(
|
|
97
|
+
@Arg('agentSessionId') agentSessionId: string,
|
|
98
|
+
@Ctx() { userPayload, providers }: AppContext,
|
|
99
|
+
): Promise<boolean> {
|
|
100
|
+
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
101
|
+
await this.assertOwnership(agentSessionId, contextUser, provider);
|
|
102
|
+
return this.sessionManager.Heartbeat(agentSessionId, contextUser, provider);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ----- internals -------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
/** Resolve the request user + read-write provider, throwing a clear error if unauthenticated. */
|
|
108
|
+
private requireUserAndProvider(
|
|
109
|
+
userPayload: AppContext['userPayload'],
|
|
110
|
+
providers: AppContext['providers'],
|
|
111
|
+
): { contextUser: UserInfo; provider: IMetadataProvider } {
|
|
112
|
+
const contextUser = this.GetUserFromPayload(userPayload);
|
|
113
|
+
if (!contextUser) {
|
|
114
|
+
throw new Error('Not authenticated: no user context for agent session operation');
|
|
115
|
+
}
|
|
116
|
+
return { contextUser, provider: GetReadWriteProvider(providers) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Inbound ownership gate (the one genuinely new session-authz primitive): the session's `UserID`
|
|
121
|
+
* must equal the caller's. Throws on mismatch or when the session can't be loaded.
|
|
122
|
+
*/
|
|
123
|
+
private async assertOwnership(
|
|
124
|
+
agentSessionId: string,
|
|
125
|
+
contextUser: UserInfo,
|
|
126
|
+
provider: IMetadataProvider,
|
|
127
|
+
): Promise<void> {
|
|
128
|
+
const session = await provider.GetEntityObject<MJAIAgentSessionEntity>('MJ: AI Agent Sessions', contextUser);
|
|
129
|
+
const loaded = await session.Load(agentSessionId);
|
|
130
|
+
if (!loaded) {
|
|
131
|
+
throw new Error(`Agent session ${agentSessionId} not found`);
|
|
132
|
+
}
|
|
133
|
+
if (!UUIDsEqual(session.UserID, contextUser.ID)) {
|
|
134
|
+
LogError(`AgentSessionResolver: ownership check failed — user ${contextUser.ID} attempted to operate on session ${agentSessionId} owned by ${session.UserID}`);
|
|
135
|
+
throw new Error('Not authorized: you do not own this agent session');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Arg, Ctx, Field, ObjectType, Query, Resolver } from 'type-graphql';
|
|
2
|
+
import { LogError } from '@memberjunction/core';
|
|
3
|
+
import { AppContext } from '../types.js';
|
|
4
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
5
|
+
import { RequireSystemUser } from '../directives/RequireSystemUser.js';
|
|
6
|
+
import { GetReadOnlyProvider } from '../util.js';
|
|
7
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
8
|
+
|
|
9
|
+
@ObjectType()
|
|
10
|
+
class EntityPermissionResult {
|
|
11
|
+
@Field(() => String)
|
|
12
|
+
declare EntityName: string;
|
|
13
|
+
|
|
14
|
+
@Field(() => Boolean)
|
|
15
|
+
declare CanRead: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
@ObjectType()
|
|
19
|
+
class CheckEntityPermissionsResult {
|
|
20
|
+
@Field(() => Boolean)
|
|
21
|
+
declare Success: boolean;
|
|
22
|
+
|
|
23
|
+
@Field(() => [EntityPermissionResult])
|
|
24
|
+
declare Results: EntityPermissionResult[];
|
|
25
|
+
|
|
26
|
+
@Field(() => String, { nullable: true })
|
|
27
|
+
ErrorMessage?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Resolver()
|
|
31
|
+
export class EntityPermissionResolver extends ResolverBase {
|
|
32
|
+
|
|
33
|
+
@RequireSystemUser()
|
|
34
|
+
@Query(() => CheckEntityPermissionsResult)
|
|
35
|
+
async CheckEntityPermissionsSystemUser(
|
|
36
|
+
@Arg('EntityNames', () => [String]) entityNames: string[],
|
|
37
|
+
@Arg('UserEmail', () => String) userEmail: string,
|
|
38
|
+
@Ctx() context: AppContext
|
|
39
|
+
): Promise<CheckEntityPermissionsResult> {
|
|
40
|
+
try {
|
|
41
|
+
const user = UserCache.Instance.Users.find(
|
|
42
|
+
u => u.Email.toLowerCase().trim() === userEmail.toLowerCase().trim()
|
|
43
|
+
);
|
|
44
|
+
if (!user) {
|
|
45
|
+
return { Success: false, Results: [], ErrorMessage: `User not found: ${userEmail}` };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Use the per-request provider (not the global default) so entity metadata + permission
|
|
49
|
+
// evaluation resolve against the connection servicing THIS request.
|
|
50
|
+
const md = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
|
|
51
|
+
const results: EntityPermissionResult[] = [];
|
|
52
|
+
|
|
53
|
+
for (const name of entityNames) {
|
|
54
|
+
const entityInfo = md.EntityByName(name);
|
|
55
|
+
if (!entityInfo) {
|
|
56
|
+
results.push({ EntityName: name, CanRead: false });
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const perms = entityInfo.GetUserPermisions(user);
|
|
60
|
+
results.push({ EntityName: name, CanRead: perms?.CanRead ?? false });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return { Success: true, Results: results };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
LogError(err);
|
|
66
|
+
return {
|
|
67
|
+
Success: false,
|
|
68
|
+
Results: [],
|
|
69
|
+
ErrorMessage: `EntityPermissionResolver::CheckEntityPermissionsSystemUser --- ${err instanceof Error ? err.message : String(err)}`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|