@memberjunction/server 6.1.0-edge.5 → 6.1.0-edge.6
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/README.md +6 -2
- package/dist/.tsbuildinfo +1 -1
- package/dist/agentSessions/SessionJanitor.js +2 -2
- package/dist/agentSessions/SessionJanitor.js.map +1 -1
- package/dist/auth/exampleNewUserSubClass.d.ts.map +1 -1
- package/dist/auth/exampleNewUserSubClass.js +9 -4
- package/dist/auth/exampleNewUserSubClass.js.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
- package/dist/auth/magicLink/MagicLinkService.js +2 -8
- package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
- package/dist/auth/newUsers.d.ts.map +1 -1
- package/dist/auth/newUsers.js +4 -12
- package/dist/auth/newUsers.js.map +1 -1
- package/dist/auth/principals.d.ts +92 -0
- package/dist/auth/principals.d.ts.map +1 -0
- package/dist/auth/principals.js +193 -0
- package/dist/auth/principals.js.map +1 -0
- package/dist/config.d.ts +70 -12
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +32 -4
- package/dist/config.js.map +1 -1
- package/dist/generated/graphql-schemas/__mj.d.ts +5347 -5291
- package/dist/generated/graphql-schemas/__mj.d.ts.map +1 -1
- package/dist/generated/graphql-schemas/__mj.js +21098 -20793
- package/dist/generated/graphql-schemas/__mj.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +35 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +128 -2
- 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 +16 -2
- package/dist/index.js.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.d.ts.map +1 -1
- package/dist/realtimeWidget/WidgetSessionService.js +2 -12
- package/dist/realtimeWidget/WidgetSessionService.js.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.d.ts.map +1 -1
- package/dist/realtimeWidget/visitorIdentity.js +20 -5
- package/dist/realtimeWidget/visitorIdentity.js.map +1 -1
- package/dist/resolvers/FileCategoryResolver.d.ts.map +1 -1
- package/dist/resolvers/FileCategoryResolver.js +1 -2
- package/dist/resolvers/FileCategoryResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +2 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts +13 -8
- package/dist/resolvers/RemoteBrowserActionResolver.d.ts.map +1 -1
- package/dist/resolvers/RemoteBrowserActionResolver.js +45 -8
- package/dist/resolvers/RemoteBrowserActionResolver.js.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.d.ts.map +1 -1
- package/dist/resolvers/SyncRolesUsersResolver.js +11 -2
- package/dist/resolvers/SyncRolesUsersResolver.js.map +1 -1
- package/dist/resolvers/UserViewResolver.d.ts.map +1 -1
- package/dist/resolvers/UserViewResolver.js +8 -4
- package/dist/resolvers/UserViewResolver.js.map +1 -1
- package/dist/rest/EntityCRUDHandler.d.ts +2 -1
- package/dist/rest/EntityCRUDHandler.d.ts.map +1 -1
- package/dist/rest/EntityCRUDHandler.js +3 -18
- package/dist/rest/EntityCRUDHandler.js.map +1 -1
- package/dist/rest/RESTEndpointHandler.d.ts +2 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +3 -16
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/telephony/TeamsMeetingsRouter.js +18 -2
- package/dist/telephony/TeamsMeetingsRouter.js.map +1 -1
- package/package.json +92 -91
- package/src/__tests__/EntityCRUDHandler.test.ts +18 -5
- package/src/__tests__/RemoteBrowserAudioStream.test.ts +81 -2
- package/src/__tests__/ResolverBase.filterEscaping.test.ts +127 -3
- package/src/__tests__/UserViewResolver.updateWhereClause.test.ts +114 -0
- package/src/__tests__/WidgetSessionService.test.ts +4 -1
- package/src/__tests__/newUserRolesDefault.test.ts +81 -0
- package/src/__tests__/newUsers.test.ts +36 -14
- package/src/__tests__/principals.callSites.test.ts +112 -0
- package/src/__tests__/principals.logging.test.ts +160 -0
- package/src/__tests__/principals.test.ts +305 -0
- package/src/__tests__/resolverBase.refusalMessage.test.ts +88 -0
- package/src/__tests__/visitorIdentity.resolve.test.ts +61 -0
- package/src/agentSessions/SessionJanitor.ts +2 -2
- package/src/auth/exampleNewUserSubClass.ts +9 -6
- package/src/auth/magicLink/MagicLinkService.ts +2 -8
- package/src/auth/newUsers.ts +7 -15
- package/src/auth/principals.ts +257 -0
- package/src/config.ts +32 -4
- package/src/generated/graphql-schemas/__mj.ts +13937 -13726
- package/src/generic/ResolverBase.ts +157 -2
- package/src/index.ts +17 -2
- package/src/realtimeWidget/WidgetSessionService.ts +2 -14
- package/src/realtimeWidget/visitorIdentity.ts +21 -6
- package/src/resolvers/FileCategoryResolver.ts +2 -3
- package/src/resolvers/IntegrationDiscoveryResolver.ts +2 -0
- package/src/resolvers/QuerySystemUserResolver.ts +1 -1
- package/src/resolvers/RemoteBrowserActionResolver.ts +47 -8
- package/src/resolvers/SyncRolesUsersResolver.ts +11 -2
- package/src/resolvers/UserViewResolver.ts +8 -4
- package/src/rest/EntityCRUDHandler.ts +3 -19
- package/src/rest/RESTEndpointHandler.ts +3 -17
- package/src/telephony/TeamsMeetingsRouter.ts +21 -2
|
@@ -32,6 +32,8 @@ import { RunViewGenericParams, UserPayload } from '../types.js';
|
|
|
32
32
|
import { RunDynamicViewInput, RunViewByIDInput, RunViewByNameInput } from './RunViewResolver.js';
|
|
33
33
|
import { DeleteOptionsInput } from './DeleteOptionsInput.js';
|
|
34
34
|
import { MJEvent, MJEventType, MJGlobal, ENCRYPTED_SENTINEL, EscapeSQLString, IsValueEncrypted, IsOnlyTimezoneShift } from '@memberjunction/global';
|
|
35
|
+
import { SQLParser } from '@memberjunction/sql-parser';
|
|
36
|
+
import { PostgreSQLDialect, SQLServerDialect, type SQLParserDialect } from '@memberjunction/sql-dialect';
|
|
35
37
|
import { EncryptionEngine } from '@memberjunction/encryption';
|
|
36
38
|
import { PUSH_STATUS_UPDATES_TOPIC, publishStatusUpdate } from './PushStatusResolver.js';
|
|
37
39
|
import { CACHE_INVALIDATION_TOPIC } from './CacheInvalidationResolver.js';
|
|
@@ -798,6 +800,129 @@ export class ResolverBase {
|
|
|
798
800
|
}
|
|
799
801
|
}
|
|
800
802
|
|
|
803
|
+
/**
|
|
804
|
+
* SECURITY — GraphQL-boundary screen for one client-supplied SQL clause fragment.
|
|
805
|
+
*
|
|
806
|
+
* `ValidateUserProvidedSQLClause` still blocks stacked statements, DML, comments, UNION
|
|
807
|
+
* and WAITFOR, and still permits SELECT (server-internal engines pass richer filters).
|
|
808
|
+
* A keyword ban on SELECT/EXISTS at this boundary (#4253) broke first-party clients that
|
|
809
|
+
* use `IN (SELECT … FROM <entity base view>)` — that is a legitimate ExtraFilter.
|
|
810
|
+
*
|
|
811
|
+
* This screen uses `@memberjunction/sql-parser` (same wrap as EDS `assertReadOnlyClause`):
|
|
812
|
+
* wrap the fragment as a single SELECT, fail closed if it does not parse as a read, then
|
|
813
|
+
* allow a FROM only when it is an entity **BaseView**. Base tables (`Meeting`, `__mj.User`)
|
|
814
|
+
* and catalogs are rejected. Server-internal RunView callers never hit this.
|
|
815
|
+
*
|
|
816
|
+
* RLS is applied by RunView as an outer WHERE around the entity being queried, not compiled
|
|
817
|
+
* into the view. Subqueries against another entity's BaseView therefore do not inherit that
|
|
818
|
+
* entity's RLS; they are still restricted to the view (not the table).
|
|
819
|
+
*/
|
|
820
|
+
protected assertClientClauseUsesEntityBaseViews(
|
|
821
|
+
clause: string | undefined | null,
|
|
822
|
+
label: string,
|
|
823
|
+
provider?: IMetadataProvider,
|
|
824
|
+
): void {
|
|
825
|
+
if (!clause?.trim()) return;
|
|
826
|
+
|
|
827
|
+
const dialect = this.dialectForProvider(provider);
|
|
828
|
+
if (SQLParser.HasStackedStatements(clause, dialect)) {
|
|
829
|
+
throw new Error(`Invalid ${label}: multiple statements are not permitted in client-supplied filters`);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const wrapped =
|
|
833
|
+
label === 'OrderBy'
|
|
834
|
+
? `SELECT 1 FROM __mj_clause_screen ORDER BY ${clause}`
|
|
835
|
+
: `SELECT 1 FROM __mj_clause_screen WHERE (${clause})`;
|
|
836
|
+
const parser = new SQLParser(wrapped, dialect);
|
|
837
|
+
if (!parser.IsValid || parser.HasWriteStatement || parser.StatementKind !== 'select') {
|
|
838
|
+
throw new Error(
|
|
839
|
+
`Invalid ${label}: not a safe read-only filter fragment — refusing under uncertainty`,
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (this.astContainsWriteNode(parser.AST)) {
|
|
843
|
+
throw new Error(`Invalid ${label}: write/DDL nested in a subquery is not permitted`);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const allowed = this.entityBaseViewAllowList(provider);
|
|
847
|
+
const tables = SQLParser.ExtractTableRefs(wrapped, dialect);
|
|
848
|
+
for (const t of tables) {
|
|
849
|
+
const table = this.stripSqlIdent(t.TableName);
|
|
850
|
+
const schema = this.stripSqlIdent(t.SchemaName);
|
|
851
|
+
if (table.toLowerCase() === '__mj_clause_screen') continue;
|
|
852
|
+
const qualified = `${schema}.${table}`.toLowerCase();
|
|
853
|
+
const bare = table.toLowerCase();
|
|
854
|
+
if (allowed.qualified.has(qualified) || allowed.bare.has(bare)) continue;
|
|
855
|
+
throw new Error(
|
|
856
|
+
`Invalid ${label}: subquery must use an entity base view, not '${schema}.${table}'`,
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Applies {@link assertClientClauseUsesEntityBaseViews} to every client-supplied clause
|
|
863
|
+
* a view request can carry. GraphQL entry points (RunViewByName, RunViewByID,
|
|
864
|
+
* RunDynamicView, RunViews) funnel through RunViewGenericInternal / RunViewsGenericInternal.
|
|
865
|
+
*/
|
|
866
|
+
protected screenClientViewClauses(
|
|
867
|
+
clauses: {
|
|
868
|
+
extraFilter?: string | null;
|
|
869
|
+
orderBy?: string | null;
|
|
870
|
+
userSearchString?: string | null;
|
|
871
|
+
overrideExcludeFilter?: string | null;
|
|
872
|
+
},
|
|
873
|
+
provider?: IMetadataProvider,
|
|
874
|
+
): void {
|
|
875
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.extraFilter, 'ExtraFilter', provider);
|
|
876
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.orderBy, 'OrderBy', provider);
|
|
877
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.userSearchString, 'UserSearchString', provider);
|
|
878
|
+
this.assertClientClauseUsesEntityBaseViews(clauses.overrideExcludeFilter, 'OverrideExcludeFilter', provider);
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** Same write-node walk as EDS `sqlReadOnlyScreen.astContainsWriteNode`. */
|
|
882
|
+
private astContainsWriteNode(node: unknown): boolean {
|
|
883
|
+
if (!node || typeof node !== 'object') return false;
|
|
884
|
+
if (Array.isArray(node)) return node.some((n) => this.astContainsWriteNode(n));
|
|
885
|
+
const obj = node as Record<string, unknown>;
|
|
886
|
+
const type = obj.type;
|
|
887
|
+
if (typeof type === 'string' && ResolverBase.WRITE_NODE_TYPES.has(type.toLowerCase())) {
|
|
888
|
+
return true;
|
|
889
|
+
}
|
|
890
|
+
return Object.values(obj).some((v) => this.astContainsWriteNode(v));
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
private static readonly WRITE_NODE_TYPES = new Set<string>([
|
|
894
|
+
'insert', 'update', 'delete', 'merge', 'replace', 'drop', 'create', 'alter', 'truncate',
|
|
895
|
+
'rename', 'call', 'exec', 'execute', 'grant', 'revoke', 'use', 'load', 'copy', 'do',
|
|
896
|
+
]);
|
|
897
|
+
|
|
898
|
+
private dialectForProvider(provider?: IMetadataProvider): SQLParserDialect {
|
|
899
|
+
const name = provider?.constructor?.name ?? '';
|
|
900
|
+
if (/postgres/i.test(name)) return new PostgreSQLDialect();
|
|
901
|
+
return new SQLServerDialect();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
private stripSqlIdent(name: string | null | undefined): string {
|
|
905
|
+
if (!name) return '';
|
|
906
|
+
return name.replace(/^\[|\]$/g, '').replace(/^"|"$/g, '').replace(/^`|`$/g, '');
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private entityBaseViewAllowList(provider?: IMetadataProvider): {
|
|
910
|
+
qualified: Set<string>;
|
|
911
|
+
bare: Set<string>;
|
|
912
|
+
} {
|
|
913
|
+
const qualified = new Set<string>();
|
|
914
|
+
const bare = new Set<string>();
|
|
915
|
+
const entities = provider?.Entities ?? [];
|
|
916
|
+
for (const e of entities) {
|
|
917
|
+
const view = this.stripSqlIdent(e.BaseView);
|
|
918
|
+
if (!view) continue;
|
|
919
|
+
const schema = this.stripSqlIdent(e.SchemaName);
|
|
920
|
+
bare.add(view.toLowerCase());
|
|
921
|
+
if (schema) qualified.add(`${schema}.${view}`.toLowerCase());
|
|
922
|
+
}
|
|
923
|
+
return { qualified, bare };
|
|
924
|
+
}
|
|
925
|
+
|
|
801
926
|
/**
|
|
802
927
|
* Optimized RunViewGenericInternal implementation with:
|
|
803
928
|
* - Field filtering at source (Fix #7)
|
|
@@ -829,6 +954,13 @@ export class ResolverBase {
|
|
|
829
954
|
try {
|
|
830
955
|
if (!viewInfo || !userPayload) return null;
|
|
831
956
|
|
|
957
|
+
// SECURITY: GraphQL ExtraFilter may contain IN (SELECT … FROM <base view>).
|
|
958
|
+
// Screen at this boundary: parse, reject writes, allow only entity BaseViews.
|
|
959
|
+
this.screenClientViewClauses(
|
|
960
|
+
{ extraFilter, orderBy, userSearchString, overrideExcludeFilter },
|
|
961
|
+
provider as unknown as IMetadataProvider,
|
|
962
|
+
);
|
|
963
|
+
|
|
832
964
|
// Check API key scope authorization for view operations
|
|
833
965
|
await this.CheckAPIKeyScopeAuthorization('view:run', viewInfo.Entity, userPayload);
|
|
834
966
|
|
|
@@ -990,6 +1122,17 @@ export class ResolverBase {
|
|
|
990
1122
|
|
|
991
1123
|
// Transform parameters
|
|
992
1124
|
for (const param of params) {
|
|
1125
|
+
// SECURITY: same GraphQL-boundary BaseView screen as RunViewGenericInternal.
|
|
1126
|
+
this.screenClientViewClauses(
|
|
1127
|
+
{
|
|
1128
|
+
extraFilter: param.extraFilter,
|
|
1129
|
+
orderBy: param.orderBy,
|
|
1130
|
+
userSearchString: param.userSearchString,
|
|
1131
|
+
overrideExcludeFilter: param.overrideExcludeFilter,
|
|
1132
|
+
},
|
|
1133
|
+
md,
|
|
1134
|
+
);
|
|
1135
|
+
|
|
993
1136
|
if (param.viewInfo) {
|
|
994
1137
|
// Validate entity only once per entity type
|
|
995
1138
|
const entityName = param.viewInfo.Entity;
|
|
@@ -1384,7 +1527,15 @@ export class ResolverBase {
|
|
|
1384
1527
|
// MapFieldNamesToCodeNames now handles encryption filtering as well
|
|
1385
1528
|
return await this.MapFieldNamesToCodeNames(entityName, entityObject.GetAll(), userInfo);
|
|
1386
1529
|
} else {
|
|
1387
|
-
|
|
1530
|
+
// CompleteMessage, not Message. A validation refusal puts its reasons in `Errors` and leaves
|
|
1531
|
+
// `Message` unset, so reading `Message` here fell through to 'Unknown error' and discarded
|
|
1532
|
+
// every field-named explanation the entity had just produced. CreateRecord already read
|
|
1533
|
+
// CompleteMessage; this path did not, which is why the SAME rule on the SAME entity explained
|
|
1534
|
+
// itself on a create and said nothing on an update. CompleteMessage is a strict superset — it
|
|
1535
|
+
// starts from `Message`, then appends `Error` and `Errors` — so nothing previously reported is
|
|
1536
|
+
// lost, and it still yields undefined when there is genuinely nothing to say, leaving the
|
|
1537
|
+
// fallback below to fire rather than showing the user a blank error.
|
|
1538
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1388
1539
|
extensions: { code: 'SAVE_ENTITY_ERROR', entityName },
|
|
1389
1540
|
});
|
|
1390
1541
|
}
|
|
@@ -1614,7 +1765,11 @@ export class ResolverBase {
|
|
|
1614
1765
|
// Cache invalidation is now handled globally by the MJGlobal listener in index.ts
|
|
1615
1766
|
return returnValue;
|
|
1616
1767
|
} else {
|
|
1617
|
-
|
|
1768
|
+
// CompleteMessage, for the same reason as the update path above. It matters more going
|
|
1769
|
+
// forward: apps already refuse deletes by overriding Delete() and returning false with the
|
|
1770
|
+
// reason on LatestResult, and #3971 proposes a first-class delete-validation seam. Every one
|
|
1771
|
+
// of those reasons was being replaced by 'Unknown error' at the API boundary.
|
|
1772
|
+
throw new GraphQLError(entityObject.LatestResult?.CompleteMessage ?? 'Unknown error', {
|
|
1618
1773
|
extensions: { code: 'DELETE_ENTITY_ERROR', entityName },
|
|
1619
1774
|
});
|
|
1620
1775
|
}
|
package/src/index.ts
CHANGED
|
@@ -127,6 +127,14 @@ export {
|
|
|
127
127
|
} from './auth/index.js';
|
|
128
128
|
export * from './auth/APIKeyScopeAuth.js';
|
|
129
129
|
export * from './auth/actingContextResolver.js';
|
|
130
|
+
// The context-user ladder (#4209). Public because `auth/exampleNewUserSubClass.ts` — the template
|
|
131
|
+
// integrators are told to copy into their OWN package — resolves through it, and `package.json`
|
|
132
|
+
// publishes only "."; without this the example compiles here and cannot be reused anywhere else.
|
|
133
|
+
// `ReportedMisconfigurationCount` / `MAX_REPORTED_MISCONFIGURATIONS` are deliberately NOT here:
|
|
134
|
+
// they exist so the LRU's bound is assertable, the tests import them from the module directly, and
|
|
135
|
+
// a published export is a maintenance commitment no caller asked for.
|
|
136
|
+
export { ResolveConfiguredPrincipal, resolvePrincipalFrom } from './auth/principals.js';
|
|
137
|
+
export type { ResolvablePrincipal, PrincipalResolution, PrincipalResolutionReason } from './auth/principals.js';
|
|
130
138
|
export { CloneUserForSessionContext } from './auth/sessionUserClone.js';
|
|
131
139
|
|
|
132
140
|
export * from './generic/PushStatusResolver.js';
|
|
@@ -1265,17 +1273,24 @@ export const serve = async (resolverPaths: Array<string>, app: Application = cre
|
|
|
1265
1273
|
// because they lack Access-Control-Allow-Origin headers, preventing the
|
|
1266
1274
|
// client from reading the error code and triggering token refresh.
|
|
1267
1275
|
const corsAllowed = configInfo.cors?.allowedOrigins ?? ['*'];
|
|
1276
|
+
const corsWildcard = corsAllowed.includes('*');
|
|
1277
|
+
// SECURITY: never combine credentials with a wildcard/reflect-any-origin policy. When
|
|
1278
|
+
// allowedOrigins is ['*'] the origin callback reflects the caller's Origin, and pairing that
|
|
1279
|
+
// with Access-Control-Allow-Credentials: true lets any site a signed-in user visits make
|
|
1280
|
+
// credentialed cross-origin reads. MJ's primary auth is a Bearer token (not auto-sent
|
|
1281
|
+
// cross-origin), so dropping credentials under the wildcard default is safe; deployments that
|
|
1282
|
+
// genuinely need credentialed CORS must configure an explicit allowedOrigins list.
|
|
1268
1283
|
app.use(cors<cors.CorsRequest>({
|
|
1269
1284
|
origin: (origin, callback) => {
|
|
1270
1285
|
// Allow all origins when ['*'] (default/backward-compatible),
|
|
1271
1286
|
// or when no Origin header (server-to-server calls).
|
|
1272
|
-
if (
|
|
1287
|
+
if (corsWildcard || !origin || corsAllowed.includes(origin)) {
|
|
1273
1288
|
callback(null, true);
|
|
1274
1289
|
} else {
|
|
1275
1290
|
callback(new Error(`Origin ${origin} not allowed by CORS`));
|
|
1276
1291
|
}
|
|
1277
1292
|
},
|
|
1278
|
-
credentials: configInfo.cors?.allowCredentials ?? true,
|
|
1293
|
+
credentials: corsWildcard ? false : (configInfo.cors?.allowCredentials ?? true),
|
|
1279
1294
|
maxAge: configInfo.cors?.maxAge ?? 86400,
|
|
1280
1295
|
}));
|
|
1281
1296
|
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { Metadata, RunView, UserInfo, LogError, LogStatus } from '@memberjunction/core';
|
|
18
18
|
import { MJLruCache, UUIDsEqual } from '@memberjunction/global';
|
|
19
19
|
import type { MJConversationWidgetInstanceEntity, MJConversationEntity } from '@memberjunction/core-entities';
|
|
20
|
-
import {
|
|
20
|
+
import { ResolveConfiguredPrincipal } from '../auth/principals.js';
|
|
21
21
|
import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
|
|
22
22
|
import { generateSessionId } from '../auth/magicLink/magicLinkCore.js';
|
|
23
23
|
import { MagicLinkService } from '../auth/magicLink/MagicLinkService.js';
|
|
@@ -694,19 +694,7 @@ export class WidgetSessionService {
|
|
|
694
694
|
|
|
695
695
|
/** Resolves the server-side context user used to READ widget config (not the guest principal). */
|
|
696
696
|
private resolveLookupUser(): UserInfo | null {
|
|
697
|
-
|
|
698
|
-
if (candidate) {
|
|
699
|
-
const byName = UserCache.Instance.UserByName(candidate);
|
|
700
|
-
if (byName) {
|
|
701
|
-
return byName;
|
|
702
|
-
}
|
|
703
|
-
LogError(`[Widget] Configured lookup user '${candidate}' not found; falling back to system/Owner.`);
|
|
704
|
-
}
|
|
705
|
-
return (
|
|
706
|
-
UserCache.Instance.GetSystemUser() ??
|
|
707
|
-
UserCache.Users.find((u) => u.IsActive && u.Type?.trim().toLowerCase() === 'owner') ??
|
|
708
|
-
null
|
|
709
|
-
);
|
|
697
|
+
return ResolveConfiguredPrincipal(this.config.contextUserForLookup, 'Widget');
|
|
710
698
|
}
|
|
711
699
|
|
|
712
700
|
/**
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* @module @memberjunction/server/widget
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
-
import { RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
|
|
27
|
+
import { CompositeKey, RunView, UserInfo, LogError, LogStatus, type IMetadataProvider } from '@memberjunction/core';
|
|
28
28
|
import type { MJConversationEntity, MJAIAgentNoteEntity } from '@memberjunction/core-entities';
|
|
29
29
|
|
|
30
30
|
const CONVERSATIONS_ENTITY = 'MJ: Conversations';
|
|
@@ -60,17 +60,24 @@ export async function resolveIdentityByEmail(
|
|
|
60
60
|
}
|
|
61
61
|
const entityName = target?.entityName?.trim() || 'Users';
|
|
62
62
|
const emailField = target?.emailField?.trim() || 'Email';
|
|
63
|
-
const
|
|
64
|
-
if (!
|
|
63
|
+
const entityInfo = provider.EntityByName(entityName);
|
|
64
|
+
if (!entityInfo) {
|
|
65
65
|
LogError(`[VisitorIdentity] identity-resolution entity '${entityName}' not found in metadata.`);
|
|
66
66
|
return undefined;
|
|
67
67
|
}
|
|
68
|
+
const entityId = entityInfo.ID;
|
|
69
|
+
// The identity entity is configurable, so its key column(s) can have any name — and may be
|
|
70
|
+
// composite. Select every key column and carry the record id in the compact record-id form
|
|
71
|
+
// (the bare value for a single-column key, "F1|v1||F2|v2" for a composite one) that
|
|
72
|
+
// CompositeKey.FromURLSegment reads back, so LinkedRecordID / PrimaryScopeRecordID round-trip
|
|
73
|
+
// for any key shape instead of being truncated to the first column.
|
|
74
|
+
const pkNames = entityInfo.PrimaryKeys.map(pk => pk.Name);
|
|
68
75
|
const rv = new RunView();
|
|
69
|
-
const result = await rv.RunView<
|
|
76
|
+
const result = await rv.RunView<Record<string, unknown>>(
|
|
70
77
|
{
|
|
71
78
|
EntityName: entityName,
|
|
72
79
|
ExtraFilter: `${emailField} = '${trimmed.replace(/'/g, "''")}'`,
|
|
73
|
-
Fields:
|
|
80
|
+
Fields: pkNames,
|
|
74
81
|
MaxRows: 1,
|
|
75
82
|
ResultType: 'simple',
|
|
76
83
|
},
|
|
@@ -80,7 +87,15 @@ export async function resolveIdentityByEmail(
|
|
|
80
87
|
LogError(`[VisitorIdentity] identity lookup failed for '${entityName}.${emailField}': ${result.ErrorMessage}`);
|
|
81
88
|
return undefined;
|
|
82
89
|
}
|
|
83
|
-
const
|
|
90
|
+
const row = result.Results?.[0];
|
|
91
|
+
if (!row) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const key = CompositeKey.FromEntityRecord(entityInfo, row);
|
|
95
|
+
if (key.KeyValuePairs.length === 0 || key.KeyValuePairs.some(kv => kv.Value == null)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const recordId = key.ToCompactURLSegment();
|
|
84
99
|
return recordId ? { entityId, recordId } : undefined;
|
|
85
100
|
} catch (e) {
|
|
86
101
|
LogError(`[VisitorIdentity] resolveIdentityByEmail failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -14,9 +14,8 @@ export class FileCategoryResolver extends FileCategoryResolverBase {
|
|
|
14
14
|
@Arg('options___', () => DeleteOptionsInput) options: DeleteOptionsInput,
|
|
15
15
|
@Ctx() { providers, userPayload }: AppContext
|
|
16
16
|
) {
|
|
17
|
-
const key =
|
|
18
|
-
|
|
19
|
-
const p = GetReadWriteProvider(providers);
|
|
17
|
+
const key = CompositeKey.FromID(ID); // first-pk-ok: this mutation deletes from MJ: File Categories (core entity keyed by ID) — see the GetEntityObject below
|
|
18
|
+
const p = GetReadWriteProvider(providers);
|
|
20
19
|
|
|
21
20
|
if (!(await this.BeforeDelete(p, key))) {
|
|
22
21
|
return null;
|
|
@@ -3,6 +3,7 @@ import { CompositeKey, DatabaseProviderBase, EntityInfo, LocalCacheManager, Meta
|
|
|
3
3
|
import { GetReadOnlyProvider, GetReadWriteProvider } from "../util.js";
|
|
4
4
|
import { NoLog } from "../logging/NoLog.js";
|
|
5
5
|
import { UUIDsEqual } from "@memberjunction/global";
|
|
6
|
+
import { DrainResponseBody } from "@memberjunction/network-utils";
|
|
6
7
|
import { CronExpressionHelper } from "@memberjunction/scheduling-engine";
|
|
7
8
|
import {
|
|
8
9
|
MJCompanyIntegrationEntity,
|
|
@@ -6472,6 +6473,7 @@ export class IntegrationDiscoveryResolver extends ResolverBase {
|
|
|
6472
6473
|
body: JSON.stringify(payload)
|
|
6473
6474
|
});
|
|
6474
6475
|
if (!response.ok) {
|
|
6476
|
+
await DrainResponseBody(response);
|
|
6475
6477
|
console.error(`[Integration] Webhook POST to ${url} returned ${response.status}`);
|
|
6476
6478
|
}
|
|
6477
6479
|
} catch (e) {
|
|
@@ -590,7 +590,7 @@ export class MJQueryResolverExtended extends MJQueryResolver {
|
|
|
590
590
|
}
|
|
591
591
|
|
|
592
592
|
const provider = GetReadWriteProvider(context.providers);
|
|
593
|
-
const key =
|
|
593
|
+
const key = CompositeKey.FromID(ID); // first-pk-ok: deletes from MJ: Queries (core entity keyed by ID) — see DeleteRecord below
|
|
594
594
|
|
|
595
595
|
// Provide default options if none provided
|
|
596
596
|
const deleteOptions = options || {
|
|
@@ -230,6 +230,20 @@ interface VisualInterpreterPayload {
|
|
|
230
230
|
* (the process-wide singleton) backs every request; ownership is enforced per call against the session's
|
|
231
231
|
* `UserID`.
|
|
232
232
|
*/
|
|
233
|
+
/**
|
|
234
|
+
* Safety cap on how long a {@link RemoteBrowserActionResolver.startedScreencasts} /
|
|
235
|
+
* {@link RemoteBrowserActionResolver.startedAudioStreams} entry is trusted without confirmation.
|
|
236
|
+
*
|
|
237
|
+
* `RemoteBrowserActionResolver` is instantiated once per process by type-graphql's default resolver
|
|
238
|
+
* container, so these idempotency maps live as long as the server does. `StopRemoteBrowserScreencast` /
|
|
239
|
+
* `StopRemoteBrowserAudioStream` are the only code paths that ever delete an entry — a session that
|
|
240
|
+
* crashes, disconnects, or times out before calling Stop (a normal occurrence: tab close, network drop,
|
|
241
|
+
* agent timeout) leaves its entry behind forever. This TTL bounds that growth the same way
|
|
242
|
+
* {@link RemoteBrowserGoalRegistry}'s own sweep bounds its run records, without requiring a dedicated
|
|
243
|
+
* session-end hook that doesn't otherwise exist for this resolver.
|
|
244
|
+
*/
|
|
245
|
+
const MAX_STREAM_ENTRY_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours — generous vs. SessionJanitor's 15-minute idle threshold
|
|
246
|
+
|
|
233
247
|
@Resolver()
|
|
234
248
|
export class RemoteBrowserActionResolver extends ResolverBase {
|
|
235
249
|
/**
|
|
@@ -237,20 +251,43 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
237
251
|
* {@link RemoteBrowserActionResolver.streamKey} (agent session **plus** instance) so a re-issued
|
|
238
252
|
* {@link RemoteBrowserActionResolver.StartRemoteBrowserScreencast} — the surface re-binding after a
|
|
239
253
|
* tab collapse — is idempotent and never stacks two screencasts on the one browser. Entries are
|
|
240
|
-
* removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserScreencast}
|
|
254
|
+
* normally removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserScreencast}; the value is
|
|
255
|
+
* the entry's start time (ms epoch) so {@link RemoteBrowserActionResolver.sweepStreamEntries} can
|
|
256
|
+
* reclaim ones whose session never called Stop.
|
|
241
257
|
*
|
|
242
258
|
* Keyed by agent session ALONE (#3531) this was the idempotency guard turning into a lockout: the
|
|
243
259
|
* first surface's entry made the SECOND surface's bind a silent `Streaming: true` no-op, so a second
|
|
244
260
|
* browser could be started and driven but never watched.
|
|
245
261
|
*/
|
|
246
|
-
private startedScreencasts = new
|
|
262
|
+
private startedScreencasts = new Map<string, number>();
|
|
247
263
|
|
|
248
264
|
/**
|
|
249
265
|
* Surfaces whose live tab-audio stream this resolver has already started, keyed by
|
|
250
|
-
* {@link RemoteBrowserActionResolver.streamKey} for the same reason the screencast
|
|
251
|
-
* are removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserAudioStream}
|
|
266
|
+
* {@link RemoteBrowserActionResolver.streamKey} for the same reason the screencast map is. Entries
|
|
267
|
+
* are normally removed by {@link RemoteBrowserActionResolver.StopRemoteBrowserAudioStream}; see
|
|
268
|
+
* {@link RemoteBrowserActionResolver.startedScreencasts} for why the value is a timestamp.
|
|
269
|
+
*/
|
|
270
|
+
private startedAudioStreams = new Map<string, number>();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Drops any {@link startedScreencasts} / {@link startedAudioStreams} entry older than
|
|
274
|
+
* {@link MAX_STREAM_ENTRY_AGE_MS} — the reclaim path for sessions that never called
|
|
275
|
+
* `StopRemoteBrowserScreencast`/`StopRemoteBrowserAudioStream` (crash, disconnect, timeout). Called
|
|
276
|
+
* on every Start mutation, mirroring {@link RemoteBrowserGoalRegistry.Begin}'s sweep-on-access pattern.
|
|
252
277
|
*/
|
|
253
|
-
private
|
|
278
|
+
private sweepStreamEntries(): void {
|
|
279
|
+
const cutoff = Date.now() - MAX_STREAM_ENTRY_AGE_MS;
|
|
280
|
+
for (const [key, startedAt] of this.startedScreencasts) {
|
|
281
|
+
if (startedAt < cutoff) {
|
|
282
|
+
this.startedScreencasts.delete(key);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const [key, startedAt] of this.startedAudioStreams) {
|
|
286
|
+
if (startedAt < cutoff) {
|
|
287
|
+
this.startedAudioStreams.delete(key);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
254
291
|
|
|
255
292
|
/**
|
|
256
293
|
* Execute ONE browser action relayed from the client-direct realtime session, returning the outcome +
|
|
@@ -569,6 +606,7 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
569
606
|
|
|
570
607
|
// Idempotent PER SURFACE: a re-bind must not stack a second screencast on the one live browser,
|
|
571
608
|
// but a SECOND surface binding for the first time is not a re-bind (#3531).
|
|
609
|
+
this.sweepStreamEntries();
|
|
572
610
|
const streamKey = this.streamKey(agentSessionID, instanceKey);
|
|
573
611
|
if (this.startedScreencasts.has(streamKey)) {
|
|
574
612
|
return { Streaming: true };
|
|
@@ -580,7 +618,7 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
580
618
|
await liveSession.StartScreencast((frame) =>
|
|
581
619
|
this.publishFrame(pubSub, userPayload, agentSessionID, instanceKey, frame, liveSession.GetCurrentUrl()),
|
|
582
620
|
);
|
|
583
|
-
this.startedScreencasts.
|
|
621
|
+
this.startedScreencasts.set(streamKey, Date.now());
|
|
584
622
|
return { Streaming: true };
|
|
585
623
|
} catch (err) {
|
|
586
624
|
if (err instanceof RemoteBrowserCapabilityNotSupportedError) {
|
|
@@ -647,7 +685,8 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
647
685
|
const { contextUser, provider } = this.requireUserAndProvider(userPayload, providers);
|
|
648
686
|
const session = await this.loadOwnedSession(agentSessionID, contextUser, provider);
|
|
649
687
|
|
|
650
|
-
// Idempotent PER SURFACE — same reasoning as the screencast
|
|
688
|
+
// Idempotent PER SURFACE — same reasoning as the screencast map (#3531).
|
|
689
|
+
this.sweepStreamEntries();
|
|
651
690
|
const streamKey = this.streamKey(agentSessionID, instanceKey);
|
|
652
691
|
if (this.startedAudioStreams.has(streamKey)) {
|
|
653
692
|
return { Streaming: true };
|
|
@@ -657,7 +696,7 @@ export class RemoteBrowserActionResolver extends ResolverBase {
|
|
|
657
696
|
try {
|
|
658
697
|
const liveSession = await RemoteBrowserEngine.Instance.StartSessionForAgentSession(agentSessionID, contextUser, providerName, instanceKey);
|
|
659
698
|
await liveSession.StartAudioStream((chunk) => this.publishAudioChunk(pubSub, userPayload, agentSessionID, instanceKey, chunk));
|
|
660
|
-
this.startedAudioStreams.
|
|
699
|
+
this.startedAudioStreams.set(streamKey, Date.now());
|
|
661
700
|
return { Streaming: true };
|
|
662
701
|
} catch (err) {
|
|
663
702
|
if (err instanceof RemoteBrowserCapabilityNotSupportedError) {
|
|
@@ -385,8 +385,17 @@ export class SyncRolesAndUsersResolver {
|
|
|
385
385
|
if (await user.Delete()) {
|
|
386
386
|
return;
|
|
387
387
|
}
|
|
388
|
-
//
|
|
389
|
-
//
|
|
388
|
+
// Fallback when the hard delete does not happen. TWO distinct causes reach here, and this
|
|
389
|
+
// path deliberately treats them the same way:
|
|
390
|
+
// 1. FK constraints — the user has dependent records that block a hard delete.
|
|
391
|
+
// 2. Authorization — `MJUserEntityServer` (issue #4260) refuses a delete outright for any
|
|
392
|
+
// caller whose `Type` is not 'Owner'. This mutation carries `@RequireSystemUser()` and
|
|
393
|
+
// `getSystemUser()` resolves the seeded Owner, so cause 2 does not arise on a default
|
|
394
|
+
// install; it does on a deployment whose system user is not an Owner.
|
|
395
|
+
// Soft-deleting is the right response to (1) and an acceptable one to (2) — but note the
|
|
396
|
+
// subsequent Save() is subject to the SAME guard, so under cause 2 it fails too and the
|
|
397
|
+
// error below is what the operator sees. Read `LatestResult` rather than assuming FK.
|
|
398
|
+
// A failure to soft-delete IS a real error and rolls back.
|
|
390
399
|
user.IsActive = false;
|
|
391
400
|
if (!await user.Save()) {
|
|
392
401
|
throw new Error(`Failed to delete or deactivate user '${user.Email}': ${user.LatestResult?.CompleteMessage ?? 'unknown error'}`);
|
|
@@ -4,7 +4,7 @@ import { AppContext, Arg, Ctx, Int, Query, Resolver, UserPayload } from '@member
|
|
|
4
4
|
import { MJUserView_, MJUserViewResolverBase } from '../generated/generated.js';
|
|
5
5
|
import { UserResolver } from './UserResolver.js';
|
|
6
6
|
import { MJUserViewEntity, MJUserViewEntityExtended } from '@memberjunction/core-entities';
|
|
7
|
-
import { GetReadOnlyProvider } from '../util.js';
|
|
7
|
+
import { GetReadOnlyProvider, GetReadWriteProvider } from '../util.js';
|
|
8
8
|
|
|
9
9
|
@Resolver(MJUserView_)
|
|
10
10
|
export class UserViewResolver extends MJUserViewResolverBase {
|
|
@@ -52,11 +52,15 @@ export class UserViewResolver extends MJUserViewResolverBase {
|
|
|
52
52
|
// this should normally not be a factor but we have this exposed in the GraphQL API so that
|
|
53
53
|
// a dev can force the update if desired from the client. The normal path is just to update
|
|
54
54
|
// filter state which in turn will be used to update the where clause in the entity sub-class.
|
|
55
|
-
|
|
55
|
+
// This endpoint WRITES (Save below), so it must use the read-write provider — on a read-replica
|
|
56
|
+
// deployment the read-only provider would route the save to the replica.
|
|
57
|
+
const p = GetReadWriteProvider(providers, {allowFallbackToReadOnly: true});
|
|
56
58
|
const u = this.GetUserFromPayload(userPayload);
|
|
57
59
|
const viewEntity = <MJUserViewEntityExtended>await p.GetEntityObject('MJ: User Views', u);
|
|
58
|
-
await viewEntity.Load(ID)
|
|
59
|
-
|
|
60
|
+
if (!await viewEntity.Load(ID)) {
|
|
61
|
+
throw new Error(`User View ${ID} not found or access denied`);
|
|
62
|
+
}
|
|
63
|
+
await viewEntity.UpdateWhereClause(true); // force regeneration regardless of dirty state — that is the whole point of this endpoint
|
|
60
64
|
|
|
61
65
|
if (await viewEntity.Save()) {
|
|
62
66
|
return viewEntity.GetAll();
|
|
@@ -254,26 +254,10 @@ export class EntityCRUDHandler {
|
|
|
254
254
|
}
|
|
255
255
|
|
|
256
256
|
/**
|
|
257
|
-
*
|
|
257
|
+
* Build the record key from the `:id` path segment. A single-column key (any column name) is
|
|
258
|
+
* the bare value; a composite key is the URL-encoded `Field1|Value1||Field2|Value2` segment.
|
|
258
259
|
*/
|
|
259
260
|
private static createCompositeKeyFromId(entity: BaseEntity, id: string | number): CompositeKey {
|
|
260
|
-
|
|
261
|
-
// Single primary key
|
|
262
|
-
const primaryKeyField = entity.EntityInfo.PrimaryKeys[0].Name;
|
|
263
|
-
const compositeKey = new CompositeKey();
|
|
264
|
-
const strId = id.toString();
|
|
265
|
-
|
|
266
|
-
// Use key-value pairs instead of SetValue
|
|
267
|
-
compositeKey.KeyValuePairs = [
|
|
268
|
-
{ FieldName: primaryKeyField, Value: strId }
|
|
269
|
-
];
|
|
270
|
-
|
|
271
|
-
return compositeKey;
|
|
272
|
-
} else {
|
|
273
|
-
// Composite primary key - this is a simplification
|
|
274
|
-
// In a real implementation, you would need to parse a composite ID string
|
|
275
|
-
// or accept an object with all primary key values
|
|
276
|
-
throw new Error('Composite primary keys are not supported in this simplified implementation');
|
|
277
|
-
}
|
|
261
|
+
return CompositeKey.FromURLSegment(entity.EntityInfo, id.toString());
|
|
278
262
|
}
|
|
279
263
|
}
|
|
@@ -794,25 +794,11 @@ export class RESTEndpointHandler {
|
|
|
794
794
|
}
|
|
795
795
|
|
|
796
796
|
/**
|
|
797
|
-
*
|
|
797
|
+
* Build the record key from the `:id` path segment. A single-column key (any column name) is
|
|
798
|
+
* the bare value; a composite key is the URL-encoded `Field1|Value1||Field2|Value2` segment.
|
|
798
799
|
*/
|
|
799
800
|
private createCompositeKey(entityInfo: EntityInfo, id: string): CompositeKey {
|
|
800
|
-
|
|
801
|
-
// Single primary key
|
|
802
|
-
const primaryKeyField = entityInfo.PrimaryKeys[0].Name;
|
|
803
|
-
const compositeKey = new CompositeKey();
|
|
804
|
-
|
|
805
|
-
// Use key-value pairs instead of SetValue
|
|
806
|
-
compositeKey.KeyValuePairs = [
|
|
807
|
-
{ FieldName: primaryKeyField, Value: id }
|
|
808
|
-
];
|
|
809
|
-
|
|
810
|
-
return compositeKey;
|
|
811
|
-
} else {
|
|
812
|
-
// Composite primary key
|
|
813
|
-
// This is a simplification - in a real implementation, we would need to handle composite keys properly
|
|
814
|
-
throw new Error('Composite primary keys are not supported in this implementation');
|
|
815
|
-
}
|
|
801
|
+
return CompositeKey.FromURLSegment(entityInfo, id);
|
|
816
802
|
}
|
|
817
803
|
|
|
818
804
|
/**
|
|
@@ -74,14 +74,22 @@ function handleGraphNotification(
|
|
|
74
74
|
res: Response,
|
|
75
75
|
): void {
|
|
76
76
|
const validationToken = readValidationToken(req);
|
|
77
|
+
if (validationToken !== undefined && validationToken.length > 0 && !isWellFormedValidationToken(validationToken)) {
|
|
78
|
+
// Graph's token is a short ASCII sentence plus a request id. Anything else is not Graph, and the
|
|
79
|
+
// echo below must never reflect arbitrary bytes.
|
|
80
|
+
LogStatus('[Meetings][Teams] notification rejected: malformed-validation-token');
|
|
81
|
+
res.status(400).type('text/plain').send('Rejected.');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
77
84
|
const batch = (req.body ?? {}) as GraphNotificationBatch;
|
|
78
85
|
const notifications = Array.isArray(batch.value) ? batch.value : [];
|
|
79
86
|
const clientStates = notifications.map((n) => n.clientState);
|
|
80
87
|
|
|
81
88
|
const verdict = validateGraphNotification(validationToken, config.notificationClientState ?? '', clientStates);
|
|
82
89
|
if (verdict.Kind === 'validation') {
|
|
83
|
-
// The subscription-validation handshake: echo the token verbatim as text/plain 200.
|
|
84
|
-
|
|
90
|
+
// The subscription-validation handshake: echo the token verbatim as text/plain 200. The token was
|
|
91
|
+
// checked by isWellFormedValidationToken above; nosniff makes sure no client reinterprets the echo.
|
|
92
|
+
res.status(200).type('text/plain').set('X-Content-Type-Options', 'nosniff').send(verdict.ValidationToken);
|
|
85
93
|
return;
|
|
86
94
|
}
|
|
87
95
|
if (verdict.Kind === 'reject') {
|
|
@@ -111,6 +119,17 @@ function dispatchNotifications(service: TeamsMeetingsService, notifications: Gra
|
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
|
|
122
|
+
/** Upper bound for a Graph validation token; the real one is roughly a hundred characters. */
|
|
123
|
+
const MAX_VALIDATION_TOKEN_LENGTH = 1024;
|
|
124
|
+
|
|
125
|
+
/** Graph validation tokens are printable ASCII only. */
|
|
126
|
+
const PRINTABLE_ASCII = /^[\x20-\x7E]+$/;
|
|
127
|
+
|
|
128
|
+
/** True when the token is something Graph could have sent: bounded length, printable ASCII. */
|
|
129
|
+
function isWellFormedValidationToken(token: string): boolean {
|
|
130
|
+
return token.length <= MAX_VALIDATION_TOKEN_LENGTH && PRINTABLE_ASCII.test(token);
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
/** Reads the `?validationToken=…` query param (Graph URL-encodes it), when present. */
|
|
115
134
|
function readValidationToken(req: Request): string | undefined {
|
|
116
135
|
const raw = req.query?.validationToken;
|