@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
@@ -24,6 +24,9 @@ import jwt from 'jsonwebtoken';
24
24
  /** The token's `typ` claim — the route rejects any token whose `typ` differs. */
25
25
  export const MEDIA_ACCESS_TOKEN_TYPE = 'media-access';
26
26
 
27
+ /** The upload staging token's `typ` claim. */
28
+ export const MEDIA_UPLOAD_ACCESS_TOKEN_TYPE = 'media-upload';
29
+
27
30
  /** Default media-access token lifetime (hours). Long enough to cover a full playback session. */
28
31
  export const MEDIA_ACCESS_DEFAULT_TTL_HOURS = 4;
29
32
 
@@ -64,7 +67,13 @@ export class MediaAccessKeyManager extends BaseSingleton<MediaAccessKeyManager>
64
67
 
65
68
  /** Resolves the signing secret once, preferring the deployment's symmetric key. */
66
69
  private ensureSecret(): void {
67
- if (this._initialized) {
70
+ if (this._initialized && this.secret) {
71
+ return;
72
+ }
73
+ const store = this.GetGlobalObjectStore();
74
+ if (store && typeof store['___MEDIA_ACCESS_SECRET___'] === 'string') {
75
+ this.secret = store['___MEDIA_ACCESS_SECRET___'];
76
+ this._initialized = true;
68
77
  return;
69
78
  }
70
79
  const configured = process.env.MJ_BASE_ENCRYPTION_KEY?.trim();
@@ -78,6 +87,9 @@ export class MediaAccessKeyManager extends BaseSingleton<MediaAccessKeyManager>
78
87
  'MJ_BASE_ENCRYPTION_KEY for stable signing.',
79
88
  );
80
89
  }
90
+ if (store) {
91
+ store['___MEDIA_ACCESS_SECRET___'] = this.secret;
92
+ }
81
93
  this._initialized = true;
82
94
  }
83
95
 
@@ -95,6 +107,39 @@ export class MediaAccessKeyManager extends BaseSingleton<MediaAccessKeyManager>
95
107
  return { Token: token, ExpiresAt: expiresAt };
96
108
  }
97
109
 
110
+ /**
111
+ * Mints a signed upload staging token for a user, expiring after `ttlMinutes` (default 10 minutes).
112
+ */
113
+ public SignUpload(userId: string, ttlMinutes: number = 10): { Token: string; ExpiresAt: Date } {
114
+ this.ensureSecret();
115
+ const claims = { fileId: '*', userId, typ: MEDIA_UPLOAD_ACCESS_TOKEN_TYPE };
116
+ const expiresInSeconds = Math.floor(ttlMinutes * 60);
117
+ const token = jwt.sign(claims, this.secret, { algorithm: 'HS256', expiresIn: expiresInSeconds });
118
+ const expiresAt = new Date(Date.now() + expiresInSeconds * 1000);
119
+ return { Token: token, ExpiresAt: expiresAt };
120
+ }
121
+
122
+ /**
123
+ * Verifies an upload staging token.
124
+ */
125
+ public VerifyUpload(token: string): { Valid: boolean; UserId?: string; Error?: string } {
126
+ this.ensureSecret();
127
+ try {
128
+ const decoded = jwt.verify(token, this.secret, { algorithms: ['HS256'] });
129
+ if (
130
+ typeof decoded === 'object' && decoded !== null &&
131
+ (decoded as { typ?: unknown }).typ === MEDIA_UPLOAD_ACCESS_TOKEN_TYPE &&
132
+ typeof (decoded as { userId?: unknown }).userId === 'string'
133
+ ) {
134
+ return { Valid: true, UserId: (decoded as { userId: string }).userId };
135
+ }
136
+ return { Valid: false, Error: 'Invalid token payload format or claim mismatch' };
137
+ } catch (err) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ return { Valid: false, Error: msg };
140
+ }
141
+ }
142
+
98
143
  /**
99
144
  * Verifies a token's signature, expiry, and `typ`. Returns the typed claims on
100
145
  * success. Never throws — an invalid/expired/malformed token returns `{ Valid: false }`.
@@ -119,3 +164,4 @@ export class MediaAccessKeyManager extends BaseSingleton<MediaAccessKeyManager>
119
164
  }
120
165
  }
121
166
  }
167
+
@@ -25,6 +25,7 @@ import { FileStorageEngine } from '@memberjunction/storage';
25
25
  import type { FileStorageBase, ByteRange } from '@memberjunction/storage';
26
26
  import { getSystemUser } from '../auth/index.js';
27
27
  import { MediaAccessKeyManager } from './MediaAccessKeys.js';
28
+ import { UploadTokenManager } from './UploadTokenManager.js';
28
29
  import { parseRange, parseRangeHeaderLoose } from './mediaRange.js';
29
30
 
30
31
  /** A located bytes source for a file: the driver + the provider key to read. */
@@ -32,21 +33,110 @@ interface FileBytesSource {
32
33
  driver: FileStorageBase;
33
34
  providerKey: string;
34
35
  contentType: string;
36
+ fileName: string;
35
37
  }
36
38
 
37
-
38
39
  /**
39
- * Builds the Express router exposing `GET /media/:fileId`. Stateless — verification
40
- * and byte-source resolution happen per request.
40
+ * Builds the Express router exposing:
41
+ * - `GET /media/:fileId` and `GET /media/:fileId/:filename` (Range streaming)
42
+ * - `POST /media/upload-stage` (Raw binary upload staging)
41
43
  */
42
44
  export function createMediaStreamRouter(): Router {
43
45
  const router = express.Router();
46
+
47
+ router.post(
48
+ '/upload-stage',
49
+ express.raw({ type: () => true, limit: '100mb' }),
50
+ async (req: Request, res: Response) => {
51
+ await handleUploadStageRequest(req, res);
52
+ }
53
+ );
54
+
44
55
  router.get('/:fileId', async (req: Request, res: Response) => {
45
56
  await handleMediaRequest(req, res);
46
57
  });
58
+ router.get('/:fileId/:filename', async (req: Request, res: Response) => {
59
+ await handleMediaRequest(req, res);
60
+ });
47
61
  return router;
48
62
  }
49
63
 
64
+ /**
65
+ * Authenticated raw binary upload staging handler (`POST /media/upload-stage`).
66
+ *
67
+ * Accepts raw file binary bytes directly in request body, stages in UploadTokenManager
68
+ * memory cache, and returns an ephemeral single-use upload token for the GraphQL mutation.
69
+ *
70
+ * Security: Requires a cryptographically signed media-upload token minted by `CreateUploadStageToken`
71
+ * (verified via `MediaAccessKeyManager.Instance.VerifyUpload`).
72
+ */
73
+ async function handleUploadStageRequest(req: Request, res: Response): Promise<void> {
74
+ // Never let CDNs or shared caches retain upload endpoints
75
+ res.setHeader('Cache-Control', 'private, no-store');
76
+ res.setHeader('X-Content-Type-Options', 'nosniff');
77
+
78
+ // 1. Resolve signed upload token strictly from Authorization header (avoids bearer token in URL logs)
79
+ const authHeader = req.headers.authorization || '';
80
+ const token = authHeader.startsWith('Bearer ') ? authHeader.substring(7).trim() : authHeader.trim();
81
+
82
+ if (!token) {
83
+ console.warn('[MediaStreamHandler] POST /media/upload-stage: Missing or empty Authorization header');
84
+ res.status(401).json({ Success: false, ErrorMessage: 'Authorization header with Bearer token required.' });
85
+ return;
86
+ }
87
+
88
+ // Cryptographically verify token signature, expiry, and 'media-upload' typ claim
89
+ const uploadVerify = MediaAccessKeyManager.Instance.VerifyUpload(token);
90
+ if (!uploadVerify.Valid || !uploadVerify.UserId) {
91
+ console.warn(`[MediaStreamHandler] POST /media/upload-stage: Token verification failed: ${uploadVerify.Error || 'invalid or expired'}`);
92
+ res.status(401).json({ Success: false, ErrorMessage: `Invalid or expired upload token: ${uploadVerify.Error || 'unauthorized'}` });
93
+ return;
94
+ }
95
+
96
+ const userId = uploadVerify.UserId;
97
+
98
+ // 2. Validate binary body
99
+ const buffer = req.body as Buffer;
100
+ if (!buffer || !Buffer.isBuffer(buffer) || buffer.length === 0) {
101
+ res.status(400).json({ Success: false, ErrorMessage: 'Empty or invalid file payload.' });
102
+ return;
103
+ }
104
+
105
+ // 3. Extract and sanitize metadata
106
+ const rawFileName = (req.headers['x-file-name'] as string) || (req.query.fileName as string) || 'upload.bin';
107
+ let fileName = 'upload.bin';
108
+ try {
109
+ fileName = decodeURIComponent(rawFileName);
110
+ } catch {
111
+ fileName = rawFileName;
112
+ }
113
+ // Sanitize filename: remove directory traversal, leading slashes, control chars
114
+ fileName = fileName.replace(/[/\\]+/g, '_').replace(/^\.+/, '').replace(/[\x00-\x1f\x7f]/g, '').trim() || 'upload.bin';
115
+
116
+ const mimeType = (req.headers['content-type'] as string) || (req.query.mimeType as string) || 'application/octet-stream';
117
+
118
+ try {
119
+ const uploadToken = UploadTokenManager.Instance.Stage({
120
+ buffer,
121
+ fileName,
122
+ mimeType,
123
+ userId,
124
+ });
125
+
126
+ res.status(200).json({
127
+ Success: true,
128
+ UploadToken: uploadToken,
129
+ FileName: fileName,
130
+ MimeType: mimeType,
131
+ ContentLength: buffer.length,
132
+ });
133
+ } catch (err) {
134
+ const message = err instanceof Error ? err.message : String(err);
135
+ LogError(`[MediaStream] Upload staging failed for '${fileName}': ${message}`);
136
+ res.status(400).json({ Success: false, ErrorMessage: message });
137
+ }
138
+ }
139
+
50
140
  /** Top-level request handler: verify token → resolve bytes → stream/buffer with Range support. */
51
141
  async function handleMediaRequest(req: Request, res: Response): Promise<void> {
52
142
  // Express type defs allow req.params values to be string | string[]; normalize to string
@@ -136,6 +226,7 @@ async function resolveFileBytesSource(fileId: string): Promise<FileBytesSource |
136
226
  driver,
137
227
  providerKey: file.ProviderKey,
138
228
  contentType: file.ContentType ?? 'application/octet-stream',
229
+ fileName: file.Name || 'file',
139
230
  };
140
231
  }
141
232
 
@@ -166,6 +257,8 @@ async function serveViaStream(res: Response, source: FileBytesSource, rangeHeade
166
257
 
167
258
  res.setHeader('Accept-Ranges', 'bytes');
168
259
  res.setHeader('Content-Type', result.ContentType ?? source.contentType);
260
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(source.fileName)}"`);
261
+ res.setHeader('X-Content-Type-Options', 'nosniff');
169
262
  if (result.ContentLength != null) {
170
263
  res.setHeader('Content-Length', String(result.ContentLength));
171
264
  }
@@ -198,6 +291,8 @@ async function serveViaBuffer(res: Response, source: FileBytesSource, rangeHeade
198
291
 
199
292
  res.setHeader('Accept-Ranges', 'bytes');
200
293
  res.setHeader('Content-Type', source.contentType);
294
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(source.fileName)}"`);
295
+ res.setHeader('X-Content-Type-Options', 'nosniff');
201
296
 
202
297
  if (!rangeHeader) {
203
298
  res.status(200);
@@ -14,7 +14,7 @@
14
14
 
15
15
  import express from 'express';
16
16
  import { LogError, LogStatus, RunView, UserInfo } from '@memberjunction/core';
17
- import { UUIDsEqual } from '@memberjunction/global';
17
+ import { IsValidUUID, UUIDsEqual } from '@memberjunction/global';
18
18
  import { UserCache } from '@memberjunction/generic-database-provider';
19
19
  import { OAuthManager, MCPClientManager } from '@memberjunction/ai-mcp-client';
20
20
  import type { MCPServerOAuthConfig } from '@memberjunction/ai-mcp-client';
@@ -38,6 +38,23 @@ export interface OAuthCallbackHandlerOptions {
38
38
  successRedirectUrl?: string;
39
39
  /** URL to redirect to after failed authorization */
40
40
  errorRedirectUrl?: string;
41
+ /**
42
+ * Origins a caller-supplied `frontendReturnUrl` is allowed to point at, so the OAuth callback
43
+ * cannot be turned into an open redirect from the trusted MJAPI origin. Normally wired to
44
+ * `cors.allowedOrigins`. Pass `['*']` to allow any origin — MJ's backward-compatible default
45
+ * CORS posture, and what a deployment that has not narrowed `cors.allowedOrigins` gets.
46
+ *
47
+ * REQUIRED on purpose. An optional field defaulting to allow-all is fail-open: a construction
48
+ * site that forgets it loses the open-redirect protection silently, with nothing to notice.
49
+ * Being required makes that a compile error instead. The handler is not exported from this
50
+ * package's barrel and the package declares no subpath exports, so no consumer outside MJServer
51
+ * can construct it — revisit this if it is ever added to the public API.
52
+ *
53
+ * Injected rather than read from `configInfo` directly so this module has no import-time
54
+ * dependency on configuration loading — importing the config module validates the whole config
55
+ * as a side effect, which makes this handler unimportable in any context without one.
56
+ */
57
+ allowedFrontendOrigins: string[];
41
58
  }
42
59
 
43
60
  /**
@@ -46,10 +63,15 @@ export interface OAuthCallbackHandlerOptions {
46
63
  * The callback endpoint is unauthenticated because it's called by external auth servers.
47
64
  * It uses the state parameter to look up the authorization context and validate the flow.
48
65
  *
66
+ * NOTE: `allowedFrontendOrigins` is what prevents the callback becoming an open redirect, and is
67
+ * required so it cannot be forgotten. Wire it to `configInfo.cors?.allowedOrigins`; `['*']` allows
68
+ * any origin, matching MJ's default CORS posture.
69
+ *
49
70
  * @example
50
71
  * ```typescript
51
72
  * const oauthHandler = new OAuthCallbackHandler({
52
- * publicUrl: 'https://api.example.com'
73
+ * publicUrl: 'https://api.example.com',
74
+ * allowedFrontendOrigins: configInfo.cors?.allowedOrigins ?? ['*']
53
75
  * });
54
76
  *
55
77
  * // Mount unauthenticated callback route
@@ -348,7 +370,10 @@ export class OAuthCallbackHandler {
348
370
  * @param res - Express response
349
371
  */
350
372
  private async initiateFlow(req: express.Request, res: express.Response): Promise<void> {
351
- const { connectionId, additionalScopes, frontendReturnUrl } = req.body;
373
+ // Coerce request-body values to strings up front. A JSON body can carry arrays/objects, and
374
+ // passing those downstream unvalidated turns a bad request into a confusing internal failure.
375
+ const connectionId: string = typeof req.body?.connectionId === 'string' ? req.body.connectionId : '';
376
+ const { additionalScopes, frontendReturnUrl } = req.body;
352
377
  const contextUser = req['mjUser'] as UserInfo;
353
378
 
354
379
  if (!contextUser) {
@@ -368,6 +393,29 @@ export class OAuthCallbackHandler {
368
393
  return;
369
394
  }
370
395
 
396
+ // connectionId is a record ID (UUID). Reject anything else at the boundary so it can never
397
+ // reach a SQL filter or downstream consumer as injectable input.
398
+ if (!IsValidUUID(connectionId)) {
399
+ res.status(400).json({
400
+ success: false,
401
+ errorCode: 'invalid_request',
402
+ errorMessage: 'connectionId must be a valid record identifier'
403
+ });
404
+ return;
405
+ }
406
+
407
+ // Reject a disallowed return URL here rather than at the callback. Otherwise the caller only
408
+ // finds out after the whole OAuth round trip, by being silently sent to the default page.
409
+ // An absent value is acceptable — see isFrontendReturnUrlAcceptable.
410
+ if (!this.isFrontendReturnUrlAcceptable(frontendReturnUrl)) {
411
+ res.status(400).json({
412
+ success: false,
413
+ errorCode: 'invalid_request',
414
+ errorMessage: 'frontendReturnUrl is not an allowed redirect target'
415
+ });
416
+ return;
417
+ }
418
+
371
419
  try {
372
420
  // Load connection and server config
373
421
  const config = await this.loadConnectionConfig(connectionId, contextUser);
@@ -644,10 +692,13 @@ export class OAuthCallbackHandler {
644
692
  try {
645
693
  const rv = new RunView();
646
694
 
647
- // Get connection to get server ID
695
+ // Get connection to get server ID.
696
+ // connectionId is caller-supplied (POST /oauth/initiate body). MJ ExtraFilter is a raw
697
+ // SQL fragment, so single quotes MUST be doubled to prevent injection — matching the
698
+ // escaping used for stateParameter in loadAuthorizationState().
648
699
  const connResult = await rv.RunView<{ MCPServerID: string }>({
649
700
  EntityName: ENTITY_MCP_SERVER_CONNECTIONS,
650
- ExtraFilter: `ID='${connectionId}'`,
701
+ ExtraFilter: `ID='${connectionId.replace(/'/g, "''")}'`,
651
702
  Fields: ['MCPServerID'],
652
703
  ResultType: 'simple'
653
704
  }, contextUser);
@@ -696,32 +747,93 @@ export class OAuthCallbackHandler {
696
747
  }
697
748
  }
698
749
 
750
+ /**
751
+ * Validates that a caller-supplied frontend return URL points at an allowed origin, so the
752
+ * OAuth callback cannot be turned into an open redirect from the trusted MJAPI origin.
753
+ *
754
+ * Allowed when: the allowlist is "allow all" (`['*']`, the backward-compatible default),
755
+ * the URL's origin is in `options.allowedFrontendOrigins`, or it matches the origin of a
756
+ * built-in success/error redirect URL. Anything else is rejected and the caller falls back to
757
+ * the default redirect page.
758
+ */
759
+ private isFrontendReturnUrlAllowed(url: URL): boolean {
760
+ const allowed = this.options.allowedFrontendOrigins;
761
+ if (allowed.includes('*')) {
762
+ return true;
763
+ }
764
+ if (allowed.includes(url.origin)) {
765
+ return true;
766
+ }
767
+ for (const builtIn of [this.options.successRedirectUrl, this.options.errorRedirectUrl]) {
768
+ try {
769
+ if (builtIn && new URL(builtIn).origin === url.origin) {
770
+ return true;
771
+ }
772
+ } catch {
773
+ // Ignore an unparseable built-in URL and keep checking.
774
+ }
775
+ }
776
+ return false;
777
+ }
778
+
779
+ /**
780
+ * Parses a caller-supplied return URL and applies {@link isFrontendReturnUrlAllowed}.
781
+ *
782
+ * @returns the parsed URL when it is well-formed AND points at an allowed origin, otherwise null.
783
+ */
784
+ private parseAllowedFrontendReturnUrl(frontendReturnUrl: string): URL | null {
785
+ let url: URL;
786
+ try {
787
+ url = new URL(frontendReturnUrl);
788
+ } catch {
789
+ LogError(`[OAuth Callback] Invalid frontend return URL '${frontendReturnUrl}', falling back to default`);
790
+ return null;
791
+ }
792
+ if (!this.isFrontendReturnUrlAllowed(url)) {
793
+ LogError(`[OAuth Callback] frontend return URL '${frontendReturnUrl}' origin not allowed, falling back to default`);
794
+ return null;
795
+ }
796
+ return url;
797
+ }
798
+
799
+ /**
800
+ * Boundary check for `/oauth/initiate` — validates the return URL before it is persisted onto
801
+ * the authorization state, so a caller gets a 400 instead of a silent fallback later.
802
+ *
803
+ * An ABSENT value (undefined / null / empty string) is acceptable: it means "no return URL", and
804
+ * every downstream consumer treats it that way via a truthiness check. Only a value the caller
805
+ * actually supplied is validated — otherwise omitting the field, or sending an empty one, would
806
+ * newly fail a request that has always worked.
807
+ */
808
+ private isFrontendReturnUrlAcceptable(frontendReturnUrl: unknown): boolean {
809
+ if (!frontendReturnUrl) {
810
+ return true;
811
+ }
812
+ return typeof frontendReturnUrl === 'string' && this.parseAllowedFrontendReturnUrl(frontendReturnUrl) !== null;
813
+ }
814
+
699
815
  /**
700
816
  * Redirects to success page with state info.
701
817
  * If a frontend return URL is provided, redirects there instead of the default success page.
702
818
  */
703
819
  private redirectToSuccess(res: express.Response, state: string, connectionId: string, frontendReturnUrl?: string): void {
704
- // If frontend return URL is provided, redirect there with success parameters
705
- if (frontendReturnUrl) {
706
- try {
707
- const url = new URL(frontendReturnUrl);
708
- url.searchParams.set('oauth', 'success');
709
- url.searchParams.set('state', state);
710
- url.searchParams.set('connectionId', connectionId);
711
- LogStatus(`[OAuth Callback] Redirecting to frontend URL: ${url.toString()}`);
712
- res.redirect(302, url.toString());
713
- return;
714
- } catch (error) {
715
- LogError(`[OAuth Callback] Invalid frontend return URL '${frontendReturnUrl}', falling back to default`);
716
- // Fall through to default redirect
717
- }
820
+ // If frontend return URL is provided and points at an allowed origin, redirect there with
821
+ // success parameters. Anything else falls through to the built-in page.
822
+ const url = frontendReturnUrl ? this.parseAllowedFrontendReturnUrl(frontendReturnUrl) : null;
823
+ if (url) {
824
+ url.searchParams.set('oauth', 'success');
825
+ url.searchParams.set('state', state);
826
+ url.searchParams.set('connectionId', connectionId);
827
+ LogStatus(`[OAuth Callback] Redirecting to frontend URL: ${url.toString()}`);
828
+ res.redirect(302, url.toString());
829
+ return;
718
830
  }
719
831
 
720
832
  // Default: redirect to built-in success page
721
- const url = new URL(this.options.successRedirectUrl!);
722
- url.searchParams.set('state', state);
723
- url.searchParams.set('connectionId', connectionId);
724
- res.redirect(302, url.toString());
833
+ const defaultUrl = new URL(this.options.successRedirectUrl!);
834
+ defaultUrl.searchParams.set('state', state);
835
+ defaultUrl.searchParams.set('connectionId', connectionId);
836
+ res.redirect(302, defaultUrl.toString());
725
837
  }
726
838
 
727
839
  /**
@@ -729,27 +841,23 @@ export class OAuthCallbackHandler {
729
841
  * If a frontend return URL is provided, redirects there instead of the default error page.
730
842
  */
731
843
  private redirectToError(res: express.Response, errorCode: string, errorMessage: string, frontendReturnUrl?: string): void {
732
- // If frontend return URL is provided, redirect there with error parameters
733
- if (frontendReturnUrl) {
734
- try {
735
- const url = new URL(frontendReturnUrl);
736
- url.searchParams.set('oauth', 'error');
737
- url.searchParams.set('error', errorCode);
738
- url.searchParams.set('error_description', errorMessage);
739
- LogStatus(`[OAuth Callback] Redirecting to frontend URL with error: ${url.toString()}`);
740
- res.redirect(302, url.toString());
741
- return;
742
- } catch (error) {
743
- LogError(`[OAuth Callback] Invalid frontend return URL '${frontendReturnUrl}', falling back to default`);
744
- // Fall through to default redirect
745
- }
844
+ // If frontend return URL is provided and points at an allowed origin, redirect there with
845
+ // error parameters. Anything else falls through to the built-in page.
846
+ const url = frontendReturnUrl ? this.parseAllowedFrontendReturnUrl(frontendReturnUrl) : null;
847
+ if (url) {
848
+ url.searchParams.set('oauth', 'error');
849
+ url.searchParams.set('error', errorCode);
850
+ url.searchParams.set('error_description', errorMessage);
851
+ LogStatus(`[OAuth Callback] Redirecting to frontend URL with error: ${url.toString()}`);
852
+ res.redirect(302, url.toString());
853
+ return;
746
854
  }
747
855
 
748
856
  // Default: redirect to built-in error page
749
- const url = new URL(this.options.errorRedirectUrl!);
750
- url.searchParams.set('error', errorCode);
751
- url.searchParams.set('error_description', errorMessage);
752
- res.redirect(302, url.toString());
857
+ const defaultUrl = new URL(this.options.errorRedirectUrl!);
858
+ defaultUrl.searchParams.set('error', errorCode);
859
+ defaultUrl.searchParams.set('error_description', errorMessage);
860
+ res.redirect(302, defaultUrl.toString());
753
861
  }
754
862
 
755
863
  /**