@memberjunction/server 5.41.0 → 5.42.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 (76) hide show
  1. package/dist/agentSessions/index.d.ts +1 -0
  2. package/dist/agentSessions/index.d.ts.map +1 -1
  3. package/dist/agentSessions/index.js +5 -0
  4. package/dist/agentSessions/index.js.map +1 -1
  5. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts +102 -0
  6. package/dist/agentSessions/remoteBrowserGoalEngine.d.ts.map +1 -0
  7. package/dist/agentSessions/remoteBrowserGoalEngine.js +164 -0
  8. package/dist/agentSessions/remoteBrowserGoalEngine.js.map +1 -0
  9. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts +66 -0
  10. package/dist/agentSessions/remoteBrowserGoalRegistry.d.ts.map +1 -0
  11. package/dist/agentSessions/remoteBrowserGoalRegistry.js +86 -0
  12. package/dist/agentSessions/remoteBrowserGoalRegistry.js.map +1 -0
  13. package/dist/auth/initializeProviders.d.ts.map +1 -1
  14. package/dist/auth/initializeProviders.js +4 -7
  15. package/dist/auth/initializeProviders.js.map +1 -1
  16. package/dist/config.d.ts +7 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +12 -2
  19. package/dist/config.js.map +1 -1
  20. package/dist/generated/generated.d.ts +591 -0
  21. package/dist/generated/generated.d.ts.map +1 -1
  22. package/dist/generated/generated.js +6832 -3610
  23. package/dist/generated/generated.js.map +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +33 -16
  26. package/dist/index.js.map +1 -1
  27. package/dist/integration/CustomColumnPromoter.d.ts +108 -0
  28. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -0
  29. package/dist/integration/CustomColumnPromoter.js +508 -0
  30. package/dist/integration/CustomColumnPromoter.js.map +1 -0
  31. package/dist/logging/StartupLogger.d.ts +57 -1
  32. package/dist/logging/StartupLogger.d.ts.map +1 -1
  33. package/dist/logging/StartupLogger.js +115 -6
  34. package/dist/logging/StartupLogger.js.map +1 -1
  35. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +27 -0
  36. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -0
  37. package/dist/resolvers/ExecuteRemoteOperationResolver.js +136 -0
  38. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -0
  39. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +101 -1
  40. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  41. package/dist/resolvers/IntegrationDiscoveryResolver.js +544 -42
  42. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  43. package/dist/resolvers/RealtimeBridgeResolver.d.ts +121 -0
  44. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -0
  45. package/dist/resolvers/RealtimeBridgeResolver.js +533 -0
  46. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -0
  47. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +78 -5
  48. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  49. package/dist/resolvers/RemoteBrowserActionResolver.js +227 -15
  50. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  51. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  52. package/dist/resolvers/RunAIAgentResolver.js +0 -7
  53. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  54. package/dist/rest/setupRESTEndpoints.d.ts.map +1 -1
  55. package/dist/rest/setupRESTEndpoints.js +5 -3
  56. package/dist/rest/setupRESTEndpoints.js.map +1 -1
  57. package/package.json +83 -78
  58. package/src/__tests__/RealtimeBridgeResolver.test.ts +100 -0
  59. package/src/__tests__/RemoteBrowserAudioStream.test.ts +8 -1
  60. package/src/__tests__/RemoteBrowserGoalEngine.test.ts +144 -0
  61. package/src/__tests__/remoteBrowserGoalRegistry.test.ts +81 -0
  62. package/src/agentSessions/index.ts +5 -0
  63. package/src/agentSessions/remoteBrowserGoalEngine.ts +191 -0
  64. package/src/agentSessions/remoteBrowserGoalRegistry.ts +122 -0
  65. package/src/auth/initializeProviders.ts +4 -7
  66. package/src/config.ts +13 -2
  67. package/src/generated/generated.ts +2444 -205
  68. package/src/index.ts +33 -16
  69. package/src/integration/CustomColumnPromoter.ts +597 -0
  70. package/src/logging/StartupLogger.ts +130 -6
  71. package/src/resolvers/ExecuteRemoteOperationResolver.ts +108 -0
  72. package/src/resolvers/IntegrationDiscoveryResolver.ts +396 -37
  73. package/src/resolvers/RealtimeBridgeResolver.ts +450 -0
  74. package/src/resolvers/RemoteBrowserActionResolver.ts +232 -17
  75. package/src/resolvers/RunAIAgentResolver.ts +1 -9
  76. package/src/rest/setupRESTEndpoints.ts +5 -3
@@ -1,5 +1,7 @@
1
1
  import { configInfo } from '../config.js';
2
2
  import { LogStatus } from '@memberjunction/core';
3
+ import ora from 'ora';
4
+ import type { Ora } from 'ora';
3
5
 
4
6
  /**
5
7
  * Server log verbosity levels, ordered from least to most chatty.
@@ -46,6 +48,8 @@ type StartupSummaryData = {
46
48
  DbConnection?: string;
47
49
  /** Count of entities loaded into metadata. */
48
50
  EntityCount?: number;
51
+ /** Absolute path to the resolved config file (omitted from the block if absent). */
52
+ ConfigPath?: string;
49
53
  /** Names of registered auth providers. */
50
54
  AuthProviders: string[];
51
55
  /** Whether the REST API is enabled. */
@@ -82,6 +86,14 @@ export class StartupLogger {
82
86
  private readonly resolvedLevel: ServerLogLevel;
83
87
  private readonly phaseTimings: PhaseTiming[] = [];
84
88
  private readonly summary: StartupSummaryData = { AuthProviders: [] };
89
+ /** Transient boot spinner — active only at `standard` level on a TTY (see {@link spinnerEnabled}). */
90
+ private spinner: Ora | null = null;
91
+ /** Interval that ticks the live elapsed counter on the spinner line. */
92
+ private bootTimer: ReturnType<typeof setInterval> | null = null;
93
+ /** Wall-clock start of the boot indicator, for the live elapsed counter. */
94
+ private bootStartMs = 0;
95
+ /** Most recent phase label, shown on the spinner line. */
96
+ private currentPhase?: string;
85
97
 
86
98
  /**
87
99
  * @param level Optional explicit level override (primarily for tests).
@@ -140,6 +152,85 @@ export class StartupLogger {
140
152
  return performance.now();
141
153
  }
142
154
 
155
+ /**
156
+ * True when the transient boot spinner should animate: only at `standard`
157
+ * level AND when stdout is an interactive TTY. In non-TTY contexts (Docker,
158
+ * systemd, piped logs) a spinner emits escape codes that pollute log
159
+ * aggregation, and at `verbose`+ the inline phase lines would fight it — so
160
+ * both fall back to plain, line-oriented output.
161
+ */
162
+ private get spinnerEnabled(): boolean {
163
+ return this.resolvedLevel === 'standard' && process.stdout.isTTY === true;
164
+ }
165
+
166
+ /** Prefix for the transient boot indicator (no rocket — we haven't launched yet). */
167
+ private static readonly BOOT_LABEL = 'Bootstrapping MemberJunction Server';
168
+
169
+ /** Composes the boot label, plus the current phase when known (no elapsed counter). */
170
+ private bootText(phase?: string): string {
171
+ return phase ? `${StartupLogger.BOOT_LABEL} · ${phase}` : StartupLogger.BOOT_LABEL;
172
+ }
173
+
174
+ /** Repaints the spinner line with the current phase and a live elapsed counter. */
175
+ private refreshBootText(): void {
176
+ if (this.spinner) {
177
+ const elapsed = ((performance.now() - this.bootStartMs) / 1000).toFixed(1);
178
+ this.spinner.text = `${this.bootText(this.currentPhase)} ${elapsed}s`;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Begins the transient "Bootstrapping…" indicator, replaced by the 🚀 summary
184
+ * block once {@link PrintSummary} runs (so the rocket appears only after launch).
185
+ *
186
+ * - `standard` + TTY: an animated, self-clearing `ora` spinner (its glyph is the
187
+ * leading "working" icon; phase status is appended inline).
188
+ * - `verbose`/`debug`, or non-TTY at `standard`: a single plain line with an
189
+ * hourglass icon (can't be cleared, but inline phase logs / the final block
190
+ * carry the detail). `minimal` stays silent.
191
+ */
192
+ public StartBoot(): void {
193
+ if (this.spinner) {
194
+ return;
195
+ }
196
+ if (this.spinnerEnabled) {
197
+ this.bootStartMs = performance.now();
198
+ this.spinner = ora({ text: this.bootText(), stream: process.stdout }).start();
199
+ // Tick the elapsed counter independently of ora's glyph animation. Unref'd so
200
+ // it never keeps the event loop alive; cleared by StopBoot before the block prints.
201
+ this.bootTimer = setInterval(() => this.refreshBootText(), 200);
202
+ this.bootTimer.unref();
203
+ } else if (this.resolvedLevel !== 'minimal') {
204
+ // eslint-disable-next-line no-console
205
+ console.log(`⏳ ${this.bootText()}…`);
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Marks the beginning of a named phase: updates the spinner status (when active)
211
+ * and returns a start timestamp to pass to {@link EndPhase}. Pure timing helper
212
+ * when the spinner is disabled — equivalent to {@link StartPhase}.
213
+ *
214
+ * @param phase Friendly label for the upcoming phase, shown after the boot label.
215
+ */
216
+ public BeginPhase(phase: string): number {
217
+ this.currentPhase = phase;
218
+ this.refreshBootText();
219
+ return performance.now();
220
+ }
221
+
222
+ /** Stops and clears the boot spinner + its elapsed-counter interval. Safe to call when inactive. */
223
+ public StopBoot(): void {
224
+ if (this.bootTimer) {
225
+ clearInterval(this.bootTimer);
226
+ this.bootTimer = null;
227
+ }
228
+ if (this.spinner) {
229
+ this.spinner.stop();
230
+ this.spinner = null;
231
+ }
232
+ }
233
+
143
234
  /**
144
235
  * Records the elapsed time for a phase since `startedAt`, buffering it for the
145
236
  * summary. At `verbose`+ the phase is also printed immediately (matching the
@@ -163,6 +254,11 @@ export class StartupLogger {
163
254
  this.summary.Version = version;
164
255
  }
165
256
 
257
+ /** Records the resolved config-file path for the summary `Config` line. */
258
+ public SetConfigPath(path: string | undefined): void {
259
+ this.summary.ConfigPath = path;
260
+ }
261
+
166
262
  /** Records DB platform + connection + entity count for the summary `DB` line. */
167
263
  public SetDatabaseInfo(platform: string, connection: string, entityCount: number): void {
168
264
  this.summary.DbPlatform = platform;
@@ -198,6 +294,9 @@ export class StartupLogger {
198
294
  * all detail lines were already printed inline during boot).
199
295
  */
200
296
  public PrintSummary(): void {
297
+ // Clear the transient spinner before any block output so the line it
298
+ // occupied is replaced cleanly by the summary.
299
+ this.StopBoot();
201
300
  if (this.resolvedLevel === 'minimal') {
202
301
  this.printReadyLineOnly();
203
302
  return;
@@ -225,6 +324,7 @@ export class StartupLogger {
225
324
  const lines: string[] = [];
226
325
  lines.push(this.buildHeaderLine());
227
326
  lines.push(this.buildDbLine());
327
+ lines.push(this.buildConfigLine());
228
328
  lines.push(this.buildAuthLine());
229
329
  lines.push(this.buildStartupLine());
230
330
  lines.push(this.buildReadyLine());
@@ -252,6 +352,14 @@ export class StartupLogger {
252
352
  return ` DB ${parts.join(' · ')}`;
253
353
  }
254
354
 
355
+ /** ` Config <path>` (omitted when no config file was resolved). */
356
+ private buildConfigLine(): string {
357
+ if (!this.summary.ConfigPath) {
358
+ return '';
359
+ }
360
+ return ` Config ${this.summary.ConfigPath}`;
361
+ }
362
+
255
363
  /** ` Auth <providers> REST <on/off> · <N> scheduled jobs`. */
256
364
  private buildAuthLine(): string {
257
365
  const providers = this.summary.AuthProviders.length > 0
@@ -268,16 +376,32 @@ export class StartupLogger {
268
376
  return ` Auth ${providers}${trailerPart}`;
269
377
  }
270
378
 
271
- /** ` Startup <total>s (<phase> <x>s · <phase> <y>s …)`. */
379
+ /**
380
+ * ` Startup <total>s (<phase> <x>s · … · other <z>s)`.
381
+ *
382
+ * The headline `<total>` is the TRUE wall-clock since process start — in Node,
383
+ * `performance.now()` is measured from `performance.timeOrigin` (≈ process
384
+ * start), so it captures the entire bootstrap (module import, config load,
385
+ * ServerBootstrap, and `serve()`), not just the timed phases. The parenthetical
386
+ * lists each captured phase; `other` is the unattributed remainder (import +
387
+ * config + any untimed setup) shown only when it's non-trivial.
388
+ */
272
389
  private buildStartupLine(): string {
273
390
  if (this.phaseTimings.length === 0) {
274
391
  return '';
275
392
  }
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})`;
393
+ const totalMs = performance.now();
394
+ const phaseSumMs = this.phaseTimings.reduce((sum, p) => sum + p.Ms, 0);
395
+ const otherMs = totalMs - phaseSumMs;
396
+ const parts = this.phaseTimings.map(
397
+ (p) => `${this.shortPhaseLabel(p.Label)} ${(p.Ms / 1000).toFixed(1)}s`,
398
+ );
399
+ // Surface unattributed boot time only when it's meaningful (>50ms), so the
400
+ // line stays clean when phases already account for ~all of startup.
401
+ if (otherMs > 50) {
402
+ parts.push(`other ${(otherMs / 1000).toFixed(1)}s`);
403
+ }
404
+ return ` Startup ${(totalMs / 1000).toFixed(1)}s (${parts.join(' · ')})`;
281
405
  }
282
406
 
283
407
  /**
@@ -0,0 +1,108 @@
1
+ import { Arg, Ctx, Field, InputType, Mutation, ObjectType, Resolver } from 'type-graphql';
2
+ import { MJGlobal } from '@memberjunction/global';
3
+ import { BaseRemotableOperation, RemoteOpInvokeMode } from '@memberjunction/core';
4
+ import { ResolverBase } from '../generic/ResolverBase.js';
5
+ import { AppContext } from '../types.js';
6
+ import { GetReadWriteProvider } from '../util.js';
7
+
8
+ /** Input for the generic Remote Operation transport mutation. */
9
+ @InputType()
10
+ export class ExecuteRemoteOperationInput {
11
+ @Field()
12
+ operationKey: string;
13
+
14
+ @Field()
15
+ inputJSON: string;
16
+
17
+ @Field()
18
+ invokeMode: string;
19
+ }
20
+
21
+ /** Result of the generic Remote Operation transport mutation. */
22
+ @ObjectType()
23
+ export class ExecuteRemoteOperationResultType {
24
+ @Field(() => Boolean)
25
+ success: boolean;
26
+
27
+ @Field(() => String, { nullable: true })
28
+ resultCode?: string;
29
+
30
+ @Field(() => String, { nullable: true })
31
+ outputJSON?: string;
32
+
33
+ @Field(() => String, { nullable: true })
34
+ handle?: string;
35
+
36
+ @Field(() => String, { nullable: true })
37
+ errorMessage?: string;
38
+ }
39
+
40
+ /**
41
+ * The single generic server entry point for Remote Operations. Resolves the operation by key,
42
+ * applies the framework authorization gates (API-key scope, system-user, then the operation's own
43
+ * `Authorize`), and routes the operation through the per-request provider — which executes it
44
+ * in-process. Only registered, active operations are reachable, so the public `RouteOperation`
45
+ * power-tool is safe by construction.
46
+ */
47
+ @Resolver()
48
+ export class ExecuteRemoteOperationResolver extends ResolverBase {
49
+ @Mutation(() => ExecuteRemoteOperationResultType)
50
+ async ExecuteRemoteOperation(
51
+ @Arg('input') input: ExecuteRemoteOperationInput,
52
+ @Ctx() ctx: AppContext,
53
+ ): Promise<ExecuteRemoteOperationResultType> {
54
+ try {
55
+ const key = input.operationKey?.trim();
56
+ if (!key) {
57
+ return fail('INVALID_OPERATION_KEY', 'operationKey is required');
58
+ }
59
+
60
+ // Resolve the operation's declaration so we can apply its scope / system-user gates.
61
+ const registration = MJGlobal.Instance.ClassFactory.GetRegistration(BaseRemotableOperation, key);
62
+ if (!registration) {
63
+ return fail('UNKNOWN_OPERATION', `Remote operation '${key}' is not registered`);
64
+ }
65
+ const opDecl = MJGlobal.Instance.ClassFactory.CreateInstance<BaseRemotableOperation>(BaseRemotableOperation, key);
66
+
67
+ // (1) API-key scope gate — a no-op for interactive OAuth/JWT users.
68
+ if (opDecl?.RequiredScope) {
69
+ await this.CheckAPIKeyScopeAuthorization(opDecl.RequiredScope, key, ctx.userPayload);
70
+ }
71
+ // (2) System-user gate.
72
+ if (opDecl?.RequiresSystemUser && !ctx.userPayload.isSystemUser) {
73
+ return fail('FORBIDDEN', `Operation '${key}' may only be invoked by the system user`);
74
+ }
75
+ // (3) Resolve the acting user (works for OAuth/JWT and API-key sessions alike).
76
+ const user = this.GetUserFromPayload(ctx.userPayload);
77
+ if (!user) {
78
+ return fail('NO_USER', 'Not authenticated');
79
+ }
80
+
81
+ let parsedInput: unknown;
82
+ try {
83
+ parsedInput = input.inputJSON ? JSON.parse(input.inputJSON) : {};
84
+ } catch (e) {
85
+ return fail('INVALID_INPUT_JSON', `Invalid input JSON: ${e instanceof Error ? e.message : String(e)}`);
86
+ }
87
+
88
+ // Route through the per-request provider (server in-process path; runs the op's Authorize + InternalExecute).
89
+ const provider = GetReadWriteProvider(ctx.providers);
90
+ const result = await provider.RouteOperation(key, parsedInput, { user, mode: input.invokeMode as RemoteOpInvokeMode });
91
+
92
+ return {
93
+ success: result.Success,
94
+ resultCode: result.ResultCode,
95
+ outputJSON: result.Output !== undefined ? JSON.stringify(result.Output) : undefined,
96
+ handle: result.Handle,
97
+ errorMessage: result.ErrorMessage,
98
+ };
99
+ } catch (e) {
100
+ return fail('ERROR', e instanceof Error ? e.message : String(e));
101
+ }
102
+ }
103
+ }
104
+
105
+ /** Builds a failed result. */
106
+ function fail(resultCode: string, errorMessage: string): ExecuteRemoteOperationResultType {
107
+ return { success: false, resultCode, errorMessage };
108
+ }