@memberjunction/server 6.1.4 → 6.1.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 (227) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/agentSessions/SessionManager.d.ts +5 -2
  4. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  5. package/dist/agentSessions/SessionManager.js +17 -8
  6. package/dist/agentSessions/SessionManager.js.map +1 -1
  7. package/dist/agentSessions/SessionRunIDVerifier.d.ts +63 -0
  8. package/dist/agentSessions/SessionRunIDVerifier.d.ts.map +1 -0
  9. package/dist/agentSessions/SessionRunIDVerifier.js +129 -0
  10. package/dist/agentSessions/SessionRunIDVerifier.js.map +1 -0
  11. package/dist/agentSessions/index.d.ts +1 -0
  12. package/dist/agentSessions/index.d.ts.map +1 -1
  13. package/dist/agentSessions/index.js +1 -0
  14. package/dist/agentSessions/index.js.map +1 -1
  15. package/dist/auth/actionAuthorization.d.ts +40 -0
  16. package/dist/auth/actionAuthorization.d.ts.map +1 -0
  17. package/dist/auth/actionAuthorization.js +94 -0
  18. package/dist/auth/actionAuthorization.js.map +1 -0
  19. package/dist/auth/exampleNewUserSubClass.js +2 -1
  20. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  21. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  22. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  23. package/dist/auth/magicLink/MagicLinkService.js +55 -38
  24. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  25. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  26. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  27. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  28. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  29. package/dist/auth/newUsers.d.ts.map +1 -1
  30. package/dist/auth/newUsers.js +15 -11
  31. package/dist/auth/newUsers.js.map +1 -1
  32. package/dist/context.d.ts +6 -0
  33. package/dist/context.d.ts.map +1 -1
  34. package/dist/context.js +24 -34
  35. package/dist/context.js.map +1 -1
  36. package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
  37. package/dist/generated/graphql-schemas/__mj.js +3417 -3428
  38. package/dist/generated/graphql-schemas/__mj.js.map +1 -1
  39. package/dist/generic/CacheInvalidationResolver.d.ts +44 -8
  40. package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -1
  41. package/dist/generic/CacheInvalidationResolver.js +59 -19
  42. package/dist/generic/CacheInvalidationResolver.js.map +1 -1
  43. package/dist/generic/ResolverBase.d.ts +95 -11
  44. package/dist/generic/ResolverBase.d.ts.map +1 -1
  45. package/dist/generic/ResolverBase.js +186 -76
  46. package/dist/generic/ResolverBase.js.map +1 -1
  47. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  48. package/dist/generic/RunViewResolver.js +12 -0
  49. package/dist/generic/RunViewResolver.js.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +44 -45
  53. package/dist/index.js.map +1 -1
  54. package/dist/isolatedProvider.d.ts +20 -0
  55. package/dist/isolatedProvider.d.ts.map +1 -0
  56. package/dist/isolatedProvider.js +22 -0
  57. package/dist/isolatedProvider.js.map +1 -0
  58. package/dist/multiTenancy/index.d.ts.map +1 -1
  59. package/dist/multiTenancy/index.js +22 -2
  60. package/dist/multiTenancy/index.js.map +1 -1
  61. package/dist/postgresqlCompat.d.ts +8 -0
  62. package/dist/postgresqlCompat.d.ts.map +1 -0
  63. package/dist/postgresqlCompat.js +19 -0
  64. package/dist/postgresqlCompat.js.map +1 -0
  65. package/dist/postgresqlPoolSettings.d.ts +108 -0
  66. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  67. package/dist/postgresqlPoolSettings.js +134 -0
  68. package/dist/postgresqlPoolSettings.js.map +1 -0
  69. package/dist/realtimeWidget/widgetGuestElevation.d.ts +4 -2
  70. package/dist/realtimeWidget/widgetGuestElevation.d.ts.map +1 -1
  71. package/dist/realtimeWidget/widgetGuestElevation.js +4 -2
  72. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  73. package/dist/resolvers/ActionResolver.d.ts +31 -5
  74. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  75. package/dist/resolvers/ActionResolver.js +75 -14
  76. package/dist/resolvers/ActionResolver.js.map +1 -1
  77. package/dist/resolvers/AdhocQueryResolver.d.ts +35 -17
  78. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  79. package/dist/resolvers/AdhocQueryResolver.js +92 -166
  80. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  81. package/dist/resolvers/DatasetResolver.d.ts +24 -3
  82. package/dist/resolvers/DatasetResolver.d.ts.map +1 -1
  83. package/dist/resolvers/DatasetResolver.js +57 -8
  84. package/dist/resolvers/DatasetResolver.js.map +1 -1
  85. package/dist/resolvers/EntityRecordNameResolver.d.ts +8 -23
  86. package/dist/resolvers/EntityRecordNameResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityRecordNameResolver.js +11 -48
  88. package/dist/resolvers/EntityRecordNameResolver.js.map +1 -1
  89. package/dist/resolvers/FetchEntityVectorsResolver.js +2 -2
  90. package/dist/resolvers/FetchEntityVectorsResolver.js.map +1 -1
  91. package/dist/resolvers/GetDataContextDataResolver.d.ts +33 -7
  92. package/dist/resolvers/GetDataContextDataResolver.d.ts.map +1 -1
  93. package/dist/resolvers/GetDataContextDataResolver.js +117 -38
  94. package/dist/resolvers/GetDataContextDataResolver.js.map +1 -1
  95. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  96. package/dist/resolvers/GetDataResolver.js +13 -1
  97. package/dist/resolvers/GetDataResolver.js.map +1 -1
  98. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  99. package/dist/resolvers/QuerySystemUserResolver.js +2 -1
  100. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  101. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +83 -22
  102. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  103. package/dist/resolvers/RealtimeClientSessionResolver.js +174 -72
  104. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  105. package/dist/resolvers/RemoteBrowserActionResolver.d.ts +2 -0
  106. package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
  107. package/dist/resolvers/RemoteBrowserActionResolver.js +5 -1
  108. package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
  109. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  110. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  111. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  112. package/dist/resolvers/RunTemplateResolver.d.ts.map +1 -1
  113. package/dist/resolvers/RunTemplateResolver.js +12 -11
  114. package/dist/resolvers/RunTemplateResolver.js.map +1 -1
  115. package/dist/resolvers/SearchKnowledgeResolver.d.ts +1 -0
  116. package/dist/resolvers/SearchKnowledgeResolver.d.ts.map +1 -1
  117. package/dist/resolvers/SearchKnowledgeResolver.js +5 -0
  118. package/dist/resolvers/SearchKnowledgeResolver.js.map +1 -1
  119. package/dist/resolvers/SearchKnowledgeSystemUserResolver.d.ts.map +1 -1
  120. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js +1 -0
  121. package/dist/resolvers/SearchKnowledgeSystemUserResolver.js.map +1 -1
  122. package/dist/resolvers/SyncDataResolver.d.ts.map +1 -1
  123. package/dist/resolvers/SyncDataResolver.js +6 -2
  124. package/dist/resolvers/SyncDataResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js +14 -10
  128. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  129. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  130. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  131. package/dist/resolvers/adhocTimeout.js +12 -0
  132. package/dist/resolvers/adhocTimeout.js.map +1 -0
  133. package/dist/rest/ViewOperationsHandler.d.ts +3 -1
  134. package/dist/rest/ViewOperationsHandler.d.ts.map +1 -1
  135. package/dist/rest/ViewOperationsHandler.js +29 -17
  136. package/dist/rest/ViewOperationsHandler.js.map +1 -1
  137. package/dist/services/TaskGraphActionRunner.d.ts +21 -8
  138. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  139. package/dist/services/TaskGraphActionRunner.js +43 -16
  140. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  141. package/package.json +92 -92
  142. package/src/__tests__/ActionResolver.authorization.test.ts +353 -0
  143. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +14 -35
  144. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  145. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  146. package/src/__tests__/AdhocQueryResolver.sqlAuthorizer.test.ts +104 -0
  147. package/src/__tests__/DatasetResolver.itemFilters.test.ts +201 -0
  148. package/src/__tests__/EntityRecordNameResolver.fieldSecurity.test.ts +27 -72
  149. package/src/__tests__/EntityRecordNameResolver.rowSecurity.test.ts +152 -0
  150. package/src/__tests__/GetDataContextDataResolver.sqlItems.test.ts +416 -0
  151. package/src/__tests__/ListOperationsResolver.adhocScreen.test.ts +137 -0
  152. package/src/__tests__/RealtimeClientSessionResolver.configTamper.test.ts +383 -0
  153. package/src/__tests__/RealtimeClientSessionResolver.test.ts +310 -31
  154. package/src/__tests__/RemoteBrowserActionResolver.goalRunLink.test.ts +129 -0
  155. package/src/__tests__/ResolverBase.adhocReadScope.test.ts +227 -0
  156. package/src/__tests__/ResolverBase.aggregateScreen.test.ts +174 -0
  157. package/src/__tests__/ResolverBase.balancedClause.test.ts +155 -0
  158. package/src/__tests__/ResolverBase.clauseUnscopedRead.test.ts +127 -0
  159. package/src/__tests__/ResolverBase.filterEscaping.test.ts +24 -2
  160. package/src/__tests__/ResolverBase.isaPromotionCreate.test.ts +412 -0
  161. package/src/__tests__/RunTemplateResolver.test.ts +132 -0
  162. package/src/__tests__/RunViewResolver.cacheCheckScreen.test.ts +165 -0
  163. package/src/__tests__/SessionManager.test.ts +94 -17
  164. package/src/__tests__/SessionRunIDVerifier.test.ts +251 -0
  165. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +131 -0
  166. package/src/__tests__/TaskGraphActionRunner.storableOutputs.test.ts +76 -0
  167. package/src/__tests__/UpdateMJDataContextItem.oldValues.test.ts +220 -0
  168. package/src/__tests__/VectorizeEntityResolver.test.ts +106 -0
  169. package/src/__tests__/ViewOperationsHandler.clauseScreen.test.ts +157 -0
  170. package/src/__tests__/actionAuthorization.test.ts +130 -0
  171. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  172. package/src/__tests__/cacheInvalidationRecordData.test.ts +64 -0
  173. package/src/__tests__/isolatedProviderTransactions.test.ts +169 -0
  174. package/src/__tests__/magicLink.test.ts +15 -76
  175. package/src/__tests__/magicLinkService.consume.test.ts +230 -0
  176. package/src/__tests__/magicLinkService.provisionLog.test.ts +5 -0
  177. package/src/__tests__/multiTenancy.postgresRewrite.test.ts +181 -0
  178. package/src/__tests__/multiTenancy.security.test.ts +51 -0
  179. package/src/__tests__/multiTenancy.test.ts +1 -1
  180. package/src/__tests__/newUsers.test.ts +62 -5
  181. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  182. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  183. package/src/agentSessions/SessionManager.ts +23 -25
  184. package/src/agentSessions/SessionRunIDVerifier.ts +171 -0
  185. package/src/agentSessions/index.ts +1 -0
  186. package/src/auth/actionAuthorization.ts +119 -0
  187. package/src/auth/exampleNewUserSubClass.ts +2 -2
  188. package/src/auth/magicLink/MagicLinkService.ts +61 -39
  189. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  190. package/src/auth/newUsers.ts +15 -11
  191. package/src/context.ts +25 -34
  192. package/src/generated/graphql-schemas/__mj.ts +3417 -3428
  193. package/src/generic/CacheInvalidationResolver.ts +74 -19
  194. package/src/generic/ResolverBase.ts +207 -80
  195. package/src/generic/RunViewResolver.ts +17 -0
  196. package/src/index.ts +49 -46
  197. package/src/isolatedProvider.ts +22 -0
  198. package/src/multiTenancy/index.ts +24 -2
  199. package/src/postgresqlCompat.ts +21 -0
  200. package/src/postgresqlPoolSettings.ts +213 -0
  201. package/src/realtimeWidget/widgetGuestElevation.ts +4 -2
  202. package/src/resolvers/ActionResolver.ts +84 -21
  203. package/src/resolvers/AdhocQueryResolver.ts +105 -187
  204. package/src/resolvers/DatasetResolver.ts +70 -10
  205. package/src/resolvers/EntityRecordNameResolver.ts +11 -52
  206. package/src/resolvers/FetchEntityVectorsResolver.ts +2 -2
  207. package/src/resolvers/GetDataContextDataResolver.ts +139 -41
  208. package/src/resolvers/GetDataResolver.ts +14 -1
  209. package/src/resolvers/QuerySystemUserResolver.ts +2 -1
  210. package/src/resolvers/RealtimeClientSessionResolver.ts +196 -77
  211. package/src/resolvers/RemoteBrowserActionResolver.ts +11 -1
  212. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  213. package/src/resolvers/RunTemplateResolver.ts +13 -12
  214. package/src/resolvers/SearchKnowledgeResolver.ts +4 -0
  215. package/src/resolvers/SearchKnowledgeSystemUserResolver.ts +1 -0
  216. package/src/resolvers/SyncDataResolver.ts +6 -2
  217. package/src/resolvers/VectorizeEntityResolver.ts +16 -11
  218. package/src/resolvers/__tests__/GetDataResolver.validation.test.ts +95 -0
  219. package/src/resolvers/adhocTimeout.ts +12 -0
  220. package/src/rest/ViewOperationsHandler.ts +37 -18
  221. package/src/services/TaskGraphActionRunner.ts +45 -16
  222. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  223. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  224. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  225. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  226. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  227. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -0,0 +1,119 @@
1
+ import { AuthorizationInfo, AuthorizationRoleInfo, AuthorizationRoleType, IMetadataProvider, UserInfo } from '@memberjunction/core';
2
+ import { NormalizeUUID, UUIDsEqual } from '@memberjunction/global';
3
+
4
+ /** Authorizations keyed by their normalized ID. */
5
+ type AuthorizationsByID = Map<string, AuthorizationInfo>;
6
+
7
+ /** The outcome of {@link EvaluateActionRunAuthorization}. */
8
+ export interface ActionRunAuthorization {
9
+ /** True when the user may run the action. */
10
+ Allowed: boolean;
11
+ /** Why the run is allowed or refused. */
12
+ Reason: string;
13
+ }
14
+
15
+ /** One provider's authorization tree and the role grants on it. */
16
+ export type AuthorizationMetadata = Pick<IMetadataProvider, 'Authorizations' | 'AuthorizationRoles'>;
17
+
18
+ /**
19
+ * True when the user is an Owner, MJ's superuser type. The column is nchar, so the stored value is
20
+ * padded.
21
+ */
22
+ export function IsOwner(user: UserInfo): boolean {
23
+ return user.Type?.trim().toLowerCase() === 'owner';
24
+ }
25
+
26
+ /**
27
+ * Decides whether a user may run an action through a public entry point such as the `RunAction`
28
+ * mutation. It fails closed:
29
+ *
30
+ * - An Owner may run any action.
31
+ * - An action with `MJ: Action Authorizations` rows runs for a user who holds at least one of the
32
+ * linked authorizations. A linked authorization counts only when it is active and none of the
33
+ * user's roles is denied it; the user may then hold it directly or through an ancestor.
34
+ * - An action with no such rows runs for any user when its class authorizes its caller itself
35
+ * (`BaseAction.AuthorizesCaller`), so the action's own check decides. Otherwise it runs for
36
+ * Owners only.
37
+ *
38
+ * The authorizations, their parents and their role grants are read only from `metadata`, never
39
+ * from the global provider. Pass the provider the `MJ: Action Authorizations` rows were read from.
40
+ *
41
+ * Refuse scope-limited sessions before calling this; see `IsScopeLimitedPrincipal`.
42
+ *
43
+ * @param user the caller
44
+ * @param actionAuthorizationIDs the AuthorizationID of each `MJ: Action Authorizations` row for the action
45
+ * @param metadata the provider whose authorizations and role grants decide
46
+ * @param actionAuthorizesCaller true when the action's class authorizes its caller itself
47
+ * @returns whether the run is allowed, and why
48
+ */
49
+ export function EvaluateActionRunAuthorization(
50
+ user: UserInfo,
51
+ actionAuthorizationIDs: readonly string[],
52
+ metadata: AuthorizationMetadata,
53
+ actionAuthorizesCaller: boolean
54
+ ): ActionRunAuthorization {
55
+ if (IsOwner(user)) {
56
+ return { Allowed: true, Reason: 'The user is an Owner.' };
57
+ }
58
+ if (actionAuthorizationIDs.length === 0) {
59
+ return actionAuthorizesCaller
60
+ ? { Allowed: true, Reason: 'The action authorizes its caller itself.' }
61
+ : { Allowed: false, Reason: 'No Action Authorization grants this action to any role, and the action does not authorize its caller itself; only an Owner may run it.' };
62
+ }
63
+ const authorizationsByID: AuthorizationsByID = new Map(metadata.Authorizations.map((auth) => [NormalizeUUID(auth.ID), auth]));
64
+ const userGrants = grantsToUserRoles(user, metadata.AuthorizationRoles);
65
+ const granted = actionAuthorizationIDs
66
+ .map((id) => authorizationsByID.get(NormalizeUUID(id)))
67
+ .filter((auth): auth is AuthorizationInfo => auth !== undefined)
68
+ .find((auth) => holdsLinkedAuthorization(auth, authorizationsByID, userGrants));
69
+ return granted
70
+ ? { Allowed: true, Reason: `The user holds the "${granted.Name}" authorization.` }
71
+ : { Allowed: false, Reason: 'None of your roles holds an authorization this action requires.' };
72
+ }
73
+
74
+ /** The role grants that name one of the user's roles. */
75
+ function grantsToUserRoles(user: UserInfo, grants: readonly AuthorizationRoleInfo[]): AuthorizationRoleInfo[] {
76
+ const userRoleIDs = new Set((user.UserRoles ?? []).map((userRole) => NormalizeUUID(userRole.RoleID)));
77
+ return grants.filter((grant) => userRoleIDs.has(NormalizeUUID(grant.RoleID)));
78
+ }
79
+
80
+ /**
81
+ * True when the user holds a linked authorization: it is active, none of the user's roles is denied
82
+ * it, and the user holds it directly or through an ancestor. The first two are checked on the linked
83
+ * authorization itself, because the ancestor walk would otherwise grant it through the parent.
84
+ */
85
+ function holdsLinkedAuthorization(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
86
+ if (!auth.IsActive || grantTypes(auth, userGrants).includes(AuthorizationRoleType.Deny)) {
87
+ return false;
88
+ }
89
+ return holdsDirectlyOrThroughAncestor(auth, authorizationsByID, userGrants);
90
+ }
91
+
92
+ /**
93
+ * True when the user holds the authorization or one of its ancestors, following `ParentID` within
94
+ * `authorizationsByID`. This is the walk `AuthorizationEvaluator.UserCanExecuteWithAncestors` does,
95
+ * with the role grants taken from the same provider as the authorizations.
96
+ */
97
+ function holdsDirectlyOrThroughAncestor(auth: AuthorizationInfo, authorizationsByID: AuthorizationsByID, userGrants: readonly AuthorizationRoleInfo[]): boolean {
98
+ const visited = new Set<string>();
99
+ let node: AuthorizationInfo | undefined = auth;
100
+ while (node && !visited.has(NormalizeUUID(node.ID))) {
101
+ if (holdsDirectly(node, userGrants)) {
102
+ return true;
103
+ }
104
+ visited.add(NormalizeUUID(node.ID));
105
+ node = node.ParentID ? authorizationsByID.get(NormalizeUUID(node.ParentID)) : undefined;
106
+ }
107
+ return false;
108
+ }
109
+
110
+ /** True when the authorization is active and the user's roles have an Allow and no Deny on it. */
111
+ function holdsDirectly(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): boolean {
112
+ const types = grantTypes(auth, userGrants);
113
+ return auth.IsActive && types.includes(AuthorizationRoleType.Allow) && !types.includes(AuthorizationRoleType.Deny);
114
+ }
115
+
116
+ /** Allow or Deny, for each of the user's role grants on the authorization. */
117
+ function grantTypes(auth: AuthorizationInfo, userGrants: readonly AuthorizationRoleInfo[]): AuthorizationRoleType[] {
118
+ return userGrants.filter((grant) => UUIDsEqual(grant.AuthorizationID, auth.ID)).map((grant) => grant.AuthorizationType());
119
+ }
@@ -1,4 +1,4 @@
1
- import { RegisterClass } from '@memberjunction/global';
1
+ import { EscapeSQLString, RegisterClass } from '@memberjunction/global';
2
2
  import { Metadata, RunView, LogError, EntitySaveOptions } from '@memberjunction/core';
3
3
  import { NewUserBase } from './newUsers.js';
4
4
  import { ResolveConfiguredPrincipal } from './principals.js';
@@ -43,7 +43,7 @@ export class ExampleNewUserSubClass extends NewUserBase {
43
43
  const viewResults = await rv.RunView(
44
44
  {
45
45
  EntityName: 'Persons',
46
- ExtraFilter: `Email = '${email}'`,
46
+ ExtraFilter: `Email = '${EscapeSQLString(email)}'`,
47
47
  },
48
48
  contextUser
49
49
  );
@@ -34,8 +34,9 @@ import { ResolveConfiguredPrincipal } from '../principals.js';
34
34
  import { CommunicationEngine } from '@memberjunction/communication-engine';
35
35
  import { Message } from '@memberjunction/communication-types';
36
36
  import { configInfo, type MagicLinkConfig } from '../../config.js';
37
+ import { CreateIsolatedProvider } from '../../isolatedProvider.js';
37
38
  import { MagicLinkKeyManager } from './MagicLinkKeys.js';
38
- import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims, buildConsumeInviteSQL, canIssueInvites, isRoleGrantable, MAGIC_LINK_TOKEN_PREFIX } from './magicLinkCore.js';
39
+ import { generateRawToken, generateSessionId, hashToken, evaluateInvite, buildSessionClaims, buildConsumeInvitePostgresSQL, canIssueInvites, isRoleGrantable, MAGIC_LINK_TOKEN_PREFIX } from './magicLinkCore.js';
39
40
  import type {
40
41
  CreateMagicLinkInviteParams,
41
42
  CreateMagicLinkInviteResult,
@@ -48,6 +49,21 @@ const REDEMPTION_ENTITY = 'MJ: Magic Link Redemptions';
48
49
  /** Shared Anonymous principal — seeded by the Phase-4 migration; must match that UUID. */
49
50
  const ANONYMOUS_USER_ID = '273910DF-28F1-45C1-A8F8-6E9AD8E5F008';
50
51
 
52
+ /** Outcome of the atomic single-use consume — see {@link MagicLinkService.consumeInvite}. */
53
+ type ConsumeOutcome = 'won' | 'lost' | 'failed';
54
+
55
+ /**
56
+ * The SQL Server guarded single-use procedure (migration V202609291627__v6.2.x__MagicLink_Consume_Invite_Sproc).
57
+ * A procedure, not raw DML, because on SQL Server MJ grants its runtime roles EXECUTE on procedures
58
+ * and SELECT on views — never DML on base tables — so on any host whose MJAPI login is not db_owner
59
+ * a raw UPDATE is refused (#4753). Ownership chaining lets the procedure update the table on the
60
+ * caller's behalf. PostgreSQL has no counterpart function; see {@link buildConsumeInvitePostgresSQL}.
61
+ */
62
+ const CONSUME_INVITE_PROC = 'spConsumeMagicLinkInvite';
63
+
64
+ /** MagicLinkInvite base table (core schema) — the PostgreSQL consume updates it directly. */
65
+ const INVITE_TABLE = 'MagicLinkInvite';
66
+
51
67
  /** Outcome of provisioning a redeeming user. */
52
68
  interface ProvisionResult {
53
69
  success: boolean;
@@ -173,9 +189,9 @@ export class MagicLinkService {
173
189
  * session JWT. Runs in the public (pre-auth) path, so it uses the global
174
190
  * provider and a configured provisioning context user.
175
191
  *
176
- * Single-use is enforced by a compare-and-swap UPDATE ({@link consumeInvite})
177
- * BEFORE the token is minted — two concurrent redemptions of a single-use
178
- * link race on the same row and exactly one wins. The consume is the gate:
192
+ * Single-use is enforced by the guarded UPDATE inside the `spConsumeMagicLinkInvite`
193
+ * procedure ({@link consumeInvite}) BEFORE the token is minted — two concurrent
194
+ * redemptions of a single-use link race on the same row and exactly one wins. The consume is the gate:
179
195
  * if a later step fails, the use is already burned (fail-closed). That is the
180
196
  * correct trade-off for a single-use credential — burning a token on a server
181
197
  * error is recoverable (re-issue); allowing replay is not.
@@ -252,10 +268,14 @@ export class MagicLinkService {
252
268
  return done({ success: false, errorCode: 'server_error', error: 'Invite role not found.' });
253
269
  }
254
270
 
255
- // Atomic consume BEFORE minting. If we lose the race (0 rows updated), the
256
- // invite was concurrently consumed/expired — re-evaluate the now-stale
257
- // in-memory copy only to surface a precise code; default to 'consumed'.
258
- if (!(await this.consumeInvite(invite, provider, contextUser))) {
271
+ // Atomic consume BEFORE minting. A statement that never ran (permissions,
272
+ // missing procedure, connection) is a server fault, not a lost race — report
273
+ // it as such so operators aren't sent hunting a double-redeem (#4753).
274
+ const consume = await this.consumeInvite(invite, provider, contextUser);
275
+ if (consume === 'failed') {
276
+ return done({ success: false, errorCode: 'server_error', error: 'Could not redeem the invite due to a server error.' });
277
+ }
278
+ if (consume === 'lost') {
259
279
  // Lost the race or the invite expired between the pre-check and the
260
280
  // consume. The in-memory copy still looks eligible if only the DB-side
261
281
  // use count changed, so default that case to 'consumed'.
@@ -502,9 +522,11 @@ export class MagicLinkService {
502
522
  /** Creates a new user with exactly the restricted role + single app, transactionally. */
503
523
  private async createScopedUser(invite: MJMagicLinkInviteEntity, role: RoleInfo, contextUser: UserInfo): Promise<ProvisionResult> {
504
524
  // Single provider for BOTH the transaction and the entity writes, so the
505
- // BeginTransaction/Save/Commit are provably on the same connection.
506
- // global-provider-ok: redemption runs in the pre-auth flow, no per-request provider yet.
507
- const provider = Metadata.Provider as DatabaseProviderBase; // global-provider-ok: server-side magic-link service; runs under the server's single default provider
525
+ // BeginTransaction/Save/Commit are provably on the same connection. Redemption runs
526
+ // pre-auth, with no per-request provider yet, and the global Metadata.Provider is shared
527
+ // by every concurrent request: a transaction opened there nests into anyone else's and
528
+ // swallows their writes. So it gets its own (see CreateIsolatedProvider).
529
+ const provider = await CreateIsolatedProvider();
508
530
  const email = invite.Email;
509
531
  // The invite does not persist a name; provision a sensible default. (The
510
532
  // create-invite params accept first/last name but there is no column to
@@ -639,39 +661,39 @@ export class MagicLinkService {
639
661
  }
640
662
 
641
663
  /**
642
- * Atomically consumes one use of the invite via a compare-and-swap UPDATE.
643
- * The WHERE clause re-checks every eligibility condition (Active, not
644
- * exhausted, not expired) at the DB level, so the increment and the guard are
645
- * a single atomic operation. Returns true ONLY when this call updated the row
646
- * (won the race); concurrent redemptions of a single-use link see 0 rows.
664
+ * Atomically consumes one use of the invite with a single guarded UPDATE that re-checks every
665
+ * eligibility condition (Active, not exhausted, not expired) at the DB level, so the increment and
666
+ * the guard are one atomic operation. Concurrent redemptions of a single-use link race on the row
667
+ * and exactly one gets the row back.
647
668
  *
648
- * Dialect-aware: SQL Server uses an `OUTPUT`-into-table-variable win-detect;
649
- * PostgreSQL uses `UPDATE … WHERE … RETURNING` (the WHERE guard is itself the
650
- * atomic single-use gate). The platform is read from the provider itself
651
- * (`PlatformKey`), not from process env, so this is correct even under a
652
- * non-default per-connection provider. The invite ID always binds as a
653
- * parameter (`@p0` / `$1`), never interpolated.
669
+ * The statement differs per platform because the grants do:
670
+ * - **SQL Server** calls {@link CONSUME_INVITE_PROC} (`EXEC … @ID=@p0`). Runtime roles have
671
+ * EXECUTE on procedures but no DML on base tables (#4753).
672
+ * - **PostgreSQL** runs {@link buildConsumeInvitePostgresSQL}'s `UPDATE … RETURNING` directly.
673
+ * Runtime roles hold table DML there, and no PG counterpart of the procedure exists.
674
+ * The ID is always a bound parameter.
654
675
  *
655
- * Fail-closed: any DB error returns false and the redemption is rejected.
676
+ * Returns `'won'` (this call consumed a use), `'lost'` (the guard matched nothing: consumed,
677
+ * revoked or expired concurrently) or `'failed'` (the statement never ran — permissions, a missing
678
+ * procedure, a connection error). `'failed'` is kept distinct from `'lost'` on purpose: collapsing
679
+ * them is what made #4753's permission error surface to users as "already redeemed".
656
680
  */
657
- private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<boolean> {
681
+ private async consumeInvite(invite: MJMagicLinkInviteEntity, provider: DatabaseProviderBase, contextUser: UserInfo): Promise<ConsumeOutcome> {
682
+ const isPg = provider.PlatformKey === 'postgresql';
683
+ const statement = isPg ? `PostgreSQL guarded UPDATE on ${INVITE_TABLE}` : CONSUME_INVITE_PROC;
658
684
  try {
659
- const entityInfo = provider.EntityByName(INVITE_ENTITY);
660
- if (!entityInfo) {
661
- LogError(`[MagicLink] Entity metadata for '${INVITE_ENTITY}' not found; cannot consume invite.`);
662
- return false;
663
- }
664
- const isPg = provider.PlatformKey === 'postgresql';
665
- // PG identifiers are auto-quoted by PostgreSQLDataProvider.ExecuteSQL, so
666
- // pass the bare `schema.table`; SQL Server takes the bracket-quoted form.
667
- const table = isPg ? `${entityInfo.SchemaName}.${entityInfo.BaseTable}` : `[${entityInfo.SchemaName}].[${entityInfo.BaseTable}]`;
668
- // OUTPUT/RETURNING yields one row iff the WHERE matched — the atomic single-use gate.
669
- const sql = buildConsumeInviteSQL(table, isPg ? 'postgresql' : 'sqlserver');
670
- const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true }, contextUser);
671
- return Array.isArray(rows) && rows.length === 1;
685
+ const sql = isPg
686
+ ? buildConsumeInvitePostgresSQL(`${provider.MJCoreSchemaName}.${INVITE_TABLE}`)
687
+ : provider.Dialect.ProcedureCallSyntax(provider.MJCoreSchemaName, CONSUME_INVITE_PROC, [`@ID=${provider.BuildParameterPlaceholder(0)}`]);
688
+ const rows = await provider.ExecuteSQL<{ ID: string }>(sql, [invite.ID], { isMutation: true, description: `MagicLink ${statement}` }, contextUser);
689
+ if (!Array.isArray(rows)) {
690
+ LogError(`[MagicLink] ${statement} returned no result set for invite ${invite.ID}; treating the consume as failed.`);
691
+ return 'failed';
692
+ }
693
+ return rows.length === 1 ? 'won' : 'lost';
672
694
  } catch (e) {
673
- LogError(`[MagicLink] Atomic consume failed for invite ${invite.ID}: ${e instanceof Error ? e.message : String(e)}`);
674
- return false;
695
+ LogError(`[MagicLink] ${statement} FAILED for invite ${invite.ID} — the statement never ran, so this is NOT a lost race: ${e instanceof Error ? e.message : String(e)}`);
696
+ return 'failed';
675
697
  }
676
698
  }
677
699
 
@@ -106,75 +106,38 @@ export function evaluateInvite(invite: InviteEvaluationInput, nowMs: number): {
106
106
  return { ok: true };
107
107
  }
108
108
 
109
- /** SQL dialect for {@link buildConsumeInviteSQL}. */
110
- export type ConsumeInviteDialect = 'sqlserver' | 'postgresql';
111
-
112
- /** SQL Server table identifier — bracket-quoted `[schema].[table]` (word chars only). */
113
- const QUALIFIED_TABLE_PATTERN = /^\[\w+\]\.\[\w+\]$/;
114
109
  /** PostgreSQL table identifier — `schema.table` (word chars only); auto-quoted downstream. */
115
110
  const QUALIFIED_TABLE_PATTERN_PG = /^\w+\.\w+$/;
116
111
 
117
112
  /**
118
- * Builds the atomic compare-and-swap UPDATE that consumes one use of an invite,
119
- * in the given SQL dialect.
120
- *
121
- * The WHERE clause re-checks every eligibility condition at the DB level, so the
122
- * increment and the guard are a single atomic operation: concurrent redemptions
123
- * of a single-use link race on the row and exactly one matches. This is what
124
- * actually enforces single-use — the JS-side `evaluateInvite` is only a friendly
125
- * pre-check. The matched row's ID is returned to the caller (via `OUTPUT` on SQL
126
- * Server, `RETURNING` on PostgreSQL) so it can detect a win (exactly one row).
127
- *
128
- * The invite ID MUST be bound as a parameter by the caller (`@p0` on SQL Server,
129
- * `$1` on PostgreSQL) — it is never interpolated into the string, so this builder
130
- * is injection-safe regardless of the ID's contents.
113
+ * Builds the PostgreSQL atomic compare-and-swap UPDATE that consumes one use of an invite.
131
114
  *
132
- * **SQL Server** — `OUTPUT` goes `INTO` a table variable (not a bare OUTPUT):
133
- * SQL Server forbids a bare OUTPUT clause on a table that has enabled triggers,
134
- * and CodeGen adds an `__mj_UpdatedAt` trigger to every MJ table. The trailing
135
- * SELECT returns the matched row(s) regardless of trigger behavior.
115
+ * PostgreSQL only. SQL Server consumes through the granted `spConsumeMagicLinkInvite` procedure,
116
+ * because its runtime roles are never granted table DML (#4753). PostgreSQL needs neither: its
117
+ * runtime roles hold INSERT/UPDATE/DELETE on every `__mj` table
118
+ * (V202605040300__v5.33.x__Unblock_PostgreSQL_End_To_End.pg-only.sql), and no PG counterpart of
119
+ * the procedure exists — the release-time converter leaves `CREATE PROCEDURE` unhandled.
136
120
  *
137
- * **PostgreSQL** — the `UPDATE … WHERE` guard IS itself the atomic single-use
138
- * gate: the row is locked for the UPDATE's duration, so concurrent redemptions
139
- * serialize and only the first matching `Status='Active' AND UseCount < MaxUses`
140
- * wins; `RETURNING` yields the row iff the guard matched (equivalent to the SS
141
- * `OUTPUT`-into-table win-detect). PascalCase identifiers are auto-quoted by
142
- * `PostgreSQLDataProvider.ExecuteSQL`, so the table is passed unquoted (`schema.table`).
121
+ * The WHERE re-checks every eligibility condition (the same predicate as the procedure), so the
122
+ * increment and the guard are one atomic statement: concurrent redemptions of a single-use link
123
+ * serialize on the row lock and exactly one matches. `RETURNING ID` yields the row iff the guard
124
+ * matched. The invite ID MUST be bound by the caller as `$1` — it is never interpolated.
143
125
  *
144
- * `qualifiedTable` is asserted to match the dialect's whitelist pattern before
145
- * interpolation. The caller derives it from `EntityInfo` (never user input), so
146
- * this is defense-in-depth: even a future careless caller cannot turn this into
147
- * an injection vector — a non-conforming table throws.
148
- *
149
- * @param qualifiedTable `[schema].[table]` (sqlserver) or `schema.table` (postgresql) for MagicLinkInvite
150
- * @param dialect target SQL dialect (defaults to `'sqlserver'`)
126
+ * `qualifiedTable` is `schema.table` unquoted (PostgreSQLDataProvider.ExecuteSQL auto-quotes the
127
+ * PascalCase identifiers) and is asserted against a whitelist before interpolation — the caller
128
+ * never passes user input, so this is defense-in-depth.
151
129
  */
152
- export function buildConsumeInviteSQL(qualifiedTable: string, dialect: ConsumeInviteDialect = 'sqlserver'): string {
153
- if (dialect === 'postgresql') {
154
- if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
155
- throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
156
- }
157
- return (
158
- `UPDATE ${qualifiedTable} ` +
159
- `SET UseCount = UseCount + 1, ` +
160
- `ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
161
- `Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
162
- `WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
163
- `RETURNING ID;`
164
- );
165
- }
166
- if (!QUALIFIED_TABLE_PATTERN.test(qualifiedTable)) {
167
- throw new Error(`buildConsumeInviteSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
130
+ export function buildConsumeInvitePostgresSQL(qualifiedTable: string): string {
131
+ if (!QUALIFIED_TABLE_PATTERN_PG.test(qualifiedTable)) {
132
+ throw new Error(`buildConsumeInvitePostgresSQL: refusing to build SQL for non-whitelisted table identifier '${qualifiedTable}'.`);
168
133
  }
169
134
  return (
170
- `DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER); ` +
171
135
  `UPDATE ${qualifiedTable} ` +
172
136
  `SET UseCount = UseCount + 1, ` +
173
- `ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME()), ` +
137
+ `ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc')), ` +
174
138
  `Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END ` +
175
- `OUTPUT INSERTED.ID INTO @consumed ` +
176
- `WHERE ID = @p0 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > SYSUTCDATETIME(); ` +
177
- `SELECT ID FROM @consumed;`
139
+ `WHERE ID = $1 AND Status = 'Active' AND UseCount < MaxUses AND ExpiresAt > (now() AT TIME ZONE 'utc') ` +
140
+ `RETURNING ID;`
178
141
  );
179
142
  }
180
143
 
@@ -1,7 +1,8 @@
1
- import { ApplicationInfo, DatabaseProviderBase, EntitySaveOptions, LogError, LogStatus, Metadata, RunView, RunViewResult, UserInfo } from "@memberjunction/core";
1
+ import { ApplicationInfo, EntitySaveOptions, LogError, LogStatus, RunView, RunViewResult, UserInfo } from "@memberjunction/core";
2
2
  import { RegisterClass } from "@memberjunction/global";
3
3
  import { ResolveConfiguredPrincipal } from "./principals.js";
4
4
  import { configInfo } from "../config.js";
5
+ import { CreateIsolatedProvider } from "../isolatedProvider.js";
5
6
  import { MJUserEntity, MJUserRoleEntity, MJUserApplicationEntity, MJUserApplicationEntityEntity, MJApplicationEntityType, MJApplicationEntityEntityType, UserInfoEngine } from "@memberjunction/core-entities";
6
7
 
7
8
  export class NewUserBase {
@@ -16,8 +17,12 @@ export class NewUserBase {
16
17
  return null;
17
18
  }
18
19
 
19
- const md: Metadata = new Metadata(); // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
20
- const user = await md.GetEntityObject<MJUserEntity>('MJ: Users', contextUser) // To-Do - change this to be a different defined user for the user creation process
20
+ // Everything here, metadata lookups, entity objects and the transaction, goes through ONE
21
+ // isolated provider. This runs in the JWT auth flow, before AppContext.providers exists, and
22
+ // the global Metadata.Provider is shared by every concurrent request: a transaction opened
23
+ // there nests into anyone else's and swallows their writes (see CreateIsolatedProvider).
24
+ const provider = await CreateIsolatedProvider();
25
+ const user = await provider.GetEntityObject<MJUserEntity>('MJ: Users', contextUser) // To-Do - change this to be a different defined user for the user creation process
21
26
  user.NewRecord();
22
27
  user.Name = email;
23
28
  user.IsActive = true;
@@ -38,7 +43,6 @@ export class NewUserBase {
38
43
  // Create the user and all of its role/application/app-entity records atomically.
39
44
  // If any Save fails partway through, the whole provisioning rolls back so we never
40
45
  // leave a half-created user with partial roles/applications behind.
41
- const provider = Metadata.Provider as DatabaseProviderBase; // global-provider-ok: new-user creation runs in the JWT auth flow, before AppContext.providers is built
42
46
  await provider.BeginTransaction();
43
47
  try {
44
48
  if (!await user.Save()) {
@@ -48,13 +52,13 @@ export class NewUserBase {
48
52
  if(configInfo.userHandling && configInfo.userHandling.newUserRoles){
49
53
  LogStatus(`User ${user.Email} created, assigning roles`);
50
54
  for (const role of configInfo.userHandling.newUserRoles) {
51
- const userRole = md.Roles.find(r => r.Name === role);
55
+ const userRole = provider.Roles.find(r => r.Name === role);
52
56
  if (!userRole) {
53
57
  LogError(`Role ${role} not found in the database, cannot assign to new user ${user.Name}`);
54
58
  continue;
55
59
  }
56
60
 
57
- const userRoleEntity: MJUserRoleEntity = await md.GetEntityObject<MJUserRoleEntity>('MJ: User Roles', contextUser);
61
+ const userRoleEntity: MJUserRoleEntity = await provider.GetEntityObject<MJUserRoleEntity>('MJ: User Roles', contextUser);
58
62
  userRoleEntity.NewRecord();
59
63
  userRoleEntity.UserID = user.ID;
60
64
  userRoleEntity.RoleID = userRole.ID;
@@ -73,7 +77,7 @@ export class NewUserBase {
73
77
  if (configInfo.userHandling.UserApplications && configInfo.userHandling.UserApplications.length > 0) {
74
78
  for (const appName of configInfo.userHandling.UserApplications) {
75
79
  const toLowerCase: string = appName.trim().toLocaleLowerCase();
76
- const application: ApplicationInfo | undefined = md.Applications.find(a => a.Name.trim().toLocaleLowerCase() === toLowerCase);
80
+ const application: ApplicationInfo | undefined = provider.Applications.find(a => a.Name.trim().toLocaleLowerCase() === toLowerCase);
77
81
  if (application) {
78
82
  applicationsToCreate.push(application);
79
83
  } else {
@@ -85,12 +89,12 @@ export class NewUserBase {
85
89
  // Bug F2: use the shared selector, which filters to Active apps flagged
86
90
  // DefaultForNewUser (in DefaultSequence order). This path previously omitted the
87
91
  // Status = 'Active' check, so an inactive default app could be provisioned here.
88
- applicationsToCreate = UserInfoEngine.GetDefaultApplicationsForNewUser(md);
92
+ applicationsToCreate = UserInfoEngine.GetDefaultApplicationsForNewUser(provider);
89
93
  LogStatus(`Found ${applicationsToCreate.length} active applications with DefaultForNewUser=true`);
90
94
  }
91
95
 
92
96
  for (const [appIndex, application] of applicationsToCreate.entries()) {
93
- const userApplication: MJUserApplicationEntity = await md.GetEntityObject<MJUserApplicationEntity>('MJ: User Applications', contextUser);
97
+ const userApplication: MJUserApplicationEntity = await provider.GetEntityObject<MJUserApplicationEntity>('MJ: User Applications', contextUser);
94
98
  userApplication.NewRecord();
95
99
  userApplication.UserID = user.ID;
96
100
  userApplication.ApplicationID = application.ID;
@@ -102,7 +106,7 @@ export class NewUserBase {
102
106
  }
103
107
  LogStatus(`Created User Application ${application.Name} for new user ${user.Name}`);
104
108
 
105
- const rv: RunView = new RunView();
109
+ const rv: RunView = RunView.FromMetadataProvider(provider);
106
110
  // Boolean literal goes through the active provider's dialect:
107
111
  // SQL Server emits `= 1`, PostgreSQL emits `= TRUE`.
108
112
  // This keeps the filter server-side (no client-side `.filter()`
@@ -125,7 +129,7 @@ export class NewUserBase {
125
129
  LogStatus(`Creating ${defaultForNewUserEntities.length} User Application Entities for User Application ${application.Name} for new user ${user.Name}`);
126
130
 
127
131
  for(const [index, appEntity] of defaultForNewUserEntities.entries()){
128
- const userAppEntity: MJUserApplicationEntityEntity = await md.GetEntityObject<MJUserApplicationEntityEntity>('MJ: User Application Entities', contextUser);
132
+ const userAppEntity: MJUserApplicationEntityEntity = await provider.GetEntityObject<MJUserApplicationEntityEntity>('MJ: User Application Entities', contextUser);
129
133
  userAppEntity.NewRecord();
130
134
  userAppEntity.UserApplicationID = userApplication.ID;
131
135
  userAppEntity.EntityID = appEntity.EntityID;
package/src/context.ts CHANGED
@@ -10,7 +10,8 @@ import { CloneUserForSessionContext } from './auth/sessionUserClone.js';
10
10
  import { GetAPIKeyActingContextResolver } from './auth/actingContextResolver.js';
11
11
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
12
12
  import { authCache } from './cache.js';
13
- import { userEmailMap, apiKey, mj_core_schema } from './config.js';
13
+ import { userEmailMap, apiKey, mj_core_schema, configInfo } from './config.js';
14
+ import { BuildPostgreSQLConnectionConfig, PostgreSQLReadOnlyPool, ResolvePostgreSQLEndpoint, ResolvePostgreSQLReadOnlyCredentials } from './postgresqlPoolSettings.js';
14
15
  import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
15
16
  import { StartupLogger } from './logging/StartupLogger.js';
16
17
  import { DataSourceInfo, UserPayload } from './types.js';
@@ -797,36 +798,35 @@ async function createPerRequestProviders(
797
798
  { provider: p, type: 'Read-Write' }
798
799
  ];
799
800
 
800
- if (isPostgres) {
801
- const rp = await tryCreateReadOnlyPostgresProvider();
802
- if (rp) {
803
- providers.push({ provider: rp, type: 'Read-Only' });
804
- }
805
- } else {
806
- const rp = await tryCreateReadOnlyProvider(dataSources);
807
- if (rp) {
808
- providers.push({ provider: rp, type: 'Read-Only' });
809
- }
801
+ const rp = await CreateReadOnlyProvider(dataSources);
802
+ if (rp) {
803
+ providers.push({ provider: rp, type: 'Read-Only' });
810
804
  }
811
805
 
812
806
  return providers;
813
807
  }
814
808
 
809
+ /**
810
+ * Opens a provider on the read-only database login, for the current platform. Resolves null when no
811
+ * read-only login is configured; it never falls back to the read-write pool.
812
+ * @param dataSources the data sources MJAPI opened at startup
813
+ */
814
+ export async function CreateReadOnlyProvider(dataSources: DataSourceInfo[]): Promise<DatabaseProviderBase | null> {
815
+ return resolveDbPlatformFromEnv() === 'postgresql'
816
+ ? tryCreateReadOnlyPostgresProvider()
817
+ : tryCreateReadOnlyProvider(dataSources);
818
+ }
819
+
815
820
  /**
816
821
  * Creates a PostgreSQL per-request provider, sharing the connection pool
817
822
  * from the primary provider to avoid pool exhaustion.
818
823
  */
819
824
  async function createPostgresProvider(): Promise<DatabaseProviderBase> {
820
825
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
821
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
822
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
823
- const pgUser = process.env.PG_USERNAME || process.env.DB_USERNAME || 'postgres';
824
- const pgPass = process.env.PG_PASSWORD || process.env.DB_PASSWORD || '';
825
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
826
826
 
827
827
  const pgProvider = new PostgreSQLDataProvider();
828
828
  const pgConfig = new PostgreSQLProviderConfigData(
829
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: pgUser, Password: pgPass },
829
+ BuildPostgreSQLConnectionConfig(ResolvePostgreSQLEndpoint(), configInfo.databaseSettings, 'api'),
830
830
  mj_core_schema,
831
831
  0,
832
832
  undefined,
@@ -846,39 +846,30 @@ async function createPostgresProvider(): Promise<DatabaseProviderBase> {
846
846
  }
847
847
 
848
848
  /**
849
- * Attempts to create a read-only PostgreSQL provider using DB_READ_ONLY_USERNAME/PASSWORD.
850
- * Shares the connection pool from the primary provider. Returns null if no read-only credentials are configured.
849
+ * Creates a read-only PostgreSQL per-request provider on the read-only pool MJAPI opened at
850
+ * startup with the read-only login. Returns null when no read-only login is configured, so the
851
+ * caller never falls back to the read-write pool.
851
852
  */
852
853
  async function tryCreateReadOnlyPostgresProvider(): Promise<DatabaseProviderBase | null> {
853
- const roUser = process.env.PG_READ_ONLY_USERNAME || process.env.DB_READ_ONLY_USERNAME;
854
- const roPass = process.env.PG_READ_ONLY_PASSWORD || process.env.DB_READ_ONLY_PASSWORD;
855
- if (!roUser || !roPass) {
854
+ const readOnlyPool = PostgreSQLReadOnlyPool.Instance.Pool;
855
+ const credentials = ResolvePostgreSQLReadOnlyCredentials(configInfo);
856
+ if (!readOnlyPool || !credentials) {
856
857
  return null;
857
858
  }
858
859
 
859
860
  try {
860
861
  const { PostgreSQLDataProvider, PostgreSQLProviderConfigData } = await import('@memberjunction/postgresql-dataprovider');
861
- const pgHost = process.env.PG_HOST || process.env.DB_HOST || 'localhost';
862
- const pgPort = parseInt(process.env.PG_PORT || process.env.DB_PORT || '5432', 10);
863
- const pgDatabase = process.env.PG_DATABASE || process.env.DB_DATABASE || '';
864
862
 
865
863
  const roProvider = new PostgreSQLDataProvider();
866
864
  const roConfig = new PostgreSQLProviderConfigData(
867
- { Host: pgHost, Port: pgPort, Database: pgDatabase, User: roUser, Password: roPass },
865
+ BuildPostgreSQLConnectionConfig({ ...ResolvePostgreSQLEndpoint(), ...credentials }, configInfo.databaseSettings, 'read-only'),
868
866
  mj_core_schema,
869
867
  0,
870
868
  undefined,
871
869
  undefined,
872
870
  false,
873
871
  );
874
-
875
- const primaryProvider = Metadata.Provider as unknown as { DatabaseConnection?: import('pg').Pool }; // global-provider-ok: bootstrap (share primary provider's PG pool with the read-only provider)
876
- if (primaryProvider?.DatabaseConnection) {
877
- await roProvider.ConfigWithSharedPool(roConfig, primaryProvider.DatabaseConnection);
878
- } else {
879
- await roProvider.Config(roConfig);
880
- }
881
-
872
+ await roProvider.ConfigWithSharedPool(roConfig, readOnlyPool);
882
873
  return roProvider;
883
874
  } catch (_err) {
884
875
  return null;