@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.7

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 (250) hide show
  1. package/README.md +30 -11
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.js +2 -2
  4. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  5. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  6. package/dist/auth/exampleNewUserSubClass.js +9 -4
  7. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  9. package/dist/auth/magicLink/MagicLinkService.js +2 -8
  10. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  11. package/dist/auth/newUsers.d.ts.map +1 -1
  12. package/dist/auth/newUsers.js +4 -12
  13. package/dist/auth/newUsers.js.map +1 -1
  14. package/dist/auth/principals.d.ts +92 -0
  15. package/dist/auth/principals.d.ts.map +1 -0
  16. package/dist/auth/principals.js +193 -0
  17. package/dist/auth/principals.js.map +1 -0
  18. package/dist/config.d.ts +106 -12
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +41 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
  23. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  24. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
  25. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  26. package/dist/generated/graphql-schemas/__mj.d.ts +7607 -6853
  27. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  28. package/dist/generated/graphql-schemas/__mj.js +25141 -21645
  29. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  30. package/dist/generic/ResolverBase.d.ts +152 -2
  31. package/dist/generic/ResolverBase.d.ts.map +1 -1
  32. package/dist/generic/ResolverBase.js +389 -10
  33. package/dist/generic/ResolverBase.js.map +1 -1
  34. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  35. package/dist/generic/RunViewResolver.js +26 -2
  36. package/dist/generic/RunViewResolver.js.map +1 -1
  37. package/dist/generic/refusalExtensions.d.ts +52 -0
  38. package/dist/generic/refusalExtensions.d.ts.map +1 -0
  39. package/dist/generic/refusalExtensions.js +16 -0
  40. package/dist/generic/refusalExtensions.js.map +1 -0
  41. package/dist/index.d.ts +11 -3
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +131 -93
  44. package/dist/index.js.map +1 -1
  45. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  46. package/dist/realtimeWidget/WidgetSessionService.js +2 -12
  47. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  48. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  49. package/dist/realtimeWidget/visitorIdentity.js +20 -5
  50. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  51. package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
  52. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  53. package/dist/resolvers/EntityRecordNameResolver.js +64 -7
  54. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  55. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/FileCategoryResolver.js +1 -2
  57. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  58. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  59. package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
  60. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  61. package/dist/resolvers/QuerySystemUserResolver.js +1 -1
  62. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  63. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
  64. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  65. package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
  66. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  67. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
  68. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  69. package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
  70. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  71. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  72. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  73. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  74. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  75. package/dist/resolvers/UserViewResolver.js +8 -4
  76. package/dist/resolvers/UserViewResolver.js.map +1 -1
  77. package/dist/rest/EntityCRUDHandler.d.ts +2 -1
  78. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  79. package/dist/rest/EntityCRUDHandler.js +3 -18
  80. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  81. package/dist/rest/RESTEndpointHandler.d.ts +2 -1
  82. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  83. package/dist/rest/RESTEndpointHandler.js +3 -16
  84. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  85. package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
  86. package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
  87. package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
  88. package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
  89. package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
  90. package/dist/serverExtensionReservedRoots.js +2 -3
  91. package/dist/serverExtensionReservedRoots.js.map +1 -1
  92. package/package.json +92 -91
  93. package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
  94. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
  95. package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
  96. package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
  97. package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
  98. package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
  99. package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
  100. package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
  101. package/src/__tests__/WidgetSessionService.test.ts +4 -1
  102. package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
  103. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  104. package/src/__tests__/newUsers.test.ts +36 -14
  105. package/src/__tests__/principals.callSites.test.ts +112 -0
  106. package/src/__tests__/principals.logging.test.ts +160 -0
  107. package/src/__tests__/principals.test.ts +305 -0
  108. package/src/__tests__/resolverBase.fls.test.ts +483 -0
  109. package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
  110. package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
  111. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  112. package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
  113. package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
  114. package/src/agentSessions/SessionJanitor.ts +2 -2
  115. package/src/auth/exampleNewUserSubClass.ts +9 -6
  116. package/src/auth/magicLink/MagicLinkService.ts +2 -8
  117. package/src/auth/newUsers.ts +7 -15
  118. package/src/auth/principals.ts +257 -0
  119. package/src/config.ts +44 -4
  120. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
  121. package/src/generated/graphql-schemas/__mj.ts +18966 -16452
  122. package/src/generic/ResolverBase.ts +467 -9
  123. package/src/generic/RunViewResolver.ts +28 -2
  124. package/src/generic/refusalExtensions.ts +59 -0
  125. package/src/index.ts +154 -102
  126. package/src/realtimeWidget/WidgetSessionService.ts +2 -14
  127. package/src/realtimeWidget/visitorIdentity.ts +21 -6
  128. package/src/resolvers/EntityRecordNameResolver.ts +74 -8
  129. package/src/resolvers/FileCategoryResolver.ts +2 -3
  130. package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
  131. package/src/resolvers/QuerySystemUserResolver.ts +1 -1
  132. package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
  133. package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
  134. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  135. package/src/resolvers/UserViewResolver.ts +8 -4
  136. package/src/rest/EntityCRUDHandler.ts +3 -19
  137. package/src/rest/RESTEndpointHandler.ts +3 -17
  138. package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
  139. package/src/serverExtensionReservedRoots.ts +2 -3
  140. package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
  141. package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
  142. package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
  143. package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
  144. package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
  145. package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
  146. package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
  147. package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
  148. package/dist/resolvers/TelephonyResolver.d.ts +0 -26
  149. package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
  150. package/dist/resolvers/TelephonyResolver.js +0 -86
  151. package/dist/resolvers/TelephonyResolver.js.map +0 -1
  152. package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
  153. package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
  154. package/dist/resolvers/VonageTelephonyResolver.js +0 -86
  155. package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
  156. package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
  157. package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
  158. package/dist/telephony/RingCentralTelephonyService.js +0 -262
  159. package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
  160. package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
  161. package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
  162. package/dist/telephony/TeamsMeetingsRouter.js +0 -96
  163. package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
  164. package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
  165. package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
  166. package/dist/telephony/TeamsMeetingsService.js +0 -196
  167. package/dist/telephony/TeamsMeetingsService.js.map +0 -1
  168. package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
  169. package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
  170. package/dist/telephony/TwilioTelephonyRouter.js +0 -143
  171. package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
  172. package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
  173. package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
  174. package/dist/telephony/TwilioTelephonyService.js +0 -193
  175. package/dist/telephony/TwilioTelephonyService.js.map +0 -1
  176. package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
  177. package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
  178. package/dist/telephony/VonageTelephonyRouter.js +0 -201
  179. package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
  180. package/dist/telephony/VonageTelephonyService.d.ts +0 -90
  181. package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
  182. package/dist/telephony/VonageTelephonyService.js +0 -191
  183. package/dist/telephony/VonageTelephonyService.js.map +0 -1
  184. package/dist/telephony/calendar-scheduler.d.ts +0 -67
  185. package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
  186. package/dist/telephony/calendar-scheduler.js +0 -133
  187. package/dist/telephony/calendar-scheduler.js.map +0 -1
  188. package/dist/telephony/index.d.ts +0 -29
  189. package/dist/telephony/index.d.ts.map +0 -1
  190. package/dist/telephony/index.js +0 -38
  191. package/dist/telephony/index.js.map +0 -1
  192. package/dist/telephony/media-upgrade-router.d.ts +0 -47
  193. package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
  194. package/dist/telephony/media-upgrade-router.js +0 -72
  195. package/dist/telephony/media-upgrade-router.js.map +0 -1
  196. package/dist/telephony/ringcentral-runtime.d.ts +0 -14
  197. package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
  198. package/dist/telephony/ringcentral-runtime.js +0 -18
  199. package/dist/telephony/ringcentral-runtime.js.map +0 -1
  200. package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
  201. package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
  202. package/dist/telephony/teams-meetings-runtime.js +0 -20
  203. package/dist/telephony/teams-meetings-runtime.js.map +0 -1
  204. package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
  205. package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
  206. package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
  207. package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
  208. package/dist/telephony/telephony-runtime.d.ts +0 -14
  209. package/dist/telephony/telephony-runtime.d.ts.map +0 -1
  210. package/dist/telephony/telephony-runtime.js +0 -18
  211. package/dist/telephony/telephony-runtime.js.map +0 -1
  212. package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
  213. package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
  214. package/dist/telephony/twilioMediaRegistry.js +0 -106
  215. package/dist/telephony/twilioMediaRegistry.js.map +0 -1
  216. package/dist/telephony/vonage-runtime.d.ts +0 -14
  217. package/dist/telephony/vonage-runtime.d.ts.map +0 -1
  218. package/dist/telephony/vonage-runtime.js +0 -18
  219. package/dist/telephony/vonage-runtime.js.map +0 -1
  220. package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
  221. package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
  222. package/dist/telephony/vonageMediaRegistry.js +0 -158
  223. package/dist/telephony/vonageMediaRegistry.js.map +0 -1
  224. package/src/__tests__/media-upgrade-router.test.ts +0 -43
  225. package/src/__tests__/teams-meetings-service.test.ts +0 -65
  226. package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
  227. package/src/__tests__/twilio-telephony-service.test.ts +0 -23
  228. package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
  229. package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
  230. package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
  231. package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
  232. package/src/resolvers/TelephonyResolver.ts +0 -63
  233. package/src/resolvers/VonageTelephonyResolver.ts +0 -63
  234. package/src/telephony/RingCentralTelephonyService.ts +0 -342
  235. package/src/telephony/TeamsMeetingsRouter.ts +0 -124
  236. package/src/telephony/TeamsMeetingsService.ts +0 -268
  237. package/src/telephony/TwilioTelephonyRouter.ts +0 -184
  238. package/src/telephony/TwilioTelephonyService.ts +0 -261
  239. package/src/telephony/VonageTelephonyRouter.ts +0 -239
  240. package/src/telephony/VonageTelephonyService.ts +0 -259
  241. package/src/telephony/calendar-scheduler.ts +0 -176
  242. package/src/telephony/index.ts +0 -43
  243. package/src/telephony/media-upgrade-router.ts +0 -79
  244. package/src/telephony/ringcentral-runtime.ts +0 -22
  245. package/src/telephony/teams-meetings-runtime.ts +0 -24
  246. package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
  247. package/src/telephony/telephony-runtime.ts +0 -22
  248. package/src/telephony/twilioMediaRegistry.ts +0 -137
  249. package/src/telephony/vonage-runtime.ts +0 -22
  250. package/src/telephony/vonageMediaRegistry.ts +0 -200
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @fileoverview The `extensions` object every write-refusal `GraphQLError` in `ResolverBase` carries.
3
+ *
4
+ * A save refused by `Validate()` / `ValidateAsync()` leaves its reasons — one `ValidationErrorInfo`
5
+ * per field — in `LatestResult.Errors`. Until now the resolver threw only `CompleteMessage`, the
6
+ * flattened prose, so the client got readable text but lost the *structure*: which field each
7
+ * sentence belonged to. That is why a server-side `ValidateAsync()` refusal could only ever toast,
8
+ * while the same rule written in a synchronous `Validate()` painted the offending field red — the
9
+ * client runs sync validation locally and never needed the wire to tell it the field.
10
+ *
11
+ * `RefusalExtensions` keeps the message exactly as before and ADDS `validationErrors`, the
12
+ * `Errors` array flattened by `SerializeValidationErrors` (so a class instance never has to survive
13
+ * JSON), plus `messageIncludesValidationErrors: true` — the server stating that the message it threw
14
+ * is `CompleteMessage`, so the client can keep its own `CompleteMessage` from repeating the text
15
+ * without assuming anything about what the server put in `message`. `GraphQLDataProvider.Save()` rehydrates it into `LatestResult.Errors` on the client, and the
16
+ * form paints fields from there — the same path the sync branch already uses.
17
+ *
18
+ * Kept out of `ResolverBase.ts` so it is unit-testable without booting the resolver's dependency
19
+ * graph, and so the three resolvers (create / update / delete) share one definition of the shape.
20
+ */
21
+ import type { BaseEntityResult } from '@memberjunction/core';
22
+ import { SerializeValidationErrors, type SerializedValidationError } from '@memberjunction/global';
23
+
24
+ /** Error codes the write resolvers use; kept distinct so a client can tell the operations apart. */
25
+ export type WriteRefusalCode = 'CREATE_ENTITY_ERROR' | 'SAVE_ENTITY_ERROR' | 'DELETE_ENTITY_ERROR';
26
+
27
+ /** The `extensions` payload of a write-refusal `GraphQLError`. */
28
+ export type WriteRefusalExtensions = {
29
+ code: WriteRefusalCode;
30
+ entityName: string;
31
+ /**
32
+ * Present only when the refusal carried structured errors. Absent (not an empty array) otherwise,
33
+ * so a client can distinguish "validation said no" from "the database said no".
34
+ */
35
+ validationErrors?: SerializedValidationError[];
36
+ /**
37
+ * Present (and `true`) alongside `validationErrors`: a statement by THIS producer that the
38
+ * error's `message` is `LatestResult.CompleteMessage`, i.e. it already renders every entry of
39
+ * `validationErrors`. The client sets `BaseEntityResult.MessageIncludesErrors` from this — a
40
+ * fact the server asserts on the wire — rather than assuming what the server put in `message`.
41
+ * A server that does not send it gets the client's pre-existing behaviour (the text may repeat).
42
+ */
43
+ messageIncludesValidationErrors?: true;
44
+ };
45
+
46
+ /**
47
+ * Builds the `extensions` for a refused create / update / delete.
48
+ *
49
+ * @param code - Which operation was refused.
50
+ * @param entityName - The entity the operation targeted.
51
+ * @param result - The entity's `LatestResult` after the failed call, if any.
52
+ * @returns `{ code, entityName }`, plus `validationErrors` when `result.Errors` had anything to carry.
53
+ */
54
+ export function RefusalExtensions(code: WriteRefusalCode, entityName: string, result: BaseEntityResult | null | undefined): WriteRefusalExtensions {
55
+ const validationErrors = SerializeValidationErrors(result?.Errors);
56
+ return validationErrors.length > 0
57
+ ? { code, entityName, validationErrors, messageIncludesValidationErrors: true }
58
+ : { code, entityName };
59
+ }
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';
@@ -127,12 +131,31 @@ export {
127
131
  } from './auth/index.js';
128
132
  export * from './auth/APIKeyScopeAuth.js';
129
133
  export * from './auth/actingContextResolver.js';
134
+ // The context-user ladder (#4209). Public because `auth/exampleNewUserSubClass.ts` — the template
135
+ // integrators are told to copy into their OWN package — resolves through it, and `package.json`
136
+ // publishes only "."; without this the example compiles here and cannot be reused anywhere else.
137
+ // `ReportedMisconfigurationCount` / `MAX_REPORTED_MISCONFIGURATIONS` are deliberately NOT here:
138
+ // they exist so the LRU's bound is assertable, the tests import them from the module directly, and
139
+ // a published export is a maintenance commitment no caller asked for.
140
+ export { ResolveConfiguredPrincipal, resolvePrincipalFrom } from './auth/principals.js';
141
+ export type { ResolvablePrincipal, PrincipalResolution, PrincipalResolutionReason } from './auth/principals.js';
130
142
  export { CloneUserForSessionContext } from './auth/sessionUserClone.js';
131
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
+
132
154
  export * from './generic/PushStatusResolver.js';
133
155
  export * from './generic/PubSubManager.js';
134
156
  export * from './generic/CacheInvalidationResolver.js';
135
157
  export * from './generic/ResolverBase.js';
158
+ export * from './generic/refusalExtensions.js';
136
159
  export * from './generic/RunViewResolver.js';
137
160
  export * from './resolvers/RunTemplateResolver.js';
138
161
  export * from './resolvers/RunAIPromptResolver.js';
@@ -1158,6 +1181,15 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1158
1181
  startupLog.LogIf('verbose', '[OAuth] Callback route registered at /oauth/callback');
1159
1182
  }
1160
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
+
1161
1193
  // ─── eSignature webhook (unauthenticated, registered BEFORE auth) ─────
1162
1194
  // Called by external signature providers (DocuSign Connect, etc.) without an MJ bearer token.
1163
1195
  // The provider DRIVER verifies the payload signature/HMAC; MJ auth does not apply here.
@@ -1171,6 +1203,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1171
1203
  app.use('/media', cors<cors.CorsRequest>(), createMediaStreamRouter());
1172
1204
  startupLog.LogIf('verbose', '[Media] Streaming route registered at /media/:fileId');
1173
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
+
1174
1212
  // ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
1175
1213
  // Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
1176
1214
  // authenticated invite-creation router mounts AFTER it (see below).
@@ -1204,110 +1242,111 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1204
1242
  startupLog.LogIf('verbose', `[Widget] Public routes registered at ${WIDGET_MOUNT_PATH}/session and ${WIDGET_MOUNT_PATH}/session/refresh`);
1205
1243
  }
1206
1244
 
1207
- // ─── Telephony (Twilio) ingress: inbound voice webhook + Media-Streams WSS (PUBLIC) ──
1208
- // Carriers cannot present an MJ JWT — the X-Twilio-Signature HMAC is the gate. The
1209
- // public webhook router mounts BEFORE the auth middleware; the Media-Streams WSS attaches
1210
- // to the shared HTTP server. The outbound PlaceTwilioCall mutation reuses the same service.
1211
- if (configInfo.telephony?.enabled && configInfo.telephony.twilio) {
1212
- const twilioHandler = createTwilioTelephonyHandler(oauthPublicUrl, configInfo.telephony.twilio);
1213
- app.use(TWILIO_TELEPHONY_MOUNT_PATH, cors<cors.CorsRequest>(), twilioHandler.publicRouter);
1214
- twilioHandler.attachMediaStreamServer();
1215
- SetTwilioTelephonyService(twilioHandler.service);
1216
- startupLog.LogIf('verbose', `[Telephony] Twilio routes registered at ${TWILIO_TELEPHONY_MOUNT_PATH}/voice + Media-Streams WSS`);
1217
- }
1245
+ // ─── Server extensions loader & shared service registry ───────────────────
1246
+ const extensionLoader = new ServerExtensionLoader();
1247
+ _currentExtensionLoader = extensionLoader;
1218
1248
 
1219
- // ─── Telephony (Vonage) ingress: inbound answer/event webhooks + media WSS (PUBLIC) ──
1220
- // Carriers cannot present an MJ JWT — the Vonage signed-request HMAC / webhook JWT is the gate.
1221
- // The public router mounts BEFORE the auth middleware; the media WSS attaches to the shared
1222
- // HTTP server. The outbound PlaceVonageCall mutation reuses the same service.
1223
- if (configInfo.telephony?.enabled && configInfo.telephony.vonage) {
1224
- const vonageHandler = createVonageTelephonyHandler(oauthPublicUrl, configInfo.telephony.vonage);
1225
- app.use(VONAGE_TELEPHONY_MOUNT_PATH, cors<cors.CorsRequest>(), vonageHandler.publicRouter);
1226
- vonageHandler.attachMediaStreamServer();
1227
- SetVonageTelephonyService(vonageHandler.service);
1228
- 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
+ }
1229
1289
  }
1230
1290
 
1231
- // ─── Telephony (RingCentral) ingress: SIP softphone registration (no HTTP webhook / media WSS) ──
1232
- // RingCentral's only bidirectional-audio transport is a registered SIP softphone — inbound calls arrive
1233
- // as SIP INVITEs on its own SIP/TLS connection, so there is no public webhook or media WSS to mount.
1234
- // start() registers the softphone fire-and-forget so SIP registration never blocks boot; the outbound
1235
- // PlaceRingCentralCall mutation reuses the same service via the runtime holder.
1236
- if (configInfo.telephony?.enabled && configInfo.telephony.ringcentral) {
1237
- const ringCentralService = new RingCentralTelephonyService(configInfo.telephony.ringcentral);
1238
- SetRingCentralTelephonyService(ringCentralService);
1239
- void ringCentralService.start();
1240
- startupLog.LogIf('verbose', `[Telephony] RingCentral SIP softphone starting (codec ${configInfo.telephony.ringcentral.codec ?? 'OPUS/16000'})`);
1241
- }
1291
+ const rawHostExtensions = (configInfo.serverExtensions ?? []) as ServerExtensionConfig[];
1292
+ const mergedHostExtensions = mergeServerExtensionConfigs(telephonyExtensionConfigs, rawHostExtensions);
1242
1293
 
1243
- // ─── Teams meetings ingress: Graph change-notification webhook (PUBLIC) ──────────────
1244
- // Graph cannot present an MJ JWT — the subscription validationToken handshake + the per-
1245
- // notification clientState shared secret are the gate. The public webhook router mounts
1246
- // BEFORE the auth middleware. The ACS application-hosted-media audio plane is owned by the
1247
- // server's native ACS media adapter, which attaches transports to the shared registry
1248
- // (a media WSS is not needed here). The StartTeamsMeetingSession mutation reuses the same
1249
- // service via the runtime holder.
1250
- if (configInfo.telephony?.enabled && configInfo.telephony.teams?.enabled) {
1251
- const teamsHandler = createTeamsMeetingsHandler(configInfo.telephony.teams);
1252
- app.use(TEAMS_MEETINGS_MOUNT_PATH, cors<cors.CorsRequest>(), teamsHandler.publicRouter);
1253
- SetTeamsMeetingsService(teamsHandler.service);
1254
- 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)}`);
1255
1312
  }
1256
1313
 
1257
- // Install the single path-routing WebSocket-upgrade dispatcher AFTER all media WSS routes have
1258
- // registered. ws 8.x has each {server}-bound WebSocketServer 400 paths it doesn't own, so the GraphQL
1259
- // socket and the telephony media sockets cannot coexist as separate {server} servers — this strips the
1260
- // auto-listeners and routes upgrades by path. No-op when no media routes registered (telephony off).
1261
- InstallMediaUpgradeDispatcher(httpServer, webSocketServer, graphqlRootPath);
1262
-
1263
1314
  // ─── Global CORS (before auth so 401 responses include CORS headers) ─────
1264
1315
  // Without this, the browser blocks 401 responses from the auth middleware
1265
1316
  // because they lack Access-Control-Allow-Origin headers, preventing the
1266
1317
  // client from reading the error code and triggering token refresh.
1267
1318
  const corsAllowed = configInfo.cors?.allowedOrigins ?? ['*'];
1319
+ const corsWildcard = corsAllowed.includes('*');
1320
+ // SECURITY: never combine credentials with a wildcard/reflect-any-origin policy. When
1321
+ // allowedOrigins is ['*'] the origin callback reflects the caller's Origin, and pairing that
1322
+ // with Access-Control-Allow-Credentials: true lets any site a signed-in user visits make
1323
+ // credentialed cross-origin reads. MJ's primary auth is a Bearer token (not auto-sent
1324
+ // cross-origin), so dropping credentials under the wildcard default is safe; deployments that
1325
+ // genuinely need credentialed CORS must configure an explicit allowedOrigins list.
1268
1326
  app.use(cors<cors.CorsRequest>({
1269
1327
  origin: (origin, callback) => {
1270
1328
  // Allow all origins when ['*'] (default/backward-compatible),
1271
1329
  // or when no Origin header (server-to-server calls).
1272
- if (corsAllowed.includes('*') || !origin || corsAllowed.includes(origin)) {
1330
+ if (corsWildcard || !origin || corsAllowed.includes(origin)) {
1273
1331
  callback(null, true);
1274
1332
  } else {
1275
1333
  callback(new Error(`Origin ${origin} not allowed by CORS`));
1276
1334
  }
1277
1335
  },
1278
- credentials: configInfo.cors?.allowCredentials ?? true,
1336
+ credentials: corsWildcard ? false : (configInfo.cors?.allowCredentials ?? true),
1279
1337
  maxAge: configInfo.cors?.maxAge ?? 86400,
1280
1338
  }));
1281
1339
 
1282
- // ─── Server extensions (before auth — extensions handle their own auth) ─────
1340
+ // ─── Pre-auth server extensions (before auth — extensions handle their own auth) ─────
1283
1341
  // Slack uses HMAC signature verification, Teams uses Bot Framework JWT validation.
1284
1342
  // These must be registered before the unified auth middleware so webhook
1285
1343
  // requests aren't rejected for lacking an MJ bearer token.
1286
- const extensionLoader = new ServerExtensionLoader();
1287
- // Open App packages publish their extensions; host mj.config.cjs overlays by DriverClass
1288
- // (and remains the only source for host-only extensions such as Slack/Teams).
1289
- // extraReservedRoots is derived from the mounts registered above plus graphqlRootPath
1290
- // so a new pre-auth app.use(...) in serve() must also be added to
1291
- // coreReservedServerExtensionRoots() — otherwise an Open App can claim it.
1292
- const extensionConfigs = prepareServerExtensionConfigs(
1293
- mergeServerExtensionConfigs(
1294
- options?.serverExtensions ?? [],
1295
- (configInfo.serverExtensions ?? []) as ServerExtensionConfig[],
1296
- ),
1297
- {
1298
- onInvalid: (message) => LogError(message),
1299
- onOverlap: (message) => LogStatus(message),
1300
- extraReservedRoots: coreReservedServerExtensionRoots(graphqlRootPath),
1301
- },
1302
- );
1303
- // These routes mount BEFORE createUnifiedAuthMiddleware. Name every one so an
1304
- // operator who installed an Open App for its entities can see the pre-auth HTTP
1305
- // surface and suppress it with host serverExtensions[].Enabled = false.
1306
- for (const cfg of extensionConfigs) {
1307
- LogStatus(`Server extension ${describeServerExtensionMount(cfg)}`);
1308
- }
1309
1344
  if (extensionConfigs.length > 0) {
1310
- await extensionLoader.LoadExtensions(app, extensionConfigs);
1345
+ await extensionLoader.LoadExtensions(app, extensionConfigs, {
1346
+ phase: 'pre-auth',
1347
+ httpServer,
1348
+ publicUrl: oauthPublicUrl,
1349
+ });
1311
1350
  }
1312
1351
 
1313
1352
  // Extension health endpoint (always available, returns empty array if no extensions)
@@ -1357,6 +1396,34 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1357
1396
  startupLog.LogIf('verbose', `[Widget] Authenticated route registered at ${WIDGET_MOUNT_PATH}/resolve-identity`);
1358
1397
  }
1359
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
+
1360
1427
  // ─── REST API endpoints (auth already handled by unified middleware) ─────
1361
1428
  const restApiConfig = {
1362
1429
  enabled: configInfo.restApiOptions?.enabled ?? false,
@@ -1522,22 +1589,6 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1522
1589
  .catch(err => console.warn(`[TaskGraphDispatcher] Startup failed: ${err}`));
1523
1590
  }
1524
1591
 
1525
- // Launch the calendar / scheduled-bridge loop (M2): poll agent calendars for meeting invites and
1526
- // start due meeting bridges. Mirrors the SessionJanitor lifecycle (run-once + interval, timer
1527
- // unref'd). Gated on Teams meetings being enabled (the provider whose scheduled-join is wired) and
1528
- // reuses the SAME meetings service as the ingress; identities without configured calendar creds are
1529
- // skipped, so this is a harmless no-op until a Graph-backed identity + token are configured.
1530
- if (resumeUser && configInfo.telephony?.teams?.enabled) { // global-provider-ok: server-owned background poller under the server's provider + system user
1531
- const teamsMeetingsService = GetTeamsMeetingsService();
1532
- if (teamsMeetingsService) {
1533
- StartCalendarScheduler({
1534
- Provider: Metadata.Provider, // global-provider-ok: server-owned background poller under the server's single default provider + system user
1535
- ContextUser: resumeUser,
1536
- TeamsService: teamsMeetingsService,
1537
- TeamsConfig: configInfo.telephony.teams,
1538
- });
1539
- }
1540
- }
1541
1592
 
1542
1593
  // Set up graceful shutdown handlers
1543
1594
  const gracefulShutdown = async (signal: string) => {
@@ -1547,6 +1598,7 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
1547
1598
  if (extensionLoader.ExtensionCount > 0) {
1548
1599
  try {
1549
1600
  await extensionLoader.ShutdownAll();
1601
+ _currentExtensionLoader = null;
1550
1602
  console.log('✅ Server extensions shut down');
1551
1603
  } catch (error) {
1552
1604
  console.error('❌ Error shutting down server extensions:', error);
@@ -17,7 +17,7 @@
17
17
  import { Metadata, RunView, UserInfo, LogError, LogStatus } from '@memberjunction/core';
18
18
  import { MJLruCache, UUIDsEqual } from '@memberjunction/global';
19
19
  import type { MJConversationWidgetInstanceEntity, MJConversationEntity } from '@memberjunction/core-entities';
20
- import { UserCache } from '@memberjunction/generic-database-provider';
20
+ import { ResolveConfiguredPrincipal } from '../auth/principals.js';
21
21
  import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
22
22
  import { generateSessionId } from '../auth/magicLink/magicLinkCore.js';
23
23
  import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
@@ -694,19 +694,7 @@ export class WidgetSessionService {
694
694
 
695
695
  /** Resolves the server-side context user used to READ widget config (not the guest principal). */
696
696
  private resolveLookupUser(): UserInfo | null {
697
- const candidate = this.config.contextUserForLookup;
698
- if (candidate) {
699
- const byName = UserCache.Instance.UserByName(candidate);
700
- if (byName) {
701
- return byName;
702
- }
703
- LogError(`[Widget] Configured lookup user '${candidate}' not found; falling back to system/Owner.`);
704
- }
705
- return (
706
- UserCache.Instance.GetSystemUser() ??
707
- UserCache.Users.find((u) => u.IsActive && u.Type?.trim().toLowerCase() === 'owner') ??
708
- null
709
- );
697
+ return ResolveConfiguredPrincipal(this.config.contextUserForLookup, 'Widget');
710
698
  }
711
699
 
712
700
  /**
@@ -24,7 +24,7 @@
24
24
  * @module @memberjunction/server/widget
25
25
  */
26
26
 
27
- import { RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
27
+ import { CompositeKey, RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
28
28
  import type { MJConversationEntity, MJAIAgentNoteEntity } from '@memberjunction/core-entities';
29
29
 
30
30
  const CONVERSATIONS_ENTITY = 'MJ: Conversations';
@@ -60,17 +60,24 @@ export async function resolveIdentityByEmail(
60
60
  }
61
61
  const entityName = target?.entityName?.trim() || 'Users';
62
62
  const emailField = target?.emailField?.trim() || 'Email';
63
- const entityId = provider.EntityByName(entityName)?.ID;
64
- if (!entityId) {
63
+ const entityInfo = provider.EntityByName(entityName);
64
+ if (!entityInfo) {
65
65
  LogError(`[VisitorIdentity] identity-resolution entity '${entityName}' not found in metadata.`);
66
66
  return undefined;
67
67
  }
68
+ const entityId = entityInfo.ID;
69
+ // The identity entity is configurable, so its key column(s) can have any name — and may be
70
+ // composite. Select every key column and carry the record id in the compact record-id form
71
+ // (the bare value for a single-column key, "F1|v1||F2|v2" for a composite one) that
72
+ // CompositeKey.FromURLSegment reads back, so LinkedRecordID / PrimaryScopeRecordID round-trip
73
+ // for any key shape instead of being truncated to the first column.
74
+ const pkNames = entityInfo.PrimaryKeys.map(pk => pk.Name);
68
75
  const rv = new RunView();
69
- const result = await rv.RunView<{ ID: string }>(
76
+ const result = await rv.RunView<Record<string, unknown>>(
70
77
  {
71
78
  EntityName: entityName,
72
79
  ExtraFilter: `${emailField} = '${trimmed.replace(/'/g, "''")}'`,
73
- Fields: ['ID'],
80
+ Fields: pkNames,
74
81
  MaxRows: 1,
75
82
  ResultType: 'simple',
76
83
  },
@@ -80,7 +87,15 @@ export async function resolveIdentityByEmail(
80
87
  LogError(`[VisitorIdentity] identity lookup failed for '${entityName}.${emailField}': ${result.ErrorMessage}`);
81
88
  return undefined;
82
89
  }
83
- const recordId = result.Results?.[0]?.ID;
90
+ const row = result.Results?.[0];
91
+ if (!row) {
92
+ return undefined;
93
+ }
94
+ const key = CompositeKey.FromEntityRecord(entityInfo, row);
95
+ if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
96
+ return undefined;
97
+ }
98
+ const recordId = key.ToCompactURLSegment();
84
99
  return recordId ? { entityId, recordId } : undefined;
85
100
  } catch (e) {
86
101
  LogError(`[VisitorIdentity] resolveIdentityByEmail failed: ${e instanceof Error ? e.message : String(e)}`);
@@ -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;
@@ -14,9 +14,8 @@ export class FileCategoryResolver extends FileCategoryResolverBase {
14
14
  @Arg('options___', () => DeleteOptionsInput) options: DeleteOptionsInput,
15
15
  @Ctx() { providers, userPayload }: AppContext
16
16
  ) {
17
- const key = new CompositeKey();
18
- key.LoadFromSingleKeyValuePair('ID', ID);
19
- const p = GetReadWriteProvider(providers);
17
+ const key = CompositeKey.FromID(ID); // first-pk-ok: this mutation deletes from MJ: File Categories (core entity keyed by ID) — see the GetEntityObject below
18
+ const p = GetReadWriteProvider(providers);
20
19
 
21
20
  if (!(await this.BeforeDelete(p, key))) {
22
21
  return null;