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

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 (100) hide show
  1. package/README.md +6 -2
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionJanitor.js +2 -2
  4. package/dist/agentSessions/SessionJanitor.js.map +1 -1
  5. package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
  6. package/dist/auth/exampleNewUserSubClass.js +9 -4
  7. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  9. package/dist/auth/magicLink/MagicLinkService.js +2 -8
  10. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  11. package/dist/auth/newUsers.d.ts.map +1 -1
  12. package/dist/auth/newUsers.js +4 -12
  13. package/dist/auth/newUsers.js.map +1 -1
  14. package/dist/auth/principals.d.ts +92 -0
  15. package/dist/auth/principals.d.ts.map +1 -0
  16. package/dist/auth/principals.js +193 -0
  17. package/dist/auth/principals.js.map +1 -0
  18. package/dist/config.d.ts +70 -12
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +32 -4
  21. package/dist/config.js.map +1 -1
  22. package/dist/generated/graphql-schemas/__mj.d.ts +5347 -5291
  23. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  24. package/dist/generated/graphql-schemas/__mj.js +21098 -20793
  25. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +35 -0
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +128 -2
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +16 -2
  33. package/dist/index.js.map +1 -1
  34. package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
  35. package/dist/realtimeWidget/WidgetSessionService.js +2 -12
  36. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  37. package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
  38. package/dist/realtimeWidget/visitorIdentity.js +20 -5
  39. package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
  40. package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
  41. package/dist/resolvers/FileCategoryResolver.js +1 -2
  42. package/dist/resolvers/FileCategoryResolver.js.map +1 -1
  43. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  44. package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
  45. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  46. package/dist/resolvers/QuerySystemUserResolver.js +1 -1
  47. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  48. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
  49. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  50. package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
  51. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  52. package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
  53. package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
  54. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  55. package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
  56. package/dist/resolvers/UserViewResolver.js +8 -4
  57. package/dist/resolvers/UserViewResolver.js.map +1 -1
  58. package/dist/rest/EntityCRUDHandler.d.ts +2 -1
  59. package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
  60. package/dist/rest/EntityCRUDHandler.js +3 -18
  61. package/dist/rest/EntityCRUDHandler.js.map +1 -1
  62. package/dist/rest/RESTEndpointHandler.d.ts +2 -1
  63. package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
  64. package/dist/rest/RESTEndpointHandler.js +3 -16
  65. package/dist/rest/RESTEndpointHandler.js.map +1 -1
  66. package/dist/telephony/TeamsMeetingsRouter.js +18 -2
  67. package/dist/telephony/TeamsMeetingsRouter.js.map +1 -1
  68. package/package.json +92 -91
  69. package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
  70. package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
  71. package/src/__tests__/ResolverBase.filterEscaping.test.ts +127 -3
  72. package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
  73. package/src/__tests__/WidgetSessionService.test.ts +4 -1
  74. package/src/__tests__/newUserRolesDefault.test.ts +81 -0
  75. package/src/__tests__/newUsers.test.ts +36 -14
  76. package/src/__tests__/principals.callSites.test.ts +112 -0
  77. package/src/__tests__/principals.logging.test.ts +160 -0
  78. package/src/__tests__/principals.test.ts +305 -0
  79. package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
  80. package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
  81. package/src/agentSessions/SessionJanitor.ts +2 -2
  82. package/src/auth/exampleNewUserSubClass.ts +9 -6
  83. package/src/auth/magicLink/MagicLinkService.ts +2 -8
  84. package/src/auth/newUsers.ts +7 -15
  85. package/src/auth/principals.ts +257 -0
  86. package/src/config.ts +32 -4
  87. package/src/generated/graphql-schemas/__mj.ts +13937 -13726
  88. package/src/generic/ResolverBase.ts +157 -2
  89. package/src/index.ts +17 -2
  90. package/src/realtimeWidget/WidgetSessionService.ts +2 -14
  91. package/src/realtimeWidget/visitorIdentity.ts +21 -6
  92. package/src/resolvers/FileCategoryResolver.ts +2 -3
  93. package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
  94. package/src/resolvers/QuerySystemUserResolver.ts +1 -1
  95. package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
  96. package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
  97. package/src/resolvers/UserViewResolver.ts +8 -4
  98. package/src/rest/EntityCRUDHandler.ts +3 -19
  99. package/src/rest/RESTEndpointHandler.ts +3 -17
  100. package/src/telephony/TeamsMeetingsRouter.ts +21 -2
@@ -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 { UserCache } from '@memberjunction/generic-database-provider';
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
- const contextUser = UserCache.Instance.Users.find(
22
- (u) => u.Email.trim().toLowerCase() === configInfo?.userHandling?.contextUserForNewUserCreation?.trim().toLowerCase()
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
- `Failed to load context user ${configInfo?.userHandling?.contextUserForNewUserCreation}, if you've not specified this on your config.json you must do so. This is the user that is contextually used for creating a new user record dynamically.`
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; // if we had a strongly typed sub-class above, we could use this code p.ID;
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
- if (candidate) {
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. */
@@ -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 { UserCache } from "@memberjunction/generic-database-provider";
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
- let contextUser: UserInfo | null = null;
11
-
12
- const contextUserForNewUserCreation: string = configInfo?.userHandling?.contextUserForNewUserCreation;
13
- if(contextUserForNewUserCreation){
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(`Failed to load context user ${configInfo?.userHandling?.contextUserForNewUserCreation}, using an existing user with the Owner role instead`);
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
+ }
package/src/config.ts CHANGED
@@ -25,6 +25,14 @@ const userHandlingInfoSchema = z.object({
25
25
  newUserRoles: z.array(z.string()).optional().default([]),
26
26
  updateCacheWhenNotFound: z.boolean().optional().default(false),
27
27
  updateCacheWhenNotFoundDelay: z.number().optional().default(30000),
28
+ /**
29
+ * The internal user whose context creates new user records. Matched against `User.Name` FIRST,
30
+ * then `User.Email` — so either spelling of an existing user resolves. On a stock database the
31
+ * system user is `Name='System'` / `Email='not.set@nowhere.com'`; both reach it.
32
+ *
33
+ * When unset, or when the value matches no user, resolution falls back to the system user and
34
+ * then to the lowest-ID active Owner. See `src/auth/principals.ts`.
35
+ */
28
36
  contextUserForNewUserCreation: z.string().optional().default(''),
29
37
  CreateUserApplicationRecords: z.boolean().optional().default(false),
30
38
  UserApplications: z.array(z.string()).optional().default([]),
@@ -328,7 +336,11 @@ const magicLinkSchema = z.object({
328
336
  * from attaching a privileged role (e.g. Owner) to an external magic-link user.
329
337
  */
330
338
  grantableRoleNames: z.array(z.string()).optional().default([]),
331
- /** Email of the internal user whose context provisions magic-link users (falls back to userHandling.contextUserForNewUserCreation). */
339
+ /**
340
+ * The internal user whose context provisions magic-link users, matched against `User.Name` then
341
+ * `User.Email` (falls back to `userHandling.contextUserForNewUserCreation`, then to the system
342
+ * user, then to the lowest-ID active Owner).
343
+ */
332
344
  contextUserForProvisioning: z.string().optional(),
333
345
  /**
334
346
  * Guard against bolting an external magic-link role/app onto an EXISTING account
@@ -391,7 +403,10 @@ const widgetSchema = z.object({
391
403
  rateLimitWindowMs: z.coerce.number().optional().default(60_000),
392
404
  /** Server-wide default hard ceiling (minutes) on a voice session when an instance omits one (W4). */
393
405
  voiceDefaultMaxSessionMinutes: z.coerce.number().optional().default(10),
394
- /** Email/name of the internal user whose context READS widget config at mint time (falls back to system/Owner). */
406
+ /**
407
+ * The internal user whose context READS widget config at mint time, matched against `User.Name`
408
+ * then `User.Email` (falls back to the system user, then the lowest-ID active Owner).
409
+ */
395
410
  contextUserForLookup: z.string().optional(),
396
411
  /**
397
412
  * Host-identity public keys (PEM), keyed by widget PublicKey, for the `host-identity` auth
@@ -648,10 +663,23 @@ export const DEFAULT_SERVER_CONFIG: Partial<ConfigInfo> = {
648
663
  autoCreateNewUsers: true,
649
664
  newUserLimitedToAuthorizedDomains: false,
650
665
  newUserAuthorizedDomains: [],
651
- newUserRoles: ['UI', 'Developer'],
666
+ // 'UI' ONLY, deliberately (issue #4260). Auto-provisioning is on by default above, with no
667
+ // domain restriction, so this list is the standing authority of anyone the configured IdP will
668
+ // issue a token for. On the baseline seed 'Developer' and 'Integration' hold unfiltered
669
+ // CanUpdate on ~439 of the database's ~446 entities, so defaulting every such identity into
670
+ // either grants broad data-plane access no host should hand out by default. (The MJ: Users
671
+ // escalation this list also used to guard against — writing your own Type to 'Owner' — is now
672
+ // closed at the entity layer regardless of role: see MJUserEntityServer in
673
+ // @memberjunction/core-entities-server.) 'UI' carries the end-user surface (conversations,
674
+ // views, dashboards, settings) and no write on MJ: Users. Hosts that need more grant it
675
+ // per-deployment.
676
+ newUserRoles: ['UI'],
652
677
  updateCacheWhenNotFound: true,
653
678
  updateCacheWhenNotFoundDelay: 5000,
654
- contextUserForNewUserCreation: 'not.set@nowhere.com',
679
+ // The seeded system user, named by `Name`. Its Email ('not.set@nowhere.com') resolves too —
680
+ // resolution tries both columns — but naming it this way keeps the default readable as what it
681
+ // is, rather than as an address nobody can receive mail at.
682
+ contextUserForNewUserCreation: 'System',
655
683
  CreateUserApplicationRecords: true,
656
684
  UserApplications: []
657
685
  },