@memberjunction/server 6.1.4 → 6.1.5

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 (71) hide show
  1. package/README.md +30 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/auth/magicLink/MagicLinkService.d.ts +17 -15
  4. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkService.js +49 -35
  6. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  7. package/dist/auth/magicLink/magicLinkCore.d.ts +14 -33
  8. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -1
  9. package/dist/auth/magicLink/magicLinkCore.js +20 -52
  10. package/dist/auth/magicLink/magicLinkCore.js.map +1 -1
  11. package/dist/context.d.ts.map +1 -1
  12. package/dist/context.js +11 -23
  13. package/dist/context.js.map +1 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +36 -44
  16. package/dist/index.js.map +1 -1
  17. package/dist/postgresqlCompat.d.ts +8 -0
  18. package/dist/postgresqlCompat.d.ts.map +1 -0
  19. package/dist/postgresqlCompat.js +19 -0
  20. package/dist/postgresqlCompat.js.map +1 -0
  21. package/dist/postgresqlPoolSettings.d.ts +108 -0
  22. package/dist/postgresqlPoolSettings.d.ts.map +1 -0
  23. package/dist/postgresqlPoolSettings.js +134 -0
  24. package/dist/postgresqlPoolSettings.js.map +1 -0
  25. package/dist/resolvers/AdhocQueryResolver.d.ts +10 -20
  26. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  27. package/dist/resolvers/AdhocQueryResolver.js +54 -174
  28. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  29. package/dist/resolvers/RealtimeClientSessionResolver.d.ts +44 -6
  30. package/dist/resolvers/RealtimeClientSessionResolver.d.ts.map +1 -1
  31. package/dist/resolvers/RealtimeClientSessionResolver.js +90 -34
  32. package/dist/resolvers/RealtimeClientSessionResolver.js.map +1 -1
  33. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  34. package/dist/resolvers/RunAIAgentResolver.js +2 -1
  35. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  36. package/dist/resolvers/adhocTimeout.d.ts +7 -0
  37. package/dist/resolvers/adhocTimeout.d.ts.map +1 -0
  38. package/dist/resolvers/adhocTimeout.js +12 -0
  39. package/dist/resolvers/adhocTimeout.js.map +1 -0
  40. package/dist/services/TaskGraphActionRunner.d.ts +13 -8
  41. package/dist/services/TaskGraphActionRunner.d.ts.map +1 -1
  42. package/dist/services/TaskGraphActionRunner.js +16 -15
  43. package/dist/services/TaskGraphActionRunner.js.map +1 -1
  44. package/package.json +92 -92
  45. package/src/__tests__/AdhocQueryResolver.bugs.test.ts +12 -35
  46. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +6 -2
  47. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +3 -3
  48. package/src/__tests__/RealtimeClientSessionResolver.test.ts +216 -2
  49. package/src/__tests__/TaskGraphActionRunner.durablePayload.test.ts +129 -0
  50. package/src/__tests__/adhocTimeoutClamp.test.ts +26 -0
  51. package/src/__tests__/magicLink.test.ts +15 -76
  52. package/src/__tests__/magicLinkService.consume.test.ts +224 -0
  53. package/src/__tests__/postgresqlPoolSettings.test.ts +131 -0
  54. package/src/__tests__/translateBracketsToPG.test.ts +20 -0
  55. package/src/auth/magicLink/MagicLinkService.ts +55 -36
  56. package/src/auth/magicLink/magicLinkCore.ts +19 -56
  57. package/src/context.ts +11 -24
  58. package/src/index.ts +40 -45
  59. package/src/postgresqlCompat.ts +21 -0
  60. package/src/postgresqlPoolSettings.ts +213 -0
  61. package/src/resolvers/AdhocQueryResolver.ts +64 -194
  62. package/src/resolvers/RealtimeClientSessionResolver.ts +93 -35
  63. package/src/resolvers/RunAIAgentResolver.ts +4 -1
  64. package/src/resolvers/adhocTimeout.ts +12 -0
  65. package/src/services/TaskGraphActionRunner.ts +16 -15
  66. package/dist/resolvers/adhoc-query-helpers.d.ts +0 -27
  67. package/dist/resolvers/adhoc-query-helpers.d.ts.map +0 -1
  68. package/dist/resolvers/adhoc-query-helpers.js +0 -42
  69. package/dist/resolvers/adhoc-query-helpers.js.map +0 -1
  70. package/src/__tests__/adhoc-query-helpers.test.ts +0 -50
  71. package/src/resolvers/adhoc-query-helpers.ts +0 -50
@@ -3,7 +3,7 @@
3
3
  // bring it in automatically — this line MUST precede any import that pulls in the resolver file.
4
4
  import 'reflect-metadata';
5
5
 
6
- import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
7
7
 
8
8
  // --- Mock CanRun authorization (AIAgentPermissionHelper.HasPermission) plus the
9
9
  // provider-scoped AIEngineBase whose cached metadata serves the resolver's pairing-row
@@ -210,9 +210,23 @@ interface FakeSession {
210
210
  NewRecord: () => void;
211
211
  Save: () => Promise<boolean>;
212
212
  Load: (id: string) => Promise<boolean>;
213
- LatestResult?: { CompleteMessage?: string };
213
+ LatestResult?: { CompleteMessage?: string; Success?: boolean };
214
+ /** Only set on the fakes exercising `describeSaveFailure`'s "no failure detail recorded" branches. */
215
+ ResultHistory?: unknown[];
214
216
  }
215
217
 
218
+ /**
219
+ * A `console.error` spy shared by the save-failure-log tests (#4791) below. Assigned with
220
+ * `vi.spyOn(console, 'error').mockImplementation(() => undefined)` inside each test that needs it
221
+ * — restored here so no test leaks a mocked `console.error` into the ones after it.
222
+ */
223
+ let errSpy: ReturnType<typeof vi.spyOn> | undefined;
224
+
225
+ afterEach(() => {
226
+ errSpy?.mockRestore();
227
+ errSpy = undefined;
228
+ });
229
+
216
230
  function makeSessionEntity(overrides: Partial<FakeSession> = {}): FakeSession {
217
231
  return {
218
232
  ID: 'session-1',
@@ -848,6 +862,28 @@ describe('RealtimeClientSessionResolver.ExecuteRealtimeSessionTool', () => {
848
862
  resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'x', '{}', makeCtx(), makePubSub()),
849
863
  ).rejects.toThrow(/no target agent configured/i);
850
864
  });
865
+
866
+ it('logs the session id and "no failure detail recorded" when updatePendingFeedbackRunID\'s save is refused silently (#4791)', async () => {
867
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
868
+ const session = makeSessionEntity({
869
+ Config_: JSON.stringify({ targetAgentID: 'target-1' }),
870
+ Save: vi.fn(async () => false),
871
+ LatestResult: undefined,
872
+ ResultHistory: [],
873
+ });
874
+ currentProvider = makeProvider(() => session);
875
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"q":1}', Success: true, PausedRunID: 'paused-1' });
876
+ const resolver = makeResolver();
877
+
878
+ await resolver.ExecuteRealtimeSessionTool('session-1', 'call-1', 'invoke-target-agent', '{}', makeCtx(), makePubSub());
879
+
880
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
881
+ const failureLine = loggedLines.find((line) => line.includes('updatePendingFeedbackRunID'));
882
+ expect(failureLine).toBeDefined();
883
+ expect(failureLine).toContain('session-1');
884
+ expect(failureLine).toContain('no failure detail recorded');
885
+ expect(failureLine).not.toContain('unknown error');
886
+ });
851
887
  });
852
888
 
853
889
  describe('RealtimeClientSessionResolver.RelayRealtimeTranscript', () => {
@@ -1690,6 +1726,7 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1690
1726
  });
1691
1727
 
1692
1728
  it('returns a structured failure when the artifact header save fails (no version attempted)', async () => {
1729
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1693
1730
  const failingArtifact = makeSessionEntity({
1694
1731
  ID: 'artifact-fail',
1695
1732
  Save: vi.fn(async () => false),
@@ -1706,10 +1743,14 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1706
1743
 
1707
1744
  expect(result.Success).toBe(false);
1708
1745
  expect(result.ErrorMessage).toMatch(/artifact save failed/i);
1746
+ expect(result.ErrorMessage).toContain('session session-1');
1747
+ expect(result.ErrorMessage).toContain('db down');
1748
+ expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
1709
1749
  expect(version.Save).not.toHaveBeenCalled();
1710
1750
  });
1711
1751
 
1712
1752
  it('returns a structured failure (carrying the orphaned ArtifactID) when the version save fails', async () => {
1753
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1713
1754
  const failingVersion = makeSessionEntity({
1714
1755
  ID: 'version-fail',
1715
1756
  Save: vi.fn(async () => false),
@@ -1728,6 +1769,10 @@ describe('RealtimeClientSessionResolver.SaveSessionChannelArtifact', () => {
1728
1769
  expect(result.Success).toBe(false);
1729
1770
  expect(result.ArtifactID).toBe('artifact-1');
1730
1771
  expect(result.ArtifactVersionID).toBeUndefined();
1772
+ expect(result.ErrorMessage).toMatch(/artifact version save failed/i);
1773
+ expect(result.ErrorMessage).toContain('session session-1');
1774
+ expect(result.ErrorMessage).toContain('too big');
1775
+ expect(errSpy.mock.calls.map((call) => String(call[0]))).toContain(result.ErrorMessage);
1731
1776
  expect(junction.Save).not.toHaveBeenCalled();
1732
1777
  });
1733
1778
 
@@ -2880,6 +2925,44 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2880
2925
  expect(executeRelayedToolMock.mock.calls[0][1]).toBe(ANON_USER);
2881
2926
  });
2882
2927
 
2928
+ it('writes the hidden direct-action tool turn (ExecuteRealtimeSessionTool) as the CALLER, not the system user (#4791)', async () => {
2929
+ const detail = makeSessionEntity({ ID: 'detail-1' });
2930
+ currentProvider = {
2931
+ GetEntityObject: vi.fn(async (name: string) =>
2932
+ name === 'MJ: Conversation Details' ? detail : makeSessionEntity({ UserID: 'anon-1' }),
2933
+ ),
2934
+ };
2935
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
2936
+ const resolver = makeAnonResolver();
2937
+
2938
+ await resolver.ExecuteRealtimeSessionTool(
2939
+ 'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
2940
+ );
2941
+
2942
+ // The dispatch itself still runs elevated (SYSTEM_USER)…
2943
+ expect(executeRelayedToolMock.mock.calls[0][1]).toBe(SYSTEM_USER);
2944
+ // …but the hidden Conversation Detail turn is written as the CALLER, never the system user.
2945
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
2946
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
2947
+ expect(detail.Save).toHaveBeenCalled();
2948
+ expect(detail.UserID).toBe('anon-1');
2949
+ });
2950
+
2951
+ it('writes the direct-action tool turn as the caller unchanged for a normal authenticated user', async () => {
2952
+ const detail = makeSessionEntity({ ID: 'detail-2' });
2953
+ currentProvider = {
2954
+ GetEntityObject: vi.fn(async (name: string) => (name === 'MJ: Conversation Details' ? detail : makeSessionEntity())),
2955
+ };
2956
+ executeRelayedToolMock.mockResolvedValue({ ResultJson: '{"success":true}', Success: true });
2957
+ const resolver = makeResolver();
2958
+
2959
+ await resolver.ExecuteRealtimeSessionTool(
2960
+ 'session-1', 'call-1', 'Some Direct Action', '{}', makeCtx(), makePubSub(),
2961
+ );
2962
+
2963
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', USER);
2964
+ });
2965
+
2883
2966
  it('accumulates relayed usage onto the prompt run under the SYSTEM user', async () => {
2884
2967
  const session = makeSessionEntity({
2885
2968
  UserID: 'anon-1',
@@ -2942,6 +3025,32 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
2942
3025
  expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
2943
3026
  });
2944
3027
 
3028
+ it('writes the hidden co-agent tool turn (RelayRealtimeToolTurn) as the CALLER (#4791)', async () => {
3029
+ const session = makeSessionEntity({
3030
+ UserID: 'anon-1',
3031
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
3032
+ });
3033
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
3034
+ const detail = makeSessionEntity({ ID: 'detail-3' });
3035
+ currentProvider = {
3036
+ GetEntityObject: vi.fn(async (name: string) => {
3037
+ if (name === 'MJ: AI Prompt Runs') return promptRun;
3038
+ if (name === 'MJ: Conversation Details') return detail;
3039
+ return session;
3040
+ }),
3041
+ };
3042
+ const resolver = makeAnonResolver();
3043
+
3044
+ const ok = await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{"url":"x"}');
3045
+
3046
+ expect(ok).toBe(true);
3047
+ // The co-agent run mirror stays SYSTEM…
3048
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: AI Prompt Runs', SYSTEM_USER);
3049
+ // …but the hidden Conversation Detail tool-turn is written as the CALLER, never SYSTEM.
3050
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
3051
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
3052
+ });
3053
+
2945
3054
  it('prepares the session with observability under the SYSTEM user while UserID stays the visitor', async () => {
2946
3055
  hasPermissionMock.mockResolvedValue(true);
2947
3056
  currentProvider = makeProvider(() => makeSessionEntity({ UserID: 'anon-1' }));
@@ -3003,6 +3112,10 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
3003
3112
  // The junction write (an entity the anon role does NOT hold) runs as the SYSTEM user.
3004
3113
  expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Detail Artifacts', SYSTEM_USER);
3005
3114
  expect(junctions).toHaveLength(1);
3115
+ // The hidden anchor `Conversation Detail` is written as the CALLER (#4791) — like every
3116
+ // other `MJ: Conversation Details` write, the System user is refused as not the owner.
3117
+ expect(getEntityObjectSpy()).toHaveBeenCalledWith('MJ: Conversation Details', ANON_USER);
3118
+ expect(getEntityObjectSpy()).not.toHaveBeenCalledWith('MJ: Conversation Details', SYSTEM_USER);
3006
3119
  // The hidden anchor is attributed to the SESSION owner, not the elevated principal.
3007
3120
  expect(anchors).toHaveLength(1);
3008
3121
  expect(anchors[0].UserID).toBe('anon-1');
@@ -3102,6 +3215,107 @@ describe('RealtimeClientSessionResolver — scoped-anonymous elevation (issue #3
3102
3215
  expect((storeRecordingMock.mock.calls[0][0] as { ContextUser: unknown }).ContextUser).toBe(USER);
3103
3216
  expect(getSystemUserMock).not.toHaveBeenCalled();
3104
3217
  });
3218
+
3219
+ /** Provider routing a hidden-tool-turn RelayRealtimeToolTurn call: session, prompt run, detail. */
3220
+ function makeToolTurnProvider(detail: FakeSession): unknown {
3221
+ const session = makeSessionEntity({
3222
+ UserID: 'anon-1',
3223
+ Config_: JSON.stringify({ targetAgentID: 'target-1', promptRunID: 'prompt-run-1' }),
3224
+ });
3225
+ const promptRun = makeSessionEntity({ ID: 'prompt-run-1', Messages: null });
3226
+ return {
3227
+ GetEntityObject: vi.fn(async (name: string) => {
3228
+ if (name === 'MJ: AI Prompt Runs') return promptRun;
3229
+ if (name === 'MJ: Conversation Details') return detail;
3230
+ return session;
3231
+ }),
3232
+ };
3233
+ }
3234
+
3235
+ it('logs the session id, write user id and "no failure detail recorded" when a hidden tool turn save is refused without a LatestResult (#4791)', async () => {
3236
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3237
+ const detail = makeSessionEntity({
3238
+ ID: 'detail-1',
3239
+ Save: vi.fn(async () => false),
3240
+ LatestResult: undefined,
3241
+ ResultHistory: [],
3242
+ });
3243
+ currentProvider = makeToolTurnProvider(detail);
3244
+ const resolver = makeAnonResolver();
3245
+
3246
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3247
+
3248
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3249
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3250
+ expect(failureLine).toBeDefined();
3251
+ // Exact substrings: 'anon-1' alone is the caller, session.UserID AND detail.UserID at once,
3252
+ // so only the labelled form proves the WRITE user is what got logged.
3253
+ expect(failureLine).toContain('session session-1');
3254
+ expect(failureLine).toContain('write user anon-1');
3255
+ expect(failureLine).toContain('no failure detail recorded (LatestResult null; ResultHistory length 0)');
3256
+ expect(failureLine).toContain('the write was refused before reaching the provider');
3257
+ expect(failureLine).not.toContain('unknown error');
3258
+ });
3259
+
3260
+ it('does not claim WHERE the write was refused when a result was registered with an empty message (#4791)', async () => {
3261
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3262
+ const detail = makeSessionEntity({
3263
+ ID: 'detail-1',
3264
+ Save: vi.fn(async () => false),
3265
+ LatestResult: { Success: false, CompleteMessage: ' ' },
3266
+ ResultHistory: [{}],
3267
+ });
3268
+ currentProvider = makeToolTurnProvider(detail);
3269
+ const resolver = makeAnonResolver();
3270
+
3271
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3272
+
3273
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3274
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3275
+ expect(failureLine).toBeDefined();
3276
+ expect(failureLine).toContain('no failure detail recorded (LatestResult empty; ResultHistory length 1)');
3277
+ expect(failureLine).not.toContain('refused before reaching the provider');
3278
+ });
3279
+
3280
+ it('never logs a PRIOR SUCCESS entry\'s text as the failure reason when the refusal registered nothing (#4791)', async () => {
3281
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3282
+ const detail = makeSessionEntity({
3283
+ ID: 'detail-1',
3284
+ Save: vi.fn(async () => false),
3285
+ LatestResult: { Success: true, CompleteMessage: 'previous save ok' },
3286
+ ResultHistory: [{}, {}],
3287
+ });
3288
+ currentProvider = makeToolTurnProvider(detail);
3289
+ const resolver = makeAnonResolver();
3290
+
3291
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3292
+
3293
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3294
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3295
+ expect(failureLine).toBeDefined();
3296
+ expect(failureLine).toContain(
3297
+ 'no failure detail recorded (latest result entry is a prior success; ResultHistory length 2)',
3298
+ );
3299
+ expect(failureLine).not.toContain('previous save ok');
3300
+ });
3301
+
3302
+ it('logs the real CompleteMessage when present (#4791)', async () => {
3303
+ errSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
3304
+ const detail = makeSessionEntity({
3305
+ ID: 'detail-1',
3306
+ Save: vi.fn(async () => false),
3307
+ LatestResult: { CompleteMessage: 'You do not have access to this conversation.' },
3308
+ });
3309
+ currentProvider = makeToolTurnProvider(detail);
3310
+ const resolver = makeAnonResolver();
3311
+
3312
+ await resolver.RelayRealtimeToolTurn('session-1', 'browser_navigate', makeCtx(), '{}');
3313
+
3314
+ const loggedLines = errSpy.mock.calls.map((call) => String(call[0]));
3315
+ const failureLine = loggedLines.find((line) => line.includes('persistDirectActionTurn'));
3316
+ expect(failureLine).toBeDefined();
3317
+ expect(failureLine).toContain('You do not have access to this conversation.');
3318
+ });
3105
3319
  });
3106
3320
 
3107
3321
  describe('RealtimeClientSessionResolver — colleague authorization (allowedAgents)', () => {
@@ -0,0 +1,129 @@
1
+ /**
2
+ * End-to-end-in-miniature regression test for #4794.
3
+ *
4
+ * The bug: `BuildDurableDeferral` persisted a durable entity action's redacted params as a
5
+ * `LoggedParam[]` ARRAY (`RedactParamsToJSON`'s shape), while `TaskGraphActionRunner.buildParams`
6
+ * reads `Task.InputPayload` back as a name→value RECORD. Every released build with durable dispatch
7
+ * (v6.1.0 onward, including 6.1.4) hit the same real-world failure: `TaskGraphDispatcher`'s
8
+ * `mergedPayload` only merges plain objects, so it silently dropped the array before `buildParams`
9
+ * ever saw it, and the action ran with NONE of its declared inputs. `buildParams` itself has no such
10
+ * guard — `Object.assign(merged, arrayValue)` treats an array's indices as keys — so params named
11
+ * `'0'`, `'1'`, ... would only appear if the array reached it directly, bypassing the dispatcher's
12
+ * drop. That narrower path is exactly what this test exercises, by calling `RunActionForTask`
13
+ * on its own rather than through the dispatcher.
14
+ *
15
+ * This test proves the whole pipe, not just the writer or the reader in isolation: build the
16
+ * payload exactly as the deferral does (`RedactParamsToRecord`), push it through the same
17
+ * `JSON.stringify` / `JSON.parse` round trip `TaskGraphService` performs to persist and reload
18
+ * `Task.InputPayload`, then run it through the real `TaskGraphActionRunner` and assert the action
19
+ * receives its declared parameters BY NAME.
20
+ */
21
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
22
+
23
+ vi.mock('@memberjunction/actions', () => ({
24
+ ActionEngineServer: {
25
+ Instance: {
26
+ Config: vi.fn(),
27
+ Actions: [{ ID: 'A1', Name: 'Log Activity' }],
28
+ RunAction: vi.fn(),
29
+ },
30
+ },
31
+ }));
32
+
33
+ import { ActionEngineServer } from '@memberjunction/actions';
34
+ import { RedactParamsToRecord, ActionParam } from '@memberjunction/actions-base';
35
+ import type { MJActionExecutionLogEntity, MJActionParamEntity, MJEntityActionParamEntity } from '@memberjunction/core-entities';
36
+ import type { IMetadataProvider, UserInfo } from '@memberjunction/core';
37
+ import type { TaskActionRunParams } from '@memberjunction/task-graph';
38
+ import { TaskGraphActionRunner } from '../services/TaskGraphActionRunner';
39
+
40
+ // ── Test fixture builders — same shape as packages/Actions/Base/src/__tests__/ParamRedaction.test.ts ──
41
+
42
+ function actionParam(name: string, value: unknown): ActionParam {
43
+ return { Name: name, Value: value, Type: 'Input' } as ActionParam;
44
+ }
45
+
46
+ function definition(id: string, name: string, logValue: boolean = true): MJActionParamEntity {
47
+ return { ID: id, Name: name, LogValue: logValue } as MJActionParamEntity;
48
+ }
49
+
50
+ function binding(actionParamID: string, valueType: string, logValue: boolean | null = null): MJEntityActionParamEntity {
51
+ return { ActionParamID: actionParamID, ValueType: valueType, LogValue: logValue } as MJEntityActionParamEntity;
52
+ }
53
+
54
+ // The declared parameters for the durable entity action under test: three that log normally, and
55
+ // one ("InternalNotes") whose binding opts out of logging (rule 2 in ParamRedaction.ts).
56
+ const RUNTIME_PARAMS: ActionParam[] = [
57
+ actionParam('TypeCode', 'SystemEvent'),
58
+ actionParam('Title', 'Person created'),
59
+ actionParam('RecordID', 'REC-123'),
60
+ actionParam('InternalNotes', 'do not persist this'),
61
+ ];
62
+ const ACTION_PARAMS: MJActionParamEntity[] = [
63
+ definition('p1', 'TypeCode'),
64
+ definition('p2', 'Title'),
65
+ definition('p3', 'RecordID'),
66
+ definition('p4', 'InternalNotes'),
67
+ ];
68
+ const ENTITY_ACTION_PARAMS: MJEntityActionParamEntity[] = [binding('p4', 'Static', false)];
69
+
70
+ function baseRunParams(inputPayload: unknown): TaskActionRunParams {
71
+ return {
72
+ TaskID: 'T1',
73
+ ActionID: 'A1',
74
+ InputPayload: inputPayload,
75
+ DependencyOutputs: new Map(),
76
+ Provider: {} as IMetadataProvider,
77
+ ContextUser: {} as UserInfo,
78
+ };
79
+ }
80
+
81
+ describe('TaskGraphActionRunner — durable payload round-trips by param name (#4794)', () => {
82
+ beforeEach(() => {
83
+ // vitest's `restoreMocks: true` clears mock implementations before every test, including
84
+ // the ones assigned inside the `vi.mock` factory above — so the return value is (re)armed here.
85
+ vi.mocked(ActionEngineServer.Instance.Config).mockResolvedValue(undefined);
86
+ vi.mocked(ActionEngineServer.Instance.RunAction, { partial: true }).mockResolvedValue({
87
+ Success: true,
88
+ Params: [],
89
+ LogEntry: { ID: 'L1' } as MJActionExecutionLogEntity,
90
+ });
91
+ });
92
+
93
+ it('delivers RedactParamsToRecord\'s output to the action by name, after a JSON round trip', async () => {
94
+ // Exactly what the deferral writes, then exactly what TaskGraphService's JSON column does to it.
95
+ const stored = JSON.parse(JSON.stringify(RedactParamsToRecord(RUNTIME_PARAMS, ACTION_PARAMS, ENTITY_ACTION_PARAMS)));
96
+
97
+ const result = await new TaskGraphActionRunner().RunActionForTask(baseRunParams(stored));
98
+
99
+ expect(result.Success).toBe(true);
100
+ expect(result.ActionLogID).toBe('L1');
101
+
102
+ const runActionCall = vi.mocked(ActionEngineServer.Instance.RunAction).mock.calls[0][0];
103
+ const sentParams = runActionCall.Params as ActionParam[];
104
+
105
+ // The three logged declared names arrive, order-insensitive, with their values intact.
106
+ expect(sentParams.map((p) => p.Name).sort()).toEqual(['RecordID', 'Title', 'TypeCode']);
107
+ expect(sentParams.find((p) => p.Name === 'TypeCode')?.Value).toBe('SystemEvent');
108
+ expect(sentParams.find((p) => p.Name === 'Title')?.Value).toBe('Person created');
109
+ expect(sentParams.find((p) => p.Name === 'RecordID')?.Value).toBe('REC-123');
110
+
111
+ // The binding-suppressed parameter never reaches the action — absent, not present-with-undefined.
112
+ expect(sentParams.some((p) => p.Name === 'InternalNotes')).toBe(false);
113
+ });
114
+
115
+ it('does not turn dependency outputs into params named by the upstream task ID', async () => {
116
+ // The dispatcher keys DependencyOutputs by the upstream task's ID and has ALREADY merged their
117
+ // values into InputPayload (TaskGraphDispatcher.mergedPayload). A runner that also spread the
118
+ // map handed the action one extra param per dependency, named by a GUID and holding that
119
+ // task's whole output — which then landed, unredacted, in ActionExecutionLog.Params.
120
+ const upstreamID = '20924BD7-5128-4150-8404-FB44A55AEA0F';
121
+ const params = baseRunParams({ EntityName: 'MJ: Entities', Record: { ID: 'R1', Name: 'MJ: Tags' } });
122
+ params.DependencyOutputs = new Map([[upstreamID, { Record: { ID: 'R1', Name: 'MJ: Tags' } }]]);
123
+
124
+ await new TaskGraphActionRunner().RunActionForTask(params);
125
+
126
+ const sentParams = vi.mocked(ActionEngineServer.Instance.RunAction).mock.calls[0][0].Params as ActionParam[];
127
+ expect(sentParams.map((p) => p.Name).sort()).toEqual(['EntityName', 'Record']);
128
+ });
129
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * An ad-hoc query's timeout never exceeds the server's requestTimeout, so a caller cannot hold a
3
+ * read-only connection longer than any other request may.
4
+ */
5
+ import { describe, it, expect } from 'vitest';
6
+ import { ClampAdhocTimeoutSeconds } from '../resolvers/adhocTimeout';
7
+
8
+ describe('ClampAdhocTimeoutSeconds', () => {
9
+ it('caps a longer request at requestTimeout', () => {
10
+ expect(ClampAdhocTimeoutSeconds(600, 30000)).toBe(30);
11
+ });
12
+
13
+ it('keeps a shorter request', () => {
14
+ expect(ClampAdhocTimeoutSeconds(5, 30000)).toBe(5);
15
+ });
16
+
17
+ it('uses 30 seconds when the caller names none, within the cap', () => {
18
+ expect(ClampAdhocTimeoutSeconds(undefined, 30000)).toBe(30);
19
+ expect(ClampAdhocTimeoutSeconds(undefined, 10000)).toBe(10);
20
+ expect(ClampAdhocTimeoutSeconds(0, 30000)).toBe(30);
21
+ });
22
+
23
+ it('leaves the request alone when the server sets no limit', () => {
24
+ expect(ClampAdhocTimeoutSeconds(600, 0)).toBe(600);
25
+ });
26
+ });
@@ -6,7 +6,7 @@ import {
6
6
  hashToken,
7
7
  evaluateInvite,
8
8
  buildSessionClaims,
9
- buildConsumeInviteSQL,
9
+ buildConsumeInvitePostgresSQL,
10
10
  canIssueInvites,
11
11
  isRoleGrantable,
12
12
  unionScopes,
@@ -168,71 +168,13 @@ describe('magic-link core', () => {
168
168
  });
169
169
  });
170
170
 
171
- describe('buildConsumeInviteSQL', () => {
172
- const table = '[__mj].[MagicLinkInvite]';
173
- const sql = buildConsumeInviteSQL(table);
174
-
175
- it('targets the supplied qualified table', () => {
176
- expect(sql).toContain(`UPDATE ${table} `);
177
- });
178
-
179
- it('increments UseCount', () => {
180
- expect(sql).toContain('UseCount = UseCount + 1');
181
- });
182
-
183
- it('stamps ConsumedAt only the first time (COALESCE preserves an existing value)', () => {
184
- expect(sql).toContain('ConsumedAt = COALESCE(ConsumedAt, SYSUTCDATETIME())');
185
- });
186
-
187
- it('flips Status to Consumed exactly when the last use is taken', () => {
188
- expect(sql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
189
- });
190
-
191
- it('returns the affected row via OUTPUT INTO a table var so the caller can detect a win (exactly one row)', () => {
192
- // Must be OUTPUT ... INTO, not a bare OUTPUT: SQL Server forbids a bare
193
- // OUTPUT clause on a table with enabled triggers, and CodeGen adds an
194
- // __mj_UpdatedAt trigger to every MJ table. Regression guard for that bug.
195
- expect(sql).toContain('OUTPUT INSERTED.ID INTO @consumed');
196
- expect(sql).not.toMatch(/OUTPUT INSERTED\.ID(?!\s+INTO)/);
197
- expect(sql).toContain('SELECT ID FROM @consumed');
198
- });
199
-
200
- it('guards atomically on Active + not-exhausted + not-expired — this IS the single-use gate', () => {
201
- const where = sql.slice(sql.indexOf('WHERE'));
202
- expect(where).toContain("Status = 'Active'");
203
- expect(where).toContain('UseCount < MaxUses');
204
- expect(where).toContain('ExpiresAt > SYSUTCDATETIME()');
205
- });
206
-
207
- it('binds the invite ID as a parameter, never interpolated (injection-safe)', () => {
208
- expect(sql).toContain('ID = @p0');
209
- // The id must not be string-interpolated with quotes around a value.
210
- expect(sql).not.toMatch(/ID = '/);
211
- });
212
-
213
- it('is a self-contained batch: declare table var, update-with-output, select', () => {
214
- // Intentionally a 3-statement batch (the OUTPUT-INTO requirement). The ID is
215
- // still parameterized, so the batch carries no interpolated user input.
216
- expect(sql).toContain('DECLARE @consumed TABLE (ID UNIQUEIDENTIFIER)');
217
- expect(sql.match(/;/g)?.length).toBe(3);
218
- expect(sql.trim().endsWith(';')).toBe(true);
219
- });
220
-
221
- it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
222
- // Only bracket-quoted [schema].[table] of word chars is accepted, even though
223
- // the caller derives the table from EntityInfo and never from user input.
224
- expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite')).toThrow();
225
- expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]; DROP TABLE x;--')).toThrow();
226
- expect(() => buildConsumeInviteSQL('[__mj].[Magic Link]')).toThrow();
227
- expect(() => buildConsumeInviteSQL(table)).not.toThrow();
228
- });
229
- });
230
-
231
- describe('buildConsumeInviteSQL (postgresql dialect)', () => {
232
- // PG uses `schema.table` (unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes
233
- // the PascalCase identifiers). SS uses the bracket-quoted `[schema].[table]` form.
171
+ describe('buildConsumeInvitePostgresSQL', () => {
172
+ // PostgreSQL only: SQL Server consumes through spConsumeMagicLinkInvite (#4753), but PG has no
173
+ // such function and its runtime roles hold table DML, so PG keeps this direct guarded UPDATE.
174
+ // PG uses `schema.table` unquoted — PostgreSQLDataProvider.ExecuteSQL auto-quotes the
175
+ // PascalCase identifiers.
234
176
  const pgTable = '__mj.MagicLinkInvite';
235
- const pgSql = buildConsumeInviteSQL(pgTable, 'postgresql');
177
+ const pgSql = buildConsumeInvitePostgresSQL(pgTable);
236
178
 
237
179
  it('targets the supplied unquoted schema.table', () => {
238
180
  expect(pgSql).toContain(`UPDATE ${pgTable} `);
@@ -246,35 +188,32 @@ describe('magic-link core', () => {
246
188
 
247
189
  it('binds the invite ID as PG positional param $1, not T-SQL @p0 (injection-safe)', () => {
248
190
  expect(pgSql).toContain('ID = $1');
249
- expect(pgSql).not.toContain('@p0');
250
- expect(pgSql).not.toMatch(/@/); // no T-SQL @-anything survives on PG
191
+ expect(pgSql).not.toMatch(/@/);
251
192
  expect(pgSql).not.toMatch(/ID = '/);
252
193
  });
253
194
 
254
195
  it('uses PG timestamp expressions, not T-SQL SYSUTCDATETIME()', () => {
255
196
  expect(pgSql).not.toContain('SYSUTCDATETIME');
256
- expect(pgSql).toContain("(now() AT TIME ZONE 'utc')");
257
197
  expect(pgSql).toContain("ConsumedAt = COALESCE(ConsumedAt, (now() AT TIME ZONE 'utc'))");
258
198
  });
259
199
 
260
- it('guards atomically on Active + not-exhausted + not-expired (same predicate as SS)', () => {
200
+ it('guards atomically on Active + not-exhausted + not-expired (same predicate as spConsumeMagicLinkInvite)', () => {
261
201
  const where = pgSql.slice(pgSql.indexOf('WHERE'));
262
202
  expect(where).toContain("Status = 'Active'");
263
203
  expect(where).toContain('UseCount < MaxUses');
264
204
  expect(where).toContain("ExpiresAt > (now() AT TIME ZONE 'utc')");
265
205
  });
266
206
 
267
- it('increments UseCount and flips Status on the last use (same semantics as SS)', () => {
207
+ it('increments UseCount and flips Status on the last use (same semantics as spConsumeMagicLinkInvite)', () => {
268
208
  expect(pgSql).toContain('UseCount = UseCount + 1');
269
209
  expect(pgSql).toContain("Status = CASE WHEN UseCount + 1 >= MaxUses THEN 'Consumed' ELSE Status END");
270
210
  });
271
211
 
272
- it('rejects a non-whitelisted PG table identifier (defense-in-depth against injection)', () => {
273
- // Bracket-quoted (SS) form is not a valid PG identifier here.
274
- expect(() => buildConsumeInviteSQL('[__mj].[MagicLinkInvite]', 'postgresql')).toThrow();
275
- expect(() => buildConsumeInviteSQL('__mj.MagicLinkInvite; DROP TABLE x;--', 'postgresql')).toThrow();
276
- expect(() => buildConsumeInviteSQL('__mj.Magic Link', 'postgresql')).toThrow();
277
- expect(() => buildConsumeInviteSQL(pgTable, 'postgresql')).not.toThrow();
212
+ it('rejects a non-whitelisted table identifier (defense-in-depth against injection)', () => {
213
+ expect(() => buildConsumeInvitePostgresSQL('[__mj].[MagicLinkInvite]')).toThrow();
214
+ expect(() => buildConsumeInvitePostgresSQL('__mj.MagicLinkInvite; DROP TABLE x;--')).toThrow();
215
+ expect(() => buildConsumeInvitePostgresSQL('__mj.Magic Link')).toThrow();
216
+ expect(() => buildConsumeInvitePostgresSQL(pgTable)).not.toThrow();
278
217
  });
279
218
  });
280
219