@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.
- package/dist/agentSessions/SessionManager.d.ts +19 -5
- package/dist/agentSessions/SessionManager.d.ts.map +1 -1
- package/dist/agentSessions/SessionManager.js +27 -9
- package/dist/agentSessions/SessionManager.js.map +1 -1
- package/dist/auth/index.d.ts +1 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +1 -0
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/scopeLimitedPrincipal.d.ts +39 -0
- package/dist/auth/scopeLimitedPrincipal.d.ts.map +1 -0
- package/dist/auth/scopeLimitedPrincipal.js +49 -0
- package/dist/auth/scopeLimitedPrincipal.js.map +1 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +21 -1
- package/dist/context.js.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.d.ts.map +1 -1
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js +8 -7
- package/dist/entitySubclasses/MJEntityPermissionEntityServer.server.js.map +1 -1
- package/dist/generated/generated.d.ts +118 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +630 -0
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +27 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +49 -1
- package/dist/index.js.map +1 -1
- package/dist/integration/CustomColumnPromoter.d.ts +79 -15
- package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
- package/dist/integration/CustomColumnPromoter.js +472 -97
- package/dist/integration/CustomColumnPromoter.js.map +1 -1
- package/dist/integration/InactiveRowWarnings.d.ts +48 -0
- package/dist/integration/InactiveRowWarnings.d.ts.map +1 -0
- package/dist/integration/InactiveRowWarnings.js +57 -0
- package/dist/integration/InactiveRowWarnings.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +10 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -1
- package/dist/integration/SchemaRefreshLaunch.js +17 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.d.ts +4 -0
- package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
- package/dist/resolvers/AdhocQueryResolver.js +20 -6
- package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
- package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts +37 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.d.ts.map +1 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js +220 -0
- package/dist/resolvers/IdentityClaimRedemptionResolver.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +35 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +86 -17
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.d.ts.map +1 -1
- package/dist/resolvers/meetingRecordingRegistration.js +3 -6
- package/dist/resolvers/meetingRecordingRegistration.js.map +1 -1
- package/dist/types.d.ts +8 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/AdhocQueryResolver.scopeGuard.test.ts +84 -0
- package/src/__tests__/CustomColumnPromoterOnePass.test.ts +387 -0
- package/src/__tests__/InactiveRowWarnings.test.ts +87 -0
- package/src/__tests__/ReactivateRefreshMessage.test.ts +85 -0
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +147 -0
- package/src/__tests__/SessionManager.test.ts +55 -0
- package/src/agentSessions/SessionManager.ts +27 -9
- package/src/auth/index.ts +1 -0
- package/src/auth/scopeLimitedPrincipal.ts +50 -0
- package/src/context.ts +26 -2
- package/src/entitySubclasses/MJEntityPermissionEntityServer.server.ts +8 -7
- package/src/generated/generated.ts +437 -1
- package/src/generic/ResolverBase.ts +56 -4
- package/src/index.ts +57 -1
- package/src/integration/CustomColumnPromoter.ts +520 -98
- package/src/integration/InactiveRowWarnings.ts +85 -0
- package/src/integration/SchemaRefreshLaunch.ts +16 -0
- package/src/resolvers/AdhocQueryResolver.ts +21 -6
- package/src/resolvers/IdentityClaimRedemptionResolver.ts +184 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +82 -11
- package/src/resolvers/meetingRecordingRegistration.ts +3 -7
- 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
|
|
82
|
-
* purely to coalesce {@link Heartbeat} writes
|
|
83
|
-
* used by {@link finalizeObservabilityRuns}; it
|
|
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
|
-
/**
|
|
98
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
67
|
-
|
|
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
|
|
72
|
-
if (response.
|
|
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.
|
|
79
|
+
console.error('Failed to submit queue:', response.Data?.errorMessage || 'Unknown error');
|
|
79
80
|
}
|
|
80
81
|
} catch (error) {
|
|
81
82
|
// Handle errors here
|