@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.
- package/README.md +30 -11
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +106 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +41 -4
- 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 +7607 -6853
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +25141 -21645
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +152 -2
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +389 -10
- 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 +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +131 -93
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.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/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.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/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.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 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- 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__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +190 -3
- package/src/__tests__/ResolverBase.readableFieldsTransport.test.ts +106 -0
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/generatedGraphQLSchema.structural.test.ts +46 -0
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -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__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/serverExtensionReservedRoots.test.ts +79 -11
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +44 -4
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +65 -6
- package/src/generated/graphql-schemas/__mj.ts +18966 -16452
- package/src/generic/ResolverBase.ts +467 -9
- package/src/generic/RunViewResolver.ts +28 -2
- package/src/generic/refusalExtensions.ts +59 -0
- package/src/index.ts +154 -102
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/EntityRecordNameResolver.ts +74 -8
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RealtimeClientSessionResolver.ts +128 -2
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- 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 -96
- 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 -124
- 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
|
@@ -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', () => {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// Script the identity lookup's rows and capture what it asked for; everything else in core is real
|
|
4
|
+
// (CompositeKey in particular — the record-id shape under test is its ToCompactURLSegment).
|
|
5
|
+
const runViewMock = vi.fn();
|
|
6
|
+
vi.mock('@memberjunction/core', async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import('@memberjunction/core')>();
|
|
8
|
+
return {
|
|
9
|
+
...actual,
|
|
10
|
+
RunView: class MockRunView {
|
|
11
|
+
RunView = runViewMock;
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
import { resolveIdentityByEmail } from '../realtimeWidget/visitorIdentity.js';
|
|
17
|
+
import type { EntityInfo, IMetadataProvider, UserInfo } from '@memberjunction/core';
|
|
18
|
+
|
|
19
|
+
type LookupParams = { EntityName: string; ExtraFilter: string; Fields: string[] };
|
|
20
|
+
|
|
21
|
+
function makeProvider(entityInfo: { ID: string; PrimaryKeys: Array<{ Name: string }> } | undefined): IMetadataProvider {
|
|
22
|
+
return { EntityByName: () => entityInfo as unknown as EntityInfo } as unknown as IMetadataProvider;
|
|
23
|
+
}
|
|
24
|
+
const user = { ID: 'u1' } as unknown as UserInfo;
|
|
25
|
+
function lastParams(): LookupParams {
|
|
26
|
+
return runViewMock.mock.calls[runViewMock.mock.calls.length - 1][0] as LookupParams;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('resolveIdentityByEmail — the configured identity entity can have ANY primary key', () => {
|
|
30
|
+
beforeEach(() => runViewMock.mockReset());
|
|
31
|
+
|
|
32
|
+
it('selects the real key column and returns its bare value for a single non-ID key', async () => {
|
|
33
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [{ individual_id: 4711 }] });
|
|
34
|
+
const provider = makeProvider({ ID: 'ent-persons', PrimaryKeys: [{ Name: 'individual_id' }] });
|
|
35
|
+
const out = await resolveIdentityByEmail('ada@example.com', user, provider, { entityName: 'Persons', emailField: 'EmailAddress' });
|
|
36
|
+
expect(lastParams().Fields).toEqual(['individual_id']);
|
|
37
|
+
expect(lastParams().ExtraFilter).toBe("EmailAddress = 'ada@example.com'");
|
|
38
|
+
expect(out).toEqual({ entityId: 'ent-persons', recordId: '4711' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('selects EVERY key column and carries a composite key as a full segment, not its first column', async () => {
|
|
42
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [{ OrderID: '11055', LineNo: 3 }] });
|
|
43
|
+
const provider = makeProvider({ ID: 'ent-lines', PrimaryKeys: [{ Name: 'OrderID' }, { Name: 'LineNo' }] });
|
|
44
|
+
const out = await resolveIdentityByEmail('x@y.com', user, provider, { entityName: 'Order Lines' });
|
|
45
|
+
expect(lastParams().Fields).toEqual(['OrderID', 'LineNo']);
|
|
46
|
+
expect(out).toEqual({ entityId: 'ent-lines', recordId: 'OrderID|11055||LineNo|3' });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('stays anonymous when no row matches or a key column is null', async () => {
|
|
50
|
+
const provider = makeProvider({ ID: 'ent-users', PrimaryKeys: [{ Name: 'ID' }] });
|
|
51
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [] });
|
|
52
|
+
expect(await resolveIdentityByEmail('none@y.com', user, provider)).toBeUndefined();
|
|
53
|
+
runViewMock.mockResolvedValue({ Success: true, Results: [{ ID: null }] });
|
|
54
|
+
expect(await resolveIdentityByEmail('null@y.com', user, provider)).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('stays anonymous when the configured entity is not in metadata', async () => {
|
|
58
|
+
expect(await resolveIdentityByEmail('a@b.com', user, makeProvider(undefined), { entityName: 'Nope' })).toBeUndefined();
|
|
59
|
+
expect(runViewMock).not.toHaveBeenCalled();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -285,7 +285,7 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
285
285
|
if (fetchedCount < SWEEP_PAGE_SIZE) {
|
|
286
286
|
break;
|
|
287
287
|
}
|
|
288
|
-
afterKey = CompositeKey.FromID(lastFetchedId);
|
|
288
|
+
afterKey = CompositeKey.FromID(lastFetchedId); // first-pk-ok: keyset AfterKey on SESSION_ENTITY = MJ: AI Agent Sessions, a core entity keyed by ID
|
|
289
289
|
continue;
|
|
290
290
|
}
|
|
291
291
|
// Batch-load every channel for this whole page of sessions in ONE query, then hand each
|
|
@@ -311,7 +311,7 @@ export class SessionJanitor extends BaseSingleton<SessionJanitor> implements ISh
|
|
|
311
311
|
if (fetchedCount < SWEEP_PAGE_SIZE) {
|
|
312
312
|
break; // partial page (by the SQL-matched set) => end of data
|
|
313
313
|
}
|
|
314
|
-
afterKey = CompositeKey.FromID(lastFetchedId);
|
|
314
|
+
afterKey = CompositeKey.FromID(lastFetchedId); // first-pk-ok: keyset AfterKey on SESSION_ENTITY = MJ: AI Agent Sessions, a core entity keyed by ID
|
|
315
315
|
}
|
|
316
316
|
return closedCount;
|
|
317
317
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { RegisterClass } from '@memberjunction/global';
|
|
2
2
|
import { Metadata, RunView, LogError, EntitySaveOptions } from '@memberjunction/core';
|
|
3
3
|
import { NewUserBase } from './newUsers.js';
|
|
4
|
-
import {
|
|
4
|
+
import { ResolveConfiguredPrincipal } from './principals.js';
|
|
5
5
|
import { configInfo } from '../config.js';
|
|
6
6
|
import { MJUserEntity } from '@memberjunction/core-entities';
|
|
7
7
|
|
|
@@ -18,12 +18,15 @@ export class ExampleNewUserSubClass extends NewUserBase {
|
|
|
18
18
|
try {
|
|
19
19
|
const md = new Metadata(); // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
// Resolve through the shared ladder (`auth/principals.ts`) rather than matching a single
|
|
22
|
+
// column here. This example previously matched `Email` only, which meant that copying it —
|
|
23
|
+
// which is what the note above tells you to do — reproduced issue #4209 from the other
|
|
24
|
+
// direction: MJ's default names the system user by `Name` ('System'), and an Email-only
|
|
25
|
+
// lookup can never reach it.
|
|
26
|
+
const contextUser = ResolveConfiguredPrincipal(configInfo?.userHandling?.contextUserForNewUserCreation, 'NewUser');
|
|
24
27
|
if (!contextUser) {
|
|
25
28
|
LogError(
|
|
26
|
-
|
|
29
|
+
'Failed to resolve a context user for new-user creation: userHandling.contextUserForNewUserCreation matched no user, and this deployment has neither a system user nor an active Owner to fall back to. This is the user whose context creates new user records dynamically.'
|
|
27
30
|
);
|
|
28
31
|
return undefined;
|
|
29
32
|
}
|
|
@@ -61,7 +64,7 @@ export class ExampleNewUserSubClass extends NewUserBase {
|
|
|
61
64
|
//p.Email = email;
|
|
62
65
|
//p.Status = 'active';
|
|
63
66
|
if (await p.Save()) {
|
|
64
|
-
personId = p.FirstPrimaryKey.Value; //
|
|
67
|
+
personId = p.FirstPrimaryKey.Value; // first-pk-ok: sample code for a hypothetical ID-keyed Persons entity (see row['ID'] above); personId feeds User.LinkedEntityRecordID, a single-value link. With a strongly typed sub-class this would be p.ID
|
|
65
68
|
} else {
|
|
66
69
|
LogError(`Failed to create new person ${firstName} ${lastName} ${email}`);
|
|
67
70
|
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type MJApplicationRoleEntity,
|
|
31
31
|
} from '@memberjunction/core-entities';
|
|
32
32
|
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
33
|
+
import { ResolveConfiguredPrincipal } from '../principals.js';
|
|
33
34
|
import { CommunicationEngine } from '@memberjunction/communication-engine';
|
|
34
35
|
import { Message } from '@memberjunction/communication-types';
|
|
35
36
|
import { configInfo, type MagicLinkConfig } from '../../config.js';
|
|
@@ -698,14 +699,7 @@ export class MagicLinkService {
|
|
|
698
699
|
/** Resolves the user whose context provisions magic-link users. */
|
|
699
700
|
private resolveProvisioningContextUser(): UserInfo | null {
|
|
700
701
|
const candidate = this.config.contextUserForProvisioning || configInfo.userHandling?.contextUserForNewUserCreation;
|
|
701
|
-
|
|
702
|
-
const byName = UserCache.Instance.UserByName(candidate);
|
|
703
|
-
if (byName) {
|
|
704
|
-
return byName;
|
|
705
|
-
}
|
|
706
|
-
LogError(`[MagicLink] Configured provisioning user '${candidate}' not found; falling back to an Owner.`);
|
|
707
|
-
}
|
|
708
|
-
return UserCache.Users.find((u) => u.Type?.trim().toLowerCase() === 'owner') ?? null;
|
|
702
|
+
return ResolveConfiguredPrincipal(candidate, 'MagicLink');
|
|
709
703
|
}
|
|
710
704
|
|
|
711
705
|
/** Sends the invite email via the configured communication provider. Best-effort. */
|
package/src/auth/newUsers.ts
CHANGED
|
@@ -1,27 +1,19 @@
|
|
|
1
1
|
import { ApplicationInfo, DatabaseProviderBase, EntitySaveOptions, LogError, LogStatus, Metadata, RunView, RunViewResult, UserInfo } from "@memberjunction/core";
|
|
2
2
|
import { RegisterClass } from "@memberjunction/global";
|
|
3
|
-
import {
|
|
3
|
+
import { ResolveConfiguredPrincipal } from "./principals.js";
|
|
4
4
|
import { configInfo } from "../config.js";
|
|
5
5
|
import { MJUserEntity, MJUserRoleEntity, MJUserApplicationEntity, MJUserApplicationEntityEntity, MJApplicationEntityType, MJApplicationEntityEntityType, UserInfoEngine } from "@memberjunction/core-entities";
|
|
6
6
|
|
|
7
7
|
export class NewUserBase {
|
|
8
8
|
public async createNewUser(firstName: string, lastName: string, email: string, linkedRecordType: string = 'None', linkedEntityId?: string, linkedEntityRecordId?: string): Promise<MJUserEntity | null> {
|
|
9
9
|
try {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
contextUser = UserCache.Instance.UserByName(contextUserForNewUserCreation);
|
|
15
|
-
}
|
|
16
|
-
|
|
10
|
+
const contextUser: UserInfo | null = ResolveConfiguredPrincipal(
|
|
11
|
+
configInfo?.userHandling?.contextUserForNewUserCreation,
|
|
12
|
+
'NewUser',
|
|
13
|
+
);
|
|
17
14
|
if (!contextUser) {
|
|
18
|
-
LogError(`
|
|
19
|
-
|
|
20
|
-
contextUser = UserCache.Users.find(user => user.Type.trim().toLowerCase() ==='owner')!;
|
|
21
|
-
if (!contextUser) {
|
|
22
|
-
LogError(`No existing users found in the database with the Owner role, cannot create a new user`);
|
|
23
|
-
return null;
|
|
24
|
-
}
|
|
15
|
+
LogError(`No system user or active Owner found in the database, cannot create a new user`);
|
|
16
|
+
return null;
|
|
25
17
|
}
|
|
26
18
|
|
|
27
19
|
const md: Metadata = new Metadata(); // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { LogError, UserInfo } from '@memberjunction/core';
|
|
2
|
+
import { MJLruCache, UUIDsEqual } from '@memberjunction/global';
|
|
3
|
+
import { UserCache } from '@memberjunction/generic-database-provider';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolving the principal that server-side work runs as when there is no caller to run as.
|
|
7
|
+
*
|
|
8
|
+
* MJServer names such a principal in config in three places — `contextUserForNewUserCreation`,
|
|
9
|
+
* `contextUserForProvisioning` and `contextUserForLookup` — and, before issue #4209, each
|
|
10
|
+
* consumer hand-rolled its own lookup-and-fallback. There were six of those across the server in
|
|
11
|
+
* three mutually inconsistent variants, and two of them could not resolve the value MJ itself
|
|
12
|
+
* ships: the default is `'not.set@nowhere.com'`, the system user's EMAIL, while the lookup
|
|
13
|
+
* matched `Name` (which is `'System'`). The result was an error-level log line per magic-link
|
|
14
|
+
* redeem and provisioning attributed to whichever user happened to sort first as an Owner.
|
|
15
|
+
*
|
|
16
|
+
* This module is the single ladder those consumers share.
|
|
17
|
+
*
|
|
18
|
+
* Two exports, cased to tell them apart on sight: `ResolveConfiguredPrincipal` is the shell callers
|
|
19
|
+
* use (PascalCase, like the rest of MJServer's public surface), and `resolvePrincipalFrom` is the
|
|
20
|
+
* pure core it wraps — same ladder, no cache, no logging, which is what makes the ladder testable
|
|
21
|
+
* without a singleton or a database. The casing is the signal, not an oversight.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The subset of a user this resolver reads. `UserInfo` satisfies it structurally, so callers pass
|
|
26
|
+
* `UserCache.Users` directly while tests pass plain objects — no cast, no singleton, no database.
|
|
27
|
+
*/
|
|
28
|
+
export type ResolvablePrincipal = Pick<UserInfo, 'ID' | 'Name' | 'Email' | 'Type' | 'IsActive'>;
|
|
29
|
+
|
|
30
|
+
/** Which rung of the ladder produced the principal; `none` when no rung did. */
|
|
31
|
+
export type PrincipalResolutionReason = 'name' | 'email' | 'system' | 'owner' | 'none';
|
|
32
|
+
|
|
33
|
+
export interface PrincipalResolution<T extends ResolvablePrincipal> {
|
|
34
|
+
/** The principal to act as, or null when this deployment offers none. */
|
|
35
|
+
user: T | null;
|
|
36
|
+
reason: PrincipalResolutionReason;
|
|
37
|
+
/**
|
|
38
|
+
* Operator-facing diagnostic. Set ONLY when a configured candidate failed to resolve — an
|
|
39
|
+
* unset candidate is a deployment that never asked for a specific user, not a misconfiguration,
|
|
40
|
+
* and must not be reported as one.
|
|
41
|
+
*/
|
|
42
|
+
warning?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Case- and padding-insensitive comparison, for the `NCHAR`-padded and free-text columns alike. */
|
|
46
|
+
function normalize(value: string | null | undefined): string {
|
|
47
|
+
return value?.trim().toLowerCase() ?? '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The lowest-ID ACTIVE match, or undefined when there is none.
|
|
52
|
+
*
|
|
53
|
+
* Every rung that can match more than one user resolves through this, so it carries BOTH of the
|
|
54
|
+
* ladder's cross-cutting guarantees in one place instead of restating them per rung. Stating them
|
|
55
|
+
* per rung is how the `Name` and `Email` rungs came to be missing the second one while
|
|
56
|
+
* {@link resolvePrincipalFrom}'s own docstring claimed all four rungs had it.
|
|
57
|
+
*
|
|
58
|
+
* DETERMINISM. Ties go to the lowest ID rather than the first match. `UserCache.Users`
|
|
59
|
+
* comes from an `ORDER BY`-less `SELECT * FROM vwUsers` and is mutated in place at runtime
|
|
60
|
+
* (`Users.push`), so its order differs across boots AND within a process — which is exactly how
|
|
61
|
+
* `CreatedByUserID` became noise. A rung that resolves by array order is that same defect, one
|
|
62
|
+
* rung further down.
|
|
63
|
+
*
|
|
64
|
+
* `Name` is the rung this actually protects: it carries no unique constraint. MJ's own rows do
|
|
65
|
+
* not collide (`NewUserBase` sets `Name = email`, and `UQ_User_Email` makes Email unique, so
|
|
66
|
+
* MJ-created Names are unique by consequence), but a hand-created or externally-synced row can.
|
|
67
|
+
*
|
|
68
|
+
* Plain `<` on the normalized strings rather than `localeCompare`: ordering by code point cannot
|
|
69
|
+
* shift with the process locale or the runtime's ICU build, and this ordering decides attribution.
|
|
70
|
+
*
|
|
71
|
+
* ACTIVE ONLY. A deactivated user is never a candidate, on any rung. The server acting as a
|
|
72
|
+
* disabled account is the failure this exists to prevent, and it is not less of one because an
|
|
73
|
+
* operator named that account in config — a setting outlives the person it names.
|
|
74
|
+
*/
|
|
75
|
+
function lowestActiveById<T extends ResolvablePrincipal>(matches: readonly T[]): T | undefined {
|
|
76
|
+
let lowest: T | undefined;
|
|
77
|
+
for (const match of matches) {
|
|
78
|
+
if (match.IsActive !== true) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!lowest || normalize(match.ID) < normalize(lowest.ID)) {
|
|
82
|
+
lowest = match;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lowest;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolves the principal a configured candidate names, falling back deterministically.
|
|
90
|
+
*
|
|
91
|
+
* The ladder, in order:
|
|
92
|
+
* 1. `Name` — tried first so that every host resolving today resolves to the SAME user as
|
|
93
|
+
* before. This is what makes adding the `Email` rung a compatible change rather
|
|
94
|
+
* than a semantic one. ACTIVE only, which is that compatibility claim's one
|
|
95
|
+
* deliberate exception: a host whose setting names a DEACTIVATED user stops
|
|
96
|
+
* resolving to them. That is the point, not a casualty — see below.
|
|
97
|
+
* 2. `Email` — the identity column everywhere else in MJServer, and the only one the schema
|
|
98
|
+
* makes unique (`UQ_User_Email`). This is the rung that resolves MJ's own default.
|
|
99
|
+
* ACTIVE only, for the same reason.
|
|
100
|
+
* 3. System — by ID, so it survives the system user being renamed. ACTIVE only.
|
|
101
|
+
* 4. Owner — lowest ID among ACTIVE owners. A last resort, but still deterministic: the
|
|
102
|
+
* previous "first Owner in cache order" made `CreatedByUserID` depend on the order
|
|
103
|
+
* `SELECT * FROM vwUsers` happened to return, which is unstable across boots and
|
|
104
|
+
* within a process.
|
|
105
|
+
*
|
|
106
|
+
* Every rung breaks ties by lowest ID ({@link lowestActiveById}), never by array position, and no rung
|
|
107
|
+
* returns an inactive user — the configured rungs included. An operator naming a user in config
|
|
108
|
+
* is the likeliest way to end up pointed at a disabled account (the admin whose address is in the
|
|
109
|
+
* setting leaves, and their account is deactivated), and honouring it would mean the server keeps
|
|
110
|
+
* provisioning as someone who no longer works there, silently and indefinitely. A guarantee that
|
|
111
|
+
* every rung holds except the two anyone actually configures is not a guarantee.
|
|
112
|
+
*
|
|
113
|
+
* Pure, and reports rather than logs, so the caller owns log volume and phrasing.
|
|
114
|
+
*
|
|
115
|
+
* @param candidate the configured value; blank/undefined means "no preference", not an error
|
|
116
|
+
* @param users the candidate pool, normally `UserCache.Users`
|
|
117
|
+
* @param systemUserId the deployment's system user ID (`UserCache.Instance.SYSTEM_USER_ID`)
|
|
118
|
+
*/
|
|
119
|
+
export function resolvePrincipalFrom<T extends ResolvablePrincipal>(
|
|
120
|
+
candidate: string | undefined,
|
|
121
|
+
users: readonly T[],
|
|
122
|
+
systemUserId: string,
|
|
123
|
+
): PrincipalResolution<T> {
|
|
124
|
+
if (!Array.isArray(users)) {
|
|
125
|
+
throw new Error('resolvePrincipalFrom requires a user array; received ' + typeof users);
|
|
126
|
+
}
|
|
127
|
+
if (!systemUserId) {
|
|
128
|
+
throw new Error('resolvePrincipalFrom requires a system user ID to fall back to');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const wanted = normalize(candidate);
|
|
132
|
+
|
|
133
|
+
if (wanted) {
|
|
134
|
+
const byName = lowestActiveById(users.filter((u) => normalize(u.Name) === wanted));
|
|
135
|
+
if (byName) {
|
|
136
|
+
return { user: byName, reason: 'name' };
|
|
137
|
+
}
|
|
138
|
+
// Reached when `Name` matched nobody USABLE, which includes matching only deactivated
|
|
139
|
+
// rows: an inactive `Name` holder must not shadow an active `Email` holder. The ladder is
|
|
140
|
+
// looking for the user this string names, and an unusable match is not a reason to stop.
|
|
141
|
+
const byEmail = lowestActiveById(users.filter((u) => normalize(u.Email) === wanted));
|
|
142
|
+
if (byEmail) {
|
|
143
|
+
return { user: byEmail, reason: 'email' };
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fallback = resolveFallback(users, systemUserId);
|
|
148
|
+
if (!wanted) {
|
|
149
|
+
return fallback;
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
...fallback,
|
|
153
|
+
warning:
|
|
154
|
+
`Configured user '${candidate}' ${describeMiss(users, wanted)}; ` +
|
|
155
|
+
`falling back to ${describeFallback(fallback.reason)}.`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Why a configured candidate yielded nothing. The two causes need opposite fixes, so the message
|
|
161
|
+
* has to tell them apart: "no such user" is a typo or the wrong column, while "matched, but
|
|
162
|
+
* deactivated" is a correctly spelled setting pointing at a disabled account. Reporting the second
|
|
163
|
+
* as the first sends the operator hunting for a typo in a value that is spelled right.
|
|
164
|
+
*
|
|
165
|
+
* Runs only once the configured rungs have already missed, so the extra pass is off the happy path.
|
|
166
|
+
*/
|
|
167
|
+
function describeMiss<T extends ResolvablePrincipal>(users: readonly T[], wanted: string): string {
|
|
168
|
+
const matchedButInactive = users.some((u) => normalize(u.Name) === wanted || normalize(u.Email) === wanted);
|
|
169
|
+
return matchedButInactive ? 'matched only INACTIVE users' : "matched no user's Name or Email";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The candidate-independent rungs: the system user, then the lowest-ID active Owner. */
|
|
173
|
+
function resolveFallback<T extends ResolvablePrincipal>(
|
|
174
|
+
users: readonly T[],
|
|
175
|
+
systemUserId: string,
|
|
176
|
+
): PrincipalResolution<T> {
|
|
177
|
+
// The one rung that states `IsActive` for itself: a lookup by ID matches at most one row, so
|
|
178
|
+
// there is no tie to break and nothing to route through `lowestActiveById`. It still needs the
|
|
179
|
+
// guard — this change makes the system user the SHIPPED DEFAULT, so it is the rung most hosts
|
|
180
|
+
// land on, and a host that deactivates it must fail loudly rather than act as it.
|
|
181
|
+
const system = users.find((u) => UUIDsEqual(u.ID, systemUserId) && u.IsActive === true);
|
|
182
|
+
if (system) {
|
|
183
|
+
return { user: system, reason: 'system' };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const owner = lowestActiveById(users.filter((u) => normalize(u.Type) === 'owner'));
|
|
187
|
+
|
|
188
|
+
return owner ? { user: owner, reason: 'owner' } : { user: null, reason: 'none' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* How many distinct `purpose :: candidate` problems the tracker below remembers at once.
|
|
193
|
+
*
|
|
194
|
+
* Candidates come from static config today, so the real cardinality is the number of settings
|
|
195
|
+
* (three) and this is never approached. It is published so the bound is testable rather than
|
|
196
|
+
* discovered.
|
|
197
|
+
*/
|
|
198
|
+
export const MAX_REPORTED_MISCONFIGURATIONS = 64;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Misconfigurations already reported, keyed by purpose + candidate.
|
|
202
|
+
*
|
|
203
|
+
* A misconfigured setting is a static, boot-time fact, but the code that trips over it runs per
|
|
204
|
+
* request — which is how issue #4209 turned one wrong config value into an error-level line on
|
|
205
|
+
* every magic-link redeem. Saying it once per distinct problem keeps the diagnostic without
|
|
206
|
+
* burying real errors underneath it.
|
|
207
|
+
*
|
|
208
|
+
* An LRU rather than a `Set` + cap, because the two differ exactly where it matters. A capped
|
|
209
|
+
* `Set` stops admitting once it is full, so every candidate seen after that logs on EVERY call —
|
|
210
|
+
* which is #4209's per-request error line, reintroduced for precisely the dynamic caller the cap
|
|
211
|
+
* was added to defend against. The LRU bounds memory the same way but keeps de-duplicating: the
|
|
212
|
+
* worst case is one repeated line after a cold key ages out, not a permanent flood.
|
|
213
|
+
*/
|
|
214
|
+
const reportedMisconfigurations = new MJLruCache<string, true>({ maxSize: MAX_REPORTED_MISCONFIGURATIONS });
|
|
215
|
+
|
|
216
|
+
/** Entries the tracker currently holds. Exists so the bound above can be asserted, not inferred. */
|
|
217
|
+
export function ReportedMisconfigurationCount(): number {
|
|
218
|
+
return reportedMisconfigurations.Size;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Resolves the principal named by a config setting, reading the process-wide user cache.
|
|
223
|
+
*
|
|
224
|
+
* The impure shell over {@link resolvePrincipalFrom}: it supplies the cache and owns log volume.
|
|
225
|
+
*
|
|
226
|
+
* @param candidate the configured value; blank/undefined means "no preference", not an error
|
|
227
|
+
* @param purpose short label for the setting being resolved (e.g. `'MagicLink'`), used both in
|
|
228
|
+
* the operator-facing message and to key de-duplication
|
|
229
|
+
* @returns the principal to act as, or null when this deployment offers none — callers MUST
|
|
230
|
+
* handle null rather than assume a principal is always available
|
|
231
|
+
*/
|
|
232
|
+
export function ResolveConfiguredPrincipal(candidate: string | undefined, purpose: string): UserInfo | null {
|
|
233
|
+
const resolution = resolvePrincipalFrom(candidate, UserCache.Instance.Users, UserCache.Instance.SYSTEM_USER_ID);
|
|
234
|
+
|
|
235
|
+
if (resolution.warning) {
|
|
236
|
+
const key = `${purpose} :: ${candidate ?? ''}`;
|
|
237
|
+
// `Get` rather than `Has`: it refreshes recency, so a setting that keeps being hit stays
|
|
238
|
+
// suppressed and it is the settings nobody asks about any more that age out first.
|
|
239
|
+
if (reportedMisconfigurations.Get(key) === undefined) {
|
|
240
|
+
reportedMisconfigurations.Set(key, true);
|
|
241
|
+
LogError(`[${purpose}] ${resolution.warning}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return resolution.user;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function describeFallback(reason: PrincipalResolutionReason): string {
|
|
249
|
+
switch (reason) {
|
|
250
|
+
case 'system':
|
|
251
|
+
return 'the system user';
|
|
252
|
+
case 'owner':
|
|
253
|
+
return 'the lowest-ID active Owner';
|
|
254
|
+
default:
|
|
255
|
+
return 'no user at all — this deployment has neither a system user nor an active Owner';
|
|
256
|
+
}
|
|
257
|
+
}
|