@memberjunction/server 5.39.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.
- package/dist/auth/index.js +1 -1
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkKeys.d.ts +58 -0
- package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkKeys.js +99 -0
- package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts +33 -0
- package/dist/auth/magicLink/MagicLinkRouter.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkRouter.js +184 -0
- package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts +123 -0
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -0
- package/dist/auth/magicLink/MagicLinkService.js +605 -0
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -0
- package/dist/auth/magicLink/index.d.ts +6 -0
- package/dist/auth/magicLink/index.d.ts.map +1 -0
- package/dist/auth/magicLink/index.js +6 -0
- package/dist/auth/magicLink/index.js.map +1 -0
- package/dist/auth/magicLink/magicLinkCore.d.ts +82 -0
- package/dist/auth/magicLink/magicLinkCore.d.ts.map +1 -0
- package/dist/auth/magicLink/magicLinkCore.js +164 -0
- package/dist/auth/magicLink/magicLinkCore.js.map +1 -0
- package/dist/auth/magicLink/redeemLanding.d.ts +22 -0
- package/dist/auth/magicLink/redeemLanding.d.ts.map +1 -0
- package/dist/auth/magicLink/redeemLanding.js +61 -0
- package/dist/auth/magicLink/redeemLanding.js.map +1 -0
- package/dist/auth/magicLink/types.d.ts +131 -0
- package/dist/auth/magicLink/types.d.ts.map +1 -0
- package/dist/auth/magicLink/types.js +6 -0
- package/dist/auth/magicLink/types.js.map +1 -0
- package/dist/config.d.ts +411 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +60 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +225 -2
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +877 -4
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +17779 -12750
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +35 -7
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/resolvers/CurrentUserContextResolver.d.ts +9 -3
- package/dist/resolvers/CurrentUserContextResolver.d.ts.map +1 -1
- package/dist/resolvers/CurrentUserContextResolver.js +19 -5
- package/dist/resolvers/CurrentUserContextResolver.js.map +1 -1
- package/dist/resolvers/EntityResolver.d.ts.map +1 -1
- package/dist/resolvers/EntityResolver.js +13 -2
- package/dist/resolvers/EntityResolver.js.map +1 -1
- package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts +28 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.d.ts.map +1 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.js +100 -0
- package/dist/resolvers/GenerateSeedTaxonomyResolver.js.map +1 -0
- package/dist/resolvers/RunClusterAnalysisResolver.d.ts +74 -0
- package/dist/resolvers/RunClusterAnalysisResolver.d.ts.map +1 -0
- package/dist/resolvers/RunClusterAnalysisResolver.js +243 -0
- package/dist/resolvers/RunClusterAnalysisResolver.js.map +1 -0
- package/dist/resolvers/UserResolver.d.ts +16 -2
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +45 -2
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/rest/SignatureWebhookHandler.d.ts +19 -0
- package/dist/rest/SignatureWebhookHandler.d.ts.map +1 -0
- package/dist/rest/SignatureWebhookHandler.js +86 -0
- package/dist/rest/SignatureWebhookHandler.js.map +1 -0
- package/package.json +75 -71
- package/src/__tests__/magicLink.test.ts +387 -0
- package/src/auth/index.ts +2 -2
- package/src/auth/magicLink/MagicLinkKeys.ts +122 -0
- package/src/auth/magicLink/MagicLinkRouter.ts +209 -0
- package/src/auth/magicLink/MagicLinkService.ts +724 -0
- package/src/auth/magicLink/index.ts +17 -0
- package/src/auth/magicLink/magicLinkCore.ts +216 -0
- package/src/auth/magicLink/redeemLanding.ts +62 -0
- package/src/auth/magicLink/types.ts +137 -0
- package/src/config.ts +62 -0
- package/src/context.ts +252 -3
- package/src/generated/generated.ts +12302 -8878
- package/src/generic/ResolverBase.ts +35 -7
- package/src/index.ts +28 -0
- package/src/resolvers/CurrentUserContextResolver.ts +21 -5
- package/src/resolvers/EntityResolver.ts +17 -5
- package/src/resolvers/GenerateSeedTaxonomyResolver.ts +90 -0
- package/src/resolvers/RunClusterAnalysisResolver.ts +249 -0
- package/src/resolvers/UserResolver.ts +38 -2
- package/src/rest/SignatureWebhookHandler.ts +103 -0
|
@@ -587,7 +587,16 @@ export class ResolverBase {
|
|
|
587
587
|
|
|
588
588
|
// first check permissions, the logged in user must have read permissions on the entity to run the view
|
|
589
589
|
if (entityInfo) {
|
|
590
|
-
|
|
590
|
+
// Prefer the authenticated session's payload user WHEN it carries roles — that is the
|
|
591
|
+
// authoritative per-session identity (and, for magic-link sessions, carries claims-based
|
|
592
|
+
// synthesized roles that are deliberately NOT persisted to the shared UserCache). For
|
|
593
|
+
// normal users the payload user IS the UserCache user, so this is a no-op; we only diverge
|
|
594
|
+
// for per-session synthesized identities. Fall back to the cache lookup when the payload
|
|
595
|
+
// user has no roles attached (older/edge auth paths).
|
|
596
|
+
const payloadUser = this.GetUserFromPayload(userPayload);
|
|
597
|
+
const userInfo = (payloadUser && payloadUser.UserRoles && payloadUser.UserRoles.length > 0)
|
|
598
|
+
? payloadUser
|
|
599
|
+
: UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload.email.toLowerCase().trim());
|
|
591
600
|
if (!userInfo) {
|
|
592
601
|
throw new Error(`User ${userPayload.email} not found in metadata`);
|
|
593
602
|
}
|
|
@@ -709,7 +718,12 @@ export class ResolverBase {
|
|
|
709
718
|
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
710
719
|
|
|
711
720
|
const md = provider
|
|
712
|
-
|
|
721
|
+
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
722
|
+
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
723
|
+
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
724
|
+
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
725
|
+
const user = this.GetUserFromPayload(userPayload)
|
|
726
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
713
727
|
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
714
728
|
|
|
715
729
|
const entityInfo = md.Entities.find((e) => e.Name === viewInfo.Entity);
|
|
@@ -823,8 +837,14 @@ export class ResolverBase {
|
|
|
823
837
|
// Fix #1: Get user info only once for all queries
|
|
824
838
|
let contextUser: UserInfo | null = null;
|
|
825
839
|
if (params[0]?.userPayload?.email) {
|
|
826
|
-
const
|
|
827
|
-
const
|
|
840
|
+
const userPayload = params[0].userPayload;
|
|
841
|
+
const userEmail = userPayload.email.toLowerCase().trim();
|
|
842
|
+
// Prefer the authenticated session's payload user — it is the authoritative per-request
|
|
843
|
+
// identity and (for magic-link sessions) carries the per-session resource scope / synthesized
|
|
844
|
+
// roles that drive RLS. The cached lookup is a fallback for paths where the payload user
|
|
845
|
+
// isn't populated. For normal users the two are the same instance, so this is a no-op.
|
|
846
|
+
const user = this.GetUserFromPayload(userPayload)
|
|
847
|
+
?? UserCache.Users.find(u => u.Email.toLowerCase().trim() === userEmail);
|
|
828
848
|
if (!user) {
|
|
829
849
|
throw new Error(`User ${userEmail} not found in metadata`);
|
|
830
850
|
}
|
|
@@ -921,7 +941,9 @@ export class ResolverBase {
|
|
|
921
941
|
if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
|
|
922
942
|
|
|
923
943
|
if (entityInfo.AuditRecordAccess) {
|
|
924
|
-
|
|
944
|
+
// Prefer the per-session payload user (the actual actor for this request) over the cache lookup.
|
|
945
|
+
const userInfo = this.GetUserFromPayload(userPayload)
|
|
946
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
925
947
|
const auditLogTypeName = 'Record Accessed';
|
|
926
948
|
const auditLogType = md.AuditLogTypes.find((a) => a.Name.trim().toLowerCase() === auditLogTypeName.trim().toLowerCase());
|
|
927
949
|
|
|
@@ -939,7 +961,11 @@ export class ResolverBase {
|
|
|
939
961
|
const md = provider;
|
|
940
962
|
const entityInfo = md.Entities.find((e) => e.Name.trim().toLowerCase() === entityName.trim().toLowerCase());
|
|
941
963
|
if (!entityInfo) throw new Error(`Entity ${entityName} not found in metadata`);
|
|
942
|
-
|
|
964
|
+
// Prefer the authenticated session's payload user — it is the authoritative per-request identity
|
|
965
|
+
// and (for magic-link sessions) carries the per-session resource scope / synthesized roles that
|
|
966
|
+
// the RLS WHERE clause (e.g. {{ScopeResourceID}}) depends on. Cache lookup is the fallback.
|
|
967
|
+
const user = this.GetUserFromPayload(userPayload)
|
|
968
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
943
969
|
if (!user) throw new Error(`User ${userPayload?.email} not found in metadata`);
|
|
944
970
|
|
|
945
971
|
return entityInfo.GetUserRowLevelSecurityWhereClause(user, type, returnPrefix);
|
|
@@ -957,7 +983,9 @@ export class ResolverBase {
|
|
|
957
983
|
): Promise<any> {
|
|
958
984
|
try {
|
|
959
985
|
const md = provider;
|
|
960
|
-
|
|
986
|
+
// Prefer the per-session payload user (the actual actor for this request) over the cache lookup.
|
|
987
|
+
const userInfo = this.GetUserFromPayload(userPayload)
|
|
988
|
+
?? UserCache.Users.find((u) => u.Email.toLowerCase().trim() === userPayload?.email.toLowerCase().trim());
|
|
961
989
|
const authorization = authorizationName
|
|
962
990
|
? md.Authorizations.find((a) => a.Name.trim().toLowerCase() === authorizationName.trim().toLowerCase())
|
|
963
991
|
: null;
|
package/src/index.ts
CHANGED
|
@@ -35,6 +35,8 @@ import { auditResolversForUndecoratedArgs } from './logging/bootAudit.js';
|
|
|
35
35
|
import createMSSQLConfig from './orm.js';
|
|
36
36
|
import { setupRESTEndpoints } from './rest/setupRESTEndpoints.js';
|
|
37
37
|
import { createOAuthCallbackHandler } from './rest/OAuthCallbackHandler.js';
|
|
38
|
+
import { createSignatureWebhookHandler } from './rest/SignatureWebhookHandler.js';
|
|
39
|
+
import { createMagicLinkHandler, registerMagicLinkAuthProvider, MAGIC_LINK_MOUNT_PATH } from './auth/magicLink/index.js';
|
|
38
40
|
|
|
39
41
|
import { resolve } from 'node:path';
|
|
40
42
|
import { DataSourceInfo, raiseEvent } from './types.js';
|
|
@@ -111,6 +113,8 @@ export * from './resolvers/SearchKnowledgeResolver.js';
|
|
|
111
113
|
export * from './resolvers/SearchKnowledgeStreamResolver.js';
|
|
112
114
|
export * from './resolvers/AvailableSearchProvidersResolver.js';
|
|
113
115
|
export * from './resolvers/FetchEntityVectorsResolver.js';
|
|
116
|
+
export * from './resolvers/RunClusterAnalysisResolver.js';
|
|
117
|
+
export * from './resolvers/GenerateSeedTaxonomyResolver.js';
|
|
114
118
|
export * from './resolvers/PipelineProgressResolver.js';
|
|
115
119
|
export * from './resolvers/IntegrationProgressResolver.js';
|
|
116
120
|
export * from './resolvers/ClientToolRequestResolver.js';
|
|
@@ -925,6 +929,24 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
925
929
|
console.log('[OAuth] Callback route registered at /oauth/callback');
|
|
926
930
|
}
|
|
927
931
|
|
|
932
|
+
// ─── eSignature webhook (unauthenticated, registered BEFORE auth) ─────
|
|
933
|
+
// Called by external signature providers (DocuSign Connect, etc.) without an MJ bearer token.
|
|
934
|
+
// The provider DRIVER verifies the payload signature/HMAC; MJ auth does not apply here.
|
|
935
|
+
app.use('/esignature', cors<cors.CorsRequest>(), createSignatureWebhookHandler());
|
|
936
|
+
console.log('[eSignature] Webhook route registered at /esignature/webhook/:driverKey');
|
|
937
|
+
|
|
938
|
+
// ─── Magic-link routes (MJ-issued, app-scoped external access) ───────────
|
|
939
|
+
// Public router (JWKS + redeem) mounts BEFORE the auth middleware; the
|
|
940
|
+
// authenticated invite-creation router mounts AFTER it (see below).
|
|
941
|
+
let magicLinkAuthenticatedRouter: ReturnType<typeof createMagicLinkHandler>['authenticatedRouter'] | undefined;
|
|
942
|
+
if (configInfo.magicLink?.enabled) {
|
|
943
|
+
const { publicRouter, authenticatedRouter } = createMagicLinkHandler(oauthPublicUrl, configInfo.magicLink);
|
|
944
|
+
magicLinkAuthenticatedRouter = authenticatedRouter;
|
|
945
|
+
registerMagicLinkAuthProvider(oauthPublicUrl, configInfo.magicLink);
|
|
946
|
+
app.use(MAGIC_LINK_MOUNT_PATH, cors<cors.CorsRequest>(), publicRouter);
|
|
947
|
+
console.log(`[MagicLink] Public routes registered at ${MAGIC_LINK_MOUNT_PATH}/redeem and ${MAGIC_LINK_MOUNT_PATH}/jwks.json`);
|
|
948
|
+
}
|
|
949
|
+
|
|
928
950
|
// ─── Global CORS (before auth so 401 responses include CORS headers) ─────
|
|
929
951
|
// Without this, the browser blocks 401 responses from the auth middleware
|
|
930
952
|
// because they lack Access-Control-Allow-Origin headers, preventing the
|
|
@@ -966,6 +988,12 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
966
988
|
console.log('[OAuth] Authenticated routes registered at /oauth/status, /oauth/initiate, and /oauth/exchange');
|
|
967
989
|
}
|
|
968
990
|
|
|
991
|
+
// ─── Magic-link authenticated route (invite creation) ─────────────────────
|
|
992
|
+
if (magicLinkAuthenticatedRouter) {
|
|
993
|
+
app.use(MAGIC_LINK_MOUNT_PATH, cors<cors.CorsRequest>(), magicLinkAuthenticatedRouter);
|
|
994
|
+
console.log(`[MagicLink] Authenticated route registered at ${MAGIC_LINK_MOUNT_PATH}/create`);
|
|
995
|
+
}
|
|
996
|
+
|
|
969
997
|
// ─── REST API endpoints (auth already handled by unified middleware) ─────
|
|
970
998
|
const restApiConfig = {
|
|
971
999
|
enabled: configInfo.restApiOptions?.enabled ?? false,
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolver for the `CurrentUserTenantContext` GraphQL query.
|
|
3
3
|
*
|
|
4
|
-
* Returns the server-set `TenantContext`
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Returns the server-set `TenantContext` for the authenticated user, serialized
|
|
5
|
+
* as JSON so the client can auto-stamp `UserInfo.TenantContext`.
|
|
6
|
+
*
|
|
7
|
+
* **Resolution order:**
|
|
8
|
+
* 1. Request-scoped context (`userPayload.__bcResolvedTenantContext`) — set by
|
|
9
|
+
* post-auth middleware on the per-request UserPayload. Immune to race conditions
|
|
10
|
+
* when concurrent requests share the same cached UserInfo object.
|
|
11
|
+
* 2. Fallback to `userRecord.TenantContext` — for middleware that stamps UserInfo
|
|
12
|
+
* directly (vanilla MJ deployments or non-BCSaaS middleware).
|
|
7
13
|
*
|
|
8
14
|
* On the client, `GraphQLDataProvider.GetCurrentUser()` batches this query alongside
|
|
9
15
|
* `CurrentUser` — making plugins stack-layer agnostic without any client-side code.
|
|
@@ -16,6 +22,9 @@ import { GraphQLJSONObject } from 'graphql-type-json';
|
|
|
16
22
|
import { AppContext } from '../types.js';
|
|
17
23
|
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
18
24
|
|
|
25
|
+
/** Well-known key for request-scoped TenantContext on UserPayload */
|
|
26
|
+
const RESOLVED_TENANT_CONTEXT_KEY = '__bcResolvedTenantContext';
|
|
27
|
+
|
|
19
28
|
@Resolver()
|
|
20
29
|
export class CurrentUserContextResolver extends ResolverBase {
|
|
21
30
|
@Query(() => GraphQLJSONObject, {
|
|
@@ -27,13 +36,20 @@ export class CurrentUserContextResolver extends ResolverBase {
|
|
|
27
36
|
): Promise<Record<string, unknown> | null> {
|
|
28
37
|
await this.CheckAPIKeyScopeAuthorization('user:read', '*', context.userPayload);
|
|
29
38
|
|
|
39
|
+
// Prefer request-scoped context (set by BCSaaS middleware on userPayload).
|
|
40
|
+
// This avoids race conditions with concurrent requests mutating the shared
|
|
41
|
+
// UserInfo.TenantContext on the same cached UserInfo object from UserCache.
|
|
42
|
+
const requestScoped = (context.userPayload as Record<string, unknown>)[RESOLVED_TENANT_CONTEXT_KEY];
|
|
43
|
+
if (requestScoped && typeof requestScoped === 'object') {
|
|
44
|
+
return { ...(requestScoped as Record<string, unknown>) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Fallback: read from the shared UserInfo (vanilla MJ / non-BCSaaS middleware)
|
|
30
48
|
const userRecord = context.userPayload.userRecord;
|
|
31
49
|
if (!userRecord?.TenantContext) {
|
|
32
50
|
return null;
|
|
33
51
|
}
|
|
34
52
|
|
|
35
|
-
// Serialize the full TenantContext (which may be an extended type like BCTenantContext).
|
|
36
|
-
// JSON serialization captures all enumerable properties including those from subtypes.
|
|
37
53
|
return { ...userRecord.TenantContext } as Record<string, unknown>;
|
|
38
54
|
}
|
|
39
55
|
}
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
import { EntityPermissionType, IRunViewProvider } from '@memberjunction/core';
|
|
2
2
|
import { AppContext } from '../types.js';
|
|
3
|
-
import { Arg, Ctx, Query, Resolver
|
|
3
|
+
import { Arg, Ctx, Query, Resolver } from 'type-graphql';
|
|
4
4
|
import { MJEntity_, MJEntityResolverBase } from '../generated/generated.js';
|
|
5
|
-
import sql from 'mssql';
|
|
6
5
|
import { GetReadOnlyProvider } from '../util.js';
|
|
7
6
|
|
|
7
|
+
const VALID_SCHEMA_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
|
8
|
+
|
|
9
|
+
function sanitizeSchemaNames(names: string[]): string[] {
|
|
10
|
+
return names.filter((s) => VALID_SCHEMA_NAME.test(s));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildSchemaInClause(columnName: string, schemas: string[], negate: boolean): string {
|
|
14
|
+
const sanitized = sanitizeSchemaNames(schemas);
|
|
15
|
+
if (sanitized.length === 0) return '';
|
|
16
|
+
const operator = negate ? 'NOT IN' : 'IN';
|
|
17
|
+
return `${columnName} ${operator} (${sanitized.map((s) => `'${s}'`).join(',')})`;
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
@Resolver(MJEntity_)
|
|
9
21
|
export class EntityResolver extends MJEntityResolverBase {
|
|
10
22
|
@Query(() => [MJEntity_])
|
|
@@ -17,9 +29,9 @@ export class EntityResolver extends MJEntityResolverBase {
|
|
|
17
29
|
const provider = GetReadOnlyProvider(providers, { allowFallbackToReadWrite: true });
|
|
18
30
|
const rlsWhere = this.getRowLevelSecurityWhereClause(provider, 'Entities', userPayload, EntityPermissionType.Read, ' WHERE');
|
|
19
31
|
const includeSchemaSQL =
|
|
20
|
-
IncludeSchemas && IncludeSchemas.length > 0 ?
|
|
32
|
+
IncludeSchemas && IncludeSchemas.length > 0 ? buildSchemaInClause('SchemaName', IncludeSchemas, false) : '';
|
|
21
33
|
const excludeSchemaSQL =
|
|
22
|
-
ExcludeSchemas && ExcludeSchemas.length > 0 ?
|
|
34
|
+
ExcludeSchemas && ExcludeSchemas.length > 0 ? buildSchemaInClause('SchemaName', ExcludeSchemas, true) : '';
|
|
23
35
|
let schemaSQL = '';
|
|
24
36
|
if (includeSchemaSQL) schemaSQL = includeSchemaSQL;
|
|
25
37
|
if (excludeSchemaSQL) {
|
|
@@ -32,7 +44,7 @@ export class EntityResolver extends MJEntityResolverBase {
|
|
|
32
44
|
if (totalWhere) totalWhere = `${totalWhere} AND ${rlsWhere}`;
|
|
33
45
|
else totalWhere = ` ${rlsWhere}`;
|
|
34
46
|
}
|
|
35
|
-
const rv = provider as
|
|
47
|
+
const rv = provider as IRunViewProvider;
|
|
36
48
|
const result = await rv.RunView({
|
|
37
49
|
EntityName: 'MJ: Entities',
|
|
38
50
|
ExtraFilter: totalWhere,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Resolver, Mutation, Arg, Ctx, ObjectType, Field, Int } from 'type-graphql';
|
|
2
|
+
import { AppContext } from '../types.js';
|
|
3
|
+
import { LogError, LogStatus } from '@memberjunction/core';
|
|
4
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
5
|
+
import { TagEngine, SeedTaxonomyResult } from '@memberjunction/tag-engine';
|
|
6
|
+
|
|
7
|
+
/* ───── GraphQL output ───── */
|
|
8
|
+
|
|
9
|
+
@ObjectType()
|
|
10
|
+
export class GenerateSeedTaxonomyResult {
|
|
11
|
+
@Field()
|
|
12
|
+
Success: boolean;
|
|
13
|
+
|
|
14
|
+
@Field({ nullable: true })
|
|
15
|
+
ErrorMessage?: string;
|
|
16
|
+
|
|
17
|
+
/** How the taxonomy was produced: 'clustering' or 'prompt-fallback'. */
|
|
18
|
+
@Field({ nullable: true })
|
|
19
|
+
Method?: string;
|
|
20
|
+
|
|
21
|
+
/** Number of content items sampled / considered. */
|
|
22
|
+
@Field(() => Int)
|
|
23
|
+
SampleSize: number;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* JSON-serialized array of SeedTaxonomyNode (the proposed, NON-persisted tree).
|
|
27
|
+
* Kept as a JSON string to keep the GraphQL schema simple, mirroring
|
|
28
|
+
* RunClusterAnalysis's *JSON fields.
|
|
29
|
+
*/
|
|
30
|
+
@Field()
|
|
31
|
+
NodesJSON: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ───── Resolver ───── */
|
|
35
|
+
|
|
36
|
+
@Resolver()
|
|
37
|
+
export class GenerateSeedTaxonomyResolver extends ResolverBase {
|
|
38
|
+
/**
|
|
39
|
+
* Propose a hierarchical tag taxonomy for a content source WITHOUT persisting
|
|
40
|
+
* anything. Thin wrapper over {@link TagEngine.generateSeedTaxonomy}.
|
|
41
|
+
*
|
|
42
|
+
* @param sourceID The ContentSource ID whose items seed the taxonomy.
|
|
43
|
+
* @param sampleSize Max number of content items to consider (defaults to 200 when <= 0).
|
|
44
|
+
*/
|
|
45
|
+
@Mutation(() => GenerateSeedTaxonomyResult)
|
|
46
|
+
async GenerateSeedTaxonomy(
|
|
47
|
+
@Arg('sourceID', () => String) sourceID: string,
|
|
48
|
+
@Arg('sampleSize', () => Int, { nullable: true }) sampleSize: number,
|
|
49
|
+
@Ctx() { userPayload }: AppContext = {} as AppContext
|
|
50
|
+
): Promise<GenerateSeedTaxonomyResult> {
|
|
51
|
+
try {
|
|
52
|
+
const currentUser = this.GetUserFromPayload(userPayload);
|
|
53
|
+
if (!currentUser) {
|
|
54
|
+
return this.errorResult('Unable to determine current user');
|
|
55
|
+
}
|
|
56
|
+
if (!sourceID || sourceID.trim().length === 0) {
|
|
57
|
+
return this.errorResult('sourceID is required');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
LogStatus(`GenerateSeedTaxonomy: sourceID=${sourceID} sampleSize=${sampleSize ?? 'default'}`);
|
|
61
|
+
|
|
62
|
+
const result: SeedTaxonomyResult = await TagEngine.Instance.generateSeedTaxonomy(
|
|
63
|
+
sourceID,
|
|
64
|
+
sampleSize ?? 0,
|
|
65
|
+
currentUser
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
Success: true,
|
|
70
|
+
Method: result.Method,
|
|
71
|
+
SampleSize: result.SampleSize,
|
|
72
|
+
NodesJSON: JSON.stringify(result.Nodes),
|
|
73
|
+
ErrorMessage: result.Message,
|
|
74
|
+
};
|
|
75
|
+
} catch (error) {
|
|
76
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
77
|
+
LogError(`GenerateSeedTaxonomy mutation failed: ${msg}`);
|
|
78
|
+
return this.errorResult(msg);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private errorResult(message: string): GenerateSeedTaxonomyResult {
|
|
83
|
+
return {
|
|
84
|
+
Success: false,
|
|
85
|
+
ErrorMessage: message,
|
|
86
|
+
SampleSize: 0,
|
|
87
|
+
NodesJSON: '[]',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Resolver, Mutation, Arg, Ctx, ObjectType, InputType, Field, Float, Int } from 'type-graphql';
|
|
2
|
+
import { AppContext } from '../types.js';
|
|
3
|
+
import { LogError, LogStatus, UserInfo, IMetadataProvider } from '@memberjunction/core';
|
|
4
|
+
import { ResolverBase } from '../generic/ResolverBase.js';
|
|
5
|
+
import { GetReadWriteProvider } from '../util.js';
|
|
6
|
+
import {
|
|
7
|
+
ClusteringEngine,
|
|
8
|
+
EntityDocumentVectorSource,
|
|
9
|
+
ClusterConfig,
|
|
10
|
+
ClusterAlgorithm,
|
|
11
|
+
ClusterDistanceMetric,
|
|
12
|
+
} from '@memberjunction/clustering-engine';
|
|
13
|
+
|
|
14
|
+
/* ───── GraphQL input ───── */
|
|
15
|
+
|
|
16
|
+
@InputType()
|
|
17
|
+
export class RunClusterAnalysisInput {
|
|
18
|
+
@Field({ nullable: true })
|
|
19
|
+
EntityName?: string;
|
|
20
|
+
|
|
21
|
+
@Field({ nullable: true })
|
|
22
|
+
EntityID?: string;
|
|
23
|
+
|
|
24
|
+
@Field({ nullable: true })
|
|
25
|
+
EntityDocumentID?: string;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Multi-entity clustering: one or more Entity Document IDs whose vectors are
|
|
29
|
+
* merged into a single analysis. Takes precedence over EntityDocumentID when
|
|
30
|
+
* non-empty. All selected docs must share the same embedding model (the engine
|
|
31
|
+
* hard-blocks mismatches).
|
|
32
|
+
*/
|
|
33
|
+
@Field(() => [String], { nullable: true })
|
|
34
|
+
EntityDocumentIDs?: string[];
|
|
35
|
+
|
|
36
|
+
/** Legend mode: color points by 'cluster' (default) or 'entity'. */
|
|
37
|
+
@Field({ nullable: true })
|
|
38
|
+
ColorBy?: string;
|
|
39
|
+
|
|
40
|
+
/** Clustering algorithm: 'kmeans' or 'dbscan'. */
|
|
41
|
+
@Field({ nullable: true })
|
|
42
|
+
Algorithm?: string;
|
|
43
|
+
|
|
44
|
+
/** Number of clusters for K-Means. */
|
|
45
|
+
@Field(() => Int, { nullable: true })
|
|
46
|
+
K?: number;
|
|
47
|
+
|
|
48
|
+
/** Epsilon neighbourhood radius for DBSCAN. */
|
|
49
|
+
@Field(() => Float, { nullable: true })
|
|
50
|
+
Epsilon?: number;
|
|
51
|
+
|
|
52
|
+
/** Minimum points to form a dense region in DBSCAN. */
|
|
53
|
+
@Field(() => Int, { nullable: true })
|
|
54
|
+
MinPoints?: number;
|
|
55
|
+
|
|
56
|
+
/** Distance metric: 'cosine', 'euclidean' or 'dotproduct'. */
|
|
57
|
+
@Field({ nullable: true })
|
|
58
|
+
DistanceMetric?: string;
|
|
59
|
+
|
|
60
|
+
/** Maximum number of records to fetch for clustering. */
|
|
61
|
+
@Field(() => Int, { nullable: true })
|
|
62
|
+
MaxRecords?: number;
|
|
63
|
+
|
|
64
|
+
/** Optional SQL-style filter applied by the vector source. */
|
|
65
|
+
@Field({ nullable: true })
|
|
66
|
+
Filter?: string;
|
|
67
|
+
|
|
68
|
+
/** When true, runs the optional LLM cluster-naming step. */
|
|
69
|
+
@Field({ nullable: true })
|
|
70
|
+
NameClusters?: boolean;
|
|
71
|
+
|
|
72
|
+
/** Number of dimensions for the projected layout (2 or 3). Defaults to 2. */
|
|
73
|
+
@Field(() => Int, { nullable: true })
|
|
74
|
+
Dimensions?: number;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* When supplied, the result is persisted as an `MJ: Cluster Analysis` row
|
|
78
|
+
* with this Name and the returned `AnalysisID` is populated.
|
|
79
|
+
*/
|
|
80
|
+
@Field({ nullable: true })
|
|
81
|
+
PersistName?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ───── GraphQL output ───── */
|
|
85
|
+
|
|
86
|
+
@ObjectType()
|
|
87
|
+
export class RunClusterAnalysisResult {
|
|
88
|
+
@Field()
|
|
89
|
+
Success: boolean;
|
|
90
|
+
|
|
91
|
+
@Field({ nullable: true })
|
|
92
|
+
ErrorMessage?: string;
|
|
93
|
+
|
|
94
|
+
/** Number of clusters discovered. */
|
|
95
|
+
@Field(() => Int)
|
|
96
|
+
ClusterCount: number;
|
|
97
|
+
|
|
98
|
+
/** Number of records that were clustered. */
|
|
99
|
+
@Field(() => Int)
|
|
100
|
+
RecordCount: number;
|
|
101
|
+
|
|
102
|
+
/** Number of outlier points (DBSCAN only). */
|
|
103
|
+
@Field(() => Int)
|
|
104
|
+
OutlierCount: number;
|
|
105
|
+
|
|
106
|
+
/** Silhouette score (-1 .. 1). */
|
|
107
|
+
@Field(() => Float)
|
|
108
|
+
SilhouetteScore: number;
|
|
109
|
+
|
|
110
|
+
/** Wall-clock computation time in milliseconds. */
|
|
111
|
+
@Field(() => Float)
|
|
112
|
+
ComputationTimeMs: number;
|
|
113
|
+
|
|
114
|
+
/** JSON-serialized {@link ClusterMetrics}. */
|
|
115
|
+
@Field()
|
|
116
|
+
MetricsJSON: string;
|
|
117
|
+
|
|
118
|
+
/** JSON-serialized array of {@link ClusterInfo}. */
|
|
119
|
+
@Field()
|
|
120
|
+
ClustersJSON: string;
|
|
121
|
+
|
|
122
|
+
/** JSON-serialized array of {@link ClusterPoint} (can be large). */
|
|
123
|
+
@Field()
|
|
124
|
+
PointsJSON: string;
|
|
125
|
+
|
|
126
|
+
/** The persisted `MJ: Cluster Analysis` ID when a PersistName was supplied. */
|
|
127
|
+
@Field({ nullable: true })
|
|
128
|
+
AnalysisID?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ───── Resolver ───── */
|
|
132
|
+
|
|
133
|
+
@Resolver()
|
|
134
|
+
export class RunClusterAnalysisResolver extends ResolverBase {
|
|
135
|
+
@Mutation(() => RunClusterAnalysisResult)
|
|
136
|
+
async RunClusterAnalysis(
|
|
137
|
+
@Arg('input', () => RunClusterAnalysisInput) input: RunClusterAnalysisInput,
|
|
138
|
+
@Ctx() context: AppContext = {} as AppContext
|
|
139
|
+
): Promise<RunClusterAnalysisResult> {
|
|
140
|
+
try {
|
|
141
|
+
const currentUser = this.GetUserFromPayload(context.userPayload);
|
|
142
|
+
if (!currentUser) {
|
|
143
|
+
return this.errorResult('Unable to determine current user');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Per-request provider so reads/writes use this user's connection +
|
|
147
|
+
// security context (multi-user safe), not the process global.
|
|
148
|
+
const provider = GetReadWriteProvider(context.providers) as unknown as IMetadataProvider;
|
|
149
|
+
const config = this.buildConfig(input);
|
|
150
|
+
const engine = new ClusteringEngine();
|
|
151
|
+
const vectorSource = new EntityDocumentVectorSource(currentUser, provider);
|
|
152
|
+
|
|
153
|
+
LogStatus(
|
|
154
|
+
`RunClusterAnalysis: algorithm=${config.Algorithm} entity=${config.EntityName ?? config.EntityID ?? config.EntityDocumentID}`
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const result = await engine.RunPipeline(config, vectorSource, currentUser);
|
|
158
|
+
|
|
159
|
+
let analysisID: string | undefined;
|
|
160
|
+
if (input.PersistName && input.PersistName.trim().length > 0) {
|
|
161
|
+
analysisID = await this.persistAnalysis(engine, result, config, input.PersistName.trim(), currentUser, provider);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
Success: true,
|
|
166
|
+
ClusterCount: result.Metrics.ClusterCount,
|
|
167
|
+
RecordCount: result.Metrics.RecordCount,
|
|
168
|
+
OutlierCount: result.Metrics.OutlierCount,
|
|
169
|
+
SilhouetteScore: result.Metrics.SilhouetteScore,
|
|
170
|
+
ComputationTimeMs: result.Metrics.ComputationTimeMs,
|
|
171
|
+
MetricsJSON: JSON.stringify(result.Metrics),
|
|
172
|
+
ClustersJSON: JSON.stringify(result.Clusters),
|
|
173
|
+
PointsJSON: JSON.stringify(result.Points),
|
|
174
|
+
AnalysisID: analysisID,
|
|
175
|
+
};
|
|
176
|
+
} catch (error) {
|
|
177
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
178
|
+
LogError(`RunClusterAnalysis mutation failed: ${msg}`);
|
|
179
|
+
return this.errorResult(msg);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Build a {@link ClusterConfig} from the GraphQL input, applying defaults. */
|
|
184
|
+
private buildConfig(input: RunClusterAnalysisInput): ClusterConfig {
|
|
185
|
+
return {
|
|
186
|
+
EntityName: input.EntityName ?? '',
|
|
187
|
+
EntityID: input.EntityID ?? '',
|
|
188
|
+
EntityDocumentID: input.EntityDocumentID ?? '',
|
|
189
|
+
EntityDocumentIDs: (input.EntityDocumentIDs ?? []).filter((id) => !!id && id.trim().length > 0),
|
|
190
|
+
ColorBy: input.ColorBy === 'entity' ? 'entity' : 'cluster',
|
|
191
|
+
Algorithm: this.normalizeAlgorithm(input.Algorithm),
|
|
192
|
+
K: input.K ?? 4,
|
|
193
|
+
Epsilon: input.Epsilon ?? 0.3,
|
|
194
|
+
MinPoints: input.MinPoints ?? 3,
|
|
195
|
+
DistanceMetric: this.normalizeMetric(input.DistanceMetric),
|
|
196
|
+
MaxRecords: input.MaxRecords ?? 500,
|
|
197
|
+
Filter: input.Filter ?? '',
|
|
198
|
+
NameClusters: input.NameClusters ?? false,
|
|
199
|
+
Dimensions: input.Dimensions === 3 ? 3 : 2,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Coerce a raw algorithm string to a supported {@link ClusterAlgorithm}. */
|
|
204
|
+
private normalizeAlgorithm(value: string | undefined): ClusterAlgorithm {
|
|
205
|
+
return value?.toLowerCase() === 'dbscan' ? 'dbscan' : 'kmeans';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Coerce a raw metric string to a supported {@link ClusterDistanceMetric}. */
|
|
209
|
+
private normalizeMetric(value: string | undefined): ClusterDistanceMetric {
|
|
210
|
+
const v = value?.toLowerCase();
|
|
211
|
+
if (v === 'euclidean' || v === 'dotproduct') {
|
|
212
|
+
return v;
|
|
213
|
+
}
|
|
214
|
+
return 'cosine';
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Persist the result and return the new analysis ID (or undefined on failure). */
|
|
218
|
+
private async persistAnalysis(
|
|
219
|
+
engine: ClusteringEngine,
|
|
220
|
+
result: Awaited<ReturnType<ClusteringEngine['RunPipeline']>>,
|
|
221
|
+
config: ClusterConfig,
|
|
222
|
+
persistName: string,
|
|
223
|
+
currentUser: UserInfo,
|
|
224
|
+
provider?: IMetadataProvider
|
|
225
|
+
): Promise<string | undefined> {
|
|
226
|
+
const owner = { Name: persistName, UserID: currentUser.ID };
|
|
227
|
+
const savedID = await engine.SaveAnalysis(result, config, owner, currentUser, provider);
|
|
228
|
+
if (!savedID) {
|
|
229
|
+
LogError(`RunClusterAnalysis: failed to persist analysis "${persistName}".`);
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
return savedID;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private errorResult(message: string): RunClusterAnalysisResult {
|
|
236
|
+
return {
|
|
237
|
+
Success: false,
|
|
238
|
+
ErrorMessage: message,
|
|
239
|
+
ClusterCount: 0,
|
|
240
|
+
RecordCount: 0,
|
|
241
|
+
OutlierCount: 0,
|
|
242
|
+
SilhouetteScore: 0,
|
|
243
|
+
ComputationTimeMs: 0,
|
|
244
|
+
MetricsJSON: '{}',
|
|
245
|
+
ClustersJSON: '[]',
|
|
246
|
+
PointsJSON: '[]',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -1,9 +1,45 @@
|
|
|
1
|
-
import { AppContext, Arg, Ctx, Int, Query, Resolver } from '@memberjunction/server';
|
|
2
|
-
import {
|
|
1
|
+
import { AppContext, Arg, Ctx, FieldResolver, Int, PubSub, PubSubEngine, Query, Resolver, Root } from '@memberjunction/server';
|
|
2
|
+
import { UUIDsEqual } from '@memberjunction/global';
|
|
3
|
+
import { MJUser_, MJUserRole_, MJUserResolverBase } from '../generated/generated.js';
|
|
3
4
|
import { GetReadOnlyProvider } from '../util.js';
|
|
4
5
|
|
|
5
6
|
@Resolver(MJUser_)
|
|
6
7
|
export class UserResolver extends MJUserResolverBase {
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Roles for the current user row.
|
|
11
|
+
*
|
|
12
|
+
* Overrides the generated field resolver to support ANONYMOUS magic-link sessions: those
|
|
13
|
+
* resolve to a shared Anonymous principal that holds NO roles in the database (by design —
|
|
14
|
+
* persisting them would let anonymous sessions accrete privileges across links). The roles
|
|
15
|
+
* are synthesized per-request in {@link buildMagicLinkSessionUser} and live only on the
|
|
16
|
+
* request's `UserInfo`. For the session's OWN user row we serve those synthesized roles
|
|
17
|
+
* directly, skipping the generated DB query (which returns empty) and its `MJ: User Roles`
|
|
18
|
+
* read-permission check (which the restricted anon role wouldn't pass). Every other case —
|
|
19
|
+
* named users, named magic-link users, listing other users' roles — falls through to the
|
|
20
|
+
* generated resolver unchanged, so there is no behavioral or security change off this path.
|
|
21
|
+
*/
|
|
22
|
+
@FieldResolver(() => [MJUserRole_])
|
|
23
|
+
async MJUserRoles_UserIDArray(@Root() mjuser_: MJUser_, @Ctx() context: AppContext, @PubSub() pubSub: PubSubEngine) {
|
|
24
|
+
const sessionUser = context.userPayload?.userRecord;
|
|
25
|
+
if (sessionUser?.IsMagicLinkAnonymous && UUIDsEqual(mjuser_.ID, sessionUser.ID)) {
|
|
26
|
+
const now = new Date();
|
|
27
|
+
// Build rows with the entity's RAW field names, then run them through the same
|
|
28
|
+
// name→code-name mapping the generated resolver uses (e.g. __mj_CreatedAt →
|
|
29
|
+
// _mj__CreatedAt). Returning raw names directly trips GraphQL's non-nullable check.
|
|
30
|
+
const rows = (sessionUser.UserRoles ?? []).map((r: { UserID: string; RoleID: string; RoleName?: string; Role?: string }) => ({
|
|
31
|
+
ID: sessionUser.ID,
|
|
32
|
+
UserID: r.UserID,
|
|
33
|
+
RoleID: r.RoleID,
|
|
34
|
+
User: sessionUser.Name,
|
|
35
|
+
Role: r.RoleName ?? r.Role,
|
|
36
|
+
__mj_CreatedAt: now,
|
|
37
|
+
__mj_UpdatedAt: now,
|
|
38
|
+
}));
|
|
39
|
+
return this.ArrayMapFieldNamesToCodeNames('MJ: User Roles', rows, sessionUser);
|
|
40
|
+
}
|
|
41
|
+
return super.MJUserRoles_UserIDArray(mjuser_, context, pubSub);
|
|
42
|
+
}
|
|
7
43
|
@Query(() => MJUser_)
|
|
8
44
|
async CurrentUser(@Ctx() context: AppContext) {
|
|
9
45
|
// Check API key scope authorization for user read (self)
|