@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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apply-time warnings for DECLARED integration rows that an apply silently leaves out.
|
|
3
|
+
*
|
|
4
|
+
* An IntegrationObject / IntegrationObjectField is never deleted — a comprehensive rediscovery
|
|
5
|
+
* that does not observe a declared row sets it `Status='Disabled'` instead (reversible on a later
|
|
6
|
+
* discovery), and a schema-limit breach can do the same. The apply path then rebuilds its source
|
|
7
|
+
* schema from ACTIVE rows only, so those rows simply are not in the schema it materializes: the
|
|
8
|
+
* table appears without the column, or a requested object is not created at all, and nothing in
|
|
9
|
+
* the apply output says why.
|
|
10
|
+
*
|
|
11
|
+
* The decision is kept pure here — the resolver supplies the rows and surfaces the strings — so it
|
|
12
|
+
* is testable without standing up the resolver or the metadata provider, matching how
|
|
13
|
+
* `decideAbsentDeactivations` and `ComputeRemovedDependencyWarnings` are factored.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A row reduced to what the warning needs: its name and its lifecycle status. */
|
|
17
|
+
export type NamedStatusRow = { Name: string; Status: string | null };
|
|
18
|
+
|
|
19
|
+
export type InactiveRowWarningInput = {
|
|
20
|
+
/**
|
|
21
|
+
* The object names this apply asked for, exactly as requested. `null`/empty means "everything
|
|
22
|
+
* active", in which case deactivated OBJECTS are not reported: a large catalog can legitimately
|
|
23
|
+
* carry hundreds of them and announcing those on every apply is noise, not signal. Requested
|
|
24
|
+
* objects are always reported, because the caller named them and got nothing.
|
|
25
|
+
*/
|
|
26
|
+
RequestedNames?: string[] | null;
|
|
27
|
+
/** Every object on the integration, active or not. */
|
|
28
|
+
AllObjects: NamedStatusRow[];
|
|
29
|
+
/**
|
|
30
|
+
* Fields keyed by object name, for the objects this apply actually materializes. Only those
|
|
31
|
+
* objects need their fields: a non-materialized object's fields are reported by the object
|
|
32
|
+
* warning, not one line per field.
|
|
33
|
+
*/
|
|
34
|
+
FieldsByObjectName: Record<string, NamedStatusRow[]>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Renders a name list for an operator-facing warning: every name when the list is short, and a
|
|
39
|
+
* capped sample plus a count when it is not. Apply runs against catalogs with hundreds of objects,
|
|
40
|
+
* so an uncapped list turns a useful warning into a wall of text nobody reads.
|
|
41
|
+
*/
|
|
42
|
+
export function SummarizeNames(names: string[], max = 12): string {
|
|
43
|
+
if (names.length <= max) return names.join(', ');
|
|
44
|
+
return `${names.slice(0, max).join(', ')}, and ${names.length - max} more`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isActive = (row: NamedStatusRow): boolean => row.Status === 'Active';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Builds the operator-facing warnings for declared rows this apply will not materialize.
|
|
51
|
+
* Returns an empty array when everything declared and in scope is Active.
|
|
52
|
+
*/
|
|
53
|
+
export function ComputeInactiveRowWarnings(input: InactiveRowWarningInput): string[] {
|
|
54
|
+
const warnings: string[] = [];
|
|
55
|
+
const requested = input.RequestedNames && input.RequestedNames.length > 0
|
|
56
|
+
? new Set(input.RequestedNames.map(n => n.toLowerCase()))
|
|
57
|
+
: null;
|
|
58
|
+
|
|
59
|
+
if (requested) {
|
|
60
|
+
const inactiveRequested = input.AllObjects
|
|
61
|
+
.filter(o => requested.has(o.Name.toLowerCase()) && !isActive(o))
|
|
62
|
+
.map(o => `${o.Name} (${o.Status ?? 'no status'})`);
|
|
63
|
+
if (inactiveRequested.length > 0) {
|
|
64
|
+
warnings.push(
|
|
65
|
+
`${inactiveRequested.length} requested object(s) are not Active and were NOT materialized — ` +
|
|
66
|
+
`${SummarizeNames(inactiveRequested)}. An object is deactivated (never deleted) when an ` +
|
|
67
|
+
`authoritative rediscovery does not observe it, or when it exceeded a schema limit; re-run ` +
|
|
68
|
+
`discovery against a source that exposes it, or re-enable it on MJ: Integration Objects.`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (const [objectName, fields] of Object.entries(input.FieldsByObjectName)) {
|
|
74
|
+
const dropped = fields.filter(f => !isActive(f)).map(f => `${f.Name} (${f.Status ?? 'no status'})`);
|
|
75
|
+
if (dropped.length === 0) continue;
|
|
76
|
+
warnings.push(
|
|
77
|
+
`${objectName}: ${dropped.length} declared field(s) are not Active and were NOT materialized — ` +
|
|
78
|
+
`${SummarizeNames(dropped)}. A field is deactivated (never deleted) when an authoritative ` +
|
|
79
|
+
`rediscovery does not observe it; re-run discovery against a source that exposes it, or re-enable ` +
|
|
80
|
+
`it on MJ: Integration Object Fields.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return warnings;
|
|
85
|
+
}
|
|
@@ -75,3 +75,19 @@ export function BuildUpdateConnectionMessage(summary: SchemaRefreshSummaryLike):
|
|
|
75
75
|
export function BuildDetachedRefreshMessage(runID: string): string {
|
|
76
76
|
return `Updated, schema refresh running — tail run ${runID}`;
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Builds the human-facing `Message` for IntegrationReactivateConnection.
|
|
81
|
+
*
|
|
82
|
+
* Reactivation is committed before any refresh starts, so every message here begins by saying so.
|
|
83
|
+
* That ordering is the whole point: whatever the refresh does or fails to do afterwards, the
|
|
84
|
+
* connection IS active, and the operator must not read a refresh problem as "resume didn't work".
|
|
85
|
+
*
|
|
86
|
+
* @param summary the refresh summary, or undefined when no refresh was requested
|
|
87
|
+
*/
|
|
88
|
+
export function BuildReactivateMessage(summary: SchemaRefreshSummaryLike | undefined): string {
|
|
89
|
+
if (!summary) return 'Reactivated';
|
|
90
|
+
// Never report a detached run's placeholder counts as findings — point at the run stream instead.
|
|
91
|
+
if (summary.InProgress) return `Reactivated, schema refresh running — tail run ${summary.RunID}`;
|
|
92
|
+
return `Reactivated, ${describeFinishedRefresh(summary)}`;
|
|
93
|
+
}
|
|
@@ -5,6 +5,7 @@ import { RenderPipeline } from '@memberjunction/generic-database-provider';
|
|
|
5
5
|
import { AppContext } from '../types.js';
|
|
6
6
|
import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
|
|
7
7
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
8
|
+
import { IsScopeLimitedPrincipal } from '../auth/scopeLimitedPrincipal.js';
|
|
8
9
|
import { RunQueryResultType } from './QueryResolver.js';
|
|
9
10
|
import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
|
|
10
11
|
import sql from 'mssql';
|
|
@@ -36,6 +37,10 @@ class AdhocQueryInput {
|
|
|
36
37
|
* - Executes on read-only connection pool only (no fallback to read-write)
|
|
37
38
|
* - Configurable timeout (default 30s)
|
|
38
39
|
* - Requires authenticated user (standard GraphQL auth, no @RequireSystemUser)
|
|
40
|
+
* - Refuses scope-limited principals (see {@link IsScopeLimitedPrincipal}). Raw SQL bypasses
|
|
41
|
+
* RunView, entity permissions and row-level security, so the RLS scope tokens that confine a
|
|
42
|
+
* magic-link session are never applied on this path — there is no narrower filter to fall back
|
|
43
|
+
* to, only refusal.
|
|
39
44
|
*
|
|
40
45
|
* Auto-discovered by MJServer's dynamic resolver import.
|
|
41
46
|
*/
|
|
@@ -49,14 +54,24 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
49
54
|
const startTime = Date.now();
|
|
50
55
|
|
|
51
56
|
try {
|
|
52
|
-
// 1.
|
|
57
|
+
// 1. SECURITY: refuse principals whose read authority is narrower than their role
|
|
58
|
+
// grant. This resolver runs a raw SELECT on the read-only pool — it does NOT go
|
|
59
|
+
// through RunView, entity permissions or row-level security, so the RLS scope tokens
|
|
60
|
+
// that confine a magic-link session are never substituted here. There is no filter
|
|
61
|
+
// to narrow, so the only correct answer for such a principal is no. Checked before
|
|
62
|
+
// the SQL is even validated: authorization gates the work, it does not race it.
|
|
63
|
+
if (IsScopeLimitedPrincipal(context.userPayload?.userRecord)) {
|
|
64
|
+
return this.buildErrorResult('Ad-hoc SQL execution is not permitted for scope-limited sessions.');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 2. Security: validate SQL using SQLExpressionValidator
|
|
53
68
|
const validator = SQLExpressionValidator.Instance;
|
|
54
69
|
const validation = validator.validateFullQuery(input.SQL);
|
|
55
70
|
if (!validation.valid) {
|
|
56
71
|
return this.buildErrorResult(validation.error || 'SQL validation failed');
|
|
57
72
|
}
|
|
58
73
|
|
|
59
|
-
//
|
|
74
|
+
// 3. Get READ-ONLY data source (no fallback to read-write)
|
|
60
75
|
let readOnlyDS: sql.ConnectionPool;
|
|
61
76
|
try {
|
|
62
77
|
readOnlyDS = GetReadOnlyDataSource(context.dataSources, { allowFallbackToReadWrite: false });
|
|
@@ -64,7 +79,7 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
64
79
|
return this.buildErrorResult('No read-only data source available for ad-hoc query execution');
|
|
65
80
|
}
|
|
66
81
|
|
|
67
|
-
//
|
|
82
|
+
// 4. Resolve platform from the read-only provider for the render pipeline.
|
|
68
83
|
let platform: DatabasePlatform = 'sqlserver';
|
|
69
84
|
try {
|
|
70
85
|
const provider = GetReadOnlyProvider(context.providers, { allowFallbackToReadWrite: false });
|
|
@@ -74,7 +89,7 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
74
89
|
}
|
|
75
90
|
const contextUser = context.userPayload?.userRecord;
|
|
76
91
|
|
|
77
|
-
//
|
|
92
|
+
// 5. Route the SQL through RenderPipeline so composition tokens
|
|
78
93
|
// resolve, comments and templates are processed, and the row cap
|
|
79
94
|
// is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
|
|
80
95
|
//
|
|
@@ -106,7 +121,7 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
106
121
|
return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
|
|
107
122
|
}
|
|
108
123
|
|
|
109
|
-
//
|
|
124
|
+
// 6. Execute the page (and, only when a full page needs it, the count) under
|
|
110
125
|
// a shared wall-clock deadline derived from the request's timeout budget.
|
|
111
126
|
const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
|
|
112
127
|
const { recordset, totalRowCount } = await this.executeDataAndCount(
|
|
@@ -114,7 +129,7 @@ export class AdhocQueryResolver extends ResolverBase {
|
|
|
114
129
|
);
|
|
115
130
|
const executionTimeMs = Date.now() - startTime;
|
|
116
131
|
|
|
117
|
-
//
|
|
132
|
+
// 7. Return as RunQueryResultType
|
|
118
133
|
return {
|
|
119
134
|
QueryID: '',
|
|
120
135
|
QueryName: 'Ad-Hoc Query',
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview GraphQL redemption surface for MJ: Identity Claims.
|
|
3
|
+
*
|
|
4
|
+
* Exposes the server-side claim lifecycle (`IdentityClaimEngineServer`) to authenticated
|
|
5
|
+
* clients: list my pending claims, redeem one claim (optionally with the emailed verification
|
|
6
|
+
* token), and redeem everything pending for my email in one call. This is the surface the
|
|
7
|
+
* claim emails' `/claims/redeem?id=..&token=..` links land on (via the Explorer redeem page).
|
|
8
|
+
*
|
|
9
|
+
* Security posture:
|
|
10
|
+
* - Authentication is required (the engine refuses without a user); authorization is the
|
|
11
|
+
* engine's own gate — email match or timing-safe token match, further constrained by the
|
|
12
|
+
* claim type's Configuration and the IdP's `email_verified` assertion carried on
|
|
13
|
+
* `UserPayload.emailVerified`.
|
|
14
|
+
* - A small in-memory fixed-window rate limit bounds redemption attempts per user, since
|
|
15
|
+
* GraphQL mutations get no per-operation limit from the transport tier.
|
|
16
|
+
*
|
|
17
|
+
* @module @memberjunction/server/resolvers/IdentityClaimRedemptionResolver
|
|
18
|
+
*/
|
|
19
|
+
import { Arg, Ctx, Field, Mutation, ObjectType, Query, Resolver } from 'type-graphql';
|
|
20
|
+
import { UserInfo } from '@memberjunction/core';
|
|
21
|
+
import { IdentityClaimEngineServer } from '@memberjunction/core-entities-server';
|
|
22
|
+
import { AppContext } from '../types.js';
|
|
23
|
+
import { GetReadWriteProvider } from '../util.js';
|
|
24
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
25
|
+
|
|
26
|
+
@ObjectType()
|
|
27
|
+
export class IdentityClaimRedemptionResult {
|
|
28
|
+
@Field()
|
|
29
|
+
Success: boolean;
|
|
30
|
+
|
|
31
|
+
@Field({ nullable: true })
|
|
32
|
+
ErrorMessage?: string;
|
|
33
|
+
|
|
34
|
+
@Field({ nullable: true })
|
|
35
|
+
ClaimID?: string;
|
|
36
|
+
|
|
37
|
+
/** JSON-serialized driver result data (shape is driver-specific) */
|
|
38
|
+
@Field({ nullable: true })
|
|
39
|
+
Data?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@ObjectType()
|
|
43
|
+
export class PendingIdentityClaim {
|
|
44
|
+
@Field()
|
|
45
|
+
ID: string;
|
|
46
|
+
|
|
47
|
+
@Field()
|
|
48
|
+
ClaimTypeID: string;
|
|
49
|
+
|
|
50
|
+
@Field({ nullable: true })
|
|
51
|
+
ClaimTypeName?: string;
|
|
52
|
+
|
|
53
|
+
@Field({ nullable: true })
|
|
54
|
+
EntityID?: string;
|
|
55
|
+
|
|
56
|
+
@Field({ nullable: true })
|
|
57
|
+
RecordID?: string;
|
|
58
|
+
|
|
59
|
+
@Field()
|
|
60
|
+
Status: string;
|
|
61
|
+
|
|
62
|
+
@Field()
|
|
63
|
+
ExpiresAt: Date;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Fixed-window per-user attempt limiter for redemption mutations. Redemption is a
|
|
68
|
+
* human-interactive flow — a handful of attempts per minute is generous; dozens is
|
|
69
|
+
* token guessing.
|
|
70
|
+
*/
|
|
71
|
+
const REDEEM_WINDOW_MS = 60_000;
|
|
72
|
+
const REDEEM_MAX_ATTEMPTS_PER_WINDOW = 10;
|
|
73
|
+
const REDEEM_ATTEMPTS_CACHE_MAX = 10_000;
|
|
74
|
+
const redeemAttempts = new Map<string, { windowStart: number; count: number }>();
|
|
75
|
+
|
|
76
|
+
function redeemRateLimitExceeded(userId: string): boolean {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const entry = redeemAttempts.get(userId);
|
|
79
|
+
if (!entry || now - entry.windowStart >= REDEEM_WINDOW_MS) {
|
|
80
|
+
if (redeemAttempts.size >= REDEEM_ATTEMPTS_CACHE_MAX) {
|
|
81
|
+
// Bounded map: evict the oldest insertion rather than growing without limit.
|
|
82
|
+
const oldest = redeemAttempts.keys().next().value;
|
|
83
|
+
if (oldest !== undefined) {
|
|
84
|
+
redeemAttempts.delete(oldest);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
redeemAttempts.set(userId, { windowStart: now, count: 1 });
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
entry.count++;
|
|
91
|
+
return entry.count > REDEEM_MAX_ATTEMPTS_PER_WINDOW;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
@Resolver()
|
|
95
|
+
export class IdentityClaimRedemptionResolver extends ResolverBase {
|
|
96
|
+
/**
|
|
97
|
+
* Lists the caller's pending, unexpired identity claims (matched on their normalized email).
|
|
98
|
+
*/
|
|
99
|
+
@Query(() => [PendingIdentityClaim])
|
|
100
|
+
async GetMyPendingIdentityClaims(@Ctx() ctx: AppContext): Promise<PendingIdentityClaim[]> {
|
|
101
|
+
const user = ctx.userPayload.userRecord as UserInfo;
|
|
102
|
+
if (!user) {
|
|
103
|
+
throw new Error('User is not authenticated');
|
|
104
|
+
}
|
|
105
|
+
await this.CheckAPIKeyScopeAuthorization('identity:claims', 'read', ctx.userPayload);
|
|
106
|
+
|
|
107
|
+
const engine = IdentityClaimEngineServer.Instance;
|
|
108
|
+
await engine.Config(false, user);
|
|
109
|
+
const provider = GetReadWriteProvider(ctx.providers);
|
|
110
|
+
const claims = await engine.GetPendingClaimsForEmail(user.Email, user, provider);
|
|
111
|
+
return claims.map((c) => ({
|
|
112
|
+
ID: c.ID,
|
|
113
|
+
ClaimTypeID: c.ClaimTypeID,
|
|
114
|
+
ClaimTypeName: engine.GetClaimTypeByID(c.ClaimTypeID)?.Name,
|
|
115
|
+
EntityID: c.EntityID ?? undefined,
|
|
116
|
+
RecordID: c.RecordID ?? undefined,
|
|
117
|
+
Status: c.Status,
|
|
118
|
+
ExpiresAt: c.ExpiresAt,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Redeems a single identity claim for the authenticated caller. The engine enforces the
|
|
124
|
+
* authorization gate: email match (subject to the IdP's email_verified assertion and the
|
|
125
|
+
* claim type's Configuration) OR the emailed verification token.
|
|
126
|
+
*/
|
|
127
|
+
@Mutation(() => IdentityClaimRedemptionResult)
|
|
128
|
+
async RedeemIdentityClaim(
|
|
129
|
+
@Arg('ClaimID') claimID: string,
|
|
130
|
+
@Ctx() ctx: AppContext,
|
|
131
|
+
@Arg('Token', { nullable: true }) token?: string,
|
|
132
|
+
): Promise<IdentityClaimRedemptionResult> {
|
|
133
|
+
const user = ctx.userPayload.userRecord as UserInfo;
|
|
134
|
+
if (!user) {
|
|
135
|
+
throw new Error('User is not authenticated');
|
|
136
|
+
}
|
|
137
|
+
await this.CheckAPIKeyScopeAuthorization('identity:claims', claimID, ctx.userPayload);
|
|
138
|
+
|
|
139
|
+
if (redeemRateLimitExceeded(user.ID)) {
|
|
140
|
+
return { Success: false, ErrorMessage: 'Too many redemption attempts — please wait a minute and try again', ClaimID: claimID };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const engine = IdentityClaimEngineServer.Instance;
|
|
144
|
+
await engine.Config(false, user);
|
|
145
|
+
const provider = GetReadWriteProvider(ctx.providers);
|
|
146
|
+
const result = await engine.RedeemClaim(claimID, user, provider, token ?? undefined, {
|
|
147
|
+
EmailVerified: ctx.userPayload.emailVerified,
|
|
148
|
+
});
|
|
149
|
+
return {
|
|
150
|
+
Success: result.Success,
|
|
151
|
+
ErrorMessage: result.ErrorMessage,
|
|
152
|
+
ClaimID: claimID,
|
|
153
|
+
Data: result.Data ? JSON.stringify(result.Data) : undefined,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Discovers and redeems every pending claim addressed to the caller's email — the on-demand
|
|
159
|
+
* form of the automatic claim-on-login sweep. Each redemption runs through the full engine
|
|
160
|
+
* gate; failures are returned per-claim, never thrown.
|
|
161
|
+
*/
|
|
162
|
+
@Mutation(() => [IdentityClaimRedemptionResult])
|
|
163
|
+
async AutoClaimPendingIdentityClaims(@Ctx() ctx: AppContext): Promise<IdentityClaimRedemptionResult[]> {
|
|
164
|
+
const user = ctx.userPayload.userRecord as UserInfo;
|
|
165
|
+
if (!user) {
|
|
166
|
+
throw new Error('User is not authenticated');
|
|
167
|
+
}
|
|
168
|
+
await this.CheckAPIKeyScopeAuthorization('identity:claims', 'auto', ctx.userPayload);
|
|
169
|
+
|
|
170
|
+
if (redeemRateLimitExceeded(user.ID)) {
|
|
171
|
+
return [{ Success: false, ErrorMessage: 'Too many redemption attempts — please wait a minute and try again' }];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const engine = IdentityClaimEngineServer.Instance;
|
|
175
|
+
await engine.Config(false, user);
|
|
176
|
+
const provider = GetReadWriteProvider(ctx.providers);
|
|
177
|
+
const results = await engine.AutoClaimForUser(user, provider, { EmailVerified: ctx.userPayload.emailVerified });
|
|
178
|
+
return results.map((r) => ({
|
|
179
|
+
Success: r.Success,
|
|
180
|
+
ErrorMessage: r.ErrorMessage,
|
|
181
|
+
Data: r.Data ? JSON.stringify(r.Data) : undefined,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -56,7 +56,8 @@ import type { IntegrationRunSnapshot, IntegrationRunKind } from "@memberjunction
|
|
|
56
56
|
import { ResolverBase } from "../generic/ResolverBase.js";
|
|
57
57
|
import { IntegrationCustomColumnPromoter } from "../integration/CustomColumnPromoter.js";
|
|
58
58
|
import { ComputeCascadeRemovalSet, ComputeRemovedDependencyWarnings, DisableUnselectedEntityMaps, ReenableFieldMapsForEntityMap, ResetPullWatermarks, SetEntityMapEnabled } from "../integration/EntityMapLifecycle.js";
|
|
59
|
-
import {
|
|
59
|
+
import { ComputeInactiveRowWarnings } from "../integration/InactiveRowWarnings.js";
|
|
60
|
+
import { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildReactivateMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
|
|
60
61
|
// Type-only: the registered runtime class for 'MJ: Company Integrations'. Lets the create path name the
|
|
61
62
|
// server subclass it actually gets back from GetEntityObject with a real type rather than a cast.
|
|
62
63
|
import type { MJCompanyIntegrationEntityServer } from "@memberjunction/core-entities-server";
|
|
@@ -2338,6 +2339,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
2338
2339
|
private buildSourceSchemaFromPersistedRows(
|
|
2339
2340
|
integrationID: string,
|
|
2340
2341
|
requestedNames?: string[],
|
|
2342
|
+
warningsOut?: string[],
|
|
2341
2343
|
): SourceSchemaInfo {
|
|
2342
2344
|
const engine = IntegrationEngineBase.Instance;
|
|
2343
2345
|
// ACTIVE-only materialization: an object/field a given tenant doesn't expose is marked
|
|
@@ -2356,10 +2358,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
2356
2358
|
for (const io of ios) ioByID.set(io.ID, io.Name);
|
|
2357
2359
|
|
|
2358
2360
|
const result: SourceSchemaInfo = { Objects: [] };
|
|
2361
|
+
const fieldsByObjectName: Record<string, Array<{ Name: string; Status: string | null }>> = {};
|
|
2359
2362
|
for (const io of ios) {
|
|
2360
2363
|
if (filter && !filter.has(io.Name.toLowerCase())) continue;
|
|
2361
2364
|
// Active fields only — an inactive (source-absent / deactivated) field is not materialized.
|
|
2362
|
-
const
|
|
2365
|
+
const allFields = engine.GetIntegrationObjectFields(io.ID);
|
|
2366
|
+
const iofs = allFields.filter(iof => iof.Status === 'Active');
|
|
2367
|
+
// Remember what this object declared, active or not — the caller's warning collector
|
|
2368
|
+
// turns the difference into the one message that explains a column that never appeared.
|
|
2369
|
+
if (warningsOut) fieldsByObjectName[io.Name] = allFields.map(f => ({ Name: f.Name, Status: f.Status }));
|
|
2363
2370
|
|
|
2364
2371
|
const fields = iofs.map(iof => {
|
|
2365
2372
|
const targetIOName = iof.RelatedIntegrationObjectID
|
|
@@ -2400,6 +2407,18 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
2400
2407
|
IncrementalWatermarkField: io.IncrementalWatermarkField ?? undefined,
|
|
2401
2408
|
});
|
|
2402
2409
|
}
|
|
2410
|
+
|
|
2411
|
+
// Declared-but-inactive rows this rebuild left out. The caller decides what to do with the
|
|
2412
|
+
// strings (the apply path puts them on its Warnings); computing them costs nothing when no
|
|
2413
|
+
// collector was passed, since the field lists above are only gathered then.
|
|
2414
|
+
if (warningsOut) {
|
|
2415
|
+
warningsOut.push(...ComputeInactiveRowWarnings({
|
|
2416
|
+
RequestedNames: requestedNames ?? null,
|
|
2417
|
+
AllObjects: engine.GetIntegrationObjectsByIntegrationID(integrationID)
|
|
2418
|
+
.map(io => ({ Name: io.Name, Status: io.Status })),
|
|
2419
|
+
FieldsByObjectName: fieldsByObjectName,
|
|
2420
|
+
}));
|
|
2421
|
+
}
|
|
2403
2422
|
return result;
|
|
2404
2423
|
}
|
|
2405
2424
|
|
|
@@ -3147,13 +3166,46 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3147
3166
|
*
|
|
3148
3167
|
* Before #3738 this mutation inherited a full schema refresh from the
|
|
3149
3168
|
* CompanyIntegration save hook, with no way to decline it. The refresh is
|
|
3150
|
-
* now this resolver's own, explicit step —
|
|
3151
|
-
*
|
|
3169
|
+
* now this resolver's own, explicit step — visible in the API, suppressible
|
|
3170
|
+
* with `runSchemaRefresh: false`, and (since it gained `awaitSchemaRefresh`)
|
|
3171
|
+
* no longer something the caller has to sit through.
|
|
3172
|
+
*
|
|
3173
|
+
* WHY REACTIVATION NO LONGER SCANS THE SOURCE BY DEFAULT. Resuming a
|
|
3174
|
+
* connection and rescanning its schema are separate decisions that this
|
|
3175
|
+
* mutation used to fuse. A one-click resume would spend minutes of a
|
|
3176
|
+
* vendor's rate budget on an introspect nobody asked for, and the catalog is
|
|
3177
|
+
* usually exactly as current as it was when the connection was paused.
|
|
3178
|
+
* `IntegrationRefreshConnectorSchema` is the operation for "rescan now", and
|
|
3179
|
+
* a caller that genuinely wants both passes `runSchemaRefresh: true`.
|
|
3180
|
+
*
|
|
3181
|
+
* WHY THAT REFRESH, WHEN REQUESTED, DEFAULTS TO DETACHED AND ITS SIBLINGS DO
|
|
3182
|
+
* NOT. Create and Update default to `awaitSchemaRefresh: true` because the
|
|
3183
|
+
* caller is in a wizard, waiting on a form, and the counts ARE the answer
|
|
3184
|
+
* they asked for. Reactivate is a one-click toggle on a connection row, and
|
|
3185
|
+
* — unlike its siblings — its entire durable effect is already committed by
|
|
3186
|
+
* the time any refresh begins: the `Save()` above returned. Blocking the
|
|
3187
|
+
* response for the minutes a live introspect takes cannot make that
|
|
3188
|
+
* reactivation any more true; it can only misreport it.
|
|
3189
|
+
*
|
|
3190
|
+
* And it did. The transport, not this resolver, is what breaks: a held
|
|
3191
|
+
* request dies at the load balancer's fixed ceiling, which the app cannot
|
|
3192
|
+
* configure and which differs by front end, and the client then shows a
|
|
3193
|
+
* resumed connection as a failure. Note the shape of that bug — the two failure paths BELOW are both
|
|
3194
|
+
* handled correctly, returning `Success: true` with the refresh problem
|
|
3195
|
+
* appended, precisely because reactivation already happened. The gateway is
|
|
3196
|
+
* the one caller of this mutation that never reaches them.
|
|
3197
|
+
*
|
|
3198
|
+
* So the default here optimises for the operation actually being requested
|
|
3199
|
+
* (resume this connection) rather than for the operation that happens to be
|
|
3200
|
+
* expensive (rescan the source). The refresh still runs — detached, on a
|
|
3201
|
+
* durable, tailable run stream — and `awaitSchemaRefresh: true` restores the
|
|
3202
|
+
* blocking behaviour for a caller that genuinely wants the counts inline.
|
|
3152
3203
|
*/
|
|
3153
3204
|
@Mutation(() => MutationResultOutput)
|
|
3154
3205
|
async IntegrationReactivateConnection(
|
|
3155
3206
|
@Arg("companyIntegrationID") companyIntegrationID: string,
|
|
3156
|
-
@Arg("runSchemaRefresh", () => Boolean, { defaultValue:
|
|
3207
|
+
@Arg("runSchemaRefresh", () => Boolean, { defaultValue: false, description: "When false (default), reactivation ONLY reactivates — no live scan of the source. Resuming a connection and rescanning its schema are separate decisions, and a resume should not spend minutes of a vendor's rate budget nobody asked for; run IntegrationRefreshConnectorSchema when the catalog actually needs refreshing. Pass true to re-run IntegrationConnectorCreationPipeline as part of the reactivation." }) runSchemaRefresh: boolean,
|
|
3208
|
+
@Arg("awaitSchemaRefresh", () => Boolean, { defaultValue: false, description: "When false (default), the schema refresh is launched detached and this mutation returns as soon as the connection is active, naming the run to tail. Reactivation is already committed at that point, so blocking on a minutes-long live introspect can only delay — or, at the load balancer's fixed request ceiling, misreport — an operation that has already succeeded. Pass true to block until the refresh finishes and get its counts in the message." }) awaitSchemaRefresh: boolean,
|
|
3157
3209
|
@Ctx() ctx: AppContext
|
|
3158
3210
|
): Promise<MutationResultOutput> {
|
|
3159
3211
|
try {
|
|
@@ -3165,13 +3217,15 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3165
3217
|
ci.IsActive = true;
|
|
3166
3218
|
if (!await ci.Save()) return { Success: false, Message: `Failed to reactivate: ${ci.LatestResult?.Message ?? 'Unknown error'}` };
|
|
3167
3219
|
|
|
3220
|
+
if (runSchemaRefresh && !awaitSchemaRefresh) {
|
|
3221
|
+
const detached = this.startSchemaRefreshPipelineDetached(companyIntegrationID, user, md);
|
|
3222
|
+
return { Success: true, Message: BuildReactivateMessage(detached) };
|
|
3223
|
+
}
|
|
3224
|
+
|
|
3168
3225
|
if (runSchemaRefresh) {
|
|
3169
3226
|
try {
|
|
3170
3227
|
const refreshResult = await this.runSchemaRefreshPipeline(companyIntegrationID, user, md);
|
|
3171
|
-
return {
|
|
3172
|
-
Success: true,
|
|
3173
|
-
Message: `Reactivated, schema refresh: ${refreshResult.ObjectsCreated} created, ${refreshResult.ObjectsUpdated} updated, ${refreshResult.UnresolvedObjects.length} PK-unresolved`,
|
|
3174
|
-
};
|
|
3228
|
+
return { Success: true, Message: BuildReactivateMessage(refreshResult) };
|
|
3175
3229
|
} catch (refreshErr) {
|
|
3176
3230
|
// Refresh failure does NOT undo the reactivation — the
|
|
3177
3231
|
// connection is active, only discovery failed, and the
|
|
@@ -3181,7 +3235,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3181
3235
|
}
|
|
3182
3236
|
}
|
|
3183
3237
|
|
|
3184
|
-
return { Success: true, Message:
|
|
3238
|
+
return { Success: true, Message: BuildReactivateMessage(undefined) };
|
|
3185
3239
|
} catch (e) {
|
|
3186
3240
|
LogError(`IntegrationReactivateConnection error: ${e}`);
|
|
3187
3241
|
return { Success: false, Message: this.formatError(e) };
|
|
@@ -3338,15 +3392,22 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3338
3392
|
// the Phase 0 v5.39.x Save hook. Fall back to live IntrospectSchema only
|
|
3339
3393
|
// for direct-API callers bypassing the wizard (empty IO cache).
|
|
3340
3394
|
const requestedNames = new Set(objects.map(o => o.SourceObjectName));
|
|
3395
|
+
// Declared rows this apply will leave out (deactivated objects/fields) — surfaced on the
|
|
3396
|
+
// mutation's Warnings so a column that never appears has a stated reason.
|
|
3397
|
+
const inactiveWarnings: string[] = [];
|
|
3341
3398
|
let sourceSchema: SourceSchemaInfo = this.buildSourceSchemaFromPersistedRows(
|
|
3342
3399
|
companyIntegration.IntegrationID,
|
|
3343
3400
|
Array.from(requestedNames),
|
|
3401
|
+
inactiveWarnings,
|
|
3344
3402
|
);
|
|
3345
3403
|
if (sourceSchema.Objects.length === 0) {
|
|
3346
3404
|
LogError(`[IntegrationApplySchema] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
|
|
3347
3405
|
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
3348
3406
|
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
3349
3407
|
sourceSchema = await introspect(companyIntegration, user);
|
|
3408
|
+
// The persisted rows are not what gets materialized on this path, so anything they
|
|
3409
|
+
// said about deactivated rows describes a schema this apply is not using.
|
|
3410
|
+
inactiveWarnings.length = 0;
|
|
3350
3411
|
} else {
|
|
3351
3412
|
console.log(
|
|
3352
3413
|
`[IntegrationApplySchema] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
|
|
@@ -3381,6 +3442,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
3381
3442
|
SkipGitCommit: skipGitCommit,
|
|
3382
3443
|
SkipRestart: skipRestart,
|
|
3383
3444
|
});
|
|
3445
|
+
if (inactiveWarnings.length > 0) SchemaOutput.Warnings.unshift(...inactiveWarnings);
|
|
3384
3446
|
|
|
3385
3447
|
return {
|
|
3386
3448
|
Success: PipelineResult.Success,
|
|
@@ -4089,6 +4151,9 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4089
4151
|
// Intacct AND silently dropped selections when the second pass returned
|
|
4090
4152
|
// fewer objects than the first (rate limits, transient errors).
|
|
4091
4153
|
let sourceSchema: SourceSchemaInfo;
|
|
4154
|
+
// Declared rows this apply will silently leave out (deactivated objects/fields). Collected
|
|
4155
|
+
// during the rebuild and surfaced on the apply's Warnings, alongside the schema-limit ones.
|
|
4156
|
+
const inactiveWarnings: string[] = [];
|
|
4092
4157
|
if (prefetchedSourceSchema) {
|
|
4093
4158
|
sourceSchema = prefetchedSourceSchema;
|
|
4094
4159
|
} else {
|
|
@@ -4096,12 +4161,16 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4096
4161
|
sourceSchema = this.buildSourceSchemaFromPersistedRows(
|
|
4097
4162
|
companyIntegration.IntegrationID,
|
|
4098
4163
|
requestedNamesForReuse,
|
|
4164
|
+
inactiveWarnings,
|
|
4099
4165
|
);
|
|
4100
4166
|
if (sourceSchema.Objects.length === 0) {
|
|
4101
4167
|
LogError(`[buildSchemaForConnector] Persisted IO cache empty for ${companyIntegration.Integration}; falling back to live introspect.`);
|
|
4102
4168
|
const introspect = connector.IntrospectSchema.bind(connector) as
|
|
4103
4169
|
(ci: unknown, u: unknown) => Promise<SourceSchemaInfo>;
|
|
4104
4170
|
sourceSchema = await introspect(companyIntegration, user);
|
|
4171
|
+
// The persisted rows are not what gets materialized on this path, so anything they
|
|
4172
|
+
// said about deactivated rows describes a schema this apply is not using.
|
|
4173
|
+
inactiveWarnings.length = 0;
|
|
4105
4174
|
} else {
|
|
4106
4175
|
console.log(
|
|
4107
4176
|
`[buildSchemaForConnector] Reusing ${sourceSchema.Objects.length} persisted IOs for ${companyIntegration.Integration} ` +
|
|
@@ -4144,8 +4213,10 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
4144
4213
|
|
|
4145
4214
|
const builder = new SchemaBuilder();
|
|
4146
4215
|
const schemaOutput = builder.BuildSchema(input);
|
|
4147
|
-
// Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output
|
|
4216
|
+
// Surface the auto-disabled over-wide objects (if any) in the apply's Warnings → CLI output,
|
|
4217
|
+
// together with the declared-but-deactivated rows this apply left out.
|
|
4148
4218
|
if (limitWarnings.length > 0) schemaOutput.Warnings.unshift(...limitWarnings);
|
|
4219
|
+
if (inactiveWarnings.length > 0) schemaOutput.Warnings.unshift(...inactiveWarnings);
|
|
4149
4220
|
|
|
4150
4221
|
if (schemaOutput.Errors.length > 0) {
|
|
4151
4222
|
throw new Error(`Schema generation failed: ${schemaOutput.Errors.join('; ')}`);
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
|
|
45
45
|
import { IMetadataProvider, UserInfo, RunView, LogError, LogStatus } from '@memberjunction/core';
|
|
46
|
+
import { EscapeSQLString } from '@memberjunction/global';
|
|
46
47
|
import { MJConversationEntity, MJFileEntity } from '@memberjunction/core-entities';
|
|
47
48
|
import { FileStorageEngine } from '@memberjunction/storage';
|
|
48
49
|
|
|
@@ -239,7 +240,7 @@ async function findConversationByEgressID(egressID: string, contextUser: UserInf
|
|
|
239
240
|
const found = await rv.RunView<{ ID: string }>(
|
|
240
241
|
{
|
|
241
242
|
EntityName: CONVERSATION_ENTITY,
|
|
242
|
-
ExtraFilter: `EgressID='${
|
|
243
|
+
ExtraFilter: `EgressID='${EscapeSQLString(egressID)}' AND Type='${EscapeSQLString(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
|
|
243
244
|
Fields: ['ID'],
|
|
244
245
|
OrderBy: '__mj_CreatedAt DESC',
|
|
245
246
|
MaxRows: 1,
|
|
@@ -259,7 +260,7 @@ async function findConversationByRoomName(roomName: string, contextUser: UserInf
|
|
|
259
260
|
const found = await rv.RunView<{ ID: string }>(
|
|
260
261
|
{
|
|
261
262
|
EntityName: CONVERSATION_ENTITY,
|
|
262
|
-
ExtraFilter: `ExternalID='${
|
|
263
|
+
ExtraFilter: `ExternalID='${EscapeSQLString(roomName)}' AND Type='${EscapeSQLString(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
|
|
263
264
|
Fields: ['ID'],
|
|
264
265
|
OrderBy: '__mj_CreatedAt DESC',
|
|
265
266
|
MaxRows: 1,
|
|
@@ -425,8 +426,3 @@ function resolveStorageAccountForProvider(providerID: string): string | null {
|
|
|
425
426
|
const accounts = FileStorageEngine.Instance.GetAccountsByProviderID(providerID);
|
|
426
427
|
return accounts.length > 0 ? accounts[0].ID : null;
|
|
427
428
|
}
|
|
428
|
-
|
|
429
|
-
/** Escapes single quotes for safe embedding in an `ExtraFilter` literal. */
|
|
430
|
-
function escapeSql(value: string): string {
|
|
431
|
-
return value.replace(/'/g, "''");
|
|
432
|
-
}
|
package/src/types.ts
CHANGED
|
@@ -25,6 +25,14 @@ export type UserPayload = {
|
|
|
25
25
|
apiKeyId?: string;
|
|
26
26
|
/** SHA-256 hash of the MJ API key (used for scope authorization) */
|
|
27
27
|
apiKeyHash?: string;
|
|
28
|
+
/**
|
|
29
|
+
* The IdP's OIDC `email_verified` assertion from the verified JWT, when present.
|
|
30
|
+
* Three-state: `true` (IdP vouched for the email), `false` (IdP explicitly says the email is
|
|
31
|
+
* unverified — security-sensitive flows must not trust an email match), `undefined` (the IdP
|
|
32
|
+
* omits the claim, or auth was via API key / magic link). Consumed by identity-claim
|
|
33
|
+
* redemption; do not treat `undefined` as `false`.
|
|
34
|
+
*/
|
|
35
|
+
emailVerified?: boolean;
|
|
28
36
|
};
|
|
29
37
|
|
|
30
38
|
/**
|