@memberjunction/server 6.1.0-edge.1 → 6.1.0-edge.2

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 (136) hide show
  1. package/dist/auth/exampleNewUserSubClass.js +1 -1
  2. package/dist/auth/exampleNewUserSubClass.js.map +1 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +9 -5
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/auth/magicLink/MagicLinkService.js +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  8. package/dist/auth/newUsers.js +1 -1
  9. package/dist/auth/newUsers.js.map +1 -1
  10. package/dist/context.js +2 -2
  11. package/dist/context.js.map +1 -1
  12. package/dist/generated/generated.d.ts +45 -4
  13. package/dist/generated/generated.d.ts.map +1 -1
  14. package/dist/generated/generated.js +229 -21
  15. package/dist/generated/generated.js.map +1 -1
  16. package/dist/generic/ResolverBase.d.ts +19 -3
  17. package/dist/generic/ResolverBase.d.ts.map +1 -1
  18. package/dist/generic/ResolverBase.js +51 -17
  19. package/dist/generic/ResolverBase.js.map +1 -1
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +85 -41
  23. package/dist/index.js.map +1 -1
  24. package/dist/logging/secretRedactor.d.ts +9 -0
  25. package/dist/logging/secretRedactor.d.ts.map +1 -1
  26. package/dist/logging/secretRedactor.js +39 -0
  27. package/dist/logging/secretRedactor.js.map +1 -1
  28. package/dist/middleware/BaseServerMiddleware.d.ts +5 -0
  29. package/dist/middleware/BaseServerMiddleware.d.ts.map +1 -1
  30. package/dist/middleware/BaseServerMiddleware.js +5 -0
  31. package/dist/middleware/BaseServerMiddleware.js.map +1 -1
  32. package/dist/realtimeWidget/WidgetSessionService.js +1 -1
  33. package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
  34. package/dist/realtimeWidget/widgetGuestElevation.js +1 -1
  35. package/dist/realtimeWidget/widgetGuestElevation.js.map +1 -1
  36. package/dist/resolvers/ActionResolver.d.ts.map +1 -1
  37. package/dist/resolvers/ActionResolver.js +12 -0
  38. package/dist/resolvers/ActionResolver.js.map +1 -1
  39. package/dist/resolvers/ComponentRegistryResolver.js +1 -1
  40. package/dist/resolvers/ComponentRegistryResolver.js.map +1 -1
  41. package/dist/resolvers/EntityPermissionResolver.js +1 -1
  42. package/dist/resolvers/EntityPermissionResolver.js.map +1 -1
  43. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +9 -0
  44. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  45. package/dist/resolvers/IntegrationDiscoveryResolver.js +3 -1
  46. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  47. package/dist/resolvers/PotentialDuplicateRecordResolver.js +1 -1
  48. package/dist/resolvers/PotentialDuplicateRecordResolver.js.map +1 -1
  49. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +10 -0
  50. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  51. package/dist/resolvers/RealtimeClientSessionResolver.js +5 -0
  52. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  53. package/dist/resolvers/SearchEntitiesResolver.js +1 -1
  54. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -1
  55. package/dist/resolvers/SqlLoggingConfigResolver.d.ts.map +1 -1
  56. package/dist/resolvers/SqlLoggingConfigResolver.js +1 -1
  57. package/dist/resolvers/SqlLoggingConfigResolver.js.map +1 -1
  58. package/dist/resolvers/SyncRolesUsersResolver.js +2 -2
  59. package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
  60. package/dist/resolvers/UserFavoriteResolver.js +1 -1
  61. package/dist/resolvers/UserFavoriteResolver.js.map +1 -1
  62. package/dist/rest/OAuthCallbackHandler.js +1 -1
  63. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  64. package/dist/rest/SignatureWebhookHandler.js +1 -1
  65. package/dist/rest/SignatureWebhookHandler.js.map +1 -1
  66. package/dist/services/DurableEntityActionTaskSubmitter.d.ts +26 -0
  67. package/dist/services/DurableEntityActionTaskSubmitter.d.ts.map +1 -0
  68. package/dist/services/DurableEntityActionTaskSubmitter.js +55 -0
  69. package/dist/services/DurableEntityActionTaskSubmitter.js.map +1 -0
  70. package/dist/services/ScheduledJobsService.js +1 -1
  71. package/dist/services/ScheduledJobsService.js.map +1 -1
  72. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  73. package/dist/services/StartTaskGraphDispatcher.js +16 -1
  74. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  75. package/dist/services/TaskGraphActionRunner.d.ts +34 -0
  76. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -0
  77. package/dist/services/TaskGraphActionRunner.js +95 -0
  78. package/dist/services/TaskGraphActionRunner.js.map +1 -0
  79. package/dist/services/TaskGraphAgentRunner.d.ts +13 -0
  80. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  81. package/dist/services/TaskGraphAgentRunner.js +30 -0
  82. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  83. package/dist/services/TaskGraphPromptRunner.d.ts +31 -0
  84. package/dist/services/TaskGraphPromptRunner.d.ts.map +1 -0
  85. package/dist/services/TaskGraphPromptRunner.js +134 -0
  86. package/dist/services/TaskGraphPromptRunner.js.map +1 -0
  87. package/dist/telephony/RingCentralTelephonyService.js +1 -1
  88. package/dist/telephony/RingCentralTelephonyService.js.map +1 -1
  89. package/dist/telephony/TwilioTelephonyRouter.js +1 -1
  90. package/dist/telephony/TwilioTelephonyRouter.js.map +1 -1
  91. package/dist/telephony/VonageTelephonyRouter.js +1 -1
  92. package/dist/telephony/VonageTelephonyRouter.js.map +1 -1
  93. package/package.json +90 -90
  94. package/src/__tests__/RealtimeClientSessionResolver.test.ts +1 -1
  95. package/src/__tests__/ResolverBase.frozenRecordMapping.test.ts +133 -0
  96. package/src/__tests__/ResolverBase.transportMapping.test.ts +289 -0
  97. package/src/__tests__/SessionManager.test.ts +1 -1
  98. package/src/__tests__/WidgetSessionService.test.ts +1 -1
  99. package/src/__tests__/databaseAbstraction.test.ts +1 -1
  100. package/src/__tests__/magicLinkService.provisionLog.test.ts +1 -1
  101. package/src/__tests__/resolverBase.rls.test.ts +3 -0
  102. package/src/__tests__/secretRedactor.failOpen.test.ts +135 -0
  103. package/src/__tests__/widgetGuestElevation.test.ts +1 -1
  104. package/src/auth/exampleNewUserSubClass.ts +1 -1
  105. package/src/auth/index.ts +10 -6
  106. package/src/auth/magicLink/MagicLinkService.ts +1 -1
  107. package/src/auth/newUsers.ts +1 -1
  108. package/src/context.ts +2 -2
  109. package/src/generated/generated.ts +166 -17
  110. package/src/generic/ResolverBase.ts +52 -17
  111. package/src/index.ts +92 -42
  112. package/src/logging/secretRedactor.ts +43 -0
  113. package/src/middleware/BaseServerMiddleware.ts +5 -0
  114. package/src/realtimeWidget/WidgetSessionService.ts +1 -1
  115. package/src/realtimeWidget/widgetGuestElevation.ts +1 -1
  116. package/src/resolvers/ActionResolver.ts +13 -0
  117. package/src/resolvers/ComponentRegistryResolver.ts +1 -1
  118. package/src/resolvers/EntityPermissionResolver.ts +1 -1
  119. package/src/resolvers/IntegrationDiscoveryResolver.ts +12 -2
  120. package/src/resolvers/PotentialDuplicateRecordResolver.ts +1 -1
  121. package/src/resolvers/RealtimeClientSessionResolver.ts +13 -0
  122. package/src/resolvers/SearchEntitiesResolver.ts +1 -1
  123. package/src/resolvers/SqlLoggingConfigResolver.ts +1 -2
  124. package/src/resolvers/SyncRolesUsersResolver.ts +2 -2
  125. package/src/resolvers/UserFavoriteResolver.ts +1 -1
  126. package/src/rest/OAuthCallbackHandler.ts +1 -1
  127. package/src/rest/SignatureWebhookHandler.ts +1 -1
  128. package/src/services/DurableEntityActionTaskSubmitter.ts +78 -0
  129. package/src/services/ScheduledJobsService.ts +1 -1
  130. package/src/services/StartTaskGraphDispatcher.ts +17 -0
  131. package/src/services/TaskGraphActionRunner.ts +99 -0
  132. package/src/services/TaskGraphAgentRunner.ts +35 -1
  133. package/src/services/TaskGraphPromptRunner.ts +144 -0
  134. package/src/telephony/RingCentralTelephonyService.ts +1 -1
  135. package/src/telephony/TwilioTelephonyRouter.ts +1 -1
  136. package/src/telephony/VonageTelephonyRouter.ts +1 -1
@@ -0,0 +1,289 @@
1
+ // ResolverBase transitively pulls in type-graphql decorators, which need the
2
+ // Reflect.metadata polyfill at import time.
3
+ import 'reflect-metadata';
4
+ import { describe, it, expect } from 'vitest';
5
+ import type { DatabaseProviderBase, RunViewParams, RunViewResult, UserInfo } from '@memberjunction/core';
6
+ import type { MJUserViewEntityExtended } from '@memberjunction/core-entities';
7
+ import { FieldMapper } from '@memberjunction/graphql-dataprovider';
8
+ import { ResolverBase } from '../generic/ResolverBase.js';
9
+ import type { UserPayload } from '../types.js';
10
+
11
+ /**
12
+ * Regression guard for the server-cache corruption behind
13
+ * "Field _mj__CreatedAt does not exist on MJ: Template Categories".
14
+ *
15
+ * GraphQL reserves the `__` prefix, so MJ renames `__mj_*` columns to the
16
+ * transport alias `_mj__*` on the way out. `ResolverBase` used to apply that
17
+ * rename IN PLACE over `result.Results` — but those are the data provider's own
18
+ * row objects, and the server's cache holds them BY REFERENCE. Preparing one
19
+ * GraphQL response therefore rewrote the keys inside the live cache, and every
20
+ * subsequent read served from it handed the client transport-shaped rows that
21
+ * `BaseEntity.SetMany` rejects. Process-wide cache ⇒ one response poisoned every
22
+ * later request, across all workers.
23
+ *
24
+ * The fix is copy-then-map. The first block exercises the REAL resolver path (so a
25
+ * revert to in-place mapping fails here); the second pins the two `FieldMapper`
26
+ * invariants that make the fix correct.
27
+ *
28
+ * These copies are now LOAD-BEARING rather than merely defensive: `LocalCacheManager`
29
+ * deep-freezes cached rows on reference-sharing storage providers, so mapping in place
30
+ * would throw a `TypeError` instead of silently corrupting the cache. The third block
31
+ * pins that the copy-then-map pattern still works on frozen input — the shape the cache
32
+ * actually hands out — and that in-place mapping is now refused outright.
33
+ */
34
+
35
+ /** A cache-held row, shaped the way the provider hands them back. */
36
+ type CachedRow = Record<string, unknown>;
37
+
38
+ /**
39
+ * Reaches the protected `RunViewGenericInternal` and records what
40
+ * `ArrayFilterEncryptedFieldsForAPI` was handed — the second in-place mutator that
41
+ * must also see copies, and stubbed here so the assertion needs no live Metadata.
42
+ */
43
+ class Probe extends ResolverBase {
44
+ public filteredRows: Record<string, unknown>[] | null = null;
45
+
46
+ protected override async ArrayFilterEncryptedFieldsForAPI(
47
+ _entityName: string,
48
+ dataObjectArray: Record<string, unknown>[]
49
+ ): Promise<Record<string, unknown>[]> {
50
+ this.filteredRows = dataObjectArray;
51
+ return dataObjectArray;
52
+ }
53
+
54
+ public Run(provider: DatabaseProviderBase, viewInfo: MJUserViewEntityExtended, userPayload: UserPayload) {
55
+ return this.RunViewGenericInternal(
56
+ provider,
57
+ viewInfo,
58
+ '', // extraFilter
59
+ '', // orderBy
60
+ '', // userSearchString
61
+ undefined, // excludeUserViewRunID
62
+ undefined, // overrideExcludeFilter
63
+ undefined, // saveViewResults
64
+ undefined, // fields
65
+ undefined, // ignoreMaxRows
66
+ undefined, // excludeDataFromAllPriorViewRuns
67
+ undefined, // forceAuditLog
68
+ undefined, // auditLogDescription
69
+ 'simple', // resultType
70
+ userPayload,
71
+ undefined, // maxRows
72
+ undefined // startRow
73
+ );
74
+ }
75
+ }
76
+
77
+ const ENTITY_NAME = 'MJ: Template Categories';
78
+
79
+ /** Minimal provider: the entity lookup + the RunView the resolver awaits. */
80
+ function fakeProvider(rows: CachedRow[]): DatabaseProviderBase {
81
+ return {
82
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
83
+ RunView: async (_params: RunViewParams): Promise<RunViewResult> =>
84
+ ({ Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult),
85
+ } as unknown as DatabaseProviderBase;
86
+ }
87
+
88
+ const fakeViewInfo = () =>
89
+ ({ ID: 'view-1', Name: 'Test View', Entity: ENTITY_NAME } as unknown as MJUserViewEntityExtended);
90
+
91
+ /** `userRecord` short-circuits the UserCache lookup; no apiKeyHash skips the scope check. */
92
+ const fakePayload = () =>
93
+ ({ email: 'tester@example.com', userRecord: { Email: 'tester@example.com' } as UserInfo } as UserPayload);
94
+
95
+ describe('ResolverBase.RunViewGenericInternal — cache safety', () => {
96
+ it('leaves the provider\'s (cache-held) rows untouched while returning transport keys', async () => {
97
+ const cachedRow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
98
+ const probe = new Probe();
99
+
100
+ const result = await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
101
+
102
+ // The pre-fix code renamed these keys in place — this is the assertion that fails on a revert.
103
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
104
+ expect(cachedRow.__mj_UpdatedAt).toBe('T1');
105
+ expect(cachedRow._mj__CreatedAt).toBeUndefined();
106
+
107
+ // The outgoing rows carry the GraphQL-legal aliases, and are NOT the cached objects.
108
+ const wire = result?.Results as Record<string, unknown>[];
109
+ expect(wire[0]._mj__CreatedAt).toBe('T0');
110
+ expect(wire[0]).not.toBe(cachedRow);
111
+ });
112
+
113
+ it('hands the encrypted-field filter the copies, not the cached rows', async () => {
114
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
115
+ const probe = new Probe();
116
+
117
+ await probe.Run(fakeProvider([cachedRow]), fakeViewInfo(), fakePayload());
118
+
119
+ expect(probe.filteredRows).not.toBeNull();
120
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
121
+ });
122
+ });
123
+
124
+ describe('FieldMapper transport mapping — the invariants the fix rests on', () => {
125
+ it('MapFields MUTATES its argument (the hazard the resolver must not expose the cache to)', () => {
126
+ // Documents WHY the resolver must copy. If this ever becomes non-mutating,
127
+ // the copy in ResolverBase is redundant rather than load-bearing — and this
128
+ // test failing is the signal to revisit it.
129
+ const row: Record<string, unknown> = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' };
130
+
131
+ new FieldMapper().MapFields(row);
132
+
133
+ expect(row.__mj_CreatedAt).toBeUndefined();
134
+ expect(row._mj__CreatedAt).toBe('T0');
135
+ });
136
+
137
+ it('post-map mutation of the copy cannot reach the cached row', () => {
138
+ // ArrayFilterEncryptedFieldsForAPI redacts in place after mapping; on the
139
+ // pre-fix code that stripped secrets out of the CACHED row too.
140
+ const mapper = new FieldMapper();
141
+ const cachedRow: Record<string, unknown> = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
142
+
143
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
144
+ wireRow.Secret = null; // stand-in for the encrypted-field filter
145
+
146
+ expect(cachedRow.Secret).toBe('plaintext');
147
+ expect(wireRow.Secret).toBeNull();
148
+ });
149
+
150
+ it('round-trips a mapped copy back to entity field names', () => {
151
+ // The client's ConvertBackToMJFields must undo exactly what the server did.
152
+ const mapper = new FieldMapper();
153
+ const original: Record<string, unknown> = { ID: 'a1', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
154
+
155
+ const wire = mapper.MapFields({ ...original })!;
156
+ const back = mapper.ReverseMapFields({ ...wire });
157
+
158
+ expect(back).toEqual(original);
159
+ });
160
+
161
+ it('leaves rows without __mj_ fields structurally unchanged', () => {
162
+ const mapper = new FieldMapper();
163
+ const row = { ID: 'a1', Name: 'Cat' };
164
+
165
+ expect(mapper.MapFields({ ...row })).toEqual(row);
166
+ });
167
+ });
168
+
169
+ describe('transport mapping against FROZEN cache rows', () => {
170
+ // The cache deep-freezes rows it hands out (LocalCacheManager freeze-on-write), so the
171
+ // resolver's input is frozen in the deployment that matters — MJAPI on the in-memory
172
+ // storage provider. That turns the copy from a precaution into a requirement.
173
+ it('copy-then-map works on a frozen row', () => {
174
+ const mapper = new FieldMapper();
175
+ const cachedRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
176
+
177
+ const wireRow = mapper.MapFields({ ...cachedRow })!;
178
+
179
+ expect(wireRow._mj__CreatedAt).toBe('T0');
180
+ expect(wireRow.__mj_CreatedAt).toBeUndefined();
181
+ // The copy is mutable, so the encrypted-field filter can still redact in place.
182
+ expect(Object.isFrozen(wireRow)).toBe(false);
183
+ // And the frozen cached row is untouched.
184
+ expect(cachedRow.__mj_CreatedAt).toBe('T0');
185
+ });
186
+
187
+ it('in-place mapping of a frozen row THROWS — silent corruption is now impossible', () => {
188
+ // This is the safety net the freeze adds on top of the copy: if a future refactor
189
+ // reverts to in-place mapping, it fails loudly at the offending line instead of
190
+ // quietly rewriting process-wide state.
191
+ const mapper = new FieldMapper();
192
+ const cachedRow = Object.freeze({ ID: 'a1', __mj_CreatedAt: 'T0' }) as Record<string, unknown>;
193
+
194
+ expect(() => mapper.MapFields(cachedRow)).toThrow(TypeError);
195
+ });
196
+ });
197
+
198
+ /**
199
+ * The BATCH path (`RunViewsGenericInternal`), which the single-view tests above do not reach
200
+ * (PR #3425 review, finding M8).
201
+ *
202
+ * This is the path multi-view clients actually take — MJExplorer batches its view loads — so
203
+ * leaving it uncovered meant the original corruption bug could be reintroduced on the more
204
+ * heavily used of the two legs with CI still green.
205
+ */
206
+ class BatchProbe extends ResolverBase {
207
+ public filteredRows: Record<string, unknown>[] | null = null;
208
+
209
+ protected override async ArrayFilterEncryptedFieldsForAPI(
210
+ _entityName: string,
211
+ dataObjectArray: Record<string, unknown>[]
212
+ ): Promise<Record<string, unknown>[]> {
213
+ this.filteredRows = dataObjectArray;
214
+ return dataObjectArray;
215
+ }
216
+
217
+ public RunBatch(provider: DatabaseProviderBase, viewInfos: MJUserViewEntityExtended[], userPayload: UserPayload) {
218
+ return this.RunViewsGenericInternal(
219
+ viewInfos.map(viewInfo => ({ provider, viewInfo, userPayload, resultType: 'simple' })) as never
220
+ );
221
+ }
222
+ }
223
+
224
+ /** Batch provider: `RunViews` returns one result per param, in order. */
225
+ function fakeBatchProvider(rowsPerView: CachedRow[][]): DatabaseProviderBase {
226
+ return {
227
+ Entities: [{ Name: ENTITY_NAME, PrimaryKeys: [{ Name: 'ID' }] }],
228
+ EntityByName: (name: string) => (name === ENTITY_NAME ? { Name: ENTITY_NAME } : undefined),
229
+ RunViews: async (params: RunViewParams[]): Promise<RunViewResult[]> =>
230
+ params.map((_p, i) => {
231
+ const rows = rowsPerView[i] ?? [];
232
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
233
+ }),
234
+ } as unknown as DatabaseProviderBase;
235
+ }
236
+
237
+ describe('ResolverBase.RunViewsGenericInternal (batch) — cache safety', () => {
238
+ it('leaves every view\'s cache-held rows untouched while returning transport keys', async () => {
239
+ const viewARow: CachedRow = { ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0', __mj_UpdatedAt: 'T1' };
240
+ const viewBRow: CachedRow = { ID: 'b1', Name: 'Dog', __mj_CreatedAt: 'T2', __mj_UpdatedAt: 'T3' };
241
+ const probe = new BatchProbe();
242
+
243
+ const results = await probe.RunBatch(
244
+ fakeBatchProvider([[viewARow], [viewBRow]]),
245
+ [fakeViewInfo(), fakeViewInfo()],
246
+ fakePayload()
247
+ );
248
+
249
+ // Both source rows keep entity field names — the assertion that fails on a revert
250
+ // to in-place mapping in the batch loop.
251
+ expect(viewARow.__mj_CreatedAt).toBe('T0');
252
+ expect(viewARow._mj__CreatedAt).toBeUndefined();
253
+ expect(viewBRow.__mj_CreatedAt).toBe('T2');
254
+ expect(viewBRow._mj__CreatedAt).toBeUndefined();
255
+
256
+ // ...and both outgoing views carry the aliases, on objects that are not the cached ones.
257
+ const wireA = results[0].Results as Record<string, unknown>[];
258
+ const wireB = results[1].Results as Record<string, unknown>[];
259
+ expect(wireA[0]._mj__CreatedAt).toBe('T0');
260
+ expect(wireB[0]._mj__CreatedAt).toBe('T2');
261
+ expect(wireA[0]).not.toBe(viewARow);
262
+ expect(wireB[0]).not.toBe(viewBRow);
263
+ });
264
+
265
+ it('maps FROZEN cache rows rather than throwing on them', async () => {
266
+ // The shape the cache actually hands out on a hit.
267
+ const frozenRow = Object.freeze({ ID: 'a1', Name: 'Cat', __mj_CreatedAt: 'T0' }) as CachedRow;
268
+ const probe = new BatchProbe();
269
+
270
+ const results = await probe.RunBatch(
271
+ fakeBatchProvider([[frozenRow]]),
272
+ [fakeViewInfo()],
273
+ fakePayload()
274
+ );
275
+
276
+ expect((results[0].Results as Record<string, unknown>[])[0]._mj__CreatedAt).toBe('T0');
277
+ expect(frozenRow.__mj_CreatedAt).toBe('T0');
278
+ });
279
+
280
+ it('hands the encrypted-field filter copies, not the cached rows', async () => {
281
+ const cachedRow: CachedRow = { ID: 'a1', Secret: 'plaintext', __mj_CreatedAt: 'T0' };
282
+ const probe = new BatchProbe();
283
+
284
+ await probe.RunBatch(fakeBatchProvider([[cachedRow]]), [fakeViewInfo()], fakePayload());
285
+
286
+ expect(probe.filteredRows).not.toBeNull();
287
+ expect(probe.filteredRows![0]).not.toBe(cachedRow);
288
+ });
289
+ });
@@ -43,7 +43,7 @@ vi.mock('@memberjunction/core', async (importOriginal) => {
43
43
  // --- Partial mock: control ONLY UserCache.GetSystemUser (the scoped-anonymous elevation seam,
44
44
  // issue #3371); everything else in the data provider stays real ---
45
45
  const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
46
- vi.mock('@memberjunction/sqlserver-dataprovider', async (importOriginal) => {
46
+ vi.mock('@memberjunction/generic-database-provider', async (importOriginal) => {
47
47
  const actual = await importOriginal<Record<string, unknown>>();
48
48
  return {
49
49
  ...actual,
@@ -8,7 +8,7 @@ import { describe, it, expect, vi } from 'vitest';
8
8
  vi.mock('../config.js', () => ({
9
9
  configInfo: {},
10
10
  }));
11
- vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
11
+ vi.mock('@memberjunction/generic-database-provider', () => ({
12
12
  UserCache: {
13
13
  Instance: { Users: [], UserByName: () => undefined, GetSystemUser: () => undefined },
14
14
  Users: [],
@@ -100,6 +100,7 @@ vi.mock('@memberjunction/core', () => ({
100
100
  // Inline the actual implementation here so the env-var-handling tests below
101
101
  // exercise the genuine behavior (they're testing this function).
102
102
  vi.mock('@memberjunction/generic-database-provider', () => ({
103
+ UserCache: { Instance: { Users: [], GetSystemUser: vi.fn() } },
103
104
  resolveDbPlatformFromEnv: (envVarName: string = 'DB_PLATFORM'): 'sqlserver' | 'postgresql' | undefined => {
104
105
  const raw = process.env[envVarName];
105
106
  if (raw === undefined) return undefined;
@@ -117,7 +118,6 @@ vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
117
118
  SQLServerDataProvider: vi.fn(),
118
119
  SQLServerProviderConfigData: vi.fn(),
119
120
  setupSQLServerClient: vi.fn(),
120
- UserCache: { Instance: { Users: [], GetSystemUser: vi.fn() } },
121
121
  }));
122
122
 
123
123
  vi.mock('@memberjunction/api-keys', () => ({
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
4
4
  // data-provider and communication stacks in at module load. Stub those out;
5
5
  // this suite only exercises provisioning's console-log behavior, so
6
6
  // @memberjunction/core (the logging implementation) stays real.
7
- vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
7
+ vi.mock('@memberjunction/generic-database-provider', () => ({
8
8
  UserCache: { Instance: { Users: [] } },
9
9
  }));
10
10
  vi.mock('@memberjunction/communication-engine', () => ({
@@ -16,6 +16,9 @@ const { mockUserCacheUsers } = vi.hoisted(() => ({
16
16
  // Stub external deps before imports
17
17
  vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
18
18
  SQLServerDataProvider: class {},
19
+ }));
20
+
21
+ vi.mock('@memberjunction/generic-database-provider', () => ({
19
22
  UserCache: {
20
23
  get Users() { return mockUserCacheUsers; },
21
24
  },
@@ -0,0 +1,135 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { IMetadataProvider } from '@memberjunction/core';
3
+ import { redactArg, ResetUnboundCrudInputWarnings } from '../logging/secretRedactor.js';
4
+
5
+ /**
6
+ * `redactArg` binds a GraphQL input type to an entity by NAME:
7
+ *
8
+ * ```ts
9
+ * const match = /^(Create|Update|Delete)(?<name>.+)Input$/.exec(inputTypeName);
10
+ * const entity = provider.Entities.find((e) => e.ClassName === match.groups.name);
11
+ * ```
12
+ *
13
+ * Codegen inputs embed the entity ClassName (`CreateMJCredentialInput` → `MJCredential`)
14
+ * so the lookup succeeds. Hand-written resolver inputs do not (`CreateConnectionInput`
15
+ * → `Connection`, which is no entity), so the lookup fails, no encrypted-field names
16
+ * are contributed, and the arg falls through to `shortenForLog` with values intact.
17
+ *
18
+ * The boot audit tests only the NAME pattern, so it classifies these as metadata-bound
19
+ * and stays silent — the reason the gap went unnoticed. These tests pin both the
20
+ * fail-open behaviour and the warning that now surfaces it.
21
+ */
22
+
23
+ const SECRET = 'sk-live-CREDENTIAL-PAYLOAD';
24
+
25
+ /** Minimal provider exposing one entity with one encrypted column. */
26
+ function providerWith(entities: { ClassName: string; EncryptedFields: { Name: string }[] }[]): IMetadataProvider {
27
+ return { Entities: entities } as unknown as IMetadataProvider;
28
+ }
29
+
30
+ const CREDENTIAL_ENTITY = providerWith([
31
+ { ClassName: 'MJCredential', EncryptedFields: [{ Name: 'Values' }] },
32
+ ]);
33
+
34
+ beforeEach(() => {
35
+ ResetUnboundCrudInputWarnings();
36
+ vi.spyOn(console, 'warn').mockImplementation(() => undefined);
37
+ });
38
+
39
+ afterEach(() => {
40
+ vi.restoreAllMocks();
41
+ });
42
+
43
+ describe('redactArg — entity-bound inputs (the codegen case)', () => {
44
+ it('redacts an encrypted column, keeping the rest', () => {
45
+ const out = redactArg({
46
+ inputTypeName: 'CreateMJCredentialInput',
47
+ rawValue: { Name: 'Stripe', Values: SECRET },
48
+ provider: CREDENTIAL_ENTITY,
49
+ noLogParameter: false,
50
+ noLogFields: new Set(),
51
+ }) as Record<string, unknown>;
52
+
53
+ expect(out.Values).toBe('<redacted>');
54
+ expect(out.Name).toBe('Stripe');
55
+ });
56
+
57
+ it('emits no warning, because the binding resolved', () => {
58
+ redactArg({
59
+ inputTypeName: 'CreateMJCredentialInput',
60
+ rawValue: { Values: SECRET },
61
+ provider: CREDENTIAL_ENTITY,
62
+ noLogParameter: false,
63
+ noLogFields: new Set(),
64
+ });
65
+ expect(console.warn).not.toHaveBeenCalled();
66
+ });
67
+ });
68
+
69
+ describe('redactArg — name matches the convention but no entity exists', () => {
70
+ const unbound = {
71
+ inputTypeName: 'CreateConnectionInput',
72
+ rawValue: { CredentialName: 'acme', CredentialValues: SECRET },
73
+ provider: CREDENTIAL_ENTITY,
74
+ noLogParameter: false,
75
+ noLogFields: new Set<string>(),
76
+ };
77
+
78
+ it('falls open — values are logged, which is the defect being surfaced', () => {
79
+ const out = redactArg({ ...unbound });
80
+ // Documents current behaviour: no entity binding and no @NoLog means no
81
+ // redaction source, so the value survives into the log.
82
+ expect(JSON.stringify(out)).toContain(SECRET);
83
+ });
84
+
85
+ it('now warns, naming the input type and what to do about it', () => {
86
+ redactArg({ ...unbound });
87
+
88
+ expect(console.warn).toHaveBeenCalledTimes(1);
89
+ const message = vi.mocked(console.warn).mock.calls[0][0] as string;
90
+ expect(message).toContain('CreateConnectionInput');
91
+ expect(message).toContain('@NoLog');
92
+ // The warning itself must not become a second leak.
93
+ expect(message).not.toContain(SECRET);
94
+ });
95
+
96
+ it('warns only once per input type, however hot the mutation is', () => {
97
+ redactArg({ ...unbound });
98
+ redactArg({ ...unbound });
99
+ redactArg({ ...unbound });
100
+ expect(console.warn).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it('warns separately for a different unbound input type', () => {
104
+ redactArg({ ...unbound });
105
+ redactArg({ ...unbound, inputTypeName: 'CreateScheduleInput' });
106
+ expect(console.warn).toHaveBeenCalledTimes(2);
107
+ });
108
+ });
109
+
110
+ describe('redactArg — @NoLog closes the gap the metadata cannot', () => {
111
+ it('redacts a marked field on an input with no entity binding', () => {
112
+ const out = redactArg({
113
+ inputTypeName: 'CreateConnectionInput',
114
+ rawValue: { CredentialName: 'acme', CredentialValues: SECRET },
115
+ provider: CREDENTIAL_ENTITY,
116
+ noLogParameter: false,
117
+ noLogFields: new Set(['CredentialValues']),
118
+ }) as Record<string, unknown>;
119
+
120
+ expect(out.CredentialValues).toBe('<redacted>');
121
+ expect(out.CredentialName).toBe('acme');
122
+ expect(JSON.stringify(out)).not.toContain(SECRET);
123
+ });
124
+
125
+ it('a parameter-level mark redacts the whole arg regardless of binding', () => {
126
+ const out = redactArg({
127
+ inputTypeName: 'CreateConnectionInput',
128
+ rawValue: { CredentialValues: SECRET },
129
+ provider: CREDENTIAL_ENTITY,
130
+ noLogParameter: true,
131
+ noLogFields: new Set(),
132
+ });
133
+ expect(out).toBe('<redacted>');
134
+ });
135
+ });
@@ -4,7 +4,7 @@ import type { DatabaseProviderBase, UserInfo } from '@memberjunction/core';
4
4
  // Controls what UserCache.Instance.GetSystemUser() serves — undefined simulates an unpopulated
5
5
  // cache (no system user available), the fail-closed path.
6
6
  const getSystemUserMock = vi.fn<[], UserInfo | undefined>();
7
- vi.mock('@memberjunction/sqlserver-dataprovider', () => ({
7
+ vi.mock('@memberjunction/generic-database-provider', () => ({
8
8
  UserCache: {
9
9
  get Instance() {
10
10
  return { GetSystemUser: getSystemUserMock };
@@ -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/sqlserver-dataprovider';
4
+ import { UserCache } from '@memberjunction/generic-database-provider';
5
5
  import { configInfo } from '../config.js';
6
6
  import { MJUserEntity } from '@memberjunction/core-entities';
7
7
 
package/src/auth/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { JwtHeader, SigningKeyCallback, JwtPayload } from 'jsonwebtoken';
2
2
  import { configInfo } from '../config.js';
3
- import { UserCache } from '@memberjunction/sqlserver-dataprovider';
3
+ import { UserCache } from '@memberjunction/generic-database-provider';
4
4
  import sql from 'mssql';
5
- import { Metadata, RoleInfo, UserInfo } from '@memberjunction/core';
5
+ import { DatabaseProviderBase, Metadata, RoleInfo, UserInfo } from '@memberjunction/core';
6
6
  import { NewUserBase } from './newUsers.js';
7
7
  import { MJGlobal } from '@memberjunction/global';
8
8
  import { MJUserEntity, MJUserEntityType } from '@memberjunction/core-entities';
@@ -21,9 +21,13 @@ class MissingAuthError extends Error {
21
21
  }
22
22
  }
23
23
 
24
- const refreshUserCache = async (dataSource?: sql.ConnectionPool) => {
24
+ const refreshUserCache = async () => {
25
25
  const startTime: number = Date.now();
26
- await UserCache.Instance.Refresh(dataSource);
26
+ // UserCache is process-global in this phase, and so is the provider MJServer configures at
27
+ // bootstrap — the callers below only hold a ConnectionPool, which they use purely as a
28
+ // "the database is reachable" gate before asking for a refresh.
29
+ const provider = Metadata.Provider as unknown as DatabaseProviderBase; // global-provider-ok: server auth path; runs under the server's single default provider
30
+ await UserCache.Instance.Refresh(provider);
27
31
  const endTime: number = Date.now();
28
32
  const elapsed: number = endTime - startTime;
29
33
 
@@ -179,7 +183,7 @@ export const getSystemUser = async (dataSource?: sql.ConnectionPool, attemptCach
179
183
  if (dataSource && attemptCacheUpdateIfNeeded) {
180
184
  console.warn(`System user not found in cache. Updating cache in attempt to find the user...`);
181
185
 
182
- await refreshUserCache(dataSource);
186
+ await refreshUserCache();
183
187
  return getSystemUser(dataSource, false); // try one more time but do not update cache next time if not found
184
188
  }
185
189
  throw new Error(`System user ID '${UserCache.Instance.SYSTEM_USER_ID}' not found in database`);
@@ -260,7 +264,7 @@ export const verifyUserRecord = async (
260
264
  // if we get here that means in the above, if we were attempting to create a new user, it did not work, or it wasn't attempted and we have a config that asks us to auto update the cache
261
265
  console.warn(`User ${email} not found in cache. Updating cache in attempt to find the user...`);
262
266
 
263
- await refreshUserCache(dataSource);
267
+ await refreshUserCache();
264
268
 
265
269
  return verifyUserRecord(email, firstName, lastName, requestDomain, dataSource, false); // try one more time but do not update cache next time if not found
266
270
  }
@@ -29,7 +29,7 @@ import {
29
29
  type MJUserEntityType,
30
30
  type MJApplicationRoleEntity,
31
31
  } from '@memberjunction/core-entities';
32
- import { UserCache } from '@memberjunction/sqlserver-dataprovider';
32
+ import { UserCache } from '@memberjunction/generic-database-provider';
33
33
  import { CommunicationEngine } from '@memberjunction/communication-engine';
34
34
  import { Message } from '@memberjunction/communication-types';
35
35
  import { configInfo, type MagicLinkConfig } from '../../config.js';
@@ -1,6 +1,6 @@
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/sqlserver-dataprovider";
3
+ import { UserCache } from "@memberjunction/generic-database-provider";
4
4
  import { configInfo } from "../config.js";
5
5
  import { MJUserEntity, MJUserRoleEntity, MJUserApplicationEntity, MJUserApplicationEntityEntity, MJApplicationEntityType, MJApplicationEntityEntityType, UserInfoEngine } from "@memberjunction/core-entities";
6
6
 
package/src/context.ts CHANGED
@@ -19,10 +19,10 @@ import { v4 as uuidv4 } from 'uuid';
19
19
  import e from 'express';
20
20
  import type { RequestHandler, Request, Response, NextFunction } from 'express';
21
21
  import { DatabaseProviderBase, UserInfo, type MagicLinkScope, type ReturningVisitorContext, type WidgetGuestContext } from '@memberjunction/core';
22
- import { SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
22
+ import { SQLServerDataProvider, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
23
23
  import { Metadata } from '@memberjunction/core';
24
24
  import { UUIDsEqual } from '@memberjunction/global';
25
- import { resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
25
+ import { UserCache, resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
26
26
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
27
27
 
28
28
  // ── Session / login audit (Phase 3) ───────────────────────────────────────────