@memberjunction/server 5.38.0 → 5.40.0

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 (180) hide show
  1. package/README.md +67 -0
  2. package/dist/apolloServer/index.d.ts +0 -8
  3. package/dist/apolloServer/index.d.ts.map +1 -1
  4. package/dist/apolloServer/index.js +61 -0
  5. package/dist/apolloServer/index.js.map +1 -1
  6. package/dist/auth/index.js +1 -1
  7. package/dist/auth/index.js.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
  9. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
  10. package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
  11. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
  12. package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
  13. package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
  14. package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
  15. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
  16. package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
  17. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
  18. package/dist/auth/magicLink/MagicLinkService.js +605 -0
  19. package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
  20. package/dist/auth/magicLink/index.d.ts +6 -0
  21. package/dist/auth/magicLink/index.d.ts.map +1 -0
  22. package/dist/auth/magicLink/index.js +6 -0
  23. package/dist/auth/magicLink/index.js.map +1 -0
  24. package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
  25. package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
  26. package/dist/auth/magicLink/magicLinkCore.js +164 -0
  27. package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
  28. package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
  29. package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
  30. package/dist/auth/magicLink/redeemLanding.js +61 -0
  31. package/dist/auth/magicLink/redeemLanding.js.map +1 -0
  32. package/dist/auth/magicLink/types.d.ts +131 -0
  33. package/dist/auth/magicLink/types.d.ts.map +1 -0
  34. package/dist/auth/magicLink/types.js +6 -0
  35. package/dist/auth/magicLink/types.js.map +1 -0
  36. package/dist/config.d.ts +479 -0
  37. package/dist/config.d.ts.map +1 -1
  38. package/dist/config.js +84 -0
  39. package/dist/config.js.map +1 -1
  40. package/dist/context.d.ts.map +1 -1
  41. package/dist/context.js +223 -19
  42. package/dist/context.js.map +1 -1
  43. package/dist/generated/generated.d.ts +952 -4
  44. package/dist/generated/generated.d.ts.map +1 -1
  45. package/dist/generated/generated.js +25663 -20321
  46. package/dist/generated/generated.js.map +1 -1
  47. package/dist/generic/FireAndForgetHeartbeat.d.ts +51 -0
  48. package/dist/generic/FireAndForgetHeartbeat.d.ts.map +1 -0
  49. package/dist/generic/FireAndForgetHeartbeat.js +44 -0
  50. package/dist/generic/FireAndForgetHeartbeat.js.map +1 -0
  51. package/dist/generic/ResolverBase.d.ts.map +1 -1
  52. package/dist/generic/ResolverBase.js +35 -7
  53. package/dist/generic/ResolverBase.js.map +1 -1
  54. package/dist/index.d.ts +5 -0
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +145 -2
  57. package/dist/index.js.map +1 -1
  58. package/dist/logging/NoLog.d.ts +50 -0
  59. package/dist/logging/NoLog.d.ts.map +1 -0
  60. package/dist/logging/NoLog.js +80 -0
  61. package/dist/logging/NoLog.js.map +1 -0
  62. package/dist/logging/bootAudit.d.ts +43 -0
  63. package/dist/logging/bootAudit.d.ts.map +1 -0
  64. package/dist/logging/bootAudit.js +83 -0
  65. package/dist/logging/bootAudit.js.map +1 -0
  66. package/dist/logging/boundaryLogPayload.d.ts +18 -0
  67. package/dist/logging/boundaryLogPayload.d.ts.map +1 -0
  68. package/dist/logging/boundaryLogPayload.js +18 -0
  69. package/dist/logging/boundaryLogPayload.js.map +1 -0
  70. package/dist/logging/secretRedactor.d.ts +23 -0
  71. package/dist/logging/secretRedactor.d.ts.map +1 -0
  72. package/dist/logging/secretRedactor.js +53 -0
  73. package/dist/logging/secretRedactor.js.map +1 -0
  74. package/dist/logging/shortenForLog.d.ts +8 -0
  75. package/dist/logging/shortenForLog.d.ts.map +1 -0
  76. package/dist/logging/shortenForLog.js +21 -0
  77. package/dist/logging/shortenForLog.js.map +1 -0
  78. package/dist/logging/variablesLoggingMiddleware.d.ts +22 -0
  79. package/dist/logging/variablesLoggingMiddleware.d.ts.map +1 -0
  80. package/dist/logging/variablesLoggingMiddleware.js +127 -0
  81. package/dist/logging/variablesLoggingMiddleware.js.map +1 -0
  82. package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
  83. package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
  84. package/dist/resolvers/CurrentUserContextResolver.js +19 -5
  85. package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
  86. package/dist/resolvers/EntityResolver.d.ts.map +1 -1
  87. package/dist/resolvers/EntityResolver.js +13 -2
  88. package/dist/resolvers/EntityResolver.js.map +1 -1
  89. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
  90. package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
  91. package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
  92. package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
  93. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  94. package/dist/resolvers/GetDataResolver.js +8 -4
  95. package/dist/resolvers/GetDataResolver.js.map +1 -1
  96. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +259 -2
  97. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  98. package/dist/resolvers/IntegrationDiscoveryResolver.js +1276 -117
  99. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  100. package/dist/resolvers/IntegrationProgressResolver.d.ts +90 -0
  101. package/dist/resolvers/IntegrationProgressResolver.d.ts.map +1 -0
  102. package/dist/resolvers/IntegrationProgressResolver.js +196 -0
  103. package/dist/resolvers/IntegrationProgressResolver.js.map +1 -0
  104. package/dist/resolvers/MCPResolver.d.ts.map +1 -1
  105. package/dist/resolvers/MCPResolver.js +19 -14
  106. package/dist/resolvers/MCPResolver.js.map +1 -1
  107. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  108. package/dist/resolvers/RunAIAgentResolver.js +26 -5
  109. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  110. package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
  111. package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
  112. package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
  113. package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
  114. package/dist/resolvers/RunTestResolver.d.ts.map +1 -1
  115. package/dist/resolvers/RunTestResolver.js +12 -2
  116. package/dist/resolvers/RunTestResolver.js.map +1 -1
  117. package/dist/resolvers/SearchEntitiesResolver.d.ts +46 -0
  118. package/dist/resolvers/SearchEntitiesResolver.d.ts.map +1 -0
  119. package/dist/resolvers/SearchEntitiesResolver.js +216 -0
  120. package/dist/resolvers/SearchEntitiesResolver.js.map +1 -0
  121. package/dist/resolvers/UserResolver.d.ts +16 -2
  122. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  123. package/dist/resolvers/UserResolver.js +45 -2
  124. package/dist/resolvers/UserResolver.js.map +1 -1
  125. package/dist/resolvers/VectorizeEntityResolver.d.ts.map +1 -1
  126. package/dist/resolvers/VectorizeEntityResolver.js +14 -1
  127. package/dist/resolvers/VectorizeEntityResolver.js.map +1 -1
  128. package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
  129. package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
  130. package/dist/rest/SignatureWebhookHandler.js +86 -0
  131. package/dist/rest/SignatureWebhookHandler.js.map +1 -0
  132. package/dist/services/ScheduledJobsService.d.ts.map +1 -1
  133. package/dist/services/ScheduledJobsService.js +14 -2
  134. package/dist/services/ScheduledJobsService.js.map +1 -1
  135. package/package.json +79 -74
  136. package/src/__tests__/NoLog.test.ts +76 -0
  137. package/src/__tests__/bootAudit.test.ts +188 -0
  138. package/src/__tests__/boundaryLogPayload.test.ts +31 -0
  139. package/src/__tests__/getDataTokenRedaction.test.ts +84 -0
  140. package/src/__tests__/magicLink.test.ts +387 -0
  141. package/src/__tests__/secretRedactor.test.ts +163 -0
  142. package/src/__tests__/subscriptionRedaction.test.ts +217 -0
  143. package/src/apolloServer/index.ts +58 -0
  144. package/src/auth/index.ts +2 -2
  145. package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
  146. package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
  147. package/src/auth/magicLink/MagicLinkService.ts +724 -0
  148. package/src/auth/magicLink/index.ts +17 -0
  149. package/src/auth/magicLink/magicLinkCore.ts +216 -0
  150. package/src/auth/magicLink/redeemLanding.ts +62 -0
  151. package/src/auth/magicLink/types.ts +137 -0
  152. package/src/config.ts +89 -0
  153. package/src/context.ts +249 -17
  154. package/src/generated/generated.ts +12528 -8866
  155. package/src/generic/FireAndForgetHeartbeat.ts +85 -0
  156. package/src/generic/ResolverBase.ts +35 -7
  157. package/src/generic/__tests__/FireAndForgetHeartbeat.test.ts +99 -0
  158. package/src/index.ts +165 -2
  159. package/src/logging/NoLog.ts +88 -0
  160. package/src/logging/bootAudit.ts +117 -0
  161. package/src/logging/boundaryLogPayload.ts +19 -0
  162. package/src/logging/secretRedactor.ts +82 -0
  163. package/src/logging/shortenForLog.ts +17 -0
  164. package/src/logging/variablesLoggingMiddleware.ts +191 -0
  165. package/src/resolvers/CurrentUserContextResolver.ts +21 -5
  166. package/src/resolvers/EntityResolver.ts +17 -5
  167. package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
  168. package/src/resolvers/GetDataResolver.ts +9 -5
  169. package/src/resolvers/IntegrationDiscoveryResolver.ts +1111 -120
  170. package/src/resolvers/IntegrationProgressResolver.ts +220 -0
  171. package/src/resolvers/MCPResolver.ts +18 -14
  172. package/src/resolvers/RunAIAgentResolver.ts +28 -5
  173. package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
  174. package/src/resolvers/RunTestResolver.ts +14 -2
  175. package/src/resolvers/SearchEntitiesResolver.ts +173 -0
  176. package/src/resolvers/UserResolver.ts +38 -2
  177. package/src/resolvers/VectorizeEntityResolver.ts +14 -1
  178. package/src/resolvers/__tests__/IntegrationProgressResolver.test.ts +170 -0
  179. package/src/rest/SignatureWebhookHandler.ts +103 -0
  180. package/src/services/ScheduledJobsService.ts +15 -2
package/src/context.ts CHANGED
@@ -9,34 +9,244 @@ import { getSigningKeys, getSystemUser, getValidationOptions, verifyUserRecord,
9
9
  import { TokenExpiredError, AuthProviderFactory } from '@memberjunction/auth-providers';
10
10
  import { authCache } from './cache.js';
11
11
  import { userEmailMap, apiKey, mj_core_schema } from './config.js';
12
+ import { buildBoundaryLogPayload } from './logging/boundaryLogPayload.js';
12
13
  import { DataSourceInfo, UserPayload } from './types.js';
13
14
  import { GetReadOnlyDataSource, GetReadWriteDataSource } from './util.js';
14
15
  import { v4 as uuidv4 } from 'uuid';
15
16
  import e from 'express';
16
17
  import type { RequestHandler, Request, Response, NextFunction } from 'express';
17
- import { DatabaseProviderBase } from '@memberjunction/core';
18
+ import { DatabaseProviderBase, UserInfo, type MagicLinkScope } from '@memberjunction/core';
18
19
  import { SQLServerDataProvider, SQLServerProviderConfigData, UserCache } from '@memberjunction/sqlserver-dataprovider';
19
20
  import { Metadata } from '@memberjunction/core';
20
21
  import { UUIDsEqual } from '@memberjunction/global';
21
22
  import { resolveDbPlatformFromEnv } from '@memberjunction/generic-database-provider';
22
23
  import { GetAPIKeyEngine } from '@memberjunction/api-keys';
23
24
 
25
+ // ── Session / login audit (Phase 3) ───────────────────────────────────────────
26
+ // Writes one `MJ: Audit Logs` row per session establishment (and per auth failure),
27
+ // across EVERY provider, hooked at token validation below. Deduped by (iss, sub, iat)
28
+ // in a bounded, no-TTL map so a given issued token logs ONCE per process — not per
29
+ // request. (We deliberately do NOT reuse authCache for this: its 1h TTL would re-log
30
+ // long-lived sessions every hour.) Reviewers asked to audit all user access with
31
+ // timestamp/IP/browser — that payload lives in the Details JSON; the table has no
32
+ // dedicated columns. Best-effort throughout: an audit failure NEVER blocks auth.
33
+ const SESSION_AUDIT_TYPE = 'Session Established';
34
+ const LOGIN_FAILED_AUDIT_TYPE = 'Login Failed';
35
+ const SESSION_AUDIT_CACHE_MAX = 50_000;
36
+ const sessionAuditSeen = new Map<string, number>(); // insertion-ordered → evict oldest
37
+
38
+ function sessionAuditKey(prefix: string, payload: jwt.JwtPayload | null): string {
39
+ return `${prefix}|${payload?.iss ?? 'unknown'}|${payload?.sub ?? 'nosub'}|${payload?.iat ?? 0}`;
40
+ }
41
+
42
+ /** First-seen guard: true the first time a key is seen this process; bounds the map. */
43
+ function markSessionAuditSeen(key: string): boolean {
44
+ if (sessionAuditSeen.has(key)) {
45
+ return false;
46
+ }
47
+ if (sessionAuditSeen.size >= SESSION_AUDIT_CACHE_MAX) {
48
+ const oldest = sessionAuditSeen.keys().next().value;
49
+ if (oldest !== undefined) {
50
+ sessionAuditSeen.delete(oldest);
51
+ }
52
+ }
53
+ sessionAuditSeen.set(key, Date.now());
54
+ return true;
55
+ }
56
+
57
+ let _usersEntityId: string | null | undefined; // resolved lazily; null once known-missing
58
+ function resolveUsersEntityId(): string | null {
59
+ if (_usersEntityId === undefined) {
60
+ const md = Metadata.Provider; // global-provider-ok: server auth path; Users entity ID is process-global metadata
61
+ _usersEntityId = md?.EntityByName('Users')?.ID ?? md?.EntityByName('MJ: Users')?.ID ?? null;
62
+ }
63
+ return _usersEntityId;
64
+ }
65
+
24
66
  /**
25
- * Renders a value for one-line console logging without Node's `[Object]` truncation.
26
- * Arrays keep their structure; non-array objects collapse to JSON, truncated at `maxLen`.
27
- * Objects whose JSON exceeds `maxLen` and contain nested structure are recursed into so
28
- * outer keys remain visible.
67
+ * Writes one session/login audit row via the provider's CreateAuditLogRecord helper
68
+ * (resolves the audit-log type by name from the in-memory cache, fire-and-forget save).
69
+ * EntityID points at the Users entity + the user's ID as RecordID — a session event
70
+ * is "about" the user. Full IP/UA/origin go in Details (per reviewer request); a
71
+ * deployment may later truncate/hash them via an audit-policy knob.
29
72
  */
30
- function shortenForLog(value: unknown, maxLen = 300): unknown {
31
- if (value === null || typeof value !== 'object') return value;
32
- if (Array.isArray(value)) return value.map((v) => shortenForLog(v, maxLen));
33
- const json = JSON.stringify(value);
34
- if (json.length <= maxLen) return json;
35
- const result: Record<string, unknown> = {};
36
- for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
37
- result[k] = shortenForLog(v, maxLen);
73
+ async function writeSessionAudit(args: {
74
+ user: UserInfo;
75
+ auditTypeName: string;
76
+ status: 'Success' | 'Failed';
77
+ payload: jwt.JwtPayload | null;
78
+ requestContext: RequestContext | undefined;
79
+ requestDomain: string | undefined;
80
+ description: string;
81
+ extraDetails?: Record<string, unknown>;
82
+ }): Promise<void> {
83
+ try {
84
+ // global-provider-ok: pre-context auth path; the audit-log-type cache is process-global metadata.
85
+ const provider = Metadata.Provider as unknown as DatabaseProviderBase; // global-provider-ok: server auth audit path; runs under the server's single default provider
86
+ const usersEntityId = resolveUsersEntityId();
87
+ if (!provider || !usersEntityId) {
88
+ return;
89
+ }
90
+ // Audit rows MUST be saved under a PRIVILEGED context. The session user may be a
91
+ // permission-less magic-link guest with no create rights on MJ: Audit Logs — saving
92
+ // as them silently fails the permission check (this is exactly why the built-in
93
+ // CreateAuditLogRecord, which saves as `user`, drops guest session rows). We record
94
+ // the real session user in UserID but write the row as the system user.
95
+ const writer = await getSystemUser();
96
+ const auditType = provider.AuditLogTypes?.find(
97
+ (t) => t?.Name?.trim().toLowerCase() === args.auditTypeName.trim().toLowerCase(),
98
+ );
99
+ if (!writer || !auditType) {
100
+ return;
101
+ }
102
+ const details = JSON.stringify({
103
+ provider: args.payload?.iss ?? null,
104
+ sub: args.payload?.sub ?? null,
105
+ iat: args.payload?.iat ?? null,
106
+ ipAddress: args.requestContext?.ipAddress ?? null,
107
+ userAgent: args.requestContext?.userAgent ?? null,
108
+ origin: args.requestDomain ?? null,
109
+ endpoint: args.requestContext?.endpoint ?? null,
110
+ ...(args.extraDetails ?? {}),
111
+ });
112
+ const row = await provider.GetEntityObject('MJ: Audit Logs', writer);
113
+ row.NewRecord();
114
+ row.Set('UserID', args.user.ID);
115
+ row.Set('AuditLogTypeID', auditType.ID);
116
+ row.Set('Status', args.status);
117
+ row.Set('EntityID', usersEntityId);
118
+ row.Set('RecordID', args.user.ID);
119
+ row.Set('Details', details);
120
+ row.Set('Description', args.description);
121
+ if (!(await row.Save())) {
122
+ console.warn('[SessionAudit] audit row save returned false:', row.LatestResult?.CompleteMessage ?? 'unknown');
123
+ }
124
+ } catch (e) {
125
+ console.warn('[SessionAudit] failed to write audit row:', e instanceof Error ? e.message : String(e));
126
+ }
127
+ }
128
+
129
+ /** Best-effort failure audit: decodes the attempted identity, dedupes, attributes to the system user. */
130
+ async function auditLoginFailure(
131
+ bearerToken: string | undefined,
132
+ error: unknown,
133
+ requestContext: RequestContext | undefined,
134
+ requestDomain: string | undefined,
135
+ ): Promise<void> {
136
+ try {
137
+ const token = bearerToken ? bearerToken.replace('Bearer ', '') : '';
138
+ if (!token) {
139
+ // No credential was presented — a public/unauthenticated request (favicon, health
140
+ // checks, anonymous GraphQL), NOT a failed login. Auditing these is pure noise.
141
+ return;
142
+ }
143
+ let payload: jwt.JwtPayload | null = null;
144
+ const decoded = jwt.decode(token);
145
+ if (decoded && typeof decoded !== 'string') {
146
+ payload = decoded;
147
+ }
148
+ // Dedup by (iss,sub,iat) when the token decodes; otherwise by the token tail so that
149
+ // a scan presenting many DIFFERENT garbage tokens doesn't collapse into a single row.
150
+ const key = payload ? sessionAuditKey('fail', payload) : `fail|raw|${token.slice(-24)}`;
151
+ if (!markSessionAuditSeen(key)) {
152
+ return; // already logged this failing identity/token — don't let a retry loop spam
153
+ }
154
+ // No-arg: getSystemUser pulls from the process-global UserCache (no ConnectionPool needed here).
155
+ const systemUser = await getSystemUser();
156
+ if (!systemUser) {
157
+ return;
158
+ }
159
+ await writeSessionAudit({
160
+ user: systemUser,
161
+ auditTypeName: LOGIN_FAILED_AUDIT_TYPE,
162
+ status: 'Failed',
163
+ payload,
164
+ requestContext,
165
+ requestDomain,
166
+ description: 'Authentication failed during token validation',
167
+ extraDetails: {
168
+ attemptedEmail: payload && typeof payload === 'object' ? (payload as Record<string, unknown>)['email'] ?? null : null,
169
+ reason: error instanceof Error ? error.message : String(error),
170
+ },
171
+ });
172
+ } catch (e) {
173
+ console.warn('[SessionAudit] failed to write login-failure row:', e instanceof Error ? e.message : String(e));
174
+ }
175
+ }
176
+
177
+ // ── Phase 5: magic-link session scoping (resource scope + anonymous role synthesis) ──
178
+ // Builds the per-request UserInfo for a magic-link session, carrying:
179
+ // • a per-session RESOURCE scope (ResourceID/ResourceType from the verified claims) so
180
+ // resource-pinned RLS (`{{ScopeResourceID}}`) confines a share to one resource + its
181
+ // FK-reachable dependents — the granted role stays narrow. Applies to named AND anon.
182
+ // • for ANONYMOUS sessions only, the claimed role(s) synthesized in memory (the shared
183
+ // Anonymous principal holds no DB roles, by design — so anon sessions can't accrete).
184
+ // Always returns a FRESH UserInfo when it sets per-session state, because the resolved
185
+ // userRecord may be a SHARED cached instance (e.g. multiple recruiters on one per-company
186
+ // link) — mutating it would leak one session's scope to another. Non-magic-link sessions,
187
+ // and named sessions with no resource scope, are returned untouched.
188
+ function buildMagicLinkSessionUser(userRecord: UserInfo, payload: jwt.JwtPayload): UserInfo {
189
+ if (payload['mj_magic_link'] !== true) {
190
+ return userRecord;
191
+ }
192
+ const md = Metadata.Provider; // global-provider-ok: server-side magic-link session built under the server's single default provider
193
+ const isAnon = payload['mj_anon'] === true;
194
+ const scopes = payload['mj_scopes'];
195
+
196
+ // Per-session resource scope (single-resource share; multi-resource union is a follow-on).
197
+ let scope: MagicLinkScope | undefined;
198
+ if (Array.isArray(scopes)) {
199
+ const withResource = scopes.find((s) => s && typeof s.resourceId === 'string' && s.resourceId);
200
+ if (withResource) {
201
+ scope = {
202
+ ResourceID: withResource.resourceId,
203
+ ResourceType: typeof withResource.resourceType === 'string' ? withResource.resourceType : undefined,
204
+ };
205
+ }
206
+ }
207
+
208
+ // Anonymous: synthesize the claimed role(s) (persisted nowhere). Named: keep real DB roles.
209
+ let synthesizedRoles: { UserID: string; RoleID: string; RoleName: string }[] | undefined;
210
+ if (isAnon) {
211
+ const roleNames = new Set<string>();
212
+ if (Array.isArray(scopes)) {
213
+ for (const s of scopes) {
214
+ if (s && typeof s.role === 'string') {
215
+ roleNames.add(s.role);
216
+ }
217
+ }
218
+ }
219
+ if (typeof payload['mj_role'] === 'string') {
220
+ roleNames.add(payload['mj_role'] as string);
221
+ }
222
+ synthesizedRoles = [];
223
+ for (const rn of roleNames) {
224
+ const role = md?.Roles.find((r) => r.Name?.trim().toLowerCase() === rn.trim().toLowerCase());
225
+ if (role) {
226
+ synthesizedRoles.push({ UserID: userRecord.ID, RoleID: role.ID, RoleName: role.Name });
227
+ }
228
+ }
38
229
  }
39
- return result;
230
+
231
+ // Named session with no resource scope → no per-request state needed, use the cached user.
232
+ if (!isAnon && !scope) {
233
+ return userRecord;
234
+ }
235
+
236
+ const sessionUser = new UserInfo(md, {
237
+ ...userRecord,
238
+ _UserRoles: undefined,
239
+ UserRoles: isAnon ? synthesizedRoles : userRecord.UserRoles,
240
+ });
241
+ if (scope) {
242
+ sessionUser.MagicLinkScope = scope;
243
+ }
244
+ if (isAnon) {
245
+ // Mark the session so the CurrentUser field resolver serves these synthesized roles
246
+ // (the shared Anonymous principal holds none in the DB). See UserInfo.IsMagicLinkAnonymous.
247
+ sessionUser.IsMagicLinkAnonymous = true;
248
+ }
249
+ return sessionUser;
40
250
  }
41
251
 
42
252
  const verifyAsync = async (issuer: string, token: string): Promise<jwt.JwtPayload> =>
@@ -216,12 +426,34 @@ export const getUserPayload = async (
216
426
  throw new AuthorizationError();
217
427
  }
218
428
 
219
- return { userRecord, email: userRecord.Email, sessionId };
429
+ // Claims authorizer: for anonymous magic-link sessions, replace the role-less
430
+ // Anonymous principal with an in-memory UserInfo carrying the claimed role(s), so
431
+ // the session enforces exactly the link's granted scope (no DB accretion). No-op
432
+ // for everything else.
433
+ const sessionUser = buildMagicLinkSessionUser(userRecord, payload);
434
+
435
+ // Session-established audit — once per issued token (deduped), fire-and-forget so
436
+ // it never adds latency to the request. The dedup mark is set synchronously here.
437
+ if (markSessionAuditSeen(sessionAuditKey('sess', payload))) {
438
+ void writeSessionAudit({
439
+ user: sessionUser,
440
+ auditTypeName: SESSION_AUDIT_TYPE,
441
+ status: 'Success',
442
+ payload,
443
+ requestContext,
444
+ requestDomain,
445
+ description: `Session established via ${payload.iss ?? 'unknown provider'}`,
446
+ });
447
+ }
448
+
449
+ return { userRecord: sessionUser, email: sessionUser.Email, sessionId };
220
450
  } catch (error) {
221
451
  console.error(error);
222
452
  if (error instanceof TokenExpiredError) {
223
- throw error;
453
+ throw error; // expected for long-lived sessions; not a failure worth auditing
224
454
  }
455
+ // Best-effort failure audit (token scanning / brute-force signal). Never blocks auth.
456
+ void auditLoginFailure(bearerToken, error, requestContext, requestDomain);
225
457
  throw new AuthenticationError('Unable to authenticate user');
226
458
  }
227
459
  };
@@ -342,7 +574,7 @@ export const contextFunction =
342
574
  const reqAny = req as any;
343
575
  const operationName: string | undefined = reqAny.body?.operationName;
344
576
  if (operationName !== 'IntrospectionQuery') {
345
- console.dir({ operationName, variables: shortenForLog(reqAny.body?.variables) }, { depth: null, breakLength: 200 });
577
+ console.dir(buildBoundaryLogPayload(operationName), { depth: null, breakLength: 200 });
346
578
  }
347
579
 
348
580
  // Auth already happened in the unified auth middleware — just read the result