@memberjunction/server 6.1.0-edge.6 → 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 (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
@@ -0,0 +1,358 @@
1
+ /**
2
+ * Field-Level Security at the GraphQL read boundary, for `MJ: Record Changes`.
3
+ *
4
+ * `MapFieldNamesToCodeNames` is where denied fields are stripped and encrypted ones masked on
5
+ * every single-record return path. It is also where the audit trail would otherwise walk straight
6
+ * out: Record Changes has field security switched OFF, so the denied-set logic sitting beside this
7
+ * is a no-op for it, and its `ChangesJSON` carries another entity's old and new values verbatim.
8
+ *
9
+ * These tests cover the resolver half specifically — the provider half (RunView, both cache paths)
10
+ * and the projection semantics themselves live in MJCore's
11
+ * `fieldSecurity.recordChanges.test.ts`.
12
+ */
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+
15
+ // ─── Hoisted mocks (same preamble as resolverBase.fls.test.ts) ─────────────
16
+ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
17
+ SQLServerDataProvider: class {},
18
+ }));
19
+
20
+ vi.mock('@memberjunction/generic-database-provider', () => ({
21
+ UserCache: { get Users() { return []; } },
22
+ }));
23
+
24
+ vi.mock('cloudevents', () => ({
25
+ CloudEvent: class {},
26
+ httpTransport: () => () => undefined,
27
+ emitterFor: () => () => undefined,
28
+ }));
29
+
30
+ vi.mock('type-graphql', () => ({
31
+ Resolver: () => () => undefined,
32
+ Mutation: () => () => undefined,
33
+ Query: () => () => undefined,
34
+ Subscription: () => () => undefined,
35
+ Ctx: () => () => undefined,
36
+ Arg: () => () => undefined,
37
+ PubSub: () => () => undefined,
38
+ Root: () => () => undefined,
39
+ ObjectType: () => () => undefined,
40
+ InputType: () => () => undefined,
41
+ Field: () => () => undefined,
42
+ FieldResolver: () => () => undefined,
43
+ Int: () => undefined,
44
+ Float: () => undefined,
45
+ registerEnumType: () => undefined,
46
+ }));
47
+
48
+ vi.mock('graphql', () => ({
49
+ GraphQLError: class extends Error {
50
+ constructor(msg: string) { super(msg); }
51
+ },
52
+ }));
53
+
54
+ vi.mock('mssql', () => ({}));
55
+
56
+ vi.mock('@memberjunction/api-keys', () => ({
57
+ GetAPIKeyEngine: vi.fn(),
58
+ }));
59
+
60
+ vi.mock('@memberjunction/encryption', () => ({
61
+ EncryptionEngine: { Instance: {} },
62
+ }));
63
+
64
+ // Unlike the sibling FLS suite, these tests actually RUN the mapper, so the mock has to answer
65
+ // MapFieldName. Identity is correct here: none of the fixture's field names need bridging.
66
+ vi.mock('@memberjunction/graphql-dataprovider', () => ({
67
+ FieldMapper: class {
68
+ public MapFieldName(name: string): string { return name; }
69
+ static Instance = { MapFieldsFromCodeNamesToDBNames: vi.fn() };
70
+ },
71
+ }));
72
+
73
+ vi.mock('../generic/PubSubManager.js', () => ({
74
+ PubSubManager: class { static Instance = { publish: vi.fn() }; },
75
+ }));
76
+
77
+ vi.mock('../generic/PushStatusResolver.js', () => ({
78
+ PUSH_STATUS_UPDATES_TOPIC: 'test-push-topic',
79
+ PushStatusNotification: class {},
80
+ PushStatusResolver: class {},
81
+ }));
82
+
83
+ vi.mock('../generic/CacheInvalidationResolver.js', () => ({
84
+ CACHE_INVALIDATION_TOPIC: 'test-cache-topic',
85
+ }));
86
+
87
+ vi.mock('../generic/RunViewResolver.js', () => ({
88
+ RunViewByIDInput: class {},
89
+ RunViewByNameInput: class {},
90
+ RunDynamicViewInput: class {},
91
+ }));
92
+
93
+ vi.mock('../generic/DeleteOptionsInput.js', () => ({
94
+ DeleteOptionsInput: class {},
95
+ }));
96
+
97
+ vi.mock('../types.js', () => ({
98
+ RunViewGenericParams: class {},
99
+ }));
100
+
101
+ vi.mock('@memberjunction/core', async () => {
102
+ const actual = await vi.importActual<typeof import('@memberjunction/core')>('@memberjunction/core');
103
+ return {
104
+ ...actual,
105
+ LogError: vi.fn(),
106
+ LogStatus: vi.fn(),
107
+ };
108
+ });
109
+
110
+ vi.mock('@memberjunction/core-entities', () => ({}));
111
+
112
+ // ─── Import after mocks ──────────────────────────────────────────────────
113
+ import { ResolverBase } from '../generic/ResolverBase';
114
+ import { EntityInfo, IMetadataProvider, RecordChangesEntityName, UserInfo, UserRoleInfo } from '@memberjunction/core';
115
+
116
+ // ─── Fixtures ─────────────────────────────────────────────────────────────
117
+
118
+ const HR_ROLE_ID = 'A0000000-0000-0000-0000-000000000001';
119
+ const INTERN_ROLE_ID = 'A0000000-0000-0000-0000-000000000003';
120
+ const EMPLOYEES_ENTITY_ID = 'E0000000-0000-0000-0000-0000000000E1';
121
+ const RECORD_CHANGES_ENTITY_ID = 'E0000000-0000-0000-0000-0000000000E3';
122
+
123
+ function openTo(fieldId: string, roles: string[] = [HR_ROLE_ID, INTERN_ROLE_ID]): Record<string, unknown>[] {
124
+ return roles.map((roleId, i) => ({
125
+ ID: `${fieldId}-open-${i}`,
126
+ EntityFieldID: fieldId,
127
+ RoleID: roleId,
128
+ ReadAccess: 'Allow',
129
+ UpdateAccess: 'Allow',
130
+ CreateAccess: 'Allow',
131
+ }));
132
+ }
133
+
134
+ /** The entity the audit rows are ABOUT — `Salary` readable only by HR. */
135
+ function employeesEntity(): EntityInfo {
136
+ return new EntityInfo({
137
+ ID: EMPLOYEES_ENTITY_ID,
138
+ Name: 'Employees',
139
+ SchemaName: 'dbo',
140
+ BaseTable: 'Employee',
141
+ BaseView: 'vwEmployees',
142
+ IncludeInAPI: true,
143
+ EnableFieldLevelSecurity: true,
144
+ Permissions: [
145
+ { EntityID: EMPLOYEES_ENTITY_ID, RoleID: HR_ROLE_ID, CanRead: true },
146
+ { EntityID: EMPLOYEES_ENTITY_ID, RoleID: INTERN_ROLE_ID, CanRead: true },
147
+ ],
148
+ Fields: [
149
+ { ID: 'e-f-id', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 1, Name: 'ID', Entity: 'Employees', Type: 'uniqueidentifier', IsPrimaryKey: true },
150
+ { ID: 'e-f-name', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 2, Name: 'Name', Entity: 'Employees', Type: 'nvarchar', EntityFieldPermissions: openTo('e-f-name') },
151
+ { ID: 'e-f-salary', EntityID: EMPLOYEES_ENTITY_ID, Sequence: 3, Name: 'Salary', Entity: 'Employees', Type: 'money', EntityFieldPermissions: openTo('e-f-salary', [HR_ROLE_ID]) },
152
+ ],
153
+ });
154
+ }
155
+
156
+ /** The audit entity — field security OFF, which is the default and the point of the exercise. */
157
+ function recordChangesEntity(): EntityInfo {
158
+ return new EntityInfo({
159
+ ID: RECORD_CHANGES_ENTITY_ID,
160
+ Name: RecordChangesEntityName,
161
+ SchemaName: '__mj',
162
+ BaseTable: 'RecordChange',
163
+ BaseView: 'vwRecordChanges',
164
+ IncludeInAPI: true,
165
+ EnableFieldLevelSecurity: false,
166
+ Permissions: [],
167
+ Fields: [
168
+ { ID: 'rc-f-id', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 1, Name: 'ID', Entity: RecordChangesEntityName, Type: 'uniqueidentifier', IsPrimaryKey: true },
169
+ { ID: 'rc-f-entityid', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 2, Name: 'EntityID', Entity: RecordChangesEntityName, Type: 'uniqueidentifier' },
170
+ { ID: 'rc-f-changesjson', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 3, Name: 'ChangesJSON', Entity: RecordChangesEntityName, Type: 'nvarchar' },
171
+ { ID: 'rc-f-desc', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 4, Name: 'ChangesDescription', Entity: RecordChangesEntityName, Type: 'nvarchar' },
172
+ { ID: 'rc-f-full', EntityID: RECORD_CHANGES_ENTITY_ID, Sequence: 5, Name: 'FullRecordJSON', Entity: RecordChangesEntityName, Type: 'nvarchar' },
173
+ ],
174
+ });
175
+ }
176
+
177
+ function buildUser(roleIds: string[], id = 'user-1'): UserInfo {
178
+ const u = new UserInfo();
179
+ u.ID = id;
180
+ u.Name = 'Test User';
181
+ u.Email = `${id}@test.com`;
182
+ u.IsActive = true;
183
+ (u as unknown as Record<string, unknown>)['_UserRoles'] =
184
+ roleIds.map(rid => new UserRoleInfo({ UserID: id, RoleID: rid, Role: `Role-${rid}` }));
185
+ return u;
186
+ }
187
+
188
+ /** Answers only what the mapper and the projector ask of a provider. */
189
+ function testProvider(entities: EntityInfo[]): IMetadataProvider {
190
+ return {
191
+ get Entities() { return entities; },
192
+ EntityByName: (name: string) => entities.find(e => e.Name.trim().toLowerCase() === name?.trim().toLowerCase()),
193
+ EntityByID: (id: string) => entities.find(e => e.ID?.trim().toLowerCase() === id?.trim().toLowerCase()),
194
+ } as unknown as IMetadataProvider;
195
+ }
196
+
197
+ const SALARY_CHANGES_JSON = JSON.stringify({
198
+ Name: { field: 'Name', oldValue: 'Ada', newValue: 'Ada L' },
199
+ Salary: { field: 'Salary', oldValue: 100000, newValue: 120000 },
200
+ });
201
+
202
+ function changeRow(): Record<string, unknown> {
203
+ return {
204
+ ID: 'rc-1',
205
+ EntityID: EMPLOYEES_ENTITY_ID,
206
+ ChangesJSON: SALARY_CHANGES_JSON,
207
+ ChangesDescription: 'Salary changed from 100000 to 120000',
208
+ FullRecordJSON: JSON.stringify({ ID: 'emp-1', Name: 'Ada L', Salary: 120000 }),
209
+ };
210
+ }
211
+
212
+ /** Exposes the protected mapping and write-guard boundaries. */
213
+ class TestResolver extends ResolverBase {
214
+ public MapOne(dataObject: Record<string, unknown>, user: UserInfo, provider: IMetadataProvider): Promise<Record<string, unknown>> {
215
+ return this.MapFieldNamesToCodeNames(RecordChangesEntityName, dataObject, user, provider);
216
+ }
217
+ public MapMany(rows: Record<string, unknown>[], user: UserInfo, provider: IMetadataProvider): Promise<Record<string, unknown>[]> {
218
+ return this.ArrayMapFieldNamesToCodeNames(RecordChangesEntityName, rows, user, provider);
219
+ }
220
+ public StripPayload(
221
+ entityInfo: EntityInfo, user: UserInfo,
222
+ input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown>,
223
+ clientNewValues: Record<string, unknown>, provider: IMetadataProvider
224
+ ): boolean {
225
+ return this.StripRecordChangePayloadFromClientInput(entityInfo, user, input, clientNewValues, provider);
226
+ }
227
+ }
228
+
229
+ /** The shape UpdateRecord builds before applying the guards. */
230
+ function clientPayload(fields: Record<string, unknown>, oldValues?: Array<{ Key: string; Value: unknown }>) {
231
+ const input: { OldValues___?: Array<{ Key: string; Value: unknown }> } & Record<string, unknown> = { ...fields };
232
+ if (oldValues) {
233
+ input.OldValues___ = oldValues;
234
+ }
235
+ return { input, clientNewValues: { ...fields } };
236
+ }
237
+
238
+ // ─── Tests ────────────────────────────────────────────────────────────────
239
+
240
+ describe('MapFieldNamesToCodeNames — Record Changes payload security', () => {
241
+ let resolver: TestResolver;
242
+ let provider: IMetadataProvider;
243
+
244
+ beforeEach(() => {
245
+ resolver = new TestResolver();
246
+ provider = testProvider([employeesEntity(), recordChangesEntity()]);
247
+ });
248
+
249
+ it('narrows ChangesJSON and withholds ChangesDescription for a restricted caller', async () => {
250
+ const mapped = await resolver.MapOne(changeRow(), buildUser([INTERN_ROLE_ID]), provider);
251
+
252
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).not.toHaveProperty('Salary');
253
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).toHaveProperty('Name');
254
+ expect('ChangesDescription' in mapped).toBe(false);
255
+ expect(JSON.parse(mapped['FullRecordJSON'] as string)).not.toHaveProperty('Salary');
256
+ });
257
+
258
+ it('returns the full payload for a caller with no denials on the target entity', async () => {
259
+ const mapped = await resolver.MapOne(changeRow(), buildUser([HR_ROLE_ID]), provider);
260
+
261
+ expect(JSON.parse(mapped['ChangesJSON'] as string)).toHaveProperty('Salary');
262
+ expect(mapped['ChangesDescription']).toBe('Salary changed from 100000 to 120000');
263
+ });
264
+
265
+ it('does not modify the caller-supplied row — resolvers pass the cache its own objects', async () => {
266
+ const row = changeRow();
267
+ const snapshot = { ...row };
268
+ await resolver.MapOne(row, buildUser([INTERN_ROLE_ID]), provider);
269
+ expect(row).toEqual(snapshot);
270
+ });
271
+
272
+ it('applies the same treatment through the array form', async () => {
273
+ const mapped = await resolver.MapMany([changeRow(), changeRow()], buildUser([INTERN_ROLE_ID]), provider);
274
+
275
+ for (const row of mapped) {
276
+ expect(JSON.parse(row['ChangesJSON'] as string)).not.toHaveProperty('Salary');
277
+ expect('ChangesDescription' in row).toBe(false);
278
+ }
279
+ });
280
+
281
+ it('is a no-op with no context user', async () => {
282
+ const mapped = await resolver.MapOne(changeRow(), undefined as unknown as UserInfo, provider);
283
+ expect(mapped['ChangesDescription']).toBe('Salary changed from 100000 to 120000');
284
+ });
285
+ });
286
+
287
+ /**
288
+ * The write half. Without it the read projection destroys the thing it protects: a restricted
289
+ * caller is handed a NARROWED ChangesJSON, that value hydrates a client entity as an ordinary
290
+ * loaded value, and GenerateSaveSQL writes every SP-parameter field — so saving the record for any
291
+ * reason (editing Comments, say) would overwrite the stored audit payload with the narrowed one.
292
+ */
293
+ describe('StripRecordChangePayloadFromClientInput (write guard)', () => {
294
+ let resolver: TestResolver;
295
+ let provider: IMetadataProvider;
296
+ let rcEntity: EntityInfo;
297
+
298
+ beforeEach(() => {
299
+ resolver = new TestResolver();
300
+ rcEntity = recordChangesEntity();
301
+ provider = testProvider([employeesEntity(), rcEntity]);
302
+ });
303
+
304
+ it('refuses every payload column a restricted caller sends back', () => {
305
+ const { input, clientNewValues } = clientPayload({
306
+ ID: 'rc-1',
307
+ Comments: 'looks fine',
308
+ ChangesJSON: JSON.stringify({ Name: { field: 'Name', oldValue: 'Ada', newValue: 'Ada L' } }), // narrowed
309
+ FullRecordJSON: JSON.stringify({ ID: 'emp-1', Name: 'Ada L' }), // narrowed
310
+ });
311
+
312
+ const forcedTruthLoad = resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
313
+
314
+ expect(forcedTruthLoad).toBe(true);
315
+ expect(clientNewValues).toEqual({ ID: 'rc-1', Comments: 'looks fine' });
316
+ expect(input).not.toHaveProperty('ChangesJSON');
317
+ expect(input).not.toHaveProperty('FullRecordJSON');
318
+ });
319
+
320
+ it('leaves the edit the user actually made alone', () => {
321
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', Comments: 'reviewed' });
322
+ resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
323
+ expect(clientNewValues['Comments']).toBe('reviewed');
324
+ });
325
+
326
+ it('drops payload entries from OldValues so a narrowed value cannot read as a conflict', () => {
327
+ const { input, clientNewValues } = clientPayload(
328
+ { ID: 'rc-1', Comments: 'reviewed' },
329
+ [{ Key: 'ID', Value: 'rc-1' }, { Key: 'ChangesJSON', Value: '{"Name":{}}' }, { Key: 'Comments', Value: '' }]
330
+ );
331
+ resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider);
332
+ expect(input.OldValues___).toEqual([{ Key: 'ID', Value: 'rc-1' }, { Key: 'Comments', Value: '' }]);
333
+ });
334
+
335
+ it('forces the truth-load even when the client sent no payload key at all', () => {
336
+ // 'MJ: Record Changes' has TrackRecordChanges off, so a client sending OldValues would
337
+ // otherwise hydrate from those values and never read what the database holds.
338
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', Comments: 'x' }, [{ Key: 'ID', Value: 'rc-1' }]);
339
+ expect(resolver.StripPayload(rcEntity, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider)).toBe(true);
340
+ });
341
+
342
+ it('does NOT interfere with a caller who carries no denials anywhere', () => {
343
+ const payload = JSON.stringify({ Salary: { field: 'Salary', oldValue: 1, newValue: 2 } });
344
+ const { input, clientNewValues } = clientPayload({ ID: 'rc-1', ChangesJSON: payload });
345
+
346
+ const forcedTruthLoad = resolver.StripPayload(rcEntity, buildUser([HR_ROLE_ID]), input, clientNewValues, provider);
347
+
348
+ expect(forcedTruthLoad).toBe(false);
349
+ expect(clientNewValues['ChangesJSON']).toBe(payload);
350
+ });
351
+
352
+ it('does not apply to any other entity', () => {
353
+ const employees = employeesEntity();
354
+ const { input, clientNewValues } = clientPayload({ ID: 'e-1', ChangesJSON: '{}' });
355
+ expect(resolver.StripPayload(employees, buildUser([INTERN_ROLE_ID]), input, clientNewValues, provider)).toBe(false);
356
+ expect(clientNewValues['ChangesJSON']).toBe('{}');
357
+ });
358
+ });
@@ -0,0 +1,101 @@
1
+ /**
2
+ * What a write-refusal GraphQLError carries BESIDES its message.
3
+ *
4
+ * PR #3973 made the refusal's prose (`CompleteMessage`) reach the client. This is the structural
5
+ * half: a `Validate()` / `ValidateAsync()` refusal names the offending FIELD in each
6
+ * `ValidationErrorInfo.Source`, and the client's form can only paint that field red if the wire says
7
+ * which field. `RefusalExtensions` adds `extensions.validationErrors` for exactly that; the message
8
+ * is untouched. Written from the expected behaviour: the client (GraphQLDataProvider.Save) reads
9
+ * `response.errors[0].extensions.validationErrors` and hands it to `DeserializeValidationErrors`.
10
+ */
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { describe, expect, it } from 'vitest';
14
+ import { BaseEntityResult } from '@memberjunction/core';
15
+ import { DeserializeValidationErrors, ValidationErrorInfo, ValidationErrorType } from '@memberjunction/global';
16
+ import { RefusalExtensions } from '../generic/refusalExtensions';
17
+
18
+ function refused(...errors: ValidationErrorInfo[]): BaseEntityResult {
19
+ const result = new BaseEntityResult();
20
+ result.Success = false;
21
+ result.Errors = errors;
22
+ return result;
23
+ }
24
+
25
+ describe('RefusalExtensions', () => {
26
+ it('always carries the code and entity name the client already depends on', () => {
27
+ expect(RefusalExtensions('SAVE_ENTITY_ERROR', 'MJ: Tag Scopes', undefined)).toEqual({
28
+ code: 'SAVE_ENTITY_ERROR',
29
+ entityName: 'MJ: Tag Scopes',
30
+ });
31
+ });
32
+
33
+ it('omits validationErrors (not an empty array) when the refusal had no structured errors', () => {
34
+ // A SQL / provider failure sets Message and no Errors. A client must be able to tell
35
+ // "validation said no" from "the database said no" by the KEY being absent.
36
+ const dbFailure = new BaseEntityResult();
37
+ dbFailure.Success = false;
38
+ dbFailure.Message = 'Timeout expired while saving.';
39
+ const ext = RefusalExtensions('CREATE_ENTITY_ERROR', 'Widgets', dbFailure);
40
+ expect(ext).toEqual({ code: 'CREATE_ENTITY_ERROR', entityName: 'Widgets' });
41
+ expect('validationErrors' in ext).toBe(false);
42
+ expect('messageIncludesValidationErrors' in ext).toBe(false);
43
+ });
44
+
45
+ it('carries every field-named reason as a plain, JSON-safe object', () => {
46
+ const ext = RefusalExtensions(
47
+ 'SAVE_ENTITY_ERROR',
48
+ 'MJ: Tag Scopes',
49
+ refused(
50
+ new ValidationErrorInfo('TagID', 'Cannot add TagScope row for tag "Global" because it is marked IsGlobal=1.', 'abc'),
51
+ new ValidationErrorInfo('', 'Record-level note', null, ValidationErrorType.Warning),
52
+ ),
53
+ );
54
+ expect(ext.validationErrors).toEqual([
55
+ { Source: 'TagID', Message: 'Cannot add TagScope row for tag "Global" because it is marked IsGlobal=1.', Value: 'abc', Type: 'Failure' },
56
+ { Source: '', Message: 'Record-level note', Value: null, Type: 'Warning' },
57
+ ]);
58
+ // The server states that the message it throws (CompleteMessage) already renders these — the
59
+ // client must never have to infer that from the text.
60
+ expect(ext.messageIncludesValidationErrors).toBe(true);
61
+ // What GraphQL actually ships is JSON: the payload must survive it unchanged.
62
+ expect(JSON.parse(JSON.stringify(ext))).toEqual(ext);
63
+ });
64
+
65
+ it('is what the client rehydrates: DeserializeValidationErrors(extensions.validationErrors) round-trips', () => {
66
+ const original = [new ValidationErrorInfo('Amount', 'Amount must be positive', -5)];
67
+ const ext = RefusalExtensions('SAVE_ENTITY_ERROR', 'Orders', refused(...original));
68
+ const wire = JSON.parse(JSON.stringify(ext)) as { validationErrors?: unknown };
69
+ expect(DeserializeValidationErrors(wire.validationErrors)).toEqual(original);
70
+ });
71
+
72
+ it('does not alter the message the throw already carries — CompleteMessage stays the prose', () => {
73
+ const result = refused(new ValidationErrorInfo('TagID', 'No such Tag exists.', 'abc'));
74
+ // The resolver throws `CompleteMessage ?? 'Unknown error'` and attaches RefusalExtensions;
75
+ // both come from the same result, and the prose must remain a superset of the structured text.
76
+ expect(result.CompleteMessage).toContain('No such Tag exists.');
77
+ expect(RefusalExtensions('SAVE_ENTITY_ERROR', 'MJ: Tag Scopes', result).validationErrors?.[0].Message).toBe('No such Tag exists.');
78
+ });
79
+ });
80
+
81
+ describe('ResolverBase attaches RefusalExtensions on every LatestResult-backed refusal throw', () => {
82
+ const lines = readFileSync(fileURLToPath(new URL('../generic/ResolverBase.ts', import.meta.url)), 'utf8').split('\n');
83
+
84
+ it('each `LatestResult?.CompleteMessage ??` throw passes RefusalExtensions within the next two lines', () => {
85
+ const throws = lines
86
+ .map((line, i) => ({ line, n: i + 1 }))
87
+ .filter(({ line }) => /throw new GraphQLError\(entityObject\.LatestResult\?\.CompleteMessage \?\?/.test(line));
88
+ expect(throws.length, 'create, update and delete refusals').toBe(3);
89
+ for (const { n } of throws) {
90
+ const window = lines.slice(n, n + 2).join('\n');
91
+ expect(window, `throw at line ${n} must attach RefusalExtensions`).toMatch(/extensions:\s*RefusalExtensions\(/);
92
+ }
93
+ });
94
+
95
+ it('keeps the three error codes distinct and passes LatestResult, not a literal extensions object', () => {
96
+ const source = lines.join('\n');
97
+ for (const code of ['CREATE_ENTITY_ERROR', 'SAVE_ENTITY_ERROR', 'DELETE_ENTITY_ERROR']) {
98
+ expect(source).toMatch(new RegExp(`RefusalExtensions\\('${code}', entityName, entityObject\\.LatestResult\\)`));
99
+ }
100
+ });
101
+ });
@@ -37,22 +37,88 @@ describe('coreReservedServerExtensionRoots', () => {
37
37
  exportedStringConst('auth/AuthProviderCatalogRouter.ts', 'AUTH_CATALOG_MOUNT_PATH'),
38
38
  exportedStringConst('auth/magicLink/MagicLinkRouter.ts', 'MAGIC_LINK_MOUNT_PATH'),
39
39
  exportedStringConst('realtimeWidget/WidgetRouter.ts', 'WIDGET_MOUNT_PATH'),
40
- exportedStringConst('telephony/TwilioTelephonyRouter.ts', 'TWILIO_TELEPHONY_MOUNT_PATH'),
41
- exportedStringConst('telephony/VonageTelephonyRouter.ts', 'VONAGE_TELEPHONY_MOUNT_PATH'),
42
- exportedStringConst('telephony/TeamsMeetingsRouter.ts', 'TEAMS_MEETINGS_MOUNT_PATH'),
43
40
  ]);
44
41
  });
45
42
 
46
43
  it('includes the literal pre-auth paths serve() registers without a constant', () => {
47
44
  const indexSrc = readFileSync(join(SRC, 'index.ts'), 'utf8');
48
45
  for (const path of CORE_STATIC_RESERVED_SERVER_EXTENSION_ROOTS) {
49
- // /health is the prefix for /health/extensions
50
- const needle = path === '/health' ? '/health/extensions' : path;
51
- expect(indexSrc, `${path} missing from serve()`).toContain(`'${needle}'`);
46
+ if (path === '/health') {
47
+ expect(indexSrc, `${path} missing from serve()`).toContain("'/health/extensions'");
48
+ } else if (path === '/realtime') {
49
+ expect(indexSrc, `${path} missing from serve()`).toMatch(/REALTIME_SDP_EXCHANGE_PATH|'\/realtime'/);
50
+ } else if (path === '/realtime-proxy') {
51
+ expect(indexSrc, `${path} missing from serve()`).toContain('/realtime-proxy');
52
+ } else {
53
+ expect(indexSrc, `${path} missing from serve()`).toContain(`'${path}'`);
54
+ }
52
55
  }
53
56
  expect(indexContainsLiteralPath('/healthcheck')).toBe(true);
54
57
  });
55
58
 
59
+ it('structurally guarantees every pre-auth app.use / app.get / app.post mount in index.ts is reserved', () => {
60
+ const indexSrc = readFileSync(join(SRC, 'index.ts'), 'utf8');
61
+ const authMiddlewareIndex = indexSrc.indexOf('app.use(createUnifiedAuthMiddleware');
62
+ expect(authMiddlewareIndex).toBeGreaterThan(0);
63
+ const preAuthSrc = indexSrc.substring(0, authMiddlewareIndex);
64
+
65
+ const mountRegex = /app\.(?:use|get|post)\s*\(\s*([^,\s)]+)/g;
66
+ const reservedRoots = coreReservedServerExtensionRoots('/');
67
+
68
+ // Explicit allowlist of known non-path middleware mounts (e.g. app.use(mw), app.use(cors()), app.use(compression(...)))
69
+ const knownNonPathTokens = [
70
+ 'cors',
71
+ 'express.',
72
+ 'compression',
73
+ 'cookieParser',
74
+ 'createUnifiedAuthMiddleware',
75
+ 'mw',
76
+ ];
77
+ const isKnownNonPath = (t: string): boolean =>
78
+ knownNonPathTokens.some(k => t === k || t.startsWith(k));
79
+
80
+ // Strip single-line and multi-line comments so comments like `app.use(...)` are not treated as code
81
+ const cleanPreAuthSrc = preAuthSrc.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
82
+
83
+ let match: RegExpExecArray | null;
84
+ const mountedTokens: string[] = [];
85
+ while ((match = mountRegex.exec(cleanPreAuthSrc)) !== null) {
86
+ mountedTokens.push(match[1].trim());
87
+ }
88
+
89
+ expect(mountedTokens.length).toBeGreaterThan(0);
90
+
91
+ const knownConstants: Record<string, string> = {
92
+ REALTIME_SDP_EXCHANGE_PATH: '/realtime',
93
+ MAGIC_LINK_MOUNT_PATH: '/magic-link',
94
+ WIDGET_MOUNT_PATH: '/widget',
95
+ AUTH_CATALOG_MOUNT_PATH: '/auth',
96
+ };
97
+
98
+ for (const token of mountedTokens) {
99
+ const isLiteralPath = token.startsWith("'") || token.startsWith('"') || token.startsWith('`');
100
+ const isPathConstant = /^[A-Z0-9_]+_PATH$/.test(token) || token in knownConstants;
101
+ if (!isLiteralPath && !isPathConstant) {
102
+ if (isKnownNonPath(token)) {
103
+ continue;
104
+ }
105
+ throw new Error(`Unrecognized pre-auth mount argument '${token}' in index.ts. If this is a non-path middleware, add it to knownNonPathTokens; if it is a route, reserve its prefix or map its constant.`);
106
+ }
107
+
108
+ let pathPrefix: string;
109
+ if (isLiteralPath) {
110
+ pathPrefix = token.slice(1, -1);
111
+ } else if (token in knownConstants) {
112
+ pathPrefix = knownConstants[token];
113
+ } else {
114
+ throw new Error(`Unrecognized pre-auth mount constant '${token}' in index.ts. If this is a new pre-auth route, map its constant or reserve its prefix.`);
115
+ }
116
+
117
+ const isCovered = reservedRoots.some(root => pathPrefix === root || pathPrefix.startsWith(`${root}/`));
118
+ expect(isCovered, `Pre-auth mount '${pathPrefix}' (from token '${token}') must be covered by reserved roots`).toBe(true);
119
+ }
120
+ });
121
+
56
122
  it('includes graphqlRootPath and every core mount', () => {
57
123
  const roots = coreReservedServerExtensionRoots('/api');
58
124
  expect(roots).toEqual(
@@ -61,14 +127,13 @@ describe('coreReservedServerExtensionRoots', () => {
61
127
  '/auth',
62
128
  '/magic-link',
63
129
  '/widget',
64
- '/telephony/twilio',
65
- '/telephony/vonage',
66
- '/meetings/teams',
67
130
  '/healthcheck',
68
131
  '/esignature',
69
132
  '/media',
70
133
  '/oauth',
71
134
  '/health',
135
+ '/realtime',
136
+ '/realtime-proxy',
72
137
  ])
73
138
  );
74
139
  });
@@ -81,10 +146,13 @@ describe('coreReservedServerExtensionRoots', () => {
81
146
  expect(validateServerExtensionRootPath('/esignature', extra)).toMatch(/reserved prefix/);
82
147
  expect(validateServerExtensionRootPath('/media', extra)).toMatch(/reserved prefix/);
83
148
  expect(validateServerExtensionRootPath('/widget', extra)).toMatch(/reserved prefix/);
84
- expect(validateServerExtensionRootPath('/telephony', extra)).toMatch(/reserved prefix/);
85
- expect(validateServerExtensionRootPath('/meetings/teams', extra)).toMatch(/reserved prefix/);
149
+ expect(validateServerExtensionRootPath('/realtime', extra)).toMatch(/reserved prefix/);
150
+ expect(validateServerExtensionRootPath('/realtime-proxy', extra)).toMatch(/reserved prefix/);
151
+ expect(validateServerExtensionRootPath('/Realtime', extra)).toMatch(/reserved prefix/);
86
152
  expect(validateServerExtensionRootPath('/checkout', extra)).toBeNull();
87
153
  expect(validateServerExtensionRootPath('/healthcare', extra)).toBeNull();
154
+ expect(validateServerExtensionRootPath('/telephony/twilio', extra)).toBeNull();
155
+ expect(validateServerExtensionRootPath('/meetings/teams', extra)).toBeNull();
88
156
  });
89
157
 
90
158
  it('reserves a non-default graphqlRootPath and its nested paths', () => {
package/src/config.ts CHANGED
@@ -539,11 +539,17 @@ const telephonySchema = z.object({
539
539
  teams: teamsMeetingsSchema.optional(),
540
540
  }).passthrough();
541
541
 
542
+ const realtimeSchema = z.object({
543
+ /** Master switch. When false (default), the WebRTC SDP broker router is not mounted. */
544
+ enabled: zodBooleanWithTransforms().default(false),
545
+ }).passthrough();
546
+
542
547
  const configInfoSchema = z.object({
543
548
  userHandling: userHandlingInfoSchema,
544
549
  magicLink: magicLinkSchema.optional().default({}),
545
550
  widget: widgetSchema.optional().default({}),
546
551
  telephony: telephonySchema.optional().default({}),
552
+ realtime: realtimeSchema.optional().default({}),
547
553
  databaseSettings: databaseSettingsInfoSchema,
548
554
  viewingSystem: viewingSystemInfoSchema.optional(),
549
555
  restApiOptions: restApiOptionsSchema.optional().default({}),
@@ -598,6 +604,7 @@ export type UserHandlingInfo = z.infer<typeof userHandlingInfoSchema>;
598
604
  export type MagicLinkConfig = z.infer<typeof magicLinkSchema>;
599
605
  export type WidgetConfig = z.infer<typeof widgetSchema>;
600
606
  export type TelephonyConfig = z.infer<typeof telephonySchema>;
607
+ export type RealtimeConfig = z.infer<typeof realtimeSchema>;
601
608
  export type TwilioTelephonyConfig = z.infer<typeof twilioTelephonySchema>;
602
609
  export type VonageTelephonyConfig = z.infer<typeof vonageTelephonySchema>;
603
610
  export type RingCentralTelephonyConfig = z.infer<typeof ringcentralTelephonySchema>;
@@ -744,6 +751,11 @@ export const DEFAULT_SERVER_CONFIG: Partial<ConfigInfo> = {
744
751
  maxConcurrentRuns: 3
745
752
  },
746
753
 
754
+ // Realtime WebRTC SDP broker defaults (off by default)
755
+ realtime: {
756
+ enabled: parseBooleanEnv(process.env.MJ_REALTIME_ENABLED),
757
+ },
758
+
747
759
  // Telemetry defaults — on unless the operator turns it off via MJ_TELEMETRY_ENABLED.
748
760
  //
749
761
  // The env read lives HERE rather than in telemetrySchema for the same reason as