@memberjunction/server 6.1.0-edge.6 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -9
- package/dist/.tsbuildinfo +1 -1
- package/dist/config.d.ts +36 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +9 -0
- package/dist/config.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts +30 -0
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +58 -5
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +2842 -2144
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +5334 -2143
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +119 -4
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +264 -11
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +26 -2
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/generic/refusalExtensions.d.ts +52 -0
- package/dist/generic/refusalExtensions.d.ts.map +1 -0
- package/dist/generic/refusalExtensions.js +16 -0
- package/dist/generic/refusalExtensions.js.map +1 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +115 -91
- package/dist/index.js.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.d.ts +34 -5
- package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityRecordNameResolver.js +64 -7
- package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -2
- package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
- package/dist/resolvers/RealtimeClientSessionResolver.js +80 -4
- package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts +23 -0
- package/dist/rest/RealtimeSdpBrokerHandler.d.ts.map +1 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js +78 -0
- package/dist/rest/RealtimeSdpBrokerHandler.js.map +1 -0
- package/dist/serverExtensionReservedRoots.d.ts.map +1 -1
- package/dist/serverExtensionReservedRoots.js +2 -3
- package/dist/serverExtensionReservedRoots.js.map +1 -1
- package/package.json +92 -92
- package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +118 -0
- package/src/__tests__/RealtimeClientSessionResolver.test.ts +20 -0
- package/src/__tests__/RealtimeSdpBrokerHandler.test.ts +191 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +63 -0
- package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
- package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
- package/src/__tests__/resolverBase.fls.test.ts +483 -0
- package/src/__tests__/resolverBase.recordChangesFls.test.ts +358 -0
- package/src/__tests__/resolverBase.refusalExtensions.test.ts +101 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
- package/src/config.ts +12 -0
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
- package/src/generated/graphql-schemas/__mj.ts +6590 -4287
- package/src/generic/ResolverBase.ts +313 -10
- package/src/generic/RunViewResolver.ts +28 -2
- package/src/generic/refusalExtensions.ts +59 -0
- package/src/index.ts +137 -100
- package/src/resolvers/EntityRecordNameResolver.ts +74 -8
- package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
- package/src/rest/RealtimeSdpBrokerHandler.ts +101 -0
- package/src/serverExtensionReservedRoots.ts +2 -3
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts +0 -27
- package/dist/resolvers/RingCentralTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/RingCentralTelephonyResolver.js +0 -87
- package/dist/resolvers/RingCentralTelephonyResolver.js.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.d.ts +0 -28
- package/dist/resolvers/TeamsMeetingsResolver.d.ts.map +0 -1
- package/dist/resolvers/TeamsMeetingsResolver.js +0 -91
- package/dist/resolvers/TeamsMeetingsResolver.js.map +0 -1
- package/dist/resolvers/TelephonyResolver.d.ts +0 -26
- package/dist/resolvers/TelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/TelephonyResolver.js +0 -86
- package/dist/resolvers/TelephonyResolver.js.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.d.ts +0 -26
- package/dist/resolvers/VonageTelephonyResolver.d.ts.map +0 -1
- package/dist/resolvers/VonageTelephonyResolver.js +0 -86
- package/dist/resolvers/VonageTelephonyResolver.js.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.d.ts +0 -115
- package/dist/telephony/RingCentralTelephonyService.d.ts.map +0 -1
- package/dist/telephony/RingCentralTelephonyService.js +0 -262
- package/dist/telephony/RingCentralTelephonyService.js.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.d.ts +0 -40
- package/dist/telephony/TeamsMeetingsRouter.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsRouter.js +0 -112
- package/dist/telephony/TeamsMeetingsRouter.js.map +0 -1
- package/dist/telephony/TeamsMeetingsService.d.ts +0 -113
- package/dist/telephony/TeamsMeetingsService.d.ts.map +0 -1
- package/dist/telephony/TeamsMeetingsService.js +0 -196
- package/dist/telephony/TeamsMeetingsService.js.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.d.ts +0 -36
- package/dist/telephony/TwilioTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyRouter.js +0 -143
- package/dist/telephony/TwilioTelephonyRouter.js.map +0 -1
- package/dist/telephony/TwilioTelephonyService.d.ts +0 -95
- package/dist/telephony/TwilioTelephonyService.d.ts.map +0 -1
- package/dist/telephony/TwilioTelephonyService.js +0 -193
- package/dist/telephony/TwilioTelephonyService.js.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.d.ts +0 -41
- package/dist/telephony/VonageTelephonyRouter.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyRouter.js +0 -201
- package/dist/telephony/VonageTelephonyRouter.js.map +0 -1
- package/dist/telephony/VonageTelephonyService.d.ts +0 -90
- package/dist/telephony/VonageTelephonyService.d.ts.map +0 -1
- package/dist/telephony/VonageTelephonyService.js +0 -191
- package/dist/telephony/VonageTelephonyService.js.map +0 -1
- package/dist/telephony/calendar-scheduler.d.ts +0 -67
- package/dist/telephony/calendar-scheduler.d.ts.map +0 -1
- package/dist/telephony/calendar-scheduler.js +0 -133
- package/dist/telephony/calendar-scheduler.js.map +0 -1
- package/dist/telephony/index.d.ts +0 -29
- package/dist/telephony/index.d.ts.map +0 -1
- package/dist/telephony/index.js +0 -38
- package/dist/telephony/index.js.map +0 -1
- package/dist/telephony/media-upgrade-router.d.ts +0 -47
- package/dist/telephony/media-upgrade-router.d.ts.map +0 -1
- package/dist/telephony/media-upgrade-router.js +0 -72
- package/dist/telephony/media-upgrade-router.js.map +0 -1
- package/dist/telephony/ringcentral-runtime.d.ts +0 -14
- package/dist/telephony/ringcentral-runtime.d.ts.map +0 -1
- package/dist/telephony/ringcentral-runtime.js +0 -18
- package/dist/telephony/ringcentral-runtime.js.map +0 -1
- package/dist/telephony/teams-meetings-runtime.d.ts +0 -16
- package/dist/telephony/teams-meetings-runtime.d.ts.map +0 -1
- package/dist/telephony/teams-meetings-runtime.js +0 -20
- package/dist/telephony/teams-meetings-runtime.js.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.d.ts +0 -69
- package/dist/telephony/teamsAcsMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/teamsAcsMediaRegistry.js +0 -118
- package/dist/telephony/teamsAcsMediaRegistry.js.map +0 -1
- package/dist/telephony/telephony-runtime.d.ts +0 -14
- package/dist/telephony/telephony-runtime.d.ts.map +0 -1
- package/dist/telephony/telephony-runtime.js +0 -18
- package/dist/telephony/telephony-runtime.js.map +0 -1
- package/dist/telephony/twilioMediaRegistry.d.ts +0 -60
- package/dist/telephony/twilioMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/twilioMediaRegistry.js +0 -106
- package/dist/telephony/twilioMediaRegistry.js.map +0 -1
- package/dist/telephony/vonage-runtime.d.ts +0 -14
- package/dist/telephony/vonage-runtime.d.ts.map +0 -1
- package/dist/telephony/vonage-runtime.js +0 -18
- package/dist/telephony/vonage-runtime.js.map +0 -1
- package/dist/telephony/vonageMediaRegistry.d.ts +0 -78
- package/dist/telephony/vonageMediaRegistry.d.ts.map +0 -1
- package/dist/telephony/vonageMediaRegistry.js +0 -158
- package/dist/telephony/vonageMediaRegistry.js.map +0 -1
- package/src/__tests__/media-upgrade-router.test.ts +0 -43
- package/src/__tests__/teams-meetings-service.test.ts +0 -65
- package/src/__tests__/teamsAcsMediaRegistry.test.ts +0 -82
- package/src/__tests__/twilio-telephony-service.test.ts +0 -23
- package/src/__tests__/twilioMediaRegistry.test.ts +0 -103
- package/src/__tests__/vonageMediaRegistry.test.ts +0 -180
- package/src/resolvers/RingCentralTelephonyResolver.ts +0 -64
- package/src/resolvers/TeamsMeetingsResolver.ts +0 -68
- package/src/resolvers/TelephonyResolver.ts +0 -63
- package/src/resolvers/VonageTelephonyResolver.ts +0 -63
- package/src/telephony/RingCentralTelephonyService.ts +0 -342
- package/src/telephony/TeamsMeetingsRouter.ts +0 -143
- package/src/telephony/TeamsMeetingsService.ts +0 -268
- package/src/telephony/TwilioTelephonyRouter.ts +0 -184
- package/src/telephony/TwilioTelephonyService.ts +0 -261
- package/src/telephony/VonageTelephonyRouter.ts +0 -239
- package/src/telephony/VonageTelephonyService.ts +0 -259
- package/src/telephony/calendar-scheduler.ts +0 -176
- package/src/telephony/index.ts +0 -43
- package/src/telephony/media-upgrade-router.ts +0 -79
- package/src/telephony/ringcentral-runtime.ts +0 -22
- package/src/telephony/teams-meetings-runtime.ts +0 -24
- package/src/telephony/teamsAcsMediaRegistry.ts +0 -152
- package/src/telephony/telephony-runtime.ts +0 -22
- package/src/telephony/twilioMediaRegistry.ts +0 -137
- package/src/telephony/vonage-runtime.ts +0 -22
- 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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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('/
|
|
85
|
-
expect(validateServerExtensionRootPath('/
|
|
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
|