@memberjunction/server 6.1.0-edge.3 → 6.1.0-edge.4

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 (83) hide show
  1. package/dist/agentSessions/SessionManager.d.ts +19 -5
  2. package/dist/agentSessions/SessionManager.d.ts.map +1 -1
  3. package/dist/agentSessions/SessionManager.js +27 -9
  4. package/dist/agentSessions/SessionManager.js.map +1 -1
  5. package/dist/auth/index.d.ts +1 -0
  6. package/dist/auth/index.d.ts.map +1 -1
  7. package/dist/auth/index.js +1 -0
  8. package/dist/auth/index.js.map +1 -1
  9. package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
  10. package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
  11. package/dist/auth/scopeLimitedPrincipal.js +49 -0
  12. package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
  13. package/dist/context.d.ts.map +1 -1
  14. package/dist/context.js +21 -1
  15. package/dist/context.js.map +1 -1
  16. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
  17. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
  18. package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
  19. package/dist/generated/generated.d.ts +118 -0
  20. package/dist/generated/generated.d.ts.map +1 -1
  21. package/dist/generated/generated.js +630 -0
  22. package/dist/generated/generated.js.map +1 -1
  23. package/dist/generic/ResolverBase.d.ts +27 -0
  24. package/dist/generic/ResolverBase.d.ts.map +1 -1
  25. package/dist/generic/ResolverBase.js +50 -4
  26. package/dist/generic/ResolverBase.js.map +1 -1
  27. package/dist/index.d.ts +1 -0
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +49 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/integration/CustomColumnPromoter.d.ts +79 -15
  32. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  33. package/dist/integration/CustomColumnPromoter.js +472 -97
  34. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  35. package/dist/integration/InactiveRowWarnings.d.ts +48 -0
  36. package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
  37. package/dist/integration/InactiveRowWarnings.js +57 -0
  38. package/dist/integration/InactiveRowWarnings.js.map +1 -0
  39. package/dist/integration/SchemaRefreshLaunch.d.ts +10 -0
  40. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -1
  41. package/dist/integration/SchemaRefreshLaunch.js +17 -0
  42. package/dist/integration/SchemaRefreshLaunch.js.map +1 -1
  43. package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
  44. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  45. package/dist/resolvers/AdhocQueryResolver.js +20 -6
  46. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  47. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
  48. package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
  49. package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
  50. package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
  51. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +35 -3
  52. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  53. package/dist/resolvers/IntegrationDiscoveryResolver.js +86 -17
  54. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  55. package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
  56. package/dist/resolvers/meetingRecordingRegistration.js +3 -6
  57. package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
  58. package/dist/types.d.ts +8 -0
  59. package/dist/types.d.ts.map +1 -1
  60. package/dist/types.js.map +1 -1
  61. package/package.json +91 -91
  62. package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
  63. package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
  64. package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
  65. package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
  66. package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
  67. package/src/__tests__/SessionManager.test.ts +55 -0
  68. package/src/agentSessions/SessionManager.ts +27 -9
  69. package/src/auth/index.ts +1 -0
  70. package/src/auth/scopeLimitedPrincipal.ts +50 -0
  71. package/src/context.ts +26 -2
  72. package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
  73. package/src/generated/generated.ts +437 -1
  74. package/src/generic/ResolverBase.ts +56 -4
  75. package/src/index.ts +57 -1
  76. package/src/integration/CustomColumnPromoter.ts +520 -98
  77. package/src/integration/InactiveRowWarnings.ts +85 -0
  78. package/src/integration/SchemaRefreshLaunch.ts +16 -0
  79. package/src/resolvers/AdhocQueryResolver.ts +21 -6
  80. package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
  81. package/src/resolvers/IntegrationDiscoveryResolver.ts +82 -11
  82. package/src/resolvers/meetingRecordingRegistration.ts +3 -7
  83. package/src/types.ts +8 -0
@@ -0,0 +1,147 @@
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 { ResolverBase } from '../generic/ResolverBase.js';
7
+ import type { UserPayload } from '../types.js';
8
+ import type { RunViewByNameInput } from '../generic/RunViewResolver.js';
9
+ import type { PubSubEngine } from 'type-graphql';
10
+
11
+ /**
12
+ * Both of `ResolverBase`'s own filter builders interpolated client-supplied values into the
13
+ * `ExtraFilter` text they hand to `RunView`:
14
+ *
15
+ * - `findBy` — reachable through `UserByEmail`, `FileByName`, `UserViewsByName`, and the
16
+ * other by-value single-record resolvers.
17
+ * - `RunViewByNameGeneric` — the inline `Name='<ViewName>'` lookup.
18
+ *
19
+ * `ExtraFilter` does pass through `SQLExpressionValidator`, which rejects stacked statements,
20
+ * `UNION`, comments and `WAITFOR` — so the residual exposure was a same-clause boolean
21
+ * tautology rather than arbitrary SQL. These tests pin the escaping that closes it, and the
22
+ * fail-closed rule on the unquoted (numeric/boolean) slot, where a string value would BE
23
+ * SQL with no quote to break out of.
24
+ */
25
+
26
+ const ENTITY_NAME = 'MJ: Users';
27
+
28
+ /** Captures what the resolver asked `RunView` for. */
29
+ type Captured = { params: RunViewParams | null };
30
+
31
+ /**
32
+ * Minimal provider: the entity/field metadata `findBy` reads, plus a `RunView` that records
33
+ * its params. `Sequence` stands in for a numeric field (`NeedsQuotes` is false only for
34
+ * Number and Boolean TS types).
35
+ */
36
+ function fakeProvider(captured: Captured, rows: Record<string, unknown>[] = []): DatabaseProviderBase {
37
+ return {
38
+ Entities: [
39
+ {
40
+ Name: ENTITY_NAME,
41
+ Fields: [
42
+ { Name: 'Email', NeedsQuotes: true },
43
+ { Name: 'Name', NeedsQuotes: true },
44
+ { Name: 'Sequence', NeedsQuotes: false },
45
+ { Name: 'IsActive', NeedsQuotes: false },
46
+ ],
47
+ },
48
+ {
49
+ Name: 'MJ: User Views',
50
+ Fields: [{ Name: 'Name', NeedsQuotes: true }],
51
+ },
52
+ ],
53
+ RunView: async (params: RunViewParams): Promise<RunViewResult> => {
54
+ captured.params = params;
55
+ return { Success: true, Results: rows, RowCount: rows.length, TotalRowCount: rows.length, ErrorMessage: '' } as RunViewResult;
56
+ },
57
+ } as unknown as DatabaseProviderBase;
58
+ }
59
+
60
+ const fakeUser = () => ({ Email: 'tester@example.com' } as UserInfo);
61
+ const fakePayload = () => ({ email: 'tester@example.com', userRecord: fakeUser() } as UserPayload);
62
+
63
+ /** Reaches the protected `findBy`. */
64
+ class Probe extends ResolverBase {
65
+ public FindBy(provider: DatabaseProviderBase, entity: string, params: Record<string, unknown>) {
66
+ return this.findBy(provider, entity, params, fakeUser());
67
+ }
68
+ }
69
+
70
+ describe('ResolverBase.findBy — ExtraFilter escaping', () => {
71
+ it('doubles a single quote in a quoted value', async () => {
72
+ const captured: Captured = { params: null };
73
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "o'brien@example.com" });
74
+
75
+ expect(captured.params?.ExtraFilter).toBe("Email = 'o''brien@example.com'");
76
+ });
77
+
78
+ it('renders a tautology attempt inert instead of terminating the literal', async () => {
79
+ const captured: Captured = { params: null };
80
+ // The payload closes the literal and appends its own predicate. Escaped, every quote
81
+ // it carries stays *inside* the literal, so the clause matches a (non-existent)
82
+ // address rather than every row.
83
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "x' OR '1'='1" });
84
+
85
+ expect(captured.params?.ExtraFilter).toBe("Email = 'x'' OR ''1''=''1'");
86
+ // Nothing outside the literal: exactly two unescaped quotes, the delimiters.
87
+ expect(captured.params?.ExtraFilter?.replace(/''/g, '')).toBe("Email = 'x OR 1=1'");
88
+ });
89
+
90
+ it('joins multiple params with AND, escaping each', async () => {
91
+ const captured: Captured = { params: null };
92
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Email: "a'b", Name: "c'd" });
93
+
94
+ expect(captured.params?.ExtraFilter).toBe("Email = 'a''b' AND Name = 'c''d'");
95
+ });
96
+
97
+ it('leaves a genuinely numeric value unquoted', async () => {
98
+ const captured: Captured = { params: null };
99
+ await new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: 42 });
100
+
101
+ expect(captured.params?.ExtraFilter).toBe('Sequence = 42');
102
+ });
103
+
104
+ it('rejects a string aimed at an unquoted (numeric) field rather than emitting it as SQL', async () => {
105
+ const captured: Captured = { params: null };
106
+ // There is no quote to escape in an unquoted slot — the value lands in the clause as
107
+ // SQL. The only safe handling is to refuse it.
108
+ await expect(
109
+ new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: '1 OR 1=1' })
110
+ ).rejects.toThrow(/Sequence/);
111
+
112
+ expect(captured.params).toBeNull(); // never reached RunView
113
+ });
114
+
115
+ it('rejects a non-finite number in an unquoted field', async () => {
116
+ const captured: Captured = { params: null };
117
+ await expect(
118
+ new Probe().FindBy(fakeProvider(captured), ENTITY_NAME, { Sequence: Number.NaN })
119
+ ).rejects.toThrow(/Sequence/);
120
+ });
121
+
122
+ it('still rejects an unknown field name', async () => {
123
+ // Pre-existing behavior, pinned so the escaping rework cannot quietly drop it.
124
+ await expect(
125
+ new Probe().FindBy(fakeProvider({ params: null }), ENTITY_NAME, { NotAField: 'x' })
126
+ ).rejects.toThrow(/NotAField/);
127
+ });
128
+ });
129
+
130
+ describe('ResolverBase.RunViewByNameGeneric — view-name escaping', () => {
131
+ it('escapes the client-supplied view name', async () => {
132
+ const captured: Captured = { params: null };
133
+ const input = { ViewName: "My View' OR '1'='1" } as RunViewByNameInput;
134
+
135
+ // Empty Results short-circuits the method (returns null) once the lookup filter is built.
136
+ const result = await new Probe().RunViewByNameGeneric(
137
+ input,
138
+ fakeProvider(captured),
139
+ fakePayload(),
140
+ undefined as unknown as PubSubEngine // unused: the empty-result path returns before it is passed on
141
+ );
142
+
143
+ expect(result).toBeNull();
144
+ expect(captured.params?.EntityName).toBe('MJ: User Views');
145
+ expect(captured.params?.ExtraFilter).toBe("Name='My View'' OR ''1''=''1'");
146
+ });
147
+ });
@@ -461,6 +461,61 @@ describe('SessionManager.Heartbeat', () => {
461
461
  });
462
462
  });
463
463
 
464
+ describe('SessionManager.Heartbeat — bounded heartbeatLastWrite (Round 11 memory-leak fix)', () => {
465
+ // Regression coverage: heartbeatLastWrite used to be a plain, unbounded `Map`. SessionManager
466
+ // is constructed fresh by every resolver that needs one (AgentSessionResolver,
467
+ // RealtimeClientSessionResolver, RealtimeBridgeResolver) plus SessionJanitor's own instance, so
468
+ // each is itself a process-lifetime singleton with its own copy of the map. A session's
469
+ // heartbeats land through one resolver's instance, but the overwhelmingly common close path —
470
+ // a crashed tab, a dropped connection, anything that never round-trips an explicit close
471
+ // mutation — is reconciled by SessionJanitor's sweeps through ITS OWN SessionManager, which
472
+ // never touches the originating instance's map, so the entry was never deleted. The fix bounds
473
+ // the map with MJLruCache(maxSize 5_000, ttlMs 4h) so that scenario is capped instead of
474
+ // growing forever.
475
+
476
+ beforeEach(() => {
477
+ vi.useFakeTimers();
478
+ });
479
+
480
+ afterEach(() => {
481
+ vi.useRealTimers();
482
+ });
483
+
484
+ it('forces a fresh write once the 4h TTL elapses, even though CloseSession never ran on this instance', async () => {
485
+ const session = makeSessionEntity({ ID: 'session-ttl', Status: 'Active' });
486
+ const { provider } = makeProvider(() => session);
487
+ const mgr = new SessionManager();
488
+ const user = makeUser();
489
+
490
+ const first = await mgr.Heartbeat('session-ttl', user, provider);
491
+ expect(first).toBe(true);
492
+ expect(session.Save).toHaveBeenCalledTimes(1);
493
+
494
+ // Still within the coalescing window — no second write.
495
+ const second = await mgr.Heartbeat('session-ttl', user, provider);
496
+ expect(second).toBe(true);
497
+ expect(session.Save).toHaveBeenCalledTimes(1);
498
+
499
+ // Advance past the cache's 4h TTL backstop (well beyond the 3s coalescing window too).
500
+ vi.advanceTimersByTime(4 * 60 * 60 * 1000 + 1);
501
+
502
+ const third = await mgr.Heartbeat('session-ttl', user, provider);
503
+ expect(third).toBe(true);
504
+ // The TTL-expired entry forces a write again, exactly as a never-before-seen session would —
505
+ // proving the cache entry does not live forever when nothing on this instance ever calls
506
+ // CloseSession for it.
507
+ expect(session.Save).toHaveBeenCalledTimes(2);
508
+ });
509
+
510
+ it('is backed by a bounded MJLruCache, not a plain unbounded Map (structural regression guard)', () => {
511
+ const mgr = new SessionManager();
512
+ const internal = (mgr as unknown as { heartbeatLastWrite: { MaxSize: number; Size: number } })
513
+ .heartbeatLastWrite;
514
+ expect(internal.MaxSize).toBe(5_000);
515
+ expect(internal.Size).toBe(0);
516
+ });
517
+ });
518
+
464
519
  describe('SessionManager.MarkIdle', () => {
465
520
  it('transitions Active → Idle', async () => {
466
521
  const session = makeSessionEntity({ ID: 'session-1', Status: 'Active' });
@@ -1,4 +1,5 @@
1
1
  import { IMetadataProvider, UserInfo, LogError, RunView } from '@memberjunction/core';
2
+ import { MJLruCache } from '@memberjunction/global';
2
3
  import {
3
4
  MJAIAgentSessionEntity,
4
5
  MJAIAgentSessionChannelEntity,
@@ -78,9 +79,10 @@ const HEARTBEAT_MIN_WRITE_INTERVAL_MS = 3_000;
78
79
  * `contextUser` and request-scoped `IMetadataProvider` so all entity operations run under the
79
80
  * correct identity/provider and never pin the process-global provider.
80
81
  *
81
- * The only in-memory state it holds is a small per-session last-heartbeat-write timestamp map used
82
- * purely to coalesce {@link Heartbeat} writes, plus the injected {@link RealtimeClientSessionService}
83
- * used by {@link finalizeObservabilityRuns}; it carries no user or provider state across calls.
82
+ * The only in-memory state it holds is a small per-session last-heartbeat-write timestamp cache used
83
+ * purely to coalesce {@link Heartbeat} writes (see {@link heartbeatLastWrite} for why it's bounded),
84
+ * plus the injected {@link RealtimeClientSessionService} used by {@link finalizeObservabilityRuns}; it
85
+ * carries no user or provider state across calls.
84
86
  *
85
87
  * Responsibilities:
86
88
  * - **Create** — authorize (`CanRun`), optionally mint a Conversation, persist an `Active` session.
@@ -94,8 +96,24 @@ const HEARTBEAT_MIN_WRITE_INTERVAL_MS = 3_000;
94
96
  * this layer, so close here only reconciles the durable record + channel rows.
95
97
  */
96
98
  export class SessionManager {
97
- /** session ID (lowercased) → epoch ms of its last persisted `LastActiveAt` write. */
98
- private heartbeatLastWrite = new Map<string, number>();
99
+ /**
100
+ * session ID (lowercased) epoch ms of its last persisted `LastActiveAt` write. Written by
101
+ * {@link persistHeartbeat}, read by {@link heartbeatWriteDue}/{@link shouldForceWrite}, and
102
+ * normally deleted by {@link CloseSession}. Bounded with `MJLruCache` (rather than a plain `Map`)
103
+ * for the same reason as `RealtimeClientSessionService.promptRunWriteChains`: `SessionManager` is
104
+ * constructed fresh by every resolver that needs one (`AgentSessionResolver`,
105
+ * `RealtimeClientSessionResolver`, `RealtimeBridgeResolver`) plus `SessionJanitor`'s own instance,
106
+ * so each is itself a process-lifetime singleton with its own copy of this map. A session's
107
+ * heartbeats land through one resolver's instance, but the overwhelmingly common close path — a
108
+ * crashed tab, a dropped connection, any disconnect that never round-trips an explicit close
109
+ * mutation — is reconciled by `SessionJanitor`'s staleness/shutdown sweeps through ITS OWN
110
+ * `SessionManager`, which never touches the originating instance's map. The TTL/maxSize eviction
111
+ * here is what keeps that scenario bounded instead of an unbounded per-instance leak.
112
+ */
113
+ private readonly heartbeatLastWrite = new MJLruCache<string, number>({
114
+ maxSize: 5_000,
115
+ ttlMs: 4 * 60 * 60 * 1000, // 4h backstop — generous vs. the janitor's 15m staleness threshold
116
+ });
99
117
 
100
118
  /**
101
119
  * The {@link RealtimeClientSessionService} used to finalize a closing session's co-agent
@@ -169,7 +187,7 @@ export class SessionManager {
169
187
  if (!session) {
170
188
  return false;
171
189
  }
172
- this.heartbeatLastWrite.delete(agentSessionID.toLowerCase());
190
+ this.heartbeatLastWrite.Delete(agentSessionID.toLowerCase());
173
191
 
174
192
  if (session.Status === 'Closed') {
175
193
  // Idempotent — already terminal. Still notify the channel-plugin host so a session
@@ -517,7 +535,7 @@ export class SessionManager {
517
535
 
518
536
  /** True once the per-session coalescing window has elapsed since the last persisted write. */
519
537
  private heartbeatWriteDue(key: string): boolean {
520
- const last = this.heartbeatLastWrite.get(key);
538
+ const last = this.heartbeatLastWrite.Get(key);
521
539
  return last == null || (Date.now() - last) >= HEARTBEAT_MIN_WRITE_INTERVAL_MS;
522
540
  }
523
541
 
@@ -527,7 +545,7 @@ export class SessionManager {
527
545
  * swallowed by coalescing.
528
546
  */
529
547
  private shouldForceWrite(key: string, _provider: IMetadataProvider): boolean {
530
- return !this.heartbeatLastWrite.has(key);
548
+ return !this.heartbeatLastWrite.Has(key);
531
549
  }
532
550
 
533
551
  /** Loads, bumps `LastActiveAt`, reactivates `Idle → Active`, saves, and records the write time. */
@@ -546,7 +564,7 @@ export class SessionManager {
546
564
  }
547
565
  const saved = await this.saveOrLog(session, 'Heartbeat');
548
566
  if (saved) {
549
- this.heartbeatLastWrite.set(agentSessionID.toLowerCase(), Date.now());
567
+ this.heartbeatLastWrite.Set(agentSessionID.toLowerCase(), Date.now());
550
568
  }
551
569
  return saved;
552
570
  }
package/src/auth/index.ts CHANGED
@@ -10,6 +10,7 @@ import { AuthProviderFactory } from '@memberjunction/auth-providers';
10
10
  import { initializeAuthProviders } from './initializeProviders.js';
11
11
 
12
12
  export * from './APIKeyScopeAuth.js';
13
+ export * from './scopeLimitedPrincipal.js';
13
14
  export * from './actingContextResolver.js';
14
15
  export * from './AuthProviderEngine.js';
15
16
  export * from './AuthProviderCatalogRouter.js';
@@ -0,0 +1,50 @@
1
+ import { UserInfo } from '@memberjunction/core';
2
+
3
+ /**
4
+ * True when a session's read authority is deliberately narrower than the role grant it
5
+ * authenticates with — i.e. the principal is confined by per-session scope rather than by
6
+ * its roles alone.
7
+ *
8
+ * Two such principals exist today, both from the magic-link surface:
9
+ *
10
+ * - **Anonymous magic-link guests** (`IsMagicLinkAnonymous`) authenticate as the *shared*
11
+ * Anonymous principal, so their role grant is common to every guest. What separates one
12
+ * guest from another is not the role — it is the per-session scope.
13
+ * - **Resource-scoped magic-link sessions** (`MagicLinkScope`) hold a normal role, pinned to
14
+ * one shared resource (and its FK-reachable dependents) for the life of the session.
15
+ *
16
+ * In both cases the confinement is expressed as row-level-security filter tokens
17
+ * (`{{ScopeResourceID}}` / `{{ScopeResourceType}}`) that the entity-read path substitutes.
18
+ * That means the confinement only exists on code paths that go **through** entity
19
+ * permissions and RLS — `RunView`, `BaseEntity`, the generated resolvers. Any path that
20
+ * reaches the database *around* those layers (raw SQL on the read-only pool, for instance)
21
+ * sees no scope at all, so such a path must refuse these principals outright rather than
22
+ * assume a filter it never applies.
23
+ *
24
+ * Callers should treat a `true` result as "not permitted here", never as "apply a narrower
25
+ * filter" — a caller that could narrow correctly would not need this predicate.
26
+ *
27
+ * @param user the principal to classify. A missing principal returns `true`: an
28
+ * unidentifiable session is the one case where the scope certainly cannot be
29
+ * established, so it fails closed.
30
+ * @returns `true` when the principal is scope-limited (or unidentifiable), else `false`.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
35
+ * return this.buildErrorResult('Not permitted for scope-limited sessions.');
36
+ * }
37
+ * ```
38
+ */
39
+ export function IsScopeLimitedPrincipal(user: UserInfo | undefined | null): boolean {
40
+ if (!user) {
41
+ return true; // no identifiable principal — fail closed
42
+ }
43
+ if (user.IsMagicLinkAnonymous) {
44
+ return true;
45
+ }
46
+ const scope = user.MagicLinkScope;
47
+ // An empty scope object carries no confinement; only a populated one does. (Truthiness
48
+ // of the object alone would classify `{}` as scoped and lock out a session that is not.)
49
+ return !!(scope?.ResourceID || scope?.ResourceType);
50
+ }
package/src/context.ts CHANGED
@@ -18,9 +18,10 @@ import { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
18
18
  import { v4 as uuidv4 } from 'uuid';
19
19
  import e from 'express';
20
20
  import type { RequestHandler, Request, Response, NextFunction } from 'express';
21
- import { DatabaseProviderBase, UserInfo, type MagicLinkScope, type ReturningVisitorContext, type WidgetGuestContext } from '@memberjunction/core';
21
+ import { DatabaseProviderBase, UserInfo, type IMetadataProvider, type MagicLinkScope, type ReturningVisitorContext, type WidgetGuestContext } from '@memberjunction/core';
22
22
  import { SQLServerDataProvider, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
23
23
  import { Metadata } from '@memberjunction/core';
24
+ import { IdentityClaimEngineServer } from '@memberjunction/core-entities-server';
24
25
  import { UUIDsEqual } from '@memberjunction/global';
25
26
  import { UserCache, resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
26
27
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
@@ -554,7 +555,30 @@ export const getUserPayload = async (
554
555
  });
555
556
  }
556
557
 
557
- return { userRecord: sessionUser, email: sessionUser.Email, sessionId };
558
+ // The IdP's OIDC email_verified assertion, read off the JWKS-verified payload. Standard
559
+ // claim name across Auth0/Okta/Cognito/WorkOS; MSAL and others simply omit it (undefined).
560
+ const emailVerified = typeof payload.email_verified === 'boolean' ? payload.email_verified : undefined;
561
+
562
+ // Automatic claim-on-login — once per issued token (deduped, own prefix so it can't eat the
563
+ // audit's first-seen slot), fire-and-forget so it never adds latency. Discovers pending
564
+ // MJ: Identity Claims addressed to this user's email and redeems each through the FULL
565
+ // RedeemClaim gate (email/verification rules, atomic CAS, driver error handling), passing
566
+ // the IdP's email_verified assertion so an unverified email can never auto-claim.
567
+ // Anonymous magic-link guests are skipped: their principal is synthetic and per-session.
568
+ if (sessionUser.Email && !sessionUser.IsMagicLinkAnonymous && markSessionAuditSeen(sessionAuditKey('claims', payload))) {
569
+ // Pre-context auth path: per-request providers do not exist yet here, so the global is the
570
+ // only provider available — same posture as every other pre-context call on this path.
571
+ // The allowlist marker has to sit on the reference line itself; MultiProviderCompliance
572
+ // matches per-line, so a marker on its own line above is invisible to the scanner.
573
+ const claimProvider = Metadata.Provider as unknown as IMetadataProvider; // global-provider-ok: pre-context auth path
574
+ if (claimProvider) {
575
+ void IdentityClaimEngineServer.Instance.AutoClaimForUser(sessionUser, claimProvider, { EmailVerified: emailVerified }).catch(
576
+ (err) => console.warn(`Auto-claim on login failed for ${sessionUser.Email}: ${err instanceof Error ? err.message : err}`),
577
+ );
578
+ }
579
+ }
580
+
581
+ return { userRecord: sessionUser, email: sessionUser.Email, sessionId, emailVerified };
558
582
  } catch (error) {
559
583
  // An anonymous request presenting no credentials at all (a health check, a CORS
560
584
  // preflight-adjacent probe, a client mid-handshake) is routine, same as an expired
@@ -1,7 +1,7 @@
1
1
  import { RegisterClass } from '@memberjunction/global';
2
2
  import { BaseEntity, EntityDeleteOptions, EntitySaveOptions } from '@memberjunction/core';
3
3
  import { MJEntityPermissionEntity } from '@memberjunction/core-entities';
4
- import axios from 'axios';
4
+ import { HttpPost } from '@memberjunction/network-utils';
5
5
  import { ___codeGenAPIPort, ___codeGenAPISubmissionDelay, ___codeGenAPIURL } from '../config.js';
6
6
 
7
7
  /**
@@ -59,23 +59,24 @@ export class MJEntityPermissionEntityServer extends MJEntityPermissionEntity {
59
59
  protected static async SubmitQueue(): Promise<void> {
60
60
  this._lastModifiedTime = null;
61
61
 
62
- // now, use Axios to submit the queue to the API server
62
+ // now, submit the queue to the API server
63
63
  // Check if there's anything to submit
64
64
  if (this._entityIDQueue.length > 0) {
65
65
  try {
66
- // Use Axios to submit the queue to the API server
67
- const response = await axios.post(this.getSubmissionURL(), {
66
+ // The submission URL is our OWN CodeGen API (typically localhost) — deliberately NOT
67
+ // routed through the SSRF guard, which would block an internal address by design.
68
+ const response = await HttpPost<{ status?: string; errorMessage?: string }>(this.getSubmissionURL(), {
68
69
  entityIDArray: this._entityIDQueue,
69
70
  });
70
71
 
71
- // Check the Axios response code implicitly and API response explicitly
72
- if (response.status === 200 && response.data.status === 'ok') {
72
+ // Check the HTTP status implicitly and API response explicitly
73
+ if (response.Status === 200 && response.Data?.status === 'ok') {
73
74
  console.log('Queue submitted successfully.');
74
75
  // now, clear the queue and timer
75
76
  this.ClearQueue();
76
77
  } else {
77
78
  // Handle API indicating a failure
78
- console.error('Failed to submit queue:', response.data.errorMessage || 'Unknown error');
79
+ console.error('Failed to submit queue:', response.Data?.errorMessage || 'Unknown error');
79
80
  }
80
81
  } catch (error) {
81
82
  // Handle errors here