@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/LICENSE +180 -4
  2. package/dist/agentSessions/SessionManager.d.ts +19 -5
  3. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  4. package/dist/agentSessions/SessionManager.js +27 -9
  5. package/dist/agentSessions/SessionManager.js.map +1 -1
  6. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  7. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  9. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  10. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  11. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  12. package/dist/auth/AuthProviderEngine.js +245 -0
  13. package/dist/auth/AuthProviderEngine.js.map +1 -0
  14. package/dist/auth/index.d.ts +12 -0
  15. package/dist/auth/index.d.ts.map +1 -1
  16. package/dist/auth/index.js +57 -13
  17. package/dist/auth/index.js.map +1 -1
  18. package/dist/auth/initializeProviders.d.ts +64 -1
  19. package/dist/auth/initializeProviders.d.ts.map +1 -1
  20. package/dist/auth/initializeProviders.js +117 -5
  21. package/dist/auth/initializeProviders.js.map +1 -1
  22. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  23. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  24. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  25. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  26. package/dist/config.d.ts +89 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +52 -35
  29. package/dist/config.js.map +1 -1
  30. package/dist/context.d.ts.map +1 -1
  31. package/dist/context.js +21 -1
  32. package/dist/context.js.map +1 -1
  33. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  34. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  35. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  36. package/dist/generated/generated.d.ts +619 -1597
  37. package/dist/generated/generated.d.ts.map +1 -1
  38. package/dist/generated/generated.js +3341 -17021
  39. package/dist/generated/generated.js.map +1 -1
  40. package/dist/generic/ResolverBase.d.ts +28 -1
  41. package/dist/generic/ResolverBase.d.ts.map +1 -1
  42. package/dist/generic/ResolverBase.js +57 -8
  43. package/dist/generic/ResolverBase.js.map +1 -1
  44. package/dist/generic/RunViewResolver.d.ts +4 -0
  45. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  46. package/dist/generic/RunViewResolver.js +31 -0
  47. package/dist/generic/RunViewResolver.js.map +1 -1
  48. package/dist/index.d.ts +3 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +147 -14
  51. package/dist/index.js.map +1 -1
  52. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  53. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  54. package/dist/integration/CustomColumnPromoter.js +472 -97
  55. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  56. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  57. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  58. package/dist/integration/InactiveRowWarnings.js +57 -0
  59. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  60. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  61. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  62. package/dist/integration/RSUProgressBridge.js +230 -0
  63. package/dist/integration/RSUProgressBridge.js.map +1 -0
  64. package/dist/integration/SchemaRefreshLaunch.d.ts +62 -0
  65. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  66. package/dist/integration/SchemaRefreshLaunch.js +67 -0
  67. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  68. package/dist/providerConfigUnits.d.ts +20 -0
  69. package/dist/providerConfigUnits.d.ts.map +1 -0
  70. package/dist/providerConfigUnits.js +22 -0
  71. package/dist/providerConfigUnits.js.map +1 -0
  72. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  73. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  74. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  75. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  76. package/dist/resolvers/FileResolver.d.ts +34 -4
  77. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  78. package/dist/resolvers/FileResolver.js +218 -28
  79. package/dist/resolvers/FileResolver.js.map +1 -1
  80. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  81. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  82. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  83. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  84. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +108 -3
  85. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  86. package/dist/resolvers/IntegrationDiscoveryResolver.js +374 -87
  87. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  88. package/dist/resolvers/QueryResolver.d.ts +5 -4
  89. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  90. package/dist/resolvers/QueryResolver.js +29 -14
  91. package/dist/resolvers/QueryResolver.js.map +1 -1
  92. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  93. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  94. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  95. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  96. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  97. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  98. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  99. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  100. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  101. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  102. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  103. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  104. package/dist/resolvers/UserResolver.d.ts +19 -17
  105. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  106. package/dist/resolvers/UserResolver.js +66 -67
  107. package/dist/resolvers/UserResolver.js.map +1 -1
  108. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  109. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  110. package/dist/resolvers/currentUserRoles.js +36 -0
  111. package/dist/resolvers/currentUserRoles.js.map +1 -0
  112. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  113. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  114. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  115. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  116. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  117. package/dist/rest/MediaAccessKeys.js +42 -1
  118. package/dist/rest/MediaAccessKeys.js.map +1 -1
  119. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  120. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  121. package/dist/rest/MediaStreamHandler.js +83 -2
  122. package/dist/rest/MediaStreamHandler.js.map +1 -1
  123. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  124. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  125. package/dist/rest/OAuthCallbackHandler.js +129 -43
  126. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  127. package/dist/rest/UploadTokenManager.d.ts +90 -0
  128. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  129. package/dist/rest/UploadTokenManager.js +156 -0
  130. package/dist/rest/UploadTokenManager.js.map +1 -0
  131. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  132. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  133. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  134. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  135. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  136. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  137. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  138. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  139. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  140. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  141. package/dist/services/TaskGraphAgentRunner.js +23 -0
  142. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  143. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  144. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  145. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  146. package/dist/types.d.ts +14 -0
  147. package/dist/types.d.ts.map +1 -1
  148. package/dist/types.js.map +1 -1
  149. package/package.json +92 -92
  150. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  151. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  152. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  153. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  154. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  155. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  156. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  157. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  158. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  159. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  160. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  161. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  162. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  163. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  164. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  165. package/src/__tests__/SessionManager.test.ts +55 -0
  166. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  167. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  168. package/src/__tests__/config-units.test.ts +74 -0
  169. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  170. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  171. package/src/__tests__/newUsers.test.ts +726 -0
  172. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  173. package/src/agentSessions/SessionManager.ts +27 -9
  174. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  175. package/src/auth/AuthProviderEngine.ts +253 -0
  176. package/src/auth/index.ts +60 -15
  177. package/src/auth/initializeProviders.ts +128 -7
  178. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  179. package/src/config.ts +55 -36
  180. package/src/context.ts +26 -2
  181. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  182. package/src/generated/generated.ts +2347 -10551
  183. package/src/generic/ResolverBase.ts +67 -8
  184. package/src/generic/RunViewResolver.ts +31 -0
  185. package/src/index.ts +165 -16
  186. package/src/integration/CustomColumnPromoter.ts +520 -98
  187. package/src/integration/InactiveRowWarnings.ts +85 -0
  188. package/src/integration/RSUProgressBridge.ts +248 -0
  189. package/src/integration/SchemaRefreshLaunch.ts +93 -0
  190. package/src/providerConfigUnits.ts +21 -0
  191. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  192. package/src/resolvers/FileResolver.ts +200 -40
  193. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  194. package/src/resolvers/IntegrationDiscoveryResolver.ts +390 -71
  195. package/src/resolvers/QueryResolver.ts +25 -11
  196. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  197. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  198. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  199. package/src/resolvers/UserResolver.ts +99 -60
  200. package/src/resolvers/currentUserRoles.ts +47 -0
  201. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  202. package/src/rest/MediaAccessKeys.ts +47 -1
  203. package/src/rest/MediaStreamHandler.ts +98 -3
  204. package/src/rest/OAuthCallbackHandler.ts +149 -41
  205. package/src/rest/UploadTokenManager.ts +211 -0
  206. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  207. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  208. package/src/services/TaskGraphAgentRunner.ts +23 -0
  209. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  210. package/src/types.ts +14 -0
@@ -0,0 +1,211 @@
1
+ /**
2
+ * @fileoverview Ephemeral in-memory staged upload token manager.
3
+ *
4
+ * Provides a secure, single-use, time-to-live (TTL) bounded staging area for raw
5
+ * binary upload buffers received via REST streaming before being committed through
6
+ * the GraphQL `UploadStorageFile` pipeline.
7
+ *
8
+ * Security properties:
9
+ * - Single-use nonce: Token and buffer are atomically destroyed on first consumption.
10
+ * - User binding: Tokens are bound to the authenticated User ID that uploaded them.
11
+ * - Auto-eviction: Abandoned tokens are automatically evicted from RAM after the TTL.
12
+ * - Memory caps: Enforces per-file max size (default 100MB) and total pool capacity (default 500MB).
13
+ *
14
+ * @module @memberjunction/server/rest/UploadTokenManager
15
+ */
16
+
17
+ import { BaseSingleton, UUIDsEqual } from '@memberjunction/global';
18
+ import { LogError } from '@memberjunction/core';
19
+ import { randomBytes } from 'node:crypto';
20
+
21
+ /**
22
+ * An ephemeral in-memory staged upload record.
23
+ */
24
+ export interface StagedUploadEntry {
25
+ token: string;
26
+ buffer: Buffer;
27
+ fileName: string;
28
+ mimeType: string;
29
+ contentLength: number;
30
+ userId: string;
31
+ createdAt: number;
32
+ expiresAt: number;
33
+ timer: NodeJS.Timeout;
34
+ }
35
+
36
+ /**
37
+ * Ephemeral In-Memory Staged Upload Token Manager.
38
+ *
39
+ * Architecture & Deployment Topology Note:
40
+ * This implementation maintains staged buffers in-process RAM with a 5-minute sliding TTL.
41
+ * In a clustered/multi-replica deployment behind a load balancer, sticky sessions (or routing
42
+ * the subsequent GraphQL `UploadStorageFile` commit to the same node) are recommended.
43
+ * For shared-memory multi-node clustering, an external ephemeral store (such as Redis)
44
+ * can be plugged in behind this interface.
45
+ */
46
+ export class UploadTokenManager extends BaseSingleton<UploadTokenManager> {
47
+ private readonly _staged = new Map<string, StagedUploadEntry>();
48
+ private _totalMemoryBytes = 0;
49
+
50
+ public defaultTtlSeconds = 300; // 5 minutes
51
+ public maxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
52
+ public maxUserMemoryBytes = 150 * 1024 * 1024; // 150 MB per user
53
+ public maxPoolMemoryBytes = 500 * 1024 * 1024; // 500 MB
54
+
55
+ // Public constructor required by BaseSingleton
56
+ public constructor() {
57
+ super();
58
+ }
59
+
60
+ public static get Instance(): UploadTokenManager {
61
+ return UploadTokenManager.getInstance<UploadTokenManager>();
62
+ }
63
+
64
+ /**
65
+ * Calculates total active staged bytes currently held for a given user.
66
+ */
67
+ public GetUserMemoryBytes(userId: string): number {
68
+ let total = 0;
69
+ for (const entry of this._staged.values()) {
70
+ if (UUIDsEqual(entry.userId, userId)) {
71
+ total += entry.contentLength;
72
+ }
73
+ }
74
+ return total;
75
+ }
76
+
77
+ /**
78
+ * Stages a raw binary buffer in RAM, returning a single-use cryptographic token.
79
+ *
80
+ * @throws Error if buffer exceeds single file limit, per-user quota, or global pool memory capacity.
81
+ */
82
+ public Stage(params: {
83
+ buffer: Buffer;
84
+ fileName: string;
85
+ mimeType: string;
86
+ userId: string;
87
+ ttlSeconds?: number;
88
+ }): string {
89
+ const size = params.buffer.length;
90
+
91
+ if (size > this.maxFileSizeBytes) {
92
+ throw new Error(
93
+ `Staged upload exceeds maximum allowed file size of ${Math.round(this.maxFileSizeBytes / (1024 * 1024))}MB (received ${Math.round(size / (1024 * 1024))}MB).`
94
+ );
95
+ }
96
+
97
+ const userCurrentBytes = this.GetUserMemoryBytes(params.userId);
98
+ if (userCurrentBytes + size > this.maxUserMemoryBytes) {
99
+ throw new Error(
100
+ `Per-user upload memory quota reached (${Math.round(userCurrentBytes / (1024 * 1024))}MB / ${Math.round(this.maxUserMemoryBytes / (1024 * 1024))}MB used). Please wait for active uploads to finalize.`
101
+ );
102
+ }
103
+
104
+ if (this._totalMemoryBytes + size > this.maxPoolMemoryBytes) {
105
+ throw new Error(
106
+ `Staged upload memory capacity reached (${Math.round(this._totalMemoryBytes / (1024 * 1024))}MB / ${Math.round(this.maxPoolMemoryBytes / (1024 * 1024))}MB used). Please try again shortly.`
107
+ );
108
+ }
109
+
110
+ const token = 'upt_' + randomBytes(32).toString('hex');
111
+ const ttl = params.ttlSeconds || this.defaultTtlSeconds;
112
+ const now = Date.now();
113
+ const expiresAt = now + ttl * 1000;
114
+
115
+ const timer = setTimeout(() => {
116
+ this.Evict(token);
117
+ }, ttl * 1000);
118
+
119
+ // Unref timer so it doesn't hold open process shutdown in tests or CLI
120
+ if (typeof timer.unref === 'function') {
121
+ timer.unref();
122
+ }
123
+
124
+ const entry: StagedUploadEntry = {
125
+ token,
126
+ buffer: params.buffer,
127
+ fileName: params.fileName,
128
+ mimeType: params.mimeType,
129
+ contentLength: size,
130
+ userId: params.userId,
131
+ createdAt: now,
132
+ expiresAt,
133
+ timer,
134
+ };
135
+
136
+ this._staged.set(token, entry);
137
+ this._totalMemoryBytes += size;
138
+
139
+ return token;
140
+ }
141
+
142
+ /**
143
+ * Atomically consumes and evicts a staged upload token.
144
+ *
145
+ * @param token The staged upload token.
146
+ * @param claimingUserId The user ID claiming this token. If provided, must match the user who uploaded it.
147
+ * @returns The staged buffer and metadata, or null if the token is invalid, expired, or claimed by the wrong user.
148
+ */
149
+ public Consume(
150
+ token: string,
151
+ claimingUserId?: string
152
+ ): { buffer: Buffer; fileName: string; mimeType: string; contentLength: number } | null {
153
+ if (!token || !this._staged.has(token)) {
154
+ return null;
155
+ }
156
+
157
+ const entry = this._staged.get(token)!;
158
+
159
+ // Security check: verify user ownership using UUIDsEqual for robust ID normalization
160
+ if (claimingUserId && entry.userId && !UUIDsEqual(entry.userId, claimingUserId)) {
161
+ LogError(`[UploadTokenManager] Security violation: User '${claimingUserId}' attempted to claim upload token owned by User '${entry.userId}'.`);
162
+ // Evict immediately to prevent further probing
163
+ this.Evict(token);
164
+ return null;
165
+ }
166
+
167
+ // Atomic consumption & memory reclamation
168
+ this.Evict(token);
169
+
170
+ return {
171
+ buffer: entry.buffer,
172
+ fileName: entry.fileName,
173
+ mimeType: entry.mimeType,
174
+ contentLength: entry.contentLength,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Evicts a token from RAM and reclaims memory.
180
+ */
181
+ public Evict(token: string): boolean {
182
+ const entry = this._staged.get(token);
183
+ if (!entry) {
184
+ return false;
185
+ }
186
+
187
+ clearTimeout(entry.timer);
188
+ this._staged.delete(token);
189
+ this._totalMemoryBytes = Math.max(0, this._totalMemoryBytes - entry.contentLength);
190
+ return true;
191
+ }
192
+
193
+ /** Total RAM in bytes currently consumed by all active staged uploads. */
194
+ public get TotalMemoryBytes(): number {
195
+ return this._totalMemoryBytes;
196
+ }
197
+
198
+ /** Count of currently active staged uploads. */
199
+ public get ActiveCount(): number {
200
+ return this._staged.size;
201
+ }
202
+
203
+ /** Clears all active staged uploads. */
204
+ public Clear(): void {
205
+ for (const [, entry] of this._staged) {
206
+ clearTimeout(entry.timer);
207
+ }
208
+ this._staged.clear();
209
+ this._totalMemoryBytes = 0;
210
+ }
211
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @fileoverview Worker-mode poll loop for durable integration sync runs (PR 1 item 8).
3
+ * @module MJServer/services
4
+ */
5
+
6
+ import { LogError, LogStatus, UserInfo } from '@memberjunction/core';
7
+ import { UserCache } from '@memberjunction/generic-database-provider';
8
+ import { IntegrationEngine } from '@memberjunction/integration-engine';
9
+ import { IntegrationSyncWorkerConfig } from '../config.js';
10
+
11
+ /**
12
+ * Polls `CompanyIntegrationRun` for `Status='Queued'` rows, claims each one atomically,
13
+ * executes it, and releases — the enqueue/execute split that lets a sync outlive the
14
+ * request that asked for it.
15
+ *
16
+ * Multiple workers (in this process or others) may poll the same queue: the claim sproc
17
+ * admits exactly one, and every loser simply moves on to the next candidate. Nothing here
18
+ * assumes it is the only worker.
19
+ */
20
+ export class IntegrationSyncWorkerService {
21
+ private config: IntegrationSyncWorkerConfig;
22
+ private systemUser: UserInfo | null = null;
23
+ private timer: NodeJS.Timeout | null = null;
24
+ private isRunning = false;
25
+ private polling = false;
26
+ /** Run IDs currently executing in THIS process — bounds concurrency and prevents self-collision. */
27
+ private inFlight = new Set<string>();
28
+
29
+ constructor(config: IntegrationSyncWorkerConfig) {
30
+ this.config = config;
31
+ }
32
+
33
+ public get IsEnabled(): boolean {
34
+ return this.config.enabled;
35
+ }
36
+
37
+ public get IsRunning(): boolean {
38
+ return this.isRunning;
39
+ }
40
+
41
+ /** Number of queued runs this worker is currently executing. */
42
+ public get InFlightCount(): number {
43
+ return this.inFlight.size;
44
+ }
45
+
46
+ /** Resolve the system user the worker executes syncs as. */
47
+ public async Initialize(): Promise<void> {
48
+ if (!this.config.enabled) return;
49
+
50
+ const email = this.config.systemUserEmail;
51
+ this.systemUser = UserCache.Users.find(u => u.Email?.toLowerCase() === email.toLowerCase()) ?? null;
52
+ if (!this.systemUser) {
53
+ throw new Error(`[IntegrationSyncWorker] System user not found with email: ${email}`);
54
+ }
55
+ await IntegrationEngine.Instance.Config(false, this.systemUser);
56
+ }
57
+
58
+ /** Begin polling the queue. */
59
+ public Start(): void {
60
+ if (!this.config.enabled || this.isRunning) return;
61
+ if (!this.systemUser) {
62
+ throw new Error('[IntegrationSyncWorker] Not initialized — call Initialize() first');
63
+ }
64
+
65
+ this.isRunning = true;
66
+ this.timer = setInterval(() => void this.pollOnce(), this.config.pollingIntervalMs);
67
+ // Standard level, NOT verboseOnly: this states a deployment-shaping fact — that this process
68
+ // executes queued syncs — and it's one line per process lifetime. Stop() already logs at
69
+ // standard level, so hiding Start() produced logs showing a worker stopping that never
70
+ // started, and no way to confirm from logs that a deployment is in worker mode at all.
71
+ LogStatus(
72
+ `🔄 Integration Sync Worker: polling every ${Math.round(this.config.pollingIntervalMs / 1000)}s, ` +
73
+ `up to ${this.config.maxConcurrentRuns} concurrent run(s)`
74
+ );
75
+ // Don't wait a full interval to drain a queue that already has work.
76
+ void this.pollOnce();
77
+ }
78
+
79
+ /**
80
+ * Stop polling. In-flight runs are left to finish on their own — their leases are
81
+ * renewed by the engine's heartbeat, so killing them here would only strand rows.
82
+ */
83
+ public Stop(): void {
84
+ if (!this.isRunning) return;
85
+ if (this.timer) {
86
+ clearInterval(this.timer);
87
+ this.timer = null;
88
+ }
89
+ this.isRunning = false;
90
+ LogStatus(`[IntegrationSyncWorker] Stopped polling (${this.inFlight.size} run(s) still in flight)`);
91
+ }
92
+
93
+ /**
94
+ * One poll pass: fetch claimable candidates up to the free concurrency slots and start
95
+ * each. Overlapping passes are suppressed so a slow query can't stack up timers.
96
+ */
97
+ private async pollOnce(): Promise<void> {
98
+ if (this.polling || !this.isRunning || !this.systemUser) return;
99
+
100
+ const slots = this.config.maxConcurrentRuns - this.inFlight.size;
101
+ if (slots <= 0) return;
102
+
103
+ this.polling = true;
104
+ try {
105
+ const candidates = await IntegrationEngine.PollQueuedRuns(this.systemUser, slots);
106
+ for (const candidate of candidates) {
107
+ if (this.inFlight.has(candidate.ID)) continue;
108
+ this.startRun(candidate.ID);
109
+ }
110
+ } catch (err) {
111
+ LogError(`[IntegrationSyncWorker] Poll failed`, undefined, err);
112
+ } finally {
113
+ this.polling = false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Execute one queued run in the background. Losing the claim race is normal — the run
119
+ * is simply someone else's — so it is logged as information, never as an error.
120
+ */
121
+ private startRun(runID: string): void {
122
+ if (!this.systemUser) return;
123
+
124
+ this.inFlight.add(runID);
125
+ void IntegrationEngine.Instance
126
+ .ExecuteQueuedRun(runID, this.systemUser)
127
+ .then(result => {
128
+ if (result.Success) {
129
+ LogStatus(`[IntegrationSyncWorker] Run ${runID} completed: ${result.RecordsProcessed} record(s) processed`);
130
+ } else {
131
+ LogStatus(`[IntegrationSyncWorker] Run ${runID} did not complete: ${result.ErrorMessage ?? 'unknown reason'}`);
132
+ }
133
+ })
134
+ .catch(err => {
135
+ LogError(`[IntegrationSyncWorker] Run ${runID} threw`, undefined, err);
136
+ })
137
+ .finally(() => {
138
+ this.inFlight.delete(runID);
139
+ });
140
+ }
141
+ }
@@ -24,6 +24,7 @@ import { DurableEntityActionRegistry } from '@memberjunction/actions-base';
24
24
  import { CreateTaskGraphProviderFactory } from './TaskGraphProviderFactory.js';
25
25
  import { TaskGraphActionRunner } from './TaskGraphActionRunner.js';
26
26
  import { TaskGraphAgentRunner } from './TaskGraphAgentRunner.js';
27
+ import { randomBytes } from 'node:crypto';
27
28
  import { DurableEntityActionTaskSubmitter } from './DurableEntityActionTaskSubmitter.js';
28
29
  import { TaskGraphContinuationDeliverer } from './TaskGraphContinuationDeliverer.js';
29
30
  import { PubSubManager } from '../generic/PubSubManager.js';
@@ -39,6 +40,19 @@ import { TaskGraphFrameBroadcaster } from '../resolvers/TaskGraphFrameResolver.j
39
40
  *
40
41
  * The dispatcher self-registers with `ShutdownRegistry`, so the caller does not wire teardown.
41
42
  */
43
+ /**
44
+ * A per-process identity for the claim protocol.
45
+ *
46
+ * Host and pid are kept because they are what a human reads in a log; the random suffix is what
47
+ * makes the value actually unique. Generated once per call, so a restart is a new instance — which
48
+ * is correct: the previous process's claims should expire rather than be inherited.
49
+ */
50
+ function defaultInstanceID(): string {
51
+ const host = process.env.HOSTNAME ?? 'mjapi';
52
+ const entropy = randomBytes(4).toString('hex');
53
+ return `${host}-${process.pid}-${entropy}`;
54
+ }
55
+
42
56
  export async function StartTaskGraphDispatcher(
43
57
  pool: sql.ConnectionPool,
44
58
  contextUser: UserInfo,
@@ -52,7 +66,13 @@ export async function StartTaskGraphDispatcher(
52
66
  providerFactory,
53
67
  new TaskGraphAgentRunner(),
54
68
  contextUser,
55
- { InstanceID: instanceID ?? `${process.env.HOSTNAME ?? 'mjapi'}-${process.pid}` },
69
+ // A RANDOM SUFFIX, because host+pid is not unique in the deployments this runs in (C2).
70
+ // `HOSTNAME` is not exported to child processes under systemd or pm2, and a containerised
71
+ // process is routinely pid 1 — so two hosts default to the same `mjapi-1`. A shared instance
72
+ // id defeats every `ClaimedBy=@me` guard at once: A's heartbeat renews B's lease, and A's
73
+ // stale terminal write lands over B's live execution. An explicit `instanceID` still wins,
74
+ // for hosts that have a real identity to supply.
75
+ { InstanceID: instanceID ?? defaultInstanceID() },
56
76
  // Without this the dispatcher had nowhere to deliver: a finished graph logged its outcome,
57
77
  // marked itself delivered, and said nothing to the conversation that asked for it.
58
78
  new TaskGraphContinuationDeliverer(providerFactory, contextUser),
@@ -16,6 +16,7 @@ import { AgentRunner } from '@memberjunction/ai-agents';
16
16
  import { ChatMessageRole } from '@memberjunction/ai';
17
17
  import { LogError } from '@memberjunction/core';
18
18
  import { MJAIAgentEntityExtended, MJAIAgentRunEntityExtended } from '@memberjunction/ai-core-plus';
19
+ import type { AgentExecutionProgressCallback } from '@memberjunction/ai-core-plus';
19
20
  import type { TaskAgentRunner, TaskAgentRunParams, TaskAgentRunResult } from '@memberjunction/task-graph';
20
21
 
21
22
  export class TaskGraphAgentRunner implements TaskAgentRunner {
@@ -34,6 +35,10 @@ export class TaskGraphAgentRunner implements TaskAgentRunner {
34
35
  // rather than starting the chain over. Without it MAX_REINVOKE_DEPTH never fires.
35
36
  continuationDepth: params.ContinuationDepth ?? 0,
36
37
  parentRun: await this.loadSubmittingRun(params),
38
+ // The bridge from the agent's own progress stream up to graph-level observers: the
39
+ // dispatcher turns these into rate-limited `NodeProgress` frames, which is how a
40
+ // running Agent step shows "what it is doing right now" on the workflow canvas.
41
+ onProgress: this.bridgeProgress(params),
37
42
  });
38
43
 
39
44
  const success = result?.success === true;
@@ -50,6 +55,24 @@ export class TaskGraphAgentRunner implements TaskAgentRunner {
50
55
  }
51
56
  }
52
57
 
58
+ /**
59
+ * Adapts the agent framework's progress callback onto the dispatcher's plain sink.
60
+ *
61
+ * Undefined when nobody is listening, so the agent run pays nothing for observability it is not
62
+ * providing. Never throws — a progress announcement must not be able to fail the step.
63
+ */
64
+ private bridgeProgress(params: TaskAgentRunParams): AgentExecutionProgressCallback | undefined {
65
+ const sink = params.OnProgress;
66
+ if (!sink) return undefined;
67
+ return (progress) => {
68
+ try {
69
+ sink(progress.message, progress.percentage);
70
+ } catch {
71
+ // The dispatcher's sink already guards itself; this is belt over braces.
72
+ }
73
+ };
74
+ }
75
+
53
76
  /**
54
77
  * The run that submitted this graph, as the entity `RunAgent` wants for `parentRun`.
55
78
  *
@@ -135,7 +135,17 @@ export class TaskGraphContinuationDeliverer implements TaskContinuationDeliverer
135
135
  continuationDepth: params.ReinvokeDepth + 1,
136
136
  });
137
137
  } catch (e) {
138
- LogError(`[TaskGraphContinuationDeliverer] Reinvoking for "${params.WorkflowName}" threw`, undefined, e);
138
+ // FALL BACK, don't just log (C3). Both load-failure paths above already post instead of
139
+ // reinvoking, on the principle that a completed workflow's outcome must reach the user
140
+ // somehow — but a throw out of `RunAgent` skipped that and lost the outcome entirely.
141
+ // The marker has been claimed by the time this runs, so nothing will look at the graph
142
+ // again: the work finished, and the only record of it is a server log line.
143
+ LogError(`[TaskGraphContinuationDeliverer] Reinvoking for "${params.WorkflowName}" threw — posting instead`, undefined, e);
144
+ try {
145
+ await this.PostMessage(params);
146
+ } catch (fallbackError) {
147
+ LogError(`[TaskGraphContinuationDeliverer] The fallback post for "${params.WorkflowName}" also failed`, undefined, fallbackError);
148
+ }
139
149
  }
140
150
  }
141
151
 
package/src/types.ts CHANGED
@@ -25,6 +25,14 @@ export type UserPayload = {
25
25
  apiKeyId?: string;
26
26
  /** SHA-256 hash of the MJ API key (used for scope authorization) */
27
27
  apiKeyHash?: string;
28
+ /**
29
+ * The IdP's OIDC `email_verified` assertion from the verified JWT, when present.
30
+ * Three-state: `true` (IdP vouched for the email), `false` (IdP explicitly says the email is
31
+ * unverified — security-sensitive flows must not trust an email match), `undefined` (the IdP
32
+ * omits the claim, or auth was via API key / magic link). Consumed by identity-claim
33
+ * redemption; do not treat `undefined` as `false`.
34
+ */
35
+ emailVerified?: boolean;
28
36
  };
29
37
 
30
38
  /**
@@ -107,6 +115,12 @@ export type RunViewGenericParams = {
107
115
  * happens. Propagated to `RunViewParams.BypassCache`.
108
116
  */
109
117
  bypassCache?: boolean;
118
+ /**
119
+ * Optional source-of-truth selector for entities that have a base-view materialization.
120
+ * 'Materialized' routes the read to the entity's materialized wrapper view; defaults to 'Live'.
121
+ * Propagated to `RunViewParams.DataSource`.
122
+ */
123
+ dataSource?: 'Live' | 'Materialized';
110
124
  };
111
125
 
112
126