@memberjunction/server 5.40.1 → 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.
Files changed (101) hide show
  1. package/dist/agentSessions/HostInstance.d.ts +19 -0
  2. package/dist/agentSessions/HostInstance.d.ts.map +1 -0
  3. package/dist/agentSessions/HostInstance.js +48 -0
  4. package/dist/agentSessions/HostInstance.js.map +1 -0
  5. package/dist/agentSessions/SessionJanitor.d.ts +97 -0
  6. package/dist/agentSessions/SessionJanitor.d.ts.map +1 -0
  7. package/dist/agentSessions/SessionJanitor.js +222 -0
  8. package/dist/agentSessions/SessionJanitor.js.map +1 -0
  9. package/dist/agentSessions/SessionManager.d.ts +142 -0
  10. package/dist/agentSessions/SessionManager.d.ts.map +1 -0
  11. package/dist/agentSessions/SessionManager.js +308 -0
  12. package/dist/agentSessions/SessionManager.js.map +1 -0
  13. package/dist/agentSessions/index.d.ts +4 -0
  14. package/dist/agentSessions/index.d.ts.map +1 -0
  15. package/dist/agentSessions/index.js +26 -0
  16. package/dist/agentSessions/index.js.map +1 -0
  17. package/dist/auth/initializeProviders.d.ts.map +1 -1
  18. package/dist/auth/initializeProviders.js +6 -1
  19. package/dist/auth/initializeProviders.js.map +1 -1
  20. package/dist/config.d.ts +245 -0
  21. package/dist/config.d.ts.map +1 -1
  22. package/dist/config.js +34 -0
  23. package/dist/config.js.map +1 -1
  24. package/dist/context.d.ts.map +1 -1
  25. package/dist/context.js +41 -7
  26. package/dist/context.js.map +1 -1
  27. package/dist/generated/generated.d.ts +659 -3
  28. package/dist/generated/generated.d.ts.map +1 -1
  29. package/dist/generated/generated.js +5459 -1759
  30. package/dist/generated/generated.js.map +1 -1
  31. package/dist/generic/ResolverBase.js +1 -1
  32. package/dist/generic/ResolverBase.js.map +1 -1
  33. package/dist/generic/RunViewResolver.js +9 -10
  34. package/dist/generic/RunViewResolver.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +112 -42
  38. package/dist/index.js.map +1 -1
  39. package/dist/logging/StartupLogger.d.ts +121 -0
  40. package/dist/logging/StartupLogger.d.ts.map +1 -0
  41. package/dist/logging/StartupLogger.js +245 -0
  42. package/dist/logging/StartupLogger.js.map +1 -0
  43. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -1
  44. package/dist/logging/variablesLoggingMiddleware.js +21 -2
  45. package/dist/logging/variablesLoggingMiddleware.js.map +1 -1
  46. package/dist/middleware/RateLimitMiddleware.d.ts +16 -0
  47. package/dist/middleware/RateLimitMiddleware.d.ts.map +1 -0
  48. package/dist/middleware/RateLimitMiddleware.js +77 -0
  49. package/dist/middleware/RateLimitMiddleware.js.map +1 -0
  50. package/dist/middleware/index.d.ts +1 -0
  51. package/dist/middleware/index.d.ts.map +1 -1
  52. package/dist/middleware/index.js +1 -0
  53. package/dist/middleware/index.js.map +1 -1
  54. package/dist/resolvers/AgentSessionResolver.d.ts +42 -0
  55. package/dist/resolvers/AgentSessionResolver.d.ts.map +1 -0
  56. package/dist/resolvers/AgentSessionResolver.js +152 -0
  57. package/dist/resolvers/AgentSessionResolver.js.map +1 -0
  58. package/dist/resolvers/EntityPermissionResolver.d.ts +16 -0
  59. package/dist/resolvers/EntityPermissionResolver.d.ts.map +1 -0
  60. package/dist/resolvers/EntityPermissionResolver.js +95 -0
  61. package/dist/resolvers/EntityPermissionResolver.js.map +1 -0
  62. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +688 -0
  63. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -0
  64. package/dist/resolvers/RealtimeClientSessionResolver.js +1774 -0
  65. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -0
  66. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +359 -0
  67. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -0
  68. package/dist/resolvers/RemoteBrowserActionResolver.js +896 -0
  69. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -0
  70. package/dist/rest/SignatureWebhookHandler.js +3 -2
  71. package/dist/rest/SignatureWebhookHandler.js.map +1 -1
  72. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  73. package/dist/services/ScheduledJobsService.js +6 -5
  74. package/dist/services/ScheduledJobsService.js.map +1 -1
  75. package/package.json +78 -74
  76. package/src/__tests__/RealtimeClientSessionResolver.test.ts +2605 -0
  77. package/src/__tests__/RemoteBrowserAudioStream.test.ts +174 -0
  78. package/src/__tests__/SessionJanitor.test.ts +234 -0
  79. package/src/__tests__/SessionManager.test.ts +465 -0
  80. package/src/__tests__/subscriptionRedaction.test.ts +5 -0
  81. package/src/agentSessions/HostInstance.ts +53 -0
  82. package/src/agentSessions/SessionJanitor.ts +267 -0
  83. package/src/agentSessions/SessionManager.ts +446 -0
  84. package/src/agentSessions/index.ts +27 -0
  85. package/src/auth/initializeProviders.ts +6 -1
  86. package/src/config.ts +38 -0
  87. package/src/context.ts +42 -7
  88. package/src/generated/generated.ts +3111 -567
  89. package/src/generic/ResolverBase.ts +1 -1
  90. package/src/generic/RunViewResolver.ts +9 -9
  91. package/src/index.ts +112 -42
  92. package/src/logging/StartupLogger.ts +317 -0
  93. package/src/logging/variablesLoggingMiddleware.ts +25 -5
  94. package/src/middleware/RateLimitMiddleware.ts +87 -0
  95. package/src/middleware/index.ts +1 -0
  96. package/src/resolvers/AgentSessionResolver.ts +138 -0
  97. package/src/resolvers/EntityPermissionResolver.ts +73 -0
  98. package/src/resolvers/RealtimeClientSessionResolver.ts +2162 -0
  99. package/src/resolvers/RemoteBrowserActionResolver.ts +907 -0
  100. package/src/rest/SignatureWebhookHandler.ts +3 -2
  101. package/src/services/ScheduledJobsService.ts +6 -5
@@ -638,7 +638,7 @@ export class ResolverBase {
638
638
  // user has. The API key's associated user (in userPayload.userRecord) is
639
639
  // used later when the actual operation executes - their permissions are
640
640
  // the ultimate ceiling that scopes can only narrow, never expand.
641
- const systemUser = UserCache.Instance.Users.find(u => u.Type === 'System');
641
+ const systemUser = UserCache.Instance.GetSystemUser();
642
642
  if (!systemUser) {
643
643
  throw new Error('System user not found');
644
644
  }
@@ -721,7 +721,7 @@ export class RunViewResolver extends ResolverBase {
721
721
  return null;
722
722
 
723
723
  const viewInfo = super.safeFirstArrayElement<MJUserViewEntityExtended>(await super.findBy<MJUserViewEntityExtended>(provider, "MJ: User Views", { Name: input.ViewName }, userPayload.userRecord));
724
- const entity = provider.Entities.find((e) => UUIDsEqual(e.ID, viewInfo.EntityID));
724
+ const entity = provider.EntityByID(viewInfo.EntityID);
725
725
  const returnData = this.processRawData(rawData.Results, viewInfo.EntityID, entity);
726
726
  return {
727
727
  Results: returnData,
@@ -752,7 +752,7 @@ export class RunViewResolver extends ResolverBase {
752
752
  return null;
753
753
 
754
754
  const viewInfo = super.safeFirstArrayElement<MJUserViewEntityExtended>(await super.findBy<MJUserViewEntityExtended>(provider, "MJ: User Views", { ID: input.ViewID }, userPayload.userRecord));
755
- const entity = provider.Entities.find((e) => UUIDsEqual(e.ID, viewInfo.EntityID));
755
+ const entity = provider.EntityByID(viewInfo.EntityID);
756
756
  const returnData = this.processRawData(rawData.Results, viewInfo.EntityID, entity);
757
757
  return {
758
758
  Results: returnData,
@@ -781,7 +781,7 @@ export class RunViewResolver extends ResolverBase {
781
781
  const rawData = await super.RunDynamicViewGeneric(input, provider, userPayload, pubSub);
782
782
  if (rawData === null) return null;
783
783
 
784
- const entity = provider.Entities.find((e) => e.Name === input.EntityName);
784
+ const entity = provider.EntityByName(input.EntityName);
785
785
  const returnData = this.processRawData(rawData.Results, entity.ID, entity);
786
786
  return {
787
787
  Results: returnData,
@@ -816,7 +816,7 @@ export class RunViewResolver extends ResolverBase {
816
816
  let results: RunViewGenericResult[] = [];
817
817
  for (const [index, data] of rawData.entries()) {
818
818
  // EntityName is backfilled by RunViewsGeneric when ViewID/ViewName was used
819
- const entity = input[index].EntityName ? provider.Entities.find((e) => e.Name === input[index].EntityName) : null;
819
+ const entity = input[index].EntityName ? provider.EntityByName(input[index].EntityName) : null;
820
820
 
821
821
  const returnData: any[] = this.processRawData(data.Results, entity ? entity.ID : null, entity);
822
822
 
@@ -860,7 +860,7 @@ export class RunViewResolver extends ResolverBase {
860
860
  };
861
861
  }
862
862
 
863
- const entity = provider.Entities.find((e) => e.Name === input.ViewName);
863
+ const entity = provider.EntityByName(input.ViewName);
864
864
  const entityId = entity ? entity.ID : null;
865
865
  const returnData = this.processRawData(rawData.Results, entityId, entity);
866
866
  return {
@@ -908,7 +908,7 @@ export class RunViewResolver extends ResolverBase {
908
908
  }
909
909
 
910
910
  const viewInfo = super.safeFirstArrayElement<MJUserViewEntityExtended>(await super.findBy<MJUserViewEntityExtended>(provider, "MJ: User Views", { ID: input.ViewID }, userPayload.userRecord));
911
- const entity = provider.Entities.find((e) => UUIDsEqual(e.ID, viewInfo.EntityID));
911
+ const entity = provider.EntityByID(viewInfo.EntityID);
912
912
  const returnData = this.processRawData(rawData.Results, viewInfo.EntityID, entity);
913
913
  return {
914
914
  Results: returnData,
@@ -954,7 +954,7 @@ export class RunViewResolver extends ResolverBase {
954
954
  };
955
955
  }
956
956
 
957
- const entity = provider.Entities.find((e) => e.Name === input.EntityName);
957
+ const entity = provider.EntityByName(input.EntityName);
958
958
  if (!entity) {
959
959
  const errorMsg = `Entity ${input.EntityName} not found in metadata`;
960
960
  LogError(new Error(errorMsg));
@@ -1007,7 +1007,7 @@ export class RunViewResolver extends ResolverBase {
1007
1007
 
1008
1008
  let results: RunViewGenericResult[] = [];
1009
1009
  for (const [index, data] of rawData.entries()) {
1010
- const entity = provider.Entities.find((e) => e.Name === input[index].EntityName);
1010
+ const entity = provider.EntityByName(input[index].EntityName);
1011
1011
  if (!entity) {
1012
1012
  LogError(new Error(`Entity with name ${input[index].EntityName} not found`));
1013
1013
  continue;
@@ -1084,7 +1084,7 @@ export class RunViewResolver extends ResolverBase {
1084
1084
  // Transform results to include processed data rows
1085
1085
  const transformedResults: RunViewWithCacheCheckResultOutput[] = response.results.map((result, index) => {
1086
1086
  const inputItem = input[index];
1087
- const entity = provider.Entities.find(e => e.Name === inputItem.params.EntityName);
1087
+ const entity = inputItem.params.EntityName ? provider.EntityByName(inputItem.params.EntityName) : undefined;
1088
1088
 
1089
1089
  // If we have differential data but no entity, that's a configuration error
1090
1090
  if (result.status === 'differential' && result.differentialData && !entity) {
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ import { default as fg } from 'fast-glob';
17
17
  import { useServer } from 'graphql-ws/lib/use/ws';
18
18
  import { createServer } from 'node:http';
19
19
  import { fileURLToPath } from 'node:url';
20
+ import { readFileSync } from 'node:fs';
20
21
  import { sep } from 'node:path';
21
22
  import 'reflect-metadata';
22
23
  import { ReplaySubject } from 'rxjs';
@@ -32,6 +33,8 @@ import { UserPayload } from './types.js';
32
33
  import { requireSystemUserDirective, publicDirective } from './directives/index.js';
33
34
  import { variablesLoggingMiddleware } from './logging/variablesLoggingMiddleware.js';
34
35
  import { auditResolversForUndecoratedArgs } from './logging/bootAudit.js';
36
+ import { StartupLogger } from './logging/StartupLogger.js';
37
+ import { AuthProviderFactory } from '@memberjunction/auth-providers';
35
38
  import createMSSQLConfig from './orm.js';
36
39
  import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
37
40
  import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
@@ -43,7 +46,7 @@ import { DataSourceInfo, raiseEvent } from './types.js';
43
46
 
44
47
  import { ExternalChangeDetectorEngine } from '@memberjunction/external-change-detection';
45
48
  import { ScheduledJobsService } from './services/ScheduledJobsService.js';
46
- import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel } from '@memberjunction/core';
49
+ import { LocalCacheManager, StartupManager, TelemetryManager, TelemetryLevel, LogStatus, SetVerboseLogging } from '@memberjunction/core';
47
50
  import { getSystemUser } from './auth/index.js';
48
51
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
49
52
  import { RedisLocalStorageProvider } from '@memberjunction/redis-provider';
@@ -52,6 +55,7 @@ import { PubSubManager } from './generic/PubSubManager.js';
52
55
  import { IntegrationProgressEmitter } from '@memberjunction/integration-progress-artifacts';
53
56
  import { PublishIntegrationProgress } from './resolvers/IntegrationProgressResolver.js';
54
57
  import { ClientToolRequestManager, AgentRunWatchdog } from '@memberjunction/ai-agents';
58
+ import { SessionJanitor } from './agentSessions/index.js';
55
59
  import { CACHE_INVALIDATION_TOPIC } from './generic/CacheInvalidationResolver.js';
56
60
  import { ConnectorFactory, IntegrationEngine, IntegrationSyncOptions } from '@memberjunction/integration-engine';
57
61
  import { CronExpressionHelper } from '@memberjunction/scheduling-engine';
@@ -167,6 +171,10 @@ export * from './resolvers/UserViewResolver.js';
167
171
  export * from './resolvers/VersionHistoryResolver.js';
168
172
  export * from './resolvers/CurrentUserContextResolver.js';
169
173
  export * from './resolvers/RSUResolver.js';
174
+ export * from './resolvers/AgentSessionResolver.js';
175
+ export * from './resolvers/RealtimeClientSessionResolver.js';
176
+ export * from './resolvers/RemoteBrowserActionResolver.js';
177
+ export * from './agentSessions/index.js';
170
178
  export { GetReadOnlyDataSource, GetReadWriteDataSource, GetReadWriteProvider, GetReadOnlyProvider } from './util.js';
171
179
 
172
180
  export * from './generated/generated.js';
@@ -194,13 +202,35 @@ const localPath = (p: string) => {
194
202
 
195
203
  export const createApp = (): Application => express();
196
204
 
205
+ /**
206
+ * Resolves the MJServer package version for the startup summary header.
207
+ *
208
+ * Reads `@memberjunction/server`'s own `package.json` relative to this module.
209
+ * Returns `undefined` (and the header omits the version) if it can't be read,
210
+ * so version display is best-effort and never blocks startup.
211
+ */
212
+ function resolveServerVersion(): string | undefined {
213
+ try {
214
+ // package.json sits at the package root, two levels up from dist/ (or src/).
215
+ const pkgPath = fileURLToPath(new URL('../package.json', import.meta.url));
216
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8')) as { version?: string };
217
+ return pkg.version;
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ }
222
+
197
223
  export const serve = async (resolverPaths: Array<string>, app: Application = createApp(), options?: MJServerOptions): Promise<void> => {
198
224
  const t0 = performance.now();
199
- const lap = (label: string, since: number) => {
200
- const ms = performance.now() - since;
201
- console.log(`⏱️ [Startup] ${label}: ${ms.toFixed(0)}ms`);
202
- return performance.now();
203
- };
225
+ // Level-gated startup logger. Resolves verbosity from telemetry.level (single
226
+ // operator knob). At `standard` (default), per-phase timings are collapsed into
227
+ // the one-line summary; at `verbose`+ each phase prints inline as before.
228
+ const startupLog = new StartupLogger();
229
+ // Wire the GLOBAL verbose-logging gate to the resolved server level, so cross-package
230
+ // LogStatusEx({ verboseOnly: true }) lines (cache, scheduling, integration, MCP, …) honor the
231
+ // configured level instead of defaulting to hidden. Without this, "verbose-only" is "always hidden".
232
+ SetVerboseLogging(startupLog.IsAtLeast('verbose'));
233
+ const lap = (label: string, since: number) => startupLog.EndPhase(label, since);
204
234
 
205
235
  const localResolverPaths = ['resolvers/**/*Resolver.{js,ts}', 'generic/*Resolver.{js,ts}', 'generated/generated.{js,ts}'].map(localPath);
206
236
 
@@ -220,7 +250,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
220
250
 
221
251
  if (dbType === 'postgresql') {
222
252
  // ─── PostgreSQL Path ───────────────────────────────────────────
223
- console.log('Database type: PostgreSQL');
253
+ startupLog.LogIf('verbose', 'Database type: PostgreSQL');
224
254
  const pg = await import('pg');
225
255
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
226
256
 
@@ -246,7 +276,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
246
276
  const testClient = await pgPool.connect();
247
277
  await testClient.query('SELECT 1');
248
278
  testClient.release();
249
- console.log(`PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
279
+ startupLog.LogIf('verbose', `PostgreSQL pool connected to ${pgHost}:${pgPort}/${pgDatabase}`);
250
280
 
251
281
  // Create a DataSourceInfo with a MSSQL-compatible wrapper around pg.Pool
252
282
  // This allows existing code (types, util, context) to work without changes
@@ -338,7 +368,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
338
368
  const codegenPgProvider = new PostgreSQLDataProvider();
339
369
  await codegenPgProvider.Config(codegenPgConfigData); // separate pool (per-instance manager); does NOT touch the global API provider
340
370
  RuntimeSchemaManager.Instance.SetDDLProvider(codegenPgProvider);
341
- console.log('RSU DDL provider initialized with CodeGen credentials (PostgreSQL).');
371
+ startupLog.LogIf('verbose', 'RSU DDL provider initialized with CodeGen credentials (PostgreSQL).');
342
372
 
343
373
  // Set up in-process CodeGen runner for RSU
344
374
  try {
@@ -361,21 +391,21 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
361
391
  RuntimeSchemaManager.Instance.SetCodeGenRunner({
362
392
  RunInProcess: (skipDB) => runObject.RunInProcess(codegenDataSource, skipDB, rsuWorkDir),
363
393
  });
364
- console.log('RSU in-process CodeGen runner initialized (PostgreSQL).');
394
+ startupLog.LogIf('verbose', 'RSU in-process CodeGen runner initialized (PostgreSQL).');
365
395
 
366
396
  // Inject CodeGen output paths for targeted git staging
367
397
  const { initializeConfig } = await import('@memberjunction/codegen-lib');
368
398
  const cgConfig = initializeConfig(rsuWorkDir);
369
399
  const outputPaths = (cgConfig.output ?? []).map((o: { directory: string }) => o.directory);
370
400
  RuntimeSchemaManager.Instance.SetCodeGenOutputPaths(outputPaths);
371
- console.log(`RSU CodeGen output paths: ${outputPaths.length} directories configured.`);
401
+ startupLog.LogIf('verbose', `RSU CodeGen output paths: ${outputPaths.length} directories configured.`);
372
402
 
373
403
  // Point RSU's soft PK/FK writer at the SAME file CodeGen reads (mj.config.cjs
374
404
  // `additionalSchemaInfo`), or RSU writes soft PKs to its own default path while
375
405
  // CodeGen reads a different one and skips integration tables with "No primary key found".
376
406
  if (cgConfig.additionalSchemaInfo) {
377
407
  RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(cgConfig.additionalSchemaInfo);
378
- console.log(`RSU additionalSchemaInfo path: ${cgConfig.additionalSchemaInfo}`);
408
+ startupLog.LogIf('verbose', `RSU additionalSchemaInfo path: ${cgConfig.additionalSchemaInfo}`);
379
409
  }
380
410
  } catch (codegenErr) {
381
411
  console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
@@ -386,10 +416,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
386
416
  }
387
417
 
388
418
  const md = new Metadata(); // global-provider-ok: bootstrap
389
- console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
419
+ const pgEntityCount = md?.Entities ? md.Entities.length : 0;
420
+ startupLog.LogIf('verbose', `Data Source has been initialized. ${pgEntityCount} entities loaded.`);
421
+ startupLog.SetDatabaseInfo('PostgreSQL', `${pgHost}:${pgPort}/${pgDatabase}`, pgEntityCount);
390
422
  } else {
391
423
  // ─── SQL Server Path (existing behavior) ───────────────────────
392
- console.log('Database type: SQL Server');
424
+ startupLog.LogIf('verbose', 'Database type: SQL Server');
393
425
  let tPhase = performance.now();
394
426
  const pool = new sql.ConnectionPool(createMSSQLConfig());
395
427
 
@@ -421,14 +453,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
421
453
  await readOnlyPool.connect();
422
454
 
423
455
  dataSources.push(new DataSourceInfo({dataSource: readOnlyPool, type: 'Read-Only', host: dbHost, port: dbPort, database: dbDatabase, userName: configInfo.dbReadOnlyUsername}));
424
- console.log('Read-only Connection Pool has been initialized.');
456
+ startupLog.LogIf('verbose', 'Read-only Connection Pool has been initialized.');
425
457
  }
426
458
 
427
459
  const config = new SQLServerProviderConfigData(pool, mj_core_schema, cacheRefreshInterval);
428
460
  await setupSQLServerClient(config);
429
461
  tPhase = lap('Metadata + Provider Setup', tPhase);
430
462
  const md = new Metadata(); // global-provider-ok: bootstrap
431
- console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
463
+ const entityCount = md?.Entities ? md.Entities.length : 0;
464
+ startupLog.LogIf('verbose', `Data Source has been initialized. ${entityCount} entities loaded.`);
465
+ startupLog.SetDatabaseInfo('SQL Server', `${dbHost}:${dbPort}/${dbDatabase}`, entityCount);
432
466
 
433
467
  // Set up CodeGen-credentialed provider for RSU DDL operations (CREATE TABLE, CREATE SCHEMA, etc.)
434
468
  const codegenUser = process.env.CODEGEN_DB_USERNAME;
@@ -457,7 +491,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
457
491
  const codegenProvider = new SQLServerDataProvider();
458
492
  await codegenProvider.Config(codegenConfig);
459
493
  RuntimeSchemaManager.Instance.SetDDLProvider(codegenProvider);
460
- console.log('RSU DDL provider initialized with CodeGen credentials.');
494
+ startupLog.LogIf('verbose', 'RSU DDL provider initialized with CodeGen credentials.');
461
495
 
462
496
  // Set up in-process CodeGen runner for RSU
463
497
  try {
@@ -480,14 +514,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
480
514
  RuntimeSchemaManager.Instance.SetCodeGenRunner({
481
515
  RunInProcess: (skipDB) => runObject.RunInProcess(codegenDataSource, skipDB, rsuWorkDir),
482
516
  });
483
- console.log('RSU in-process CodeGen runner initialized.');
517
+ startupLog.LogIf('verbose', 'RSU in-process CodeGen runner initialized.');
484
518
 
485
519
  // Inject CodeGen output paths for targeted git staging
486
520
  const { initializeConfig } = await import('@memberjunction/codegen-lib');
487
521
  const codegenConfig = initializeConfig(rsuWorkDir);
488
522
  const outputPaths = (codegenConfig.output ?? []).map((o: { directory: string }) => o.directory);
489
523
  RuntimeSchemaManager.Instance.SetCodeGenOutputPaths(outputPaths);
490
- console.log(`RSU CodeGen output paths: ${outputPaths.length} directories configured.`);
524
+ startupLog.LogIf('verbose', `RSU CodeGen output paths: ${outputPaths.length} directories configured.`);
491
525
 
492
526
  // Point RSU's soft PK/FK writer at the SAME file CodeGen reads (mj.config.cjs
493
527
  // `additionalSchemaInfo`). Without this, RSU writes soft PKs to its own default
@@ -495,7 +529,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
495
529
  // with "No primary key found".
496
530
  if (codegenConfig.additionalSchemaInfo) {
497
531
  RuntimeSchemaManager.Instance.SetAdditionalSchemaInfoPath(codegenConfig.additionalSchemaInfo);
498
- console.log(`RSU additionalSchemaInfo path: ${codegenConfig.additionalSchemaInfo}`);
532
+ startupLog.LogIf('verbose', `RSU additionalSchemaInfo path: ${codegenConfig.additionalSchemaInfo}`);
499
533
  }
500
534
  } catch (codegenErr) {
501
535
  console.warn(`RSU in-process CodeGen runner setup failed (will fall back to child process): ${(codegenErr as Error).message}`);
@@ -521,10 +555,10 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
521
555
  if (configInfo.telemetry?.level) {
522
556
  tm.UpdateSettings({ level: configInfo.telemetry.level as TelemetryLevel });
523
557
  }
524
- console.log(`Server telemetry enabled with level: ${configInfo.telemetry.level || 'standard'}`);
558
+ startupLog.LogIf('verbose', `Server telemetry enabled with level: ${configInfo.telemetry.level || 'standard'}`);
525
559
  } else {
526
560
  tm.SetEnabled(false);
527
- console.log('Server telemetry disabled');
561
+ startupLog.LogIf('verbose', 'Server telemetry disabled');
528
562
  }
529
563
 
530
564
  // Optionally inject Redis as the shared storage provider for cross-server cache invalidation
@@ -559,7 +593,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
559
593
  }
560
594
  });
561
595
 
562
- console.log(`Redis cache provider connected: ${process.env.REDIS_URL}`);
596
+ startupLog.LogIf('verbose', `Redis cache provider connected: ${process.env.REDIS_URL}`);
563
597
  }
564
598
 
565
599
  // If Redis is available, swap LocalCacheManager's storage provider to Redis.
@@ -567,7 +601,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
567
601
  // during engine loading. SetStorageProvider migrates cached data to Redis.
568
602
  if (process.env.REDIS_URL) {
569
603
  await LocalCacheManager.Instance.SetStorageProvider(Metadata.Provider.LocalStorageProvider); // global-provider-ok: bootstrap
570
- console.log('LocalCacheManager: storage provider swapped to Redis');
604
+ startupLog.LogIf('verbose', 'LocalCacheManager: storage provider swapped to Redis');
571
605
  }
572
606
  // Ensure LocalCacheManager is initialized (no-op if already done during engine loading)
573
607
  if (!LocalCacheManager.Instance.IsInitialized) {
@@ -581,11 +615,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
581
615
  verboseLogging: cs.verboseLogging ?? false,
582
616
  };
583
617
  await LocalCacheManager.Instance.Initialize(Metadata.Provider.LocalStorageProvider, cacheConfig); // global-provider-ok: bootstrap
584
- console.log('LocalCacheManager initialized with cache config:', JSON.stringify({
585
- maxMemoryMB: cs.maxMemoryMB ?? 150,
586
- maxPercentOfCachePerEntity: cs.maxPercentOfCachePerEntity ?? 50,
587
- evictionSweepIntervalSeconds: cs.evictionSweepIntervalSeconds ?? 300,
588
- }));
618
+ if (startupLog.IsAtLeast('verbose')) {
619
+ // eslint-disable-next-line no-console
620
+ console.log('LocalCacheManager initialized with cache config:', JSON.stringify({
621
+ maxMemoryMB: cs.maxMemoryMB ?? 150,
622
+ maxPercentOfCachePerEntity: cs.maxPercentOfCachePerEntity ?? 50,
623
+ evictionSweepIntervalSeconds: cs.evictionSweepIntervalSeconds ?? 300,
624
+ }));
625
+ }
589
626
  }
590
627
 
591
628
  // Initialize APIKeyEngine singleton — reads apiKeyGeneration from mj.config.cjs automatically
@@ -657,7 +694,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
657
694
  // Initialize all middleware
658
695
  for (const mw of middlewares) {
659
696
  await mw.Initialize();
660
- console.log(` [Middleware] ${mw.Label}`);
697
+ startupLog.LogIf('verbose', ` [Middleware] ${mw.Label}`);
661
698
  }
662
699
 
663
700
  // Collect middleware contributions for each pipeline stage
@@ -708,7 +745,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
708
745
  Object.values(module).filter((value) => typeof value === 'function')
709
746
  );
710
747
  allResolvers = [...resolvers, ...mwResolvers] as BuildSchemaOptions['resolvers'];
711
- console.log(` [Middleware Resolvers] Loaded ${mwResolverFiles.length} resolver file(s) from middleware`);
748
+ startupLog.LogIf('verbose', ` [Middleware Resolvers] Loaded ${mwResolverFiles.length} resolver file(s) from middleware`);
712
749
  }
713
750
  }
714
751
 
@@ -911,7 +948,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
911
948
 
912
949
  // ─── OAuth callback routes (unauthenticated, registered BEFORE auth) ─────
913
950
  const oauthPublicUrl = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
914
- console.log(`[OAuth] publicUrl: ${oauthPublicUrl}`);
951
+ startupLog.LogIf('verbose', `[OAuth] publicUrl: ${oauthPublicUrl}`);
915
952
 
916
953
  let oauthAuthenticatedRouter: ReturnType<typeof createOAuthCallbackHandler>['authenticatedRouter'] | undefined;
917
954
  if (oauthPublicUrl) {
@@ -926,14 +963,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
926
963
 
927
964
  // OAuth callback is unauthenticated (called by external auth server)
928
965
  app.use('/oauth', oauthCors, callbackRouter);
929
- console.log('[OAuth] Callback route registered at /oauth/callback');
966
+ startupLog.LogIf('verbose', '[OAuth] Callback route registered at /oauth/callback');
930
967
  }
931
968
 
932
969
  // ─── eSignature webhook (unauthenticated, registered BEFORE auth) ─────
933
970
  // Called by external signature providers (DocuSign Connect, etc.) without an MJ bearer token.
934
971
  // The provider DRIVER verifies the payload signature/HMAC; MJ auth does not apply here.
935
972
  app.use('/esignature', cors<cors.CorsRequest>(), createSignatureWebhookHandler());
936
- console.log('[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
973
+ startupLog.LogIf('verbose', '[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
937
974
 
938
975
  // ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
939
976
  // Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
@@ -944,14 +981,27 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
944
981
  magicLinkAuthenticatedRouter = authenticatedRouter;
945
982
  registerMagicLinkAuthProvider(oauthPublicUrl, configInfo.magicLink);
946
983
  app.use(MAGIC_LINK_MOUNT_PATH, cors<cors.CorsRequest>(), publicRouter);
947
- console.log(`[MagicLink] Public routes registered at ${MAGIC_LINK_MOUNT_PATH}/redeem and ${MAGIC_LINK_MOUNT_PATH}/jwks.json`);
984
+ startupLog.LogIf('verbose', `[MagicLink] Public routes registered at ${MAGIC_LINK_MOUNT_PATH}/redeem and ${MAGIC_LINK_MOUNT_PATH}/jwks.json`);
948
985
  }
949
986
 
950
987
  // ─── Global CORS (before auth so 401 responses include CORS headers) ─────
951
988
  // Without this, the browser blocks 401 responses from the auth middleware
952
989
  // because they lack Access-Control-Allow-Origin headers, preventing the
953
990
  // client from reading the error code and triggering token refresh.
954
- app.use(cors<cors.CorsRequest>());
991
+ const corsAllowed = configInfo.cors?.allowedOrigins ?? ['*'];
992
+ app.use(cors<cors.CorsRequest>({
993
+ origin: (origin, callback) => {
994
+ // Allow all origins when ['*'] (default/backward-compatible),
995
+ // or when no Origin header (server-to-server calls).
996
+ if (corsAllowed.includes('*') || !origin || corsAllowed.includes(origin)) {
997
+ callback(null, true);
998
+ } else {
999
+ callback(new Error(`Origin ${origin} not allowed by CORS`));
1000
+ }
1001
+ },
1002
+ credentials: configInfo.cors?.allowCredentials ?? true,
1003
+ maxAge: configInfo.cors?.maxAge ?? 86400,
1004
+ }));
955
1005
 
956
1006
  // ─── Server extensions (before auth — extensions handle their own auth) ─────
957
1007
  // Slack uses HMAC signature verification, Teams uses Bot Framework JWT validation.
@@ -985,13 +1035,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
985
1035
  if (oauthAuthenticatedRouter) {
986
1036
  const oauthCors = cors<cors.CorsRequest>();
987
1037
  app.use('/oauth', oauthCors, BodyParser.json(), oauthAuthenticatedRouter);
988
- console.log('[OAuth] Authenticated routes registered at /oauth/status, /oauth/initiate, and /oauth/exchange');
1038
+ startupLog.LogIf('verbose', '[OAuth] Authenticated routes registered at /oauth/status, /oauth/initiate, and /oauth/exchange');
989
1039
  }
990
1040
 
991
1041
  // ─── Magic-link authenticated route (invite creation) ─────────────────────
992
1042
  if (magicLinkAuthenticatedRouter) {
993
1043
  app.use(MAGIC_LINK_MOUNT_PATH, cors<cors.CorsRequest>(), magicLinkAuthenticatedRouter);
994
- console.log(`[MagicLink] Authenticated route registered at ${MAGIC_LINK_MOUNT_PATH}/create`);
1044
+ startupLog.LogIf('verbose', `[MagicLink] Authenticated route registered at ${MAGIC_LINK_MOUNT_PATH}/create`);
995
1045
  }
996
1046
 
997
1047
  // ─── REST API endpoints (auth already handled by unified middleware) ─────
@@ -1008,9 +1058,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1008
1058
  if (process.env.MJ_REST_API_ENABLED !== undefined) {
1009
1059
  restApiConfig.enabled = process.env.MJ_REST_API_ENABLED === 'true';
1010
1060
  if (restApiConfig.enabled) {
1011
- console.log('REST API is enabled via environment variable');
1061
+ // Genuine config override actionable, surfaced at all levels.
1062
+ LogStatus('REST API is enabled via environment variable');
1012
1063
  }
1013
1064
  }
1065
+ startupLog.SetRestEnabled(restApiConfig.enabled);
1014
1066
 
1015
1067
  if (process.env.MJ_REST_API_INCLUDE_ENTITIES) {
1016
1068
  restApiConfig.includeEntities = process.env.MJ_REST_API_INCLUDE_ENTITIES.split(',').map(e => e.trim());
@@ -1069,9 +1121,19 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1069
1121
  tServe = lap('Apollo + Express Setup', tServe);
1070
1122
 
1071
1123
  await new Promise<void>((resolve) => httpServer.listen({ port: graphqlPort }, resolve));
1072
- lap('Total Startup', t0);
1073
- console.log(`📦 Connected to database: ${dbHost}:${dbPort}/${dbDatabase}`);
1074
- console.log(`🚀 Server ready at http://localhost:${graphqlPort}/`);
1124
+ // Total startup measured but not pushed as a phase row — the per-phase rows already
1125
+ // sum to the total in the collapsed summary line. Keep the measurement for verbose.
1126
+ startupLog.EndPhase('Total Startup', t0);
1127
+ startupLog.SetReadyUrl(`http://localhost:${graphqlPort}/`);
1128
+ // Populate summary fields that are known only now, then print the one summary block.
1129
+ for (const provider of AuthProviderFactory.Instance.getAllProviders()) {
1130
+ startupLog.AddAuthProvider(provider.name);
1131
+ }
1132
+ startupLog.SetVersion(resolveServerVersion());
1133
+ if (scheduledJobsService) {
1134
+ startupLog.SetScheduledJobCount(scheduledJobsService.GetStatus().activeJobs);
1135
+ }
1136
+ startupLog.PrintSummary();
1075
1137
 
1076
1138
  // Process pending RSU work from pre-restart (entity maps, field maps, sync)
1077
1139
  processRSUPendingWork().catch(err => console.warn(`RSU pending work processing failed: ${err}`));
@@ -1092,6 +1154,14 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1092
1154
  .catch(err => console.warn(`[AgentRunWatchdog] Startup sweep failed: ${err}`));
1093
1155
  }
1094
1156
 
1157
+ // Launch the AI Agent Session janitor: run own-host orphan recovery once at boot, then keep a
1158
+ // periodic staleness sweep running. Self-registers with ShutdownRegistry, so its timer is cleared
1159
+ // by the gracefulShutdown drain below (no explicit Stop() wiring needed here).
1160
+ if (resumeUser && Metadata.Provider instanceof DatabaseProviderBase) { // global-provider-ok: server startup recovery — boot-time session janitor uses the server's own provider
1161
+ SessionJanitor.Instance.Start(Metadata.Provider, resumeUser) // global-provider-ok: server-owned background reconciler runs under the server's provider + system user
1162
+ .catch(err => console.warn(`[SessionJanitor] Startup failed: ${err}`));
1163
+ }
1164
+
1095
1165
  // Set up graceful shutdown handlers
1096
1166
  const gracefulShutdown = async (signal: string) => {
1097
1167
  console.log(`\n${signal} received, shutting down gracefully...`);