@memberjunction/server 6.1.0-edge.6 → 6.1.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 (177) hide show
  1. package/README.md +24 -9
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/config.d.ts +36 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +9 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
  8. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  9. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
  10. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  11. package/dist/generated/graphql-schemas/__mj.d.ts +2842 -2144
  12. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  13. package/dist/generated/graphql-schemas/__mj.js +5334 -2143
  14. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  15. package/dist/generic/ResolverBase.d.ts +119 -4
  16. package/dist/generic/ResolverBase.d.ts.map +1 -1
  17. package/dist/generic/ResolverBase.js +264 -11
  18. package/dist/generic/ResolverBase.js.map +1 -1
  19. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  20. package/dist/generic/RunViewResolver.js +26 -2
  21. package/dist/generic/RunViewResolver.js.map +1 -1
  22. package/dist/generic/refusalExtensions.d.ts +52 -0
  23. package/dist/generic/refusalExtensions.d.ts.map +1 -0
  24. package/dist/generic/refusalExtensions.js +16 -0
  25. package/dist/generic/refusalExtensions.js.map +1 -0
  26. package/dist/index.d.ts +9 -3
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +115 -91
  29. package/dist/index.js.map +1 -1
  30. package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
  31. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  32. package/dist/resolvers/EntityRecordNameResolver.js +64 -7
  33. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  34. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
  35. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  36. package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
  37. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  38. package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
  39. package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
  40. package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
  41. package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
  42. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  43. package/dist/serverExtensionReservedRoots.js +2 -3
  44. package/dist/serverExtensionReservedRoots.js.map +1 -1
  45. package/package.json +92 -92
  46. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
  47. package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
  48. package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
  49. package/src/__tests__/ResolverBase.filterEscaping.test.ts +63 -0
  50. package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
  51. package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
  52. package/src/__tests__/resolverBase.fls.test.ts +483 -0
  53. package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
  54. package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
  55. package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
  56. package/src/config.ts +12 -0
  57. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
  58. package/src/generated/graphql-schemas/__mj.ts +6590 -4287
  59. package/src/generic/ResolverBase.ts +313 -10
  60. package/src/generic/RunViewResolver.ts +28 -2
  61. package/src/generic/refusalExtensions.ts +59 -0
  62. package/src/index.ts +137 -100
  63. package/src/resolvers/EntityRecordNameResolver.ts +74 -8
  64. package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
  65. package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
  66. package/src/serverExtensionReservedRoots.ts +2 -3
  67. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
  68. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
  69. package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
  70. package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
  71. package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
  72. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
  73. package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
  74. package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
  75. package/dist/resolvers/TelephonyResolver.d.ts +0 -26
  76. package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
  77. package/dist/resolvers/TelephonyResolver.js +0 -86
  78. package/dist/resolvers/TelephonyResolver.js.map +0 -1
  79. package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
  80. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
  81. package/dist/resolvers/VonageTelephonyResolver.js +0 -86
  82. package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
  83. package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
  84. package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
  85. package/dist/telephony/RingCentralTelephonyService.js +0 -262
  86. package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
  87. package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
  88. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
  89. package/dist/telephony/TeamsMeetingsRouter.js +0 -112
  90. package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
  91. package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
  92. package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
  93. package/dist/telephony/TeamsMeetingsService.js +0 -196
  94. package/dist/telephony/TeamsMeetingsService.js.map +0 -1
  95. package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
  96. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
  97. package/dist/telephony/TwilioTelephonyRouter.js +0 -143
  98. package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
  99. package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
  100. package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
  101. package/dist/telephony/TwilioTelephonyService.js +0 -193
  102. package/dist/telephony/TwilioTelephonyService.js.map +0 -1
  103. package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
  104. package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
  105. package/dist/telephony/VonageTelephonyRouter.js +0 -201
  106. package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
  107. package/dist/telephony/VonageTelephonyService.d.ts +0 -90
  108. package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
  109. package/dist/telephony/VonageTelephonyService.js +0 -191
  110. package/dist/telephony/VonageTelephonyService.js.map +0 -1
  111. package/dist/telephony/calendar-scheduler.d.ts +0 -67
  112. package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
  113. package/dist/telephony/calendar-scheduler.js +0 -133
  114. package/dist/telephony/calendar-scheduler.js.map +0 -1
  115. package/dist/telephony/index.d.ts +0 -29
  116. package/dist/telephony/index.d.ts.map +0 -1
  117. package/dist/telephony/index.js +0 -38
  118. package/dist/telephony/index.js.map +0 -1
  119. package/dist/telephony/media-upgrade-router.d.ts +0 -47
  120. package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
  121. package/dist/telephony/media-upgrade-router.js +0 -72
  122. package/dist/telephony/media-upgrade-router.js.map +0 -1
  123. package/dist/telephony/ringcentral-runtime.d.ts +0 -14
  124. package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
  125. package/dist/telephony/ringcentral-runtime.js +0 -18
  126. package/dist/telephony/ringcentral-runtime.js.map +0 -1
  127. package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
  128. package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
  129. package/dist/telephony/teams-meetings-runtime.js +0 -20
  130. package/dist/telephony/teams-meetings-runtime.js.map +0 -1
  131. package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
  132. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
  133. package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
  134. package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
  135. package/dist/telephony/telephony-runtime.d.ts +0 -14
  136. package/dist/telephony/telephony-runtime.d.ts.map +0 -1
  137. package/dist/telephony/telephony-runtime.js +0 -18
  138. package/dist/telephony/telephony-runtime.js.map +0 -1
  139. package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
  140. package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
  141. package/dist/telephony/twilioMediaRegistry.js +0 -106
  142. package/dist/telephony/twilioMediaRegistry.js.map +0 -1
  143. package/dist/telephony/vonage-runtime.d.ts +0 -14
  144. package/dist/telephony/vonage-runtime.d.ts.map +0 -1
  145. package/dist/telephony/vonage-runtime.js +0 -18
  146. package/dist/telephony/vonage-runtime.js.map +0 -1
  147. package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
  148. package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
  149. package/dist/telephony/vonageMediaRegistry.js +0 -158
  150. package/dist/telephony/vonageMediaRegistry.js.map +0 -1
  151. package/src/__tests__/media-upgrade-router.test.ts +0 -43
  152. package/src/__tests__/teams-meetings-service.test.ts +0 -65
  153. package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
  154. package/src/__tests__/twilio-telephony-service.test.ts +0 -23
  155. package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
  156. package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
  157. package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
  158. package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
  159. package/src/resolvers/TelephonyResolver.ts +0 -63
  160. package/src/resolvers/VonageTelephonyResolver.ts +0 -63
  161. package/src/telephony/RingCentralTelephonyService.ts +0 -342
  162. package/src/telephony/TeamsMeetingsRouter.ts +0 -143
  163. package/src/telephony/TeamsMeetingsService.ts +0 -268
  164. package/src/telephony/TwilioTelephonyRouter.ts +0 -184
  165. package/src/telephony/TwilioTelephonyService.ts +0 -261
  166. package/src/telephony/VonageTelephonyRouter.ts +0 -239
  167. package/src/telephony/VonageTelephonyService.ts +0 -259
  168. package/src/telephony/calendar-scheduler.ts +0 -176
  169. package/src/telephony/index.ts +0 -43
  170. package/src/telephony/media-upgrade-router.ts +0 -79
  171. package/src/telephony/ringcentral-runtime.ts +0 -22
  172. package/src/telephony/teams-meetings-runtime.ts +0 -24
  173. package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
  174. package/src/telephony/telephony-runtime.ts +0 -22
  175. package/src/telephony/twilioMediaRegistry.ts +0 -137
  176. package/src/telephony/vonage-runtime.ts +0 -22
  177. package/src/telephony/vonageMediaRegistry.ts +0 -200
package/src/index.ts CHANGED
@@ -44,14 +44,10 @@ import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
44
44
  import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
45
45
  import { createSignatureWebhookHandler } from './rest/SignatureWebhookHandler.js';
46
46
  import { createMediaStreamRouter } from './rest/MediaStreamHandler.js';
47
+ import { createRealtimeSdpBrokerRouter } from './rest/RealtimeSdpBrokerHandler.js';
48
+ import { REALTIME_SDP_EXCHANGE_PATH } from '@memberjunction/ai';
47
49
  import { createMagicLinkHandler, createMagicLinkJwksRouter, registerMagicLinkAuthProvider, MAGIC_LINK_MOUNT_PATH } from './auth/magicLink/index.js';
48
50
  import { createWidgetHandler, WIDGET_MOUNT_PATH } from './realtimeWidget/index.js';
49
- import { createTwilioTelephonyHandler, TWILIO_TELEPHONY_MOUNT_PATH, SetTwilioTelephonyService } from './telephony/index.js';
50
- import { createVonageTelephonyHandler, VONAGE_TELEPHONY_MOUNT_PATH, SetVonageTelephonyService } from './telephony/index.js';
51
- import { RingCentralTelephonyService, SetRingCentralTelephonyService } from './telephony/index.js';
52
- import { createTeamsMeetingsHandler, TEAMS_MEETINGS_MOUNT_PATH, SetTeamsMeetingsService, GetTeamsMeetingsService, StartCalendarScheduler } from './telephony/index.js';
53
- import { InstallMediaUpgradeDispatcher, IsGraphQLWsPath } from './telephony/index.js';
54
-
55
51
  import { resolve } from 'node:path';
56
52
  import { DataSourceInfo, raiseEvent } from './types.js';
57
53
 
@@ -81,7 +77,7 @@ import {
81
77
  MJCompanyIntegrationFieldMapEntity,
82
78
  MJScheduledJobEntity,
83
79
  } from '@memberjunction/core-entities';
84
- import { ServerExtensionLoader, ServerExtensionConfig, mergeServerExtensionConfigs, prepareServerExtensionConfigs, describeServerExtensionMount } from '@memberjunction/server-extensions-core';
80
+ import { ServerExtensionLoader, ServerExtensionConfig, mergeServerExtensionConfigs, prepareServerExtensionConfigs, describeServerExtensionMount, InstallMediaUpgradeDispatcher, IsGraphQLWsPath } from '@memberjunction/server-extensions-core';
85
81
  import { coreReservedServerExtensionRoots } from './serverExtensionReservedRoots.js';
86
82
  import { MetadataCacheRefreshIntervalSeconds } from './providerConfigUnits.js';
87
83
 
@@ -113,8 +109,16 @@ export * from 'type-graphql';
113
109
  export { Int, Float, ID } from 'type-graphql';
114
110
  export { NewUserBase } from './auth/newUsers.js';
115
111
  export { configInfo, DEFAULT_SERVER_CONFIG } from './config.js';
116
- export { ServerExtensionLoader, BaseServerExtension } from '@memberjunction/server-extensions-core';
117
- export type { ServerExtensionConfig, ExtensionInitResult, ExtensionHealthResult } from '@memberjunction/server-extensions-core';
112
+ export { ServerExtensionLoader, BaseServerExtension, DefaultServerExtensionServiceRegistry } from '@memberjunction/server-extensions-core';
113
+ export type {
114
+ ServerExtensionConfig,
115
+ ServerExtensionPhase,
116
+ ServerExtensionServiceRegistry,
117
+ ServerExtensionInitContext,
118
+ ExtensionInitResult,
119
+ ExtensionHealthResult,
120
+ LoadExtensionsOptions,
121
+ } from '@memberjunction/server-extensions-core';
118
122
  export * from './directives/index.js';
119
123
  export { NoLog, hasNoLogParameter, getNoLogFields } from './logging/NoLog.js';
120
124
  export * from './entitySubclasses/MJEntityPermissionEntityServer.server.js';
@@ -137,10 +141,21 @@ export { ResolveConfiguredPrincipal, resolvePrincipalFrom } from './auth/princip
137
141
  export type { ResolvablePrincipal, PrincipalResolution, PrincipalResolutionReason } from './auth/principals.js';
138
142
  export { CloneUserForSessionContext } from './auth/sessionUserClone.js';
139
143
 
144
+ let _currentExtensionLoader: ServerExtensionLoader | null = null;
145
+
146
+ /**
147
+ * Returns the active ServerExtensionLoader instance for the running server.
148
+ * Returns null if the server has not been started yet.
149
+ */
150
+ export function GetServerExtensionLoader(): ServerExtensionLoader | null {
151
+ return _currentExtensionLoader;
152
+ }
153
+
140
154
  export * from './generic/PushStatusResolver.js';
141
155
  export * from './generic/PubSubManager.js';
142
156
  export * from './generic/CacheInvalidationResolver.js';
143
157
  export * from './generic/ResolverBase.js';
158
+ export * from './generic/refusalExtensions.js';
144
159
  export * from './generic/RunViewResolver.js';
145
160
  export * from './resolvers/RunTemplateResolver.js';
146
161
  export * from './resolvers/RunAIPromptResolver.js';
@@ -1166,6 +1181,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1166
1181
  startupLog.LogIf('verbose', '[OAuth] Callback route registered at /oauth/callback');
1167
1182
  }
1168
1183
 
1184
+ // ─── Core HTTP Routes (Static Core Infrastructure) ─────────────────────────
1185
+ // IMPORTANT ARCHITECTURAL NOTE:
1186
+ // The route mounts below are strictly reserved for core framework infrastructure (OAuth, eSignature,
1187
+ // media streaming, realtime WebRTC broker, magic-link, widgets).
1188
+ // ALL new custom routes, external webhooks, vendor adapters, and application integrations MUST be
1189
+ // implemented as Server Extensions (subclasses of BaseServerExtension in @memberjunction/server-extensions-core)
1190
+ // mounted via ServerExtensionLoader — NEVER hardcoded as ad-hoc app.use() in serve().
1191
+ // See guides/SERVER_EXTENSIONS_GUIDE.md for complete patterns and best practices.
1192
+
1169
1193
  // ─── eSignature webhook (unauthenticated, registered BEFORE auth) ─────
1170
1194
  // Called by external signature providers (DocuSign Connect, etc.) without an MJ bearer token.
1171
1195
  // The provider DRIVER verifies the payload signature/HMAC; MJ auth does not apply here.
@@ -1179,6 +1203,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1179
1203
  app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter());
1180
1204
  startupLog.LogIf('verbose', '[Media] Streaming route registered at /media/:fileId');
1181
1205
 
1206
+ // ─── Realtime WebRTC SDP broker (ticket-gated, registered BEFORE auth) ───────
1207
+ if (configInfo.realtime?.enabled) {
1208
+ app.use(REALTIME_SDP_EXCHANGE_PATH, cors<cors.CorsRequest>(), createRealtimeSdpBrokerRouter());
1209
+ startupLog.LogIf('verbose', `[Realtime] WebRTC SDP broker registered at ${REALTIME_SDP_EXCHANGE_PATH}`);
1210
+ }
1211
+
1182
1212
  // ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
1183
1213
  // Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
1184
1214
  // authenticated invite-creation router mounts AFTER it (see below).
@@ -1212,62 +1242,75 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1212
1242
  startupLog.LogIf('verbose', `[Widget] Public routes registered at ${WIDGET_MOUNT_PATH}/session and ${WIDGET_MOUNT_PATH}/session/refresh`);
1213
1243
  }
1214
1244
 
1215
- // ─── Telephony (Twilio) ingress: inbound voice webhook + Media-Streams WSS (PUBLIC) ──
1216
- // Carriers cannot present an MJ JWT — the X-Twilio-Signature HMAC is the gate. The
1217
- // public webhook router mounts BEFORE the auth middleware; the Media-Streams WSS attaches
1218
- // to the shared HTTP server. The outbound PlaceTwilioCall mutation reuses the same service.
1219
- if (configInfo.telephony?.enabled && configInfo.telephony.twilio) {
1220
- const twilioHandler = createTwilioTelephonyHandler(oauthPublicUrl, configInfo.telephony.twilio);
1221
- app.use(TWILIO_TELEPHONY_MOUNT_PATH, cors<cors.CorsRequest>(), twilioHandler.publicRouter);
1222
- twilioHandler.attachMediaStreamServer();
1223
- SetTwilioTelephonyService(twilioHandler.service);
1224
- startupLog.LogIf('verbose', `[Telephony] Twilio routes registered at ${TWILIO_TELEPHONY_MOUNT_PATH}/voice + Media-Streams WSS`);
1225
- }
1245
+ // ─── Server extensions loader & shared service registry ───────────────────
1246
+ const extensionLoader = new ServerExtensionLoader();
1247
+ _currentExtensionLoader = extensionLoader;
1226
1248
 
1227
- // ─── Telephony (Vonage) ingress: inbound answer/event webhooks + media WSS (PUBLIC) ──
1228
- // Carriers cannot present an MJ JWT — the Vonage signed-request HMAC / webhook JWT is the gate.
1229
- // The public router mounts BEFORE the auth middleware; the media WSS attaches to the shared
1230
- // HTTP server. The outbound PlaceVonageCall mutation reuses the same service.
1231
- if (configInfo.telephony?.enabled && configInfo.telephony.vonage) {
1232
- const vonageHandler = createVonageTelephonyHandler(oauthPublicUrl, configInfo.telephony.vonage);
1233
- app.use(VONAGE_TELEPHONY_MOUNT_PATH, cors<cors.CorsRequest>(), vonageHandler.publicRouter);
1234
- vonageHandler.attachMediaStreamServer();
1235
- SetVonageTelephonyService(vonageHandler.service);
1236
- startupLog.LogIf('verbose', `[Telephony] Vonage routes registered at ${VONAGE_TELEPHONY_MOUNT_PATH}/answer + /event + media WSS`);
1249
+ // Open App packages publish their extensions; host mj.config.cjs overlays by DriverClass
1250
+ // Backwards-compatibility shim: synthesize ServerExtensionConfig entries from legacy configInfo.telephony
1251
+ const telephonyExtensionConfigs: ServerExtensionConfig[] = [];
1252
+ if (configInfo.telephony?.enabled) {
1253
+ if (configInfo.telephony.twilio) {
1254
+ telephonyExtensionConfigs.push({
1255
+ Enabled: true,
1256
+ DriverClass: 'TwilioTelephonyExtension',
1257
+ RootPath: '/telephony/twilio',
1258
+ Phase: 'pre-auth',
1259
+ Settings: configInfo.telephony.twilio as unknown as Record<string, unknown>,
1260
+ });
1261
+ }
1262
+ if (configInfo.telephony.vonage) {
1263
+ telephonyExtensionConfigs.push({
1264
+ Enabled: true,
1265
+ DriverClass: 'VonageTelephonyExtension',
1266
+ RootPath: '/telephony/vonage',
1267
+ Phase: 'pre-auth',
1268
+ Settings: configInfo.telephony.vonage as unknown as Record<string, unknown>,
1269
+ });
1270
+ }
1271
+ if (configInfo.telephony.ringcentral) {
1272
+ telephonyExtensionConfigs.push({
1273
+ Enabled: true,
1274
+ DriverClass: 'RingCentralTelephonyExtension',
1275
+ RootPath: '/telephony/ringcentral',
1276
+ Phase: 'pre-auth',
1277
+ Settings: configInfo.telephony.ringcentral as unknown as Record<string, unknown>,
1278
+ });
1279
+ }
1280
+ if (configInfo.telephony.teams?.enabled) {
1281
+ telephonyExtensionConfigs.push({
1282
+ Enabled: true,
1283
+ DriverClass: 'TeamsMeetingsExtension',
1284
+ RootPath: '/meetings/teams',
1285
+ Phase: 'pre-auth',
1286
+ Settings: configInfo.telephony.teams as unknown as Record<string, unknown>,
1287
+ });
1288
+ }
1237
1289
  }
1238
1290
 
1239
- // ─── Telephony (RingCentral) ingress: SIP softphone registration (no HTTP webhook / media WSS) ──
1240
- // RingCentral's only bidirectional-audio transport is a registered SIP softphone — inbound calls arrive
1241
- // as SIP INVITEs on its own SIP/TLS connection, so there is no public webhook or media WSS to mount.
1242
- // start() registers the softphone fire-and-forget so SIP registration never blocks boot; the outbound
1243
- // PlaceRingCentralCall mutation reuses the same service via the runtime holder.
1244
- if (configInfo.telephony?.enabled && configInfo.telephony.ringcentral) {
1245
- const ringCentralService = new RingCentralTelephonyService(configInfo.telephony.ringcentral);
1246
- SetRingCentralTelephonyService(ringCentralService);
1247
- void ringCentralService.start();
1248
- startupLog.LogIf('verbose', `[Telephony] RingCentral SIP softphone starting (codec ${configInfo.telephony.ringcentral.codec ?? 'OPUS/16000'})`);
1249
- }
1291
+ const rawHostExtensions = (configInfo.serverExtensions ?? []) as ServerExtensionConfig[];
1292
+ const mergedHostExtensions = mergeServerExtensionConfigs(telephonyExtensionConfigs, rawHostExtensions);
1250
1293
 
1251
- // ─── Teams meetings ingress: Graph change-notification webhook (PUBLIC) ──────────────
1252
- // Graph cannot present an MJ JWT — the subscription validationToken handshake + the per-
1253
- // notification clientState shared secret are the gate. The public webhook router mounts
1254
- // BEFORE the auth middleware. The ACS application-hosted-media audio plane is owned by the
1255
- // server's native ACS media adapter, which attaches transports to the shared registry
1256
- // (a media WSS is not needed here). The StartTeamsMeetingSession mutation reuses the same
1257
- // service via the runtime holder.
1258
- if (configInfo.telephony?.enabled && configInfo.telephony.teams?.enabled) {
1259
- const teamsHandler = createTeamsMeetingsHandler(configInfo.telephony.teams);
1260
- app.use(TEAMS_MEETINGS_MOUNT_PATH, cors<cors.CorsRequest>(), teamsHandler.publicRouter);
1261
- SetTeamsMeetingsService(teamsHandler.service);
1262
- startupLog.LogIf('verbose', `[Meetings] Teams routes registered at ${TEAMS_MEETINGS_MOUNT_PATH}/notifications`);
1294
+ // Open App packages publish their extensions; host mj.config.cjs overlays by DriverClass
1295
+ // (and remains the only source for host-only extensions such as Slack/Teams).
1296
+ // extraReservedRoots is derived from the mounts registered above plus graphqlRootPath
1297
+ // so a new pre-auth app.use(...) in serve() must also be added to
1298
+ // coreReservedServerExtensionRoots() — otherwise an Open App can claim it.
1299
+ const extensionConfigs = prepareServerExtensionConfigs(
1300
+ mergeServerExtensionConfigs(
1301
+ options?.serverExtensions ?? [],
1302
+ mergedHostExtensions,
1303
+ ),
1304
+ {
1305
+ onInvalid: (message) => LogError(message),
1306
+ onOverlap: (message) => LogStatus(message),
1307
+ extraReservedRoots: coreReservedServerExtensionRoots(graphqlRootPath),
1308
+ },
1309
+ );
1310
+ for (const cfg of extensionConfigs) {
1311
+ LogStatus(`Server extension ${describeServerExtensionMount(cfg)}`);
1263
1312
  }
1264
1313
 
1265
- // Install the single path-routing WebSocket-upgrade dispatcher AFTER all media WSS routes have
1266
- // registered. ws 8.x has each {server}-bound WebSocketServer 400 paths it doesn't own, so the GraphQL
1267
- // socket and the telephony media sockets cannot coexist as separate {server} servers — this strips the
1268
- // auto-listeners and routes upgrades by path. No-op when no media routes registered (telephony off).
1269
- InstallMediaUpgradeDispatcher(httpServer, webSocketServer, graphqlRootPath);
1270
-
1271
1314
  // ─── Global CORS (before auth so 401 responses include CORS headers) ─────
1272
1315
  // Without this, the browser blocks 401 responses from the auth middleware
1273
1316
  // because they lack Access-Control-Allow-Origin headers, preventing the
@@ -1294,35 +1337,16 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1294
1337
  maxAge: configInfo.cors?.maxAge ?? 86400,
1295
1338
  }));
1296
1339
 
1297
- // ─── Server extensions (before auth — extensions handle their own auth) ─────
1340
+ // ─── Pre-auth server extensions (before auth — extensions handle their own auth) ─────
1298
1341
  // Slack uses HMAC signature verification, Teams uses Bot Framework JWT validation.
1299
1342
  // These must be registered before the unified auth middleware so webhook
1300
1343
  // requests aren't rejected for lacking an MJ bearer token.
1301
- const extensionLoader = new ServerExtensionLoader();
1302
- // Open App packages publish their extensions; host mj.config.cjs overlays by DriverClass
1303
- // (and remains the only source for host-only extensions such as Slack/Teams).
1304
- // extraReservedRoots is derived from the mounts registered above plus graphqlRootPath
1305
- // so a new pre-auth app.use(...) in serve() must also be added to
1306
- // coreReservedServerExtensionRoots() — otherwise an Open App can claim it.
1307
- const extensionConfigs = prepareServerExtensionConfigs(
1308
- mergeServerExtensionConfigs(
1309
- options?.serverExtensions ?? [],
1310
- (configInfo.serverExtensions ?? []) as ServerExtensionConfig[],
1311
- ),
1312
- {
1313
- onInvalid: (message) => LogError(message),
1314
- onOverlap: (message) => LogStatus(message),
1315
- extraReservedRoots: coreReservedServerExtensionRoots(graphqlRootPath),
1316
- },
1317
- );
1318
- // These routes mount BEFORE createUnifiedAuthMiddleware. Name every one so an
1319
- // operator who installed an Open App for its entities can see the pre-auth HTTP
1320
- // surface and suppress it with host serverExtensions[].Enabled = false.
1321
- for (const cfg of extensionConfigs) {
1322
- LogStatus(`Server extension ${describeServerExtensionMount(cfg)}`);
1323
- }
1324
1344
  if (extensionConfigs.length > 0) {
1325
- await extensionLoader.LoadExtensions(app, extensionConfigs);
1345
+ await extensionLoader.LoadExtensions(app, extensionConfigs, {
1346
+ phase: 'pre-auth',
1347
+ httpServer,
1348
+ publicUrl: oauthPublicUrl,
1349
+ });
1326
1350
  }
1327
1351
 
1328
1352
  // Extension health endpoint (always available, returns empty array if no extensions)
@@ -1372,6 +1396,34 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1372
1396
  startupLog.LogIf('verbose', `[Widget] Authenticated route registered at ${WIDGET_MOUNT_PATH}/resolve-identity`);
1373
1397
  }
1374
1398
 
1399
+ // ─── Post-auth server extensions ──────────────────────────────────────────
1400
+ // Extensions declaring Phase: 'post-auth' mount after the unified auth middleware
1401
+ // and are protected by JWT authentication by default.
1402
+ if (extensionConfigs.length > 0) {
1403
+ await extensionLoader.LoadExtensions(app, extensionConfigs, {
1404
+ phase: 'post-auth',
1405
+ httpServer,
1406
+ publicUrl: oauthPublicUrl,
1407
+ });
1408
+ }
1409
+
1410
+ // ─── Cross-extension lifecycle hook: OnAllExtensionsMounted ───────────────
1411
+ // Called after all extensions across both pre-auth and post-auth phases are mounted.
1412
+ // Extensions can wire themselves to services registered by other extensions or core.
1413
+ await extensionLoader.NotifyAllExtensionsMounted({
1414
+ httpServer,
1415
+ publicUrl: oauthPublicUrl,
1416
+ });
1417
+
1418
+ // Install the single path-routing WebSocket-upgrade dispatcher AFTER all extensions across both
1419
+ // pre-auth and post-auth phases have mounted and registered their media WSS routes. ws 8.x has each
1420
+ // {server}-bound WebSocketServer 400 paths it doesn't own, so the GraphQL socket and the telephony
1421
+ // media sockets cannot coexist as separate {server} servers — this strips the auto-listeners and
1422
+ // routes upgrades by path. No-op when no media routes registered (telephony off).
1423
+ InstallMediaUpgradeDispatcher(httpServer, webSocketServer, graphqlRootPath, (req, socket, head) => {
1424
+ return RealtimeProxyServer.Instance.TryHandleUpgrade(req, socket, head);
1425
+ });
1426
+
1375
1427
  // ─── REST API endpoints (auth already handled by unified middleware) ─────
1376
1428
  const restApiConfig = {
1377
1429
  enabled: configInfo.restApiOptions?.enabled ?? false,
@@ -1537,22 +1589,6 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1537
1589
  .catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
1538
1590
  }
1539
1591
 
1540
- // Launch the calendar / scheduled-bridge loop (M2): poll agent calendars for meeting invites and
1541
- // start due meeting bridges. Mirrors the SessionJanitor lifecycle (run-once + interval, timer
1542
- // unref'd). Gated on Teams meetings being enabled (the provider whose scheduled-join is wired) and
1543
- // reuses the SAME meetings service as the ingress; identities without configured calendar creds are
1544
- // skipped, so this is a harmless no-op until a Graph-backed identity + token are configured.
1545
- if (resumeUser && configInfo.telephony?.teams?.enabled) { // global-provider-ok: server-owned background poller under the server's provider + system user
1546
- const teamsMeetingsService = GetTeamsMeetingsService();
1547
- if (teamsMeetingsService) {
1548
- StartCalendarScheduler({
1549
- Provider: Metadata.Provider, // global-provider-ok: server-owned background poller under the server's single default provider + system user
1550
- ContextUser: resumeUser,
1551
- TeamsService: teamsMeetingsService,
1552
- TeamsConfig: configInfo.telephony.teams,
1553
- });
1554
- }
1555
- }
1556
1592
 
1557
1593
  // Set up graceful shutdown handlers
1558
1594
  const gracefulShutdown = async (signal: string) => {
@@ -1562,6 +1598,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1562
1598
  if (extensionLoader.ExtensionCount > 0) {
1563
1599
  try {
1564
1600
  await extensionLoader.ShutdownAll();
1601
+ _currentExtensionLoader = null;
1565
1602
  console.log('✅ Server extensions shut down');
1566
1603
  } catch (error) {
1567
1604
  console.error('❌ Error shutting down server extensions:', error);
@@ -1,7 +1,8 @@
1
- import { Metadata, CompositeKey, DatabaseProviderBase } from '@memberjunction/core';
1
+ import { CompositeKey, DatabaseProviderBase, EntityInfo, LogDebug, UserInfo } from '@memberjunction/core';
2
2
  import { Arg, Ctx, Field, InputType, ObjectType, Query, Resolver } from 'type-graphql';
3
- import { AppContext } from '../types.js';
3
+ import { AppContext, UserPayload } from '../types.js';
4
4
  import { CompositeKeyInputType, CompositeKeyOutputType } from '../generic/KeyInputOutputTypes.js';
5
+ import { ResolverBase } from '../generic/ResolverBase.js';
5
6
  import { GetReadOnlyProvider } from '../util.js';
6
7
 
7
8
  @InputType()
@@ -31,8 +32,27 @@ export class EntityRecordNameResult {
31
32
  RecordName?: string;
32
33
  }
33
34
 
35
+ /**
36
+ * Resolves a record's display NAME from its primary key — the lookup behind every foreign-key
37
+ * link, breadcrumb and picker label in the UI.
38
+ *
39
+ * **This is a read of entity data and is gated like one.** It historically took no user context
40
+ * at all, which made it an open side channel: a caller denied READ on an entity's name field
41
+ * could still obtain that name here, because the name never travelled through the resolver that
42
+ * strips denied fields. Field-level security turned that latent gap into a live bypass — the
43
+ * form-field FK control falls through to this query precisely WHEN the joined display column is
44
+ * denied, so the one case the fallback exists for was the case that leaked.
45
+ *
46
+ * Two gates now apply, matching what the generated single-record resolvers do:
47
+ * 1. entity-level read permission ({@link ResolverBase.CheckUserReadPermissions});
48
+ * 2. field-level read permission on the entity's own name field.
49
+ *
50
+ * A field-level denial answers `Success: false` with no name rather than throwing. Callers
51
+ * already treat "no name" as "show the primary key", which is the intended degradation — and it
52
+ * keeps this query from becoming a probe that distinguishes "restricted" from "missing".
53
+ */
34
54
  @Resolver(EntityRecordNameResult)
35
- export class EntityRecordNameResolver {
55
+ export class EntityRecordNameResolver extends ResolverBase {
36
56
  @Query(() => EntityRecordNameResult)
37
57
  async GetEntityRecordName(
38
58
  @Arg('EntityName', () => String) EntityName: string,
@@ -40,28 +60,53 @@ export class EntityRecordNameResolver {
40
60
  @Ctx() { providers, userPayload }: AppContext
41
61
  ): Promise<EntityRecordNameResult> {
42
62
  const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
63
+ this.CheckUserReadPermissions(EntityName, userPayload, md);
43
64
 
44
- return await this.InnerGetEntityRecordName(md, EntityName, primaryKey);
65
+ return await this.InnerGetEntityRecordName(md, EntityName, primaryKey, userPayload);
45
66
  }
46
67
 
47
68
  @Query(() => [EntityRecordNameResult])
48
69
  async GetEntityRecordNames(
49
70
  @Arg('info', () => [EntityRecordNameInput]) info: EntityRecordNameInput[],
50
- @Ctx() {providers}: AppContext
71
+ @Ctx() {providers, userPayload}: AppContext
51
72
  ): Promise<EntityRecordNameResult[]> {
52
73
  const result: EntityRecordNameResult[] = [];
53
74
  const md = GetReadOnlyProvider(providers, {allowFallbackToReadWrite: true});
54
75
  for (const i of info) {
55
- result.push(await this.InnerGetEntityRecordName(md, i.EntityName, i.CompositeKey));
76
+ // Per item, because the batch spans entities: one denied entity must not fail the whole
77
+ // batch, and must not be answered from another entity's grant either.
78
+ this.CheckUserReadPermissions(i.EntityName, userPayload, md);
79
+ result.push(await this.InnerGetEntityRecordName(md, i.EntityName, i.CompositeKey, userPayload));
56
80
  }
57
81
  return result;
58
82
  }
59
83
 
60
- async InnerGetEntityRecordName(md: DatabaseProviderBase, EntityName: string, primaryKey: CompositeKeyInputType): Promise<EntityRecordNameResult> {
84
+ async InnerGetEntityRecordName(
85
+ md: DatabaseProviderBase,
86
+ EntityName: string,
87
+ primaryKey: CompositeKeyInputType,
88
+ userPayload?: UserPayload
89
+ ): Promise<EntityRecordNameResult> {
61
90
  const pk = new CompositeKey(primaryKey.KeyValuePairs);
62
91
  const e = md.Entities.find((e) => e.Name === EntityName);
63
92
  if (e) {
64
- const recordName = await md.GetEntityRecordName(e.Name, pk);
93
+ const contextUser = userPayload ? this.GetUserFromPayload(userPayload) : undefined;
94
+ if (this.IsNameFieldDeniedToUser(e, contextUser)) {
95
+ LogDebug(
96
+ `[FieldSecurity] Suppressed GetEntityRecordName for '${EntityName}': ` +
97
+ `the name field is not readable by this user`
98
+ );
99
+ return {
100
+ Success: false,
101
+ Status: `Name for record, or record ${pk.ToString()} itself not found, could be an access issue`,
102
+ CompositeKey: pk,
103
+ EntityName,
104
+ };
105
+ }
106
+ // Pass the acting user through: the provider signature accepts it, and omitting it ran the
107
+ // underlying lookup with no identity at all — so row-level security had nothing to filter on
108
+ // either. The field-level gate above is the column half; this is the row half.
109
+ const recordName = await md.GetEntityRecordName(e.Name, pk, contextUser);
65
110
  if (recordName) return { Success: true, Status: 'OK', CompositeKey: pk, RecordName: recordName, EntityName };
66
111
  else
67
112
  return {
@@ -72,6 +117,27 @@ export class EntityRecordNameResolver {
72
117
  };
73
118
  } else return { Success: false, Status: `Entity ${EntityName} not found`, CompositeKey: pk, EntityName };
74
119
  }
120
+
121
+ /**
122
+ * Whether field security withholds this entity's NAME field from the user.
123
+ *
124
+ * Resolves the name field the same way the provider's own lookup does — the designated
125
+ * `IsNameField`, else a field literally called `Name` — so the check cannot drift from the
126
+ * value it is guarding. Fails OPEN only where field security does not apply at all (flag off,
127
+ * no user, no name field), matching every other FLS gate.
128
+ */
129
+ protected IsNameFieldDeniedToUser(entity: EntityInfo, contextUser?: UserInfo): boolean {
130
+ if (!entity?.EnableFieldLevelSecurity || !contextUser) {
131
+ return false;
132
+ }
133
+ const nameField =
134
+ entity.Fields?.find(f => f.IsNameField) ??
135
+ entity.Fields?.find(f => f.Name?.trim().toLowerCase() === 'name');
136
+ if (!nameField) {
137
+ return false;
138
+ }
139
+ return entity.GetDeniedReadFields(contextUser).has(nameField.Name.trim().toLowerCase());
140
+ }
75
141
  }
76
142
 
77
143
  export default EntityRecordNameResolver;