@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,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. Security: validate SQL using SQLExpressionValidator
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
- // 2. Get READ-ONLY data source (no fallback to read-write)
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
- // 3. Resolve platform from the read-only provider for the render pipeline.
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
- // 4. Route the SQL through RenderPipeline so composition tokens
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
- // 5. Execute the page (and, only when a full page needs it, the count) under
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
- // 6. Return as RunQueryResultType
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 { BuildCreateConnectionMessage, BuildDetachedRefreshMessage, BuildUpdateConnectionMessage } from "../integration/SchemaRefreshLaunch.js";
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 iofs = engine.GetIntegrationObjectFields(io.ID).filter(iof => iof.Status === 'Active');
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 — same default behaviour, but
3151
- * visible in the API and suppressible with `runSchemaRefresh: false`.
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: true, description: "When true (default), re-runs IntegrationConnectorCreationPipeline after reactivating so the catalog reflects the source as it is now. Pass false to reactivate without a live scan useful when the catalog is known-current, or when the source is large enough that discovery should be scheduled separately." }) runSchemaRefresh: boolean,
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: 'Reactivated' };
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='${escapeSql(egressID)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
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='${escapeSql(roomName)}' AND Type='${escapeSql(MEETING_ROOM_CONVERSATION_TYPE)}' AND (IsArchived IS NULL OR IsArchived=0)`,
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
  /**