@memberjunction/server 5.42.0 → 5.44.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/SessionJanitor.d.ts.map +1 -1
  2. package/dist/agentSessions/SessionJanitor.js +5 -1
  3. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  4. package/dist/agentSessions/SessionManager.d.ts +15 -2
  5. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  6. package/dist/agentSessions/SessionManager.js +53 -11
  7. package/dist/agentSessions/SessionManager.js.map +1 -1
  8. package/dist/agentSessions/index.d.ts.map +1 -1
  9. package/dist/agentSessions/index.js +3 -1
  10. package/dist/agentSessions/index.js.map +1 -1
  11. package/dist/generated/generated.d.ts +1180 -51
  12. package/dist/generated/generated.d.ts.map +1 -1
  13. package/dist/generated/generated.js +35564 -29262
  14. package/dist/generated/generated.js.map +1 -1
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +14 -1
  18. package/dist/index.js.map +1 -1
  19. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts +20 -1
  20. package/dist/resolvers/ExecuteRemoteOperationResolver.d.ts.map +1 -1
  21. package/dist/resolvers/ExecuteRemoteOperationResolver.js +61 -5
  22. package/dist/resolvers/ExecuteRemoteOperationResolver.js.map +1 -1
  23. package/dist/resolvers/FileResolver.d.ts +74 -0
  24. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  25. package/dist/resolvers/FileResolver.js +211 -2
  26. package/dist/resolvers/FileResolver.js.map +1 -1
  27. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  28. package/dist/resolvers/IntegrationDiscoveryResolver.js +98 -29
  29. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  30. package/dist/resolvers/QueryResolver.d.ts +6 -4
  31. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  32. package/dist/resolvers/QueryResolver.js +29 -14
  33. package/dist/resolvers/QueryResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeBridgeResolver.d.ts +17 -1
  35. package/dist/resolvers/RealtimeBridgeResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeBridgeResolver.js +78 -7
  37. package/dist/resolvers/RealtimeBridgeResolver.js.map +1 -1
  38. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +81 -2
  39. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  40. package/dist/resolvers/RealtimeClientSessionResolver.js +293 -17
  41. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  42. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  43. package/dist/resolvers/RemoteBrowserActionResolver.js +17 -2
  44. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  45. package/dist/resolvers/RunAIAgentResolver.d.ts +12 -3
  46. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  47. package/dist/resolvers/RunAIAgentResolver.js +37 -15
  48. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  49. package/dist/resolvers/RunTemplateResolver.d.ts +8 -0
  50. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RunTemplateResolver.js +8 -0
  52. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  53. package/dist/resolvers/meetingRecordingRegistration.d.ts +124 -0
  54. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -0
  55. package/dist/resolvers/meetingRecordingRegistration.js +311 -0
  56. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -0
  57. package/dist/resolvers/peaksSidecar.d.ts +30 -0
  58. package/dist/resolvers/peaksSidecar.d.ts.map +1 -0
  59. package/dist/resolvers/peaksSidecar.js +51 -0
  60. package/dist/resolvers/peaksSidecar.js.map +1 -0
  61. package/dist/rest/MediaAccessKeys.d.ts +68 -0
  62. package/dist/rest/MediaAccessKeys.d.ts.map +1 -0
  63. package/dist/rest/MediaAccessKeys.js +96 -0
  64. package/dist/rest/MediaAccessKeys.js.map +1 -0
  65. package/dist/rest/MediaStreamHandler.d.ts +26 -0
  66. package/dist/rest/MediaStreamHandler.d.ts.map +1 -0
  67. package/dist/rest/MediaStreamHandler.js +192 -0
  68. package/dist/rest/MediaStreamHandler.js.map +1 -0
  69. package/dist/rest/mediaRange.d.ts +41 -0
  70. package/dist/rest/mediaRange.d.ts.map +1 -0
  71. package/dist/rest/mediaRange.js +60 -0
  72. package/dist/rest/mediaRange.js.map +1 -0
  73. package/package.json +83 -82
  74. package/src/__tests__/FileResolverPeaks.test.ts +64 -0
  75. package/src/__tests__/MediaAccessKeys.test.ts +68 -0
  76. package/src/__tests__/MediaStreamHandler.test.ts +91 -0
  77. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -1
  78. package/src/__tests__/RealtimeClientSessionResolver.test.ts +69 -0
  79. package/src/__tests__/RealtimeCoAgentAppAwareness.integration.test.ts +191 -0
  80. package/src/__tests__/RemoteBrowserSnapshot.test.ts +123 -0
  81. package/src/__tests__/SessionManager.test.ts +2 -0
  82. package/src/__tests__/meetingRecordingRegistration.test.ts +248 -0
  83. package/src/agentSessions/SessionJanitor.ts +15 -1
  84. package/src/agentSessions/SessionManager.ts +62 -10
  85. package/src/agentSessions/index.ts +3 -1
  86. package/src/generated/generated.ts +17659 -13322
  87. package/src/index.ts +15 -1
  88. package/src/resolvers/ExecuteRemoteOperationResolver.ts +60 -4
  89. package/src/resolvers/FileResolver.ts +199 -1
  90. package/src/resolvers/IntegrationDiscoveryResolver.ts +108 -30
  91. package/src/resolvers/QueryResolver.ts +33 -19
  92. package/src/resolvers/RealtimeBridgeResolver.ts +82 -7
  93. package/src/resolvers/RealtimeClientSessionResolver.ts +326 -9
  94. package/src/resolvers/RemoteBrowserActionResolver.ts +18 -2
  95. package/src/resolvers/RunAIAgentResolver.ts +44 -11
  96. package/src/resolvers/RunTemplateResolver.ts +8 -0
  97. package/src/resolvers/meetingRecordingRegistration.ts +432 -0
  98. package/src/resolvers/peaksSidecar.ts +52 -0
  99. package/src/rest/MediaAccessKeys.ts +121 -0
  100. package/src/rest/MediaStreamHandler.ts +220 -0
  101. package/src/rest/mediaRange.ts +76 -0
package/src/index.ts CHANGED
@@ -40,6 +40,7 @@ import createMSSQLConfig from './orm.js';
40
40
  import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
41
41
  import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
42
42
  import { createSignatureWebhookHandler } from './rest/SignatureWebhookHandler.js';
43
+ import { createMediaStreamRouter } from './rest/MediaStreamHandler.js';
43
44
  import { createMagicLinkHandler, registerMagicLinkAuthProvider, MAGIC_LINK_MOUNT_PATH } from './auth/magicLink/index.js';
44
45
 
45
46
  import { resolve } from 'node:path';
@@ -162,6 +163,8 @@ export * from './resolvers/ISAEntityResolver.js';
162
163
  export * from './resolvers/ArtifactFileResolver.js';
163
164
  export * from './resolvers/FileCategoryResolver.js';
164
165
  export * from './resolvers/FileResolver.js';
166
+ export * from './rest/MediaAccessKeys.js';
167
+ export * from './rest/MediaStreamHandler.js';
165
168
  export * from './resolvers/InfoResolver.js';
166
169
  export * from './resolvers/PotentialDuplicateRecordResolver.js';
167
170
  export * from './resolvers/RunTestResolver.js';
@@ -863,7 +866,11 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
863
866
  onConnect: async (ctx) => {
864
867
  try {
865
868
  const token = String(ctx.connectionParams?.Authorization);
866
- const userPayload = await getUserPayload(token, undefined, dataSources);
869
+ // Carry API keys from connectionParams so API-key / MCP / Node clients can authenticate the socket
870
+ // (validated the same way as the HTTP x-mj-api-key / x-mj-user-api-key headers).
871
+ const systemApiKey = ctx.connectionParams?.['x-mj-api-key'] ? String(ctx.connectionParams['x-mj-api-key']) : undefined;
872
+ const userApiKey = ctx.connectionParams?.['x-mj-user-api-key'] ? String(ctx.connectionParams['x-mj-user-api-key']) : undefined;
873
+ const userPayload = await getUserPayload(token, undefined, dataSources, undefined, systemApiKey, userApiKey);
867
874
 
868
875
  // Store validated payload on the connection for use in context()
869
876
  ctx.extra.userPayload = userPayload;
@@ -987,6 +994,13 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
987
994
  app.use('/esignature', cors<cors.CorsRequest>(), createSignatureWebhookHandler());
988
995
  startupLog.LogIf('verbose', '[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
989
996
 
997
+ // ─── Authenticated media streaming (token-gated, registered BEFORE auth) ─────
998
+ // The `?token=` query param (minted by CreateMediaAccessToken after a per-user permission
999
+ // check) is the capability — the route verifies it itself, so it does NOT use the MJ bearer
1000
+ // auth middleware (an <audio>/<video> element can't send Authorization headers).
1001
+ app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter());
1002
+ startupLog.LogIf('verbose', '[Media] Streaming route registered at /media/:fileId');
1003
+
990
1004
  // ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
991
1005
  // Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
992
1006
  // authenticated invite-creation router mounts AFTER it (see below).
@@ -1,10 +1,14 @@
1
- import { Arg, Ctx, Field, InputType, Mutation, ObjectType, Resolver } from 'type-graphql';
1
+ import { Arg, Ctx, Field, ID, InputType, Mutation, ObjectType, PubSub, PubSubEngine, Resolver, Root, Subscription } from 'type-graphql';
2
2
  import { MJGlobal } from '@memberjunction/global';
3
- import { BaseRemotableOperation, RemoteOpInvokeMode } from '@memberjunction/core';
3
+ import { BaseRemotableOperation, RemoteOpInvokeMode, RemoteOpProgress } from '@memberjunction/core';
4
+ import { RemoteOperationEngineBase } from '@memberjunction/core-entities';
4
5
  import { ResolverBase } from '../generic/ResolverBase.js';
5
6
  import { AppContext } from '../types.js';
6
7
  import { GetReadWriteProvider } from '../util.js';
7
8
 
9
+ /** PubSub topic carrying RemoteOpProgress envelopes for attached over-the-wire callers (RO-3). */
10
+ const REMOTE_OP_PROGRESS_TOPIC = 'RemoteOperationProgress';
11
+
8
12
  /** Input for the generic Remote Operation transport mutation. */
9
13
  @InputType()
10
14
  export class ExecuteRemoteOperationInput {
@@ -16,6 +20,25 @@ export class ExecuteRemoteOperationInput {
16
20
 
17
21
  @Field()
18
22
  invokeMode: string;
23
+
24
+ /**
25
+ * Optional client-generated channel id. When set (attached mode over the wire), the server publishes every
26
+ * `RemoteOpProgress` the operation emits to the `RemoteOperationProgress` subscription filtered by this id,
27
+ * so the caller's `onProgress` fires live while the operation runs. Omitted for sync / no-progress calls.
28
+ */
29
+ @Field(() => String, { nullable: true })
30
+ progressChannelId?: string;
31
+ }
32
+
33
+ /** A single streamed progress envelope delivered over the `RemoteOperationProgress` subscription. */
34
+ @ObjectType()
35
+ export class RemoteOperationProgressNotification {
36
+ @Field()
37
+ ChannelId: string;
38
+
39
+ /** JSON-serialized {@link RemoteOpProgress}. */
40
+ @Field()
41
+ ProgressJSON: string;
19
42
  }
20
43
 
21
44
  /** Result of the generic Remote Operation transport mutation. */
@@ -50,6 +73,7 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
50
73
  async ExecuteRemoteOperation(
51
74
  @Arg('input') input: ExecuteRemoteOperationInput,
52
75
  @Ctx() ctx: AppContext,
76
+ @PubSub() pubSub: PubSubEngine,
53
77
  ): Promise<ExecuteRemoteOperationResultType> {
54
78
  try {
55
79
  const key = input.operationKey?.trim();
@@ -78,6 +102,15 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
78
102
  return fail('NO_USER', 'Not authenticated');
79
103
  }
80
104
 
105
+ // (4) Metadata gate (defense-in-depth): honor the op's MJ: Remote Operations Status/approval, so a
106
+ // registered-but-disabled (or unapproved AI) op is rejected even though its class is still in code.
107
+ const provider = GetReadWriteProvider(ctx.providers);
108
+ await RemoteOperationEngineBase.Instance.Config(false, user, provider);
109
+ const invokability = RemoteOperationEngineBase.Instance.IsInvokable(key);
110
+ if (!invokability.Invokable) {
111
+ return fail(invokability.ResultCode ?? 'OPERATION_UNAVAILABLE', invokability.Reason ?? `Operation '${key}' is unavailable`);
112
+ }
113
+
81
114
  let parsedInput: unknown;
82
115
  try {
83
116
  parsedInput = input.inputJSON ? JSON.parse(input.inputJSON) : {};
@@ -85,9 +118,16 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
85
118
  return fail('INVALID_INPUT_JSON', `Invalid input JSON: ${e instanceof Error ? e.message : String(e)}`);
86
119
  }
87
120
 
121
+ // Attached over-the-wire (RO-3): forward each emitted RemoteOpProgress to the progress channel so a
122
+ // remote caller's onProgress fires live. No-op when the caller didn't open a channel (sync calls).
123
+ const onProgress = input.progressChannelId
124
+ ? (p: RemoteOpProgress) => {
125
+ void pubSub.publish(REMOTE_OP_PROGRESS_TOPIC, { ChannelId: input.progressChannelId!, ProgressJSON: JSON.stringify(p) });
126
+ }
127
+ : undefined;
128
+
88
129
  // 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 });
130
+ const result = await provider.RouteOperation(key, parsedInput, { user, mode: input.invokeMode as RemoteOpInvokeMode, onProgress });
91
131
 
92
132
  return {
93
133
  success: result.Success,
@@ -100,6 +140,22 @@ export class ExecuteRemoteOperationResolver extends ResolverBase {
100
140
  return fail('ERROR', e instanceof Error ? e.message : String(e));
101
141
  }
102
142
  }
143
+
144
+ /**
145
+ * Subscription the client opens (with a self-generated `channelId`) immediately before calling
146
+ * `ExecuteRemoteOperation` with the same id in `progressChannelId`. The server publishes one event per
147
+ * `RemoteOpProgress` the operation emits; filtered by `channelId` so concurrent calls never interleave.
148
+ */
149
+ @Subscription(() => RemoteOperationProgressNotification, {
150
+ topics: REMOTE_OP_PROGRESS_TOPIC,
151
+ filter: ({ payload, args }: { payload: RemoteOperationProgressNotification; args: { channelId: string } }) => payload.ChannelId === args.channelId,
152
+ })
153
+ RemoteOperationProgress(
154
+ @Root() notification: RemoteOperationProgressNotification,
155
+ @Arg('channelId', () => ID) _channelId: string,
156
+ ): RemoteOperationProgressNotification {
157
+ return notification;
158
+ }
103
159
  }
104
160
 
105
161
  /** Builds a failed result. */
@@ -1,6 +1,7 @@
1
- import { EntityPermissionType, FieldValueCollection, EntitySaveOptions } from '@memberjunction/core';
1
+ import { EntityPermissionType, FieldValueCollection, EntitySaveOptions, LogError, UserInfo } from '@memberjunction/core';
2
2
  import { NormalizeUUID } from '@memberjunction/global';
3
3
  import { MJFileEntity, MJFileStorageProviderEntity, MJFileStorageAccountEntity } from '@memberjunction/core-entities';
4
+ import { readRealtimeRecordingFile } from '@memberjunction/ai-agents';
4
5
  import {
5
6
  AppContext,
6
7
  Arg,
@@ -8,6 +9,7 @@ import {
8
9
  DeleteOptionsInput,
9
10
  Field,
10
11
  FieldResolver,
12
+ Float,
11
13
  InputType,
12
14
  Int,
13
15
  Mutation,
@@ -37,6 +39,9 @@ import {
37
39
  import { CreateMJFileInput, MJFileResolver as FileResolverBase, MJFile_, UpdateMJFileInput } from '../generated/generated.js';
38
40
  import { FieldMapper } from '@memberjunction/graphql-dataprovider';
39
41
  import { GetReadOnlyProvider } from '../util.js';
42
+ import { configInfo } from '../config.js';
43
+ import { MediaAccessKeyManager } from '../rest/MediaAccessKeys.js';
44
+ import { deriveSidecarPath, parsePeaksSidecar } from './peaksSidecar.js';
40
45
 
41
46
  @InputType()
42
47
  export class CreateUploadURLInput {
@@ -312,8 +317,161 @@ export class SearchAcrossAccountsPayload {
312
317
  failedAccounts: number;
313
318
  }
314
319
 
320
+ /**
321
+ * Result of {@link FileResolver.GetFileContents} — the bytes of an `MJ: Files` record returned as
322
+ * base64, read server-side through authenticated MJStorage (never a public pre-signed link).
323
+ */
324
+ @ObjectType()
325
+ export class FileContentsResult {
326
+ @Field(() => Boolean)
327
+ Success: boolean;
328
+
329
+ @Field(() => String, { nullable: true })
330
+ Base64?: string;
331
+
332
+ @Field(() => String, { nullable: true })
333
+ MimeType?: string;
334
+
335
+ @Field(() => String, { nullable: true })
336
+ ErrorMessage?: string;
337
+ }
338
+
339
+ /**
340
+ * Result of {@link FileResolver.CreateMediaAccessToken} — a short-lived signed token plus the
341
+ * authenticated streaming URL the browser hands to a `<audio>`/`<video>` element. The token is
342
+ * minted only after a per-user permission check on the `MJ: Files` row; the `/media/:fileId`
343
+ * route re-verifies it (signature + expiry + file match) on each Range request.
344
+ */
345
+ @ObjectType()
346
+ export class MediaAccessTokenResult {
347
+ @Field(() => Boolean)
348
+ Success: boolean;
349
+
350
+ @Field(() => String, { nullable: true })
351
+ Token?: string;
352
+
353
+ @Field(() => String, { nullable: true })
354
+ Url?: string;
355
+
356
+ @Field(() => Date, { nullable: true })
357
+ ExpiresAt?: Date;
358
+
359
+ /**
360
+ * The file's MIME type (from the already-loaded `MJ: Files` row). Lets the wrapper choose
361
+ * `<audio>` vs `<video>` WITHOUT re-downloading any bytes; the stream itself sets Content-Type.
362
+ */
363
+ @Field(() => String, { nullable: true })
364
+ MimeType?: string;
365
+
366
+ /**
367
+ * Optional precomputed waveform peaks (normalized `0..1`, one per rendered bar) read from a
368
+ * `peaks.json` sidecar that sits beside the file in storage. When present, the player renders the
369
+ * real waveform instantly with NO client-side fetch/decode of the audio. Best-effort: a missing or
370
+ * malformed sidecar simply omits this field — it never blocks token minting or fails the mutation.
371
+ */
372
+ @Field(() => [Float], { nullable: true })
373
+ Peaks?: number[];
374
+
375
+ @Field(() => String, { nullable: true })
376
+ ErrorMessage?: string;
377
+ }
378
+
315
379
  @Resolver(MJFile_)
316
380
  export class FileResolver extends FileResolverBase {
381
+ /**
382
+ * Resolves the server's public base URL the same way callbacks/magic-link do:
383
+ * `publicUrl` if configured (e.g. an ngrok URL), else `baseUrl:graphqlPort`. Trailing
384
+ * slash stripped so callers can append `/media/...`.
385
+ */
386
+ private resolvePublicBaseUrl(): string {
387
+ const base = configInfo.publicUrl || `${configInfo.baseUrl}:${configInfo.graphqlPort}${configInfo.graphqlRootPath || ''}`;
388
+ return base.replace(/\/+$/, '');
389
+ }
390
+
391
+ /**
392
+ * Mints a short-lived signed media-access token for an `MJ: Files` record and returns the
393
+ * authenticated streaming URL (`<publicBase>/media/<fileId>?token=<token>`). Permission-gated
394
+ * IDENTICALLY to {@link GetFileContents}: the file is loaded under the CALLING USER's context, so
395
+ * MJ row-level security determines access. The returned token is the capability — the streaming
396
+ * route re-verifies it without re-checking row-level access. Never throws to the client.
397
+ *
398
+ * @param fileId The `MJ: Files` id to grant streaming access to.
399
+ * @returns `{ Success, Token?, Url?, ExpiresAt?, ErrorMessage? }`.
400
+ */
401
+ @Mutation(() => MediaAccessTokenResult)
402
+ async CreateMediaAccessToken(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<MediaAccessTokenResult> {
403
+ try {
404
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
405
+ const contextUser = this.GetUserFromPayload(context.userPayload);
406
+
407
+ // Permission gate: load the file under the USER's context. A failed load means the file
408
+ // does not exist OR the user lacks read access under MJ row-level security.
409
+ const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
410
+ const loaded = await fileEntity.Load(fileId);
411
+ if (!loaded) {
412
+ return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
413
+ }
414
+
415
+ // Access authorized — mint the capability token.
416
+ const { Token, ExpiresAt } = MediaAccessKeyManager.Instance.Sign(fileId, contextUser.ID);
417
+ const url = `${this.resolvePublicBaseUrl()}/media/${encodeURIComponent(fileId)}?token=${encodeURIComponent(Token)}`;
418
+
419
+ // Best-effort: surface precomputed waveform peaks from a peaks.json sidecar beside the file, so
420
+ // the player renders the real waveform instantly without fetching/decoding the audio. Never
421
+ // blocks token minting — any failure just omits Peaks.
422
+ const peaks = await this.tryReadPeaksSidecar(fileEntity, contextUser);
423
+
424
+ return { Success: true, Token, Url: url, ExpiresAt, MimeType: fileEntity.ContentType ?? undefined, Peaks: peaks };
425
+ } catch (error) {
426
+ const message = error instanceof Error ? error.message : String(error);
427
+ LogError(`CreateMediaAccessToken failed (file ${fileId}): ${message}`);
428
+ return { Success: false, ErrorMessage: message };
429
+ }
430
+ }
431
+
432
+ /**
433
+ * Best-effort read of a `peaks.json` waveform sidecar that sits in the SAME storage folder as the
434
+ * given file (a JSON array of normalized `0..1` numbers, written at capture time). Derives the
435
+ * folder from the file's `ProviderKey` (strips the final path segment), reads `<folder>/peaks.json`
436
+ * via the file's own storage driver, parses + validates it, and returns sanitized peaks. Returns
437
+ * `undefined` on ANY failure (no ProviderKey, no sidecar, parse error, garbage) — the caller treats
438
+ * peaks as a pure optimization and must never let a sidecar problem affect token minting.
439
+ *
440
+ * @param file The already-loaded (under the user's context) `MJ: Files` row.
441
+ * @param contextUser The calling user — used to resolve the storage driver.
442
+ * @returns Sanitized `0..1` peaks (length-capped), or `undefined`.
443
+ */
444
+ private async tryReadPeaksSidecar(file: MJFileEntity, contextUser: UserInfo): Promise<number[] | undefined> {
445
+ try {
446
+ // The sidecar lives next to the recording: replace the final path segment with peaks.json.
447
+ const sidecarPath = deriveSidecarPath(file.ProviderKey);
448
+ if (!sidecarPath) {
449
+ return undefined;
450
+ }
451
+
452
+ // Resolve the file's storage account → driver (mirror of readRealtimeRecordingFile's pattern).
453
+ await FileStorageEngine.Instance.Config(false, contextUser);
454
+ let accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
455
+ if (accounts.length === 0) {
456
+ await FileStorageEngine.Instance.Config(true, contextUser);
457
+ accounts = FileStorageEngine.Instance.GetAccountsByProviderID(file.ProviderID);
458
+ }
459
+ const account = accounts[0];
460
+ if (!account) {
461
+ return undefined;
462
+ }
463
+ const driver = await FileStorageEngine.Instance.GetDriver(account.ID, contextUser);
464
+ const bytes = await driver.GetObject({ fullPath: sidecarPath });
465
+ if (!bytes || bytes.length === 0) {
466
+ return undefined;
467
+ }
468
+ return parsePeaksSidecar(bytes);
469
+ } catch {
470
+ // No sidecar / unreadable / parse failure — peaks are optional, never surface the error.
471
+ return undefined;
472
+ }
473
+ }
474
+
317
475
  /**
318
476
  * Builds UserContextOptions for storage operations that may require OAuth authentication.
319
477
  * This passes the current user's ID and context to allow the storage utilities to
@@ -431,6 +589,46 @@ export class FileResolver extends FileResolverBase {
431
589
  return url;
432
590
  }
433
591
 
592
+ /**
593
+ * Returns an `MJ: Files` record's bytes as base64, read server-side through authenticated MJStorage
594
+ * (`GetObject`) — NOT a public pre-signed link. Permission-gated: the file is first loaded under the
595
+ * calling user's context, so MJ row-level security determines access. Never throws to the client.
596
+ *
597
+ * @param fileId The `MJ: Files` id whose bytes to return.
598
+ * @returns `{ Success, Base64?, MimeType?, ErrorMessage? }`.
599
+ */
600
+ @Query(() => FileContentsResult)
601
+ async GetFileContents(@Arg('fileId', () => String) fileId: string, @Ctx() context: AppContext): Promise<FileContentsResult> {
602
+ try {
603
+ const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: true });
604
+ const contextUser = this.GetUserFromPayload(context.userPayload);
605
+
606
+ // Permission gate FIRST: load the file record under the USER's context. A failed load means the
607
+ // file does not exist OR the user lacks read access under MJ row-level security.
608
+ const fileEntity = await provider.GetEntityObject<MJFileEntity>('MJ: Files', contextUser);
609
+ const loaded = await fileEntity.Load(fileId);
610
+ if (!loaded) {
611
+ return { Success: false, ErrorMessage: 'You do not have access to this file or it does not exist.' };
612
+ }
613
+
614
+ // Read the bytes via authenticated MJStorage (server-side GetObject on the file's own account).
615
+ const result = await readRealtimeRecordingFile(fileId, contextUser, provider);
616
+ if (!result) {
617
+ return { Success: false, ErrorMessage: 'The file could not be read from storage.' };
618
+ }
619
+
620
+ return {
621
+ Success: true,
622
+ Base64: result.Bytes.toString('base64'),
623
+ MimeType: result.MimeType ?? fileEntity.ContentType ?? undefined,
624
+ };
625
+ } catch (error) {
626
+ const message = error instanceof Error ? error.message : String(error);
627
+ LogError(`GetFileContents failed (file ${fileId}): ${message}`);
628
+ return { Success: false, ErrorMessage: message };
629
+ }
630
+ }
631
+
434
632
  @Mutation(() => MJFile_)
435
633
  async UpdateFile(@Arg('input', () => UpdateMJFileInput) input: UpdateMJFileInput, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
436
634
  // if the name is changing, rename the target object as well
@@ -1,5 +1,5 @@
1
1
  import { Resolver, Query, Mutation, Arg, Ctx, ObjectType, Field, InputType, Int, Float } from "type-graphql";
2
- import { CompositeKey, DatabaseProviderBase, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
2
+ import { CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Metadata, RunView, UserInfo, LogError, LogStatus, IMetadataProvider, TransactionGroupBase } from "@memberjunction/core";
3
3
  import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
4
4
  import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
5
5
  import {
@@ -3451,9 +3451,29 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3451
3451
  ): Promise<ApplyAllEntityMapCreated[]> {
3452
3452
  const results: ApplyAllEntityMapCreated[] = [];
3453
3453
 
3454
+ // PERF (large-catalog ApplyAll, e.g. Salesforce 1695 objects): precompute O(1) lookups ONCE before
3455
+ // the per-object loop. The per-object path previously did an O(N) `provider.Entities.find` + a
3456
+ // BypassCache RunView for the existing entity map PER object → O(N²) scans + N DB round-trips, so a
3457
+ // 1695-object connector burned its whole budget in ApplyAll and FullSync never started. One entity
3458
+ // index + one batched entity-map query make both O(1) per object.
3459
+ const entityByKey = new Map(
3460
+ provider.Entities.map(e => [`${e.SchemaName.toLowerCase()}|${e.BaseTable.toLowerCase()}`, e] as const)
3461
+ );
3462
+ const existingMapByExternalName = new Map<string, MJCompanyIntegrationEntityMapEntity>();
3463
+ const allMaps = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3464
+ EntityName: 'MJ: Company Integration Entity Maps',
3465
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}'`,
3466
+ ResultType: 'entity_object',
3467
+ BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3468
+ }, user);
3469
+ if (allMaps.Success) {
3470
+ for (const m of allMaps.Results) existingMapByExternalName.set((m.ExternalObjectName ?? '').toLowerCase(), m);
3471
+ }
3472
+
3454
3473
  for (const obj of objects) {
3455
3474
  const entityMapResult = await this.createSingleEntityMap(
3456
- companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection
3475
+ companyIntegrationID, obj, connector, companyIntegration, schemaName, user, provider, defaultSyncDirection,
3476
+ entityByKey, existingMapByExternalName
3457
3477
  );
3458
3478
  if (entityMapResult) {
3459
3479
  results.push(entityMapResult);
@@ -3471,10 +3491,14 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3471
3491
  schemaName: string,
3472
3492
  user: UserInfo,
3473
3493
  md: IMetadataProvider,
3474
- defaultSyncDirection: string = 'Pull'
3494
+ defaultSyncDirection: string = 'Pull',
3495
+ entityByKey?: Map<string, EntityInfo>,
3496
+ existingMapByExternalName?: Map<string, MJCompanyIntegrationEntityMapEntity>
3475
3497
  ): Promise<ApplyAllEntityMapCreated | null> {
3476
- // Find the entity by schema + table name
3477
- const entityInfo = md.Entities.find(
3498
+ // Find the entity by schema + table name. PERF: O(1) precomputed index when provided
3499
+ // (large-catalog ApplyAll); falls back to the O(N) scan when called without it.
3500
+ const entityInfo = entityByKey?.get(`${schemaName.toLowerCase()}|${obj.TableName.toLowerCase()}`)
3501
+ ?? md.Entities.find(
3478
3502
  e => e.SchemaName.toLowerCase() === schemaName.toLowerCase()
3479
3503
  && e.BaseTable.toLowerCase() === obj.TableName.toLowerCase()
3480
3504
  );
@@ -3488,19 +3512,27 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3488
3512
  // multiplies the maps in lockstep (N applies → N duplicate maps per object), which
3489
3513
  // silently corrupts the record-map 1:1 completeness gate and makes the forward sync
3490
3514
  // process each object N times. Reuse the existing (connection, external object) map instead.
3491
- const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3492
- const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3493
- EntityName: 'MJ: Company Integration Entity Maps',
3494
- ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3495
- OrderBy: '__mj_CreatedAt ASC',
3496
- MaxRows: 1,
3497
- ResultType: 'entity_object',
3498
- BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3499
- }, user);
3515
+ // PERF: use the precomputed existing-map index when provided (one batched query for the whole
3516
+ // ApplyAll instead of one BypassCache RunView per object); falls back to a per-object query.
3517
+ let existing: MJCompanyIntegrationEntityMapEntity | undefined;
3518
+ if (existingMapByExternalName) {
3519
+ existing = existingMapByExternalName.get(obj.SourceObjectName.toLowerCase());
3520
+ } else {
3521
+ const escapedObjectName = obj.SourceObjectName.replace(/'/g, "''");
3522
+ const existingMapResult = await new RunView().RunView<MJCompanyIntegrationEntityMapEntity>({
3523
+ EntityName: 'MJ: Company Integration Entity Maps',
3524
+ ExtraFilter: `CompanyIntegrationID='${companyIntegrationID}' AND ExternalObjectName='${escapedObjectName}'`,
3525
+ OrderBy: '__mj_CreatedAt ASC',
3526
+ MaxRows: 1,
3527
+ ResultType: 'entity_object',
3528
+ BypassCache: true, // idempotency must read COMMITTED state, not a possibly-stale filtered cache
3529
+ }, user);
3530
+ if (existingMapResult.Success && existingMapResult.Results.length > 0) existing = existingMapResult.Results[0];
3531
+ }
3500
3532
 
3501
3533
  let em: MJCompanyIntegrationEntityMapEntity;
3502
- if (existingMapResult.Success && existingMapResult.Results.length > 0) {
3503
- em = existingMapResult.Results[0]; // reuse — keeps the map stable across re-applies
3534
+ if (existing) {
3535
+ em = existing; // reuse — keeps the map stable across re-applies
3504
3536
  } else {
3505
3537
  em = await md.GetEntityObject<MJCompanyIntegrationEntityMapEntity>('MJ: Company Integration Entity Maps', user);
3506
3538
  em.NewRecord();
@@ -3634,26 +3666,70 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3634
3666
  * still surfaces every object/field; only materialization is capped. Per-table column count = the selected
3635
3667
  * field subset, or the object's full discovered field count when all fields are selected.
3636
3668
  */
3637
- private enforceSchemaLimits(
3669
+ private async enforceSchemaLimits(
3638
3670
  objects: SchemaPreviewObjectInput[],
3639
3671
  filteredSchema: SourceSchemaInfo,
3640
- ): void {
3641
- const envInt = (name: string): number | null => {
3642
- const v = parseInt(process.env[name] ?? '', 10);
3643
- return Number.isFinite(v) && v > 0 ? v : null;
3644
- };
3672
+ companyIntegration: MJCompanyIntegrationEntity,
3673
+ user: UserInfo,
3674
+ md: IMetadataProvider,
3675
+ ): Promise<string[]> {
3676
+ const warnings: string[] = [];
3645
3677
  const fullCountByName = new Map(filteredSchema.Objects.map(o => [o.ExternalName.toLowerCase(), o.Fields.length]));
3646
- // Delegate the cap decision to the engine's pure, unit-tested decideSchemaLimitViolations.
3678
+ // Use the MAX of the input object's Fields and the filtered-schema count — the batch path can pass
3679
+ // objects with empty Fields (the real columns come from filteredSchema, which is what buildSchema
3680
+ // materializes). `?? ` would wrongly read an empty [] as 0 (0 is not nullish); Math.max is robust to
3681
+ // either source being empty and reflects the count that will actually become columns.
3682
+ const colCount = (o: SchemaPreviewObjectInput): number =>
3683
+ Math.max(fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0, o.Fields?.length ?? 0);
3684
+
3685
+ // ── Per-table COLUMN limit: DISABLE-the-offender, NOT reject-the-apply. The limit is clamped at
3686
+ // startup to ≤ IntegrationEngine.MAX_COLUMNS_CEILING (SQL Server's 1024 minus framework column
3687
+ // headroom). An object exceeding it is set Status='Disabled' (reversible — it re-enables on a later
3688
+ // discovery if it has shrunk back under the limit) and dropped from THIS apply, so the materializable
3689
+ // objects still sync instead of the whole apply failing CREATE TABLE. One CLI warning per disabled object.
3690
+ const limit = IntegrationEngine.Instance.MaxColumnsPerTable;
3691
+ const overLimit = objects.filter(o => colCount(o) > limit);
3692
+ if (overLimit.length > 0) {
3693
+ const ioIDByName = new Map(
3694
+ IntegrationEngineBase.Instance.GetActiveIntegrationObjects(companyIntegration.IntegrationID)
3695
+ .map(io => [io.Name.toLowerCase(), io.ID] as [string, string])
3696
+ );
3697
+ for (const o of overLimit) {
3698
+ warnings.push(`Disabled "${o.SourceObjectName}" (${colCount(o)} columns > limit ${limit}) — too wide to materialize as one table; reduce its columns or it stays disabled.`);
3699
+ const ioID = ioIDByName.get(o.SourceObjectName.toLowerCase());
3700
+ if (ioID) {
3701
+ const io = await md.GetEntityObject<MJIntegrationObjectEntity>('MJ: Integration Objects', user);
3702
+ if (await io.Load(ioID)) {
3703
+ io.Status = 'Disabled';
3704
+ if (!await io.Save()) {
3705
+ LogError(`enforceSchemaLimits: failed to disable over-wide IO '${o.SourceObjectName}': ${io.LatestResult?.CompleteMessage ?? 'unknown error'}`);
3706
+ }
3707
+ }
3708
+ }
3709
+ }
3710
+ // Drop the disabled objects from BOTH the shared objects array (used downstream by
3711
+ // buildTargetConfigs + createEntityAndFieldMaps) and filteredSchema, in place.
3712
+ const disabled = new Set(overLimit.map(o => o.SourceObjectName.toLowerCase()));
3713
+ for (let i = objects.length - 1; i >= 0; i--) {
3714
+ if (disabled.has(objects[i].SourceObjectName.toLowerCase())) objects.splice(i, 1);
3715
+ }
3716
+ filteredSchema.Objects = filteredSchema.Objects.filter(o => !disabled.has(o.ExternalName.toLowerCase()));
3717
+ }
3718
+
3719
+ // MaxTables remains an operator/env REJECT guardrail (unchanged), evaluated on the surviving objects.
3720
+ // Columns are handled above via disable, so the pure decision is asked about tables only here.
3721
+ const maxTables = ((): number | null => {
3722
+ const v = parseInt(process.env.MJ_INTEGRATION_MAX_TABLES ?? '', 10);
3723
+ return Number.isFinite(v) && v > 0 ? v : null;
3724
+ })();
3647
3725
  const violations = decideSchemaLimitViolations({
3648
3726
  TableCount: objects.length,
3649
- ColumnCountByTable: objects.map(o => ({
3650
- Name: o.SourceObjectName,
3651
- ColumnCount: o.Fields?.length ?? fullCountByName.get(o.SourceObjectName.toLowerCase()) ?? 0,
3652
- })),
3653
- MaxTables: envInt('MJ_INTEGRATION_MAX_TABLES'),
3654
- MaxColumnsPerTable: envInt('MJ_INTEGRATION_MAX_COLUMNS_PER_TABLE'),
3727
+ ColumnCountByTable: [],
3728
+ MaxTables: maxTables,
3729
+ MaxColumnsPerTable: null,
3655
3730
  });
3656
3731
  if (violations.length > 0) throw new Error(violations.join(' '));
3732
+ return warnings;
3657
3733
  }
3658
3734
 
3659
3735
  private async buildSchemaForConnector(
@@ -3717,7 +3793,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3717
3793
  // §B — reject an over-limit table/column SELECTION before ANY table is materialized. This is the
3718
3794
  // single shared gate for ApplyAll / ApplyAllBatch / ApplySchemaBatch (all route through here).
3719
3795
  // Caps are operator/env guardrails (MJ_INTEGRATION_MAX_TABLES / _MAX_COLUMNS_PER_TABLE).
3720
- this.enforceSchemaLimits(objects, filteredSchema);
3796
+ const limitWarnings = await this.enforceSchemaLimits(objects, filteredSchema, companyIntegration, user, provider);
3721
3797
 
3722
3798
  const targetConfigs = this.buildTargetConfigs(objects, filteredSchema, platform, connector);
3723
3799
 
@@ -3736,6 +3812,8 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
3736
3812
 
3737
3813
  const builder = new SchemaBuilder();
3738
3814
  const schemaOutput = builder.BuildSchema(input);
3815
+ // Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output.
3816
+ if (limitWarnings.length > 0) schemaOutput.Warnings.unshift(...limitWarnings);
3739
3817
 
3740
3818
  if (schemaOutput.Errors.length > 0) {
3741
3819
  throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);