@memberjunction/server 0.9.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/.eslintignore +5 -0
- package/.eslintrc +24 -0
- package/README.md +141 -0
- package/config.json +15 -0
- package/dist/apolloServer/TransactionPlugin.js +46 -0
- package/dist/apolloServer/TransactionPlugin.js.map +1 -0
- package/dist/apolloServer/index.js +27 -0
- package/dist/apolloServer/index.js.map +1 -0
- package/dist/auth/exampleNewUserSubClass.js +68 -0
- package/dist/auth/exampleNewUserSubClass.js.map +1 -0
- package/dist/auth/index.js +88 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/newUsers.js +67 -0
- package/dist/auth/newUsers.js.map +1 -0
- package/dist/cache.js +11 -0
- package/dist/cache.js.map +1 -0
- package/dist/config.js +56 -0
- package/dist/config.js.map +1 -0
- package/dist/context.js +101 -0
- package/dist/context.js.map +1 -0
- package/dist/directives/Public.js +34 -0
- package/dist/directives/Public.js.map +1 -0
- package/dist/directives/index.js +18 -0
- package/dist/directives/index.js.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/orm.js +23 -0
- package/dist/orm.js.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +74 -0
- package/src/apolloServer/TransactionPlugin.ts +54 -0
- package/src/apolloServer/index.ts +34 -0
- package/src/auth/exampleNewUserSubClass.ts +71 -0
- package/src/auth/index.ts +117 -0
- package/src/auth/newUsers.ts +56 -0
- package/src/cache.ts +10 -0
- package/src/config.ts +67 -0
- package/src/context.ts +105 -0
- package/src/directives/Public.ts +42 -0
- package/src/directives/index.ts +1 -0
- package/src/index.ts +103 -0
- package/src/orm.ts +20 -0
- package/src/types.ts +19 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { JwtHeader, SigningKeyCallback } from 'jsonwebtoken';
|
|
2
|
+
import jwksClient from 'jwks-rsa';
|
|
3
|
+
import { auth0Domain, auth0WebClientID, configInfo, tenantID, webClientID } from '../config';
|
|
4
|
+
import { UserCache } from '@memberjunction/sqlserver-dataprovider';
|
|
5
|
+
import { DataSource } from 'typeorm';
|
|
6
|
+
import { Metadata, UserInfo } from '@memberjunction/core';
|
|
7
|
+
import { NewUserBase } from './newUsers';
|
|
8
|
+
import { MJGlobal } from '@memberjunction/global';
|
|
9
|
+
|
|
10
|
+
const issuers = {
|
|
11
|
+
azure: `https://login.microsoftonline.com/${tenantID}/v2.0`,
|
|
12
|
+
auth0: `https://${auth0Domain}/`,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const validationOptions = {
|
|
16
|
+
[issuers.auth0]: {
|
|
17
|
+
audience: auth0WebClientID,
|
|
18
|
+
jwksUri: `https://${auth0Domain}/.well-known/jwks.json`,
|
|
19
|
+
},
|
|
20
|
+
[issuers.azure]: {
|
|
21
|
+
audience: webClientID,
|
|
22
|
+
jwksUri: `https://login.microsoftonline.com/${tenantID}/discovery/v2.0/keys`,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export class UserPayload {
|
|
27
|
+
aio?: string;
|
|
28
|
+
aud?: string;
|
|
29
|
+
exp?: number;
|
|
30
|
+
iat?: number;
|
|
31
|
+
iss?: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
nbf?: number;
|
|
34
|
+
nonce?: string;
|
|
35
|
+
oid?: string;
|
|
36
|
+
preferred_username?: string;
|
|
37
|
+
rh?: string;
|
|
38
|
+
sub?: string;
|
|
39
|
+
tid?: string;
|
|
40
|
+
uti?: string;
|
|
41
|
+
ver?: string;
|
|
42
|
+
// what about an array of roles???
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const getSigningKeys = (issuer: string) => (header: JwtHeader, cb: SigningKeyCallback) => {
|
|
46
|
+
const jwksUri = validationOptions[issuer].jwksUri;
|
|
47
|
+
|
|
48
|
+
jwksClient({ jwksUri })
|
|
49
|
+
.getSigningKey(header.kid)
|
|
50
|
+
.then((key) => {
|
|
51
|
+
cb(null, 'publicKey' in key ? key.publicKey : key.rsaPublicKey);
|
|
52
|
+
})
|
|
53
|
+
.catch((err) => console.error(err));
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const verifyUserRecord = async (email?: string, firstName?: string, lastName?: string, requestDomain?: string, dataSource?: DataSource, attemptCacheUpdateIfNeeded: boolean = true): Promise<UserInfo | undefined> => {
|
|
57
|
+
if (!email) return undefined;
|
|
58
|
+
|
|
59
|
+
let user = UserCache.Instance.Users.find((u) => {
|
|
60
|
+
if (!u.Email || u.Email.trim() === '') {
|
|
61
|
+
// this condition should never occur. If it doesn throw a console error including the user id
|
|
62
|
+
// DB requires non-null but this is just an extra check and we could in theory have a blank string in the DB
|
|
63
|
+
console.error(`SYSTEM METADATA ISSUE: User ${u.ID} has no email address`);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
return u.Email.toLowerCase().trim() === email.toLowerCase().trim()
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!user) {
|
|
71
|
+
if (configInfo.userHandling.autoCreateNewUsers && firstName && lastName && requestDomain) {
|
|
72
|
+
// check to see if the domain that we have a request coming in from matches one of the domains in the autoCreateNewUsersDomains setting
|
|
73
|
+
const domainMatch: boolean = configInfo.userHandling.newUserAuthorizedDomains.some((domain) => domain.toLowerCase().trim() === requestDomain.toLowerCase().trim());
|
|
74
|
+
if (domainMatch) {
|
|
75
|
+
// we have a domain from the request that matches one of the domains provided by the configuration, so we will create a new user
|
|
76
|
+
console.warn(`User ${email} not found in cache. Attempting to create a new user...`);
|
|
77
|
+
const newUserCreator: NewUserBase = <NewUserBase>MJGlobal.Instance.ClassFactory.CreateInstance(NewUserBase); // this will create the object that handles creating the new user for us
|
|
78
|
+
const newUser = await newUserCreator.createNewUser(firstName, lastName, email);
|
|
79
|
+
if (newUser) {
|
|
80
|
+
// new user worked! we already have the stuff we need for the cache, so no need to go to the DB now, just create a new UserInfo object and use the return value from the createNewUser method
|
|
81
|
+
// to init it, including passing in the role list for the user.
|
|
82
|
+
const initData: any = newUser.GetAll();
|
|
83
|
+
initData.UserRoles = configInfo.userHandling.newUserRoles.map((role) => { return { UserID: initData.ID, RoleName: role } });
|
|
84
|
+
user = new UserInfo(Metadata.Provider, initData);
|
|
85
|
+
UserCache.Instance.Users.push(user);
|
|
86
|
+
console.warn(` >>> New user ${email} created successfully!`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
console.warn(`User ${email} not found in cache. Request domain '${requestDomain}' does not match any of the domains in the newUserAuthorizedDomains setting. NOT creating a new user.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if(!user && configInfo.userHandling.updateCacheWhenNotFound && dataSource && attemptCacheUpdateIfNeeded) {
|
|
94
|
+
// if we get here that means in the above, if we were attempting to create a new user, it did not work, or it wasn't attempted and we have a config that asks us to auto update the cache
|
|
95
|
+
console.warn(`User ${email} not found in cache. Updating cache in attempt to find the user...`);
|
|
96
|
+
|
|
97
|
+
const startTime: number = Date.now();
|
|
98
|
+
await UserCache.Instance.Refresh(dataSource);
|
|
99
|
+
const endTime: number = Date.now();
|
|
100
|
+
const elapsed: number = endTime - startTime;
|
|
101
|
+
|
|
102
|
+
// if elapsed time is less than the delay setting, wait for the additional time to achieve the full delay
|
|
103
|
+
// the below also makes sure we never go more than a 30 second total delay
|
|
104
|
+
const delay = configInfo.userHandling.updateCacheWhenNotFoundDelay ? (configInfo.userHandling.updateCacheWhenNotFoundDelay < 30000 ? configInfo.userHandling.updateCacheWhenNotFoundDelay : 30000) : 0;
|
|
105
|
+
if (elapsed < delay)
|
|
106
|
+
await new Promise(resolve => setTimeout(resolve, delay - elapsed));
|
|
107
|
+
|
|
108
|
+
const finalTime: number = Date.now();
|
|
109
|
+
const finalElapsed: number = finalTime - startTime;
|
|
110
|
+
|
|
111
|
+
console.log(` UserCache updated in ${elapsed}ms, total elapsed time of ${finalElapsed}ms including delay of ${delay}ms (if needed). Attempting to find the user again via recursive call to verifyUserRecord()`);
|
|
112
|
+
return verifyUserRecord(email, firstName, lastName, requestDomain, dataSource, false) // try one more time but do not update cache next time if not found
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return user;
|
|
117
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { LogError, Metadata } from "@memberjunction/core";
|
|
2
|
+
import { RegisterClass } from "@memberjunction/global";
|
|
3
|
+
import { UserCache } from "@memberjunction/sqlserver-dataprovider";
|
|
4
|
+
import { configInfo } from "../config";
|
|
5
|
+
|
|
6
|
+
@RegisterClass(NewUserBase)
|
|
7
|
+
export class NewUserBase {
|
|
8
|
+
public async createNewUser(firstName: string, lastName: string, email: string, linkedRecordType: string = 'None', linkedEntityId?: number, linkedEntityRecordId?: number) {
|
|
9
|
+
try {
|
|
10
|
+
const md = new Metadata();
|
|
11
|
+
const contextUser = UserCache.Instance.Users.find(u => u.Email.trim().toLowerCase() === configInfo?.userHandling?.contextUserForNewUserCreation?.trim().toLowerCase())
|
|
12
|
+
if (!contextUser) {
|
|
13
|
+
LogError(`Failed to load context user ${configInfo?.userHandling?.contextUserForNewUserCreation}, if you've not specified this on your config.json you must do so. This is the user that is contextually used for creating a new user record dynamically.`);
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const u = await md.GetEntityObject('Users', contextUser) // To-Do - change this to be a different defined user for the user creation process
|
|
17
|
+
u.NewRecord();
|
|
18
|
+
u.Name = email;
|
|
19
|
+
u.IsActive = true;
|
|
20
|
+
u.FirstName = firstName;
|
|
21
|
+
u.LastName = lastName;
|
|
22
|
+
u.Email = email;
|
|
23
|
+
u.Type = 'User';
|
|
24
|
+
u.LinkedRecordType = linkedRecordType;
|
|
25
|
+
if (linkedEntityId)
|
|
26
|
+
u.LinkedEntityID = linkedEntityId;
|
|
27
|
+
if (linkedEntityRecordId)
|
|
28
|
+
u.LinkedEntityRecordID = linkedEntityRecordId;
|
|
29
|
+
|
|
30
|
+
if (await u.Save()) {
|
|
31
|
+
// user created, now create however many roles we need to create for this user based on the config settings
|
|
32
|
+
const ur = await md.GetEntityObject('User Roles', contextUser);
|
|
33
|
+
let bSuccess: boolean = true;
|
|
34
|
+
for (const role of configInfo.userHandling.newUserRoles) {
|
|
35
|
+
ur.NewRecord();
|
|
36
|
+
ur.UserID = u.ID;
|
|
37
|
+
ur.RoleName = role;
|
|
38
|
+
bSuccess = bSuccess && await ur.Save();
|
|
39
|
+
}
|
|
40
|
+
if (!bSuccess) {
|
|
41
|
+
LogError(`Failed to create roles for newly created user ${firstName} ${lastName} ${email}`);
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
LogError(`Failed to create new user ${firstName} ${lastName} ${email}`);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return u;
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
LogError(e);
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/cache.ts
ADDED
package/src/config.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import env from 'env-var';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
export const nodeEnv = env.get('NODE_ENV').asString();
|
|
7
|
+
|
|
8
|
+
export const dbHost = env.get('DB_HOST').required().asString();
|
|
9
|
+
export const dbPort = env.get('DB_PORT').default('1433').asPortNumber();
|
|
10
|
+
export const dbUsername = env.get('DB_USERNAME').required().asString();
|
|
11
|
+
export const dbPassword = env.get('DB_PASSWORD').required().asString();
|
|
12
|
+
export const dbDatabase = env.get('DB_DATABASE').required().asString();
|
|
13
|
+
|
|
14
|
+
export const graphqlPort = env.get('PORT').default('4000').asPortNumber();
|
|
15
|
+
export const graphqlRootPath = env.get('ROOT_PATH').default('/').asString();
|
|
16
|
+
|
|
17
|
+
export const webClientID = env.get('WEB_CLIENT_ID').asString();
|
|
18
|
+
export const tenantID = env.get('TENANT_ID').asString();
|
|
19
|
+
|
|
20
|
+
export const enableIntrospection = env.get('ENABLE_INTROSPECTION').default('false').asBool();
|
|
21
|
+
export const websiteRunFromPackage = env.get('WEBSITE_RUN_FROM_PACKAGE').asIntPositive();
|
|
22
|
+
export const userEmailMap = env.get('USER_EMAIL_MAP').default('{}').asJsonObject() as Record<string, string>;
|
|
23
|
+
|
|
24
|
+
export const auth0Domain = env.get('AUTH0_DOMAIN').asString();
|
|
25
|
+
export const auth0WebClientID = env.get('AUTH0_CLIENT_ID').asString();
|
|
26
|
+
export const auth0ClientSecret = env.get('AUTH0_CLIENT_SECRET').asString();
|
|
27
|
+
|
|
28
|
+
export const mj_core_schema = env.get('MJ_CORE_SCHEMA').asString();
|
|
29
|
+
|
|
30
|
+
export const configFile = env.get('CONFIG_FILE').asString();
|
|
31
|
+
|
|
32
|
+
const userHandlingInfoSchema = z.object({
|
|
33
|
+
autoCreateNewUsers: z.boolean(),
|
|
34
|
+
newUserAuthorizedDomains: z.array(z.string()),
|
|
35
|
+
newUserRoles: z.array(z.string()),
|
|
36
|
+
updateCacheWhenNotFound: z.boolean(),
|
|
37
|
+
updateCacheWhenNotFoundDelay: z.number(),
|
|
38
|
+
contextUserForNewUserCreation: z.string(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const databaseSettingsInfoSchema = z.object({
|
|
42
|
+
connectionTimeout: z.number(),
|
|
43
|
+
requestTimeout: z.number(),
|
|
44
|
+
metadataCacheRefreshInterval: z.number(),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const configInfoSchema = z.object({
|
|
48
|
+
userHandling: userHandlingInfoSchema,
|
|
49
|
+
databaseSettings: databaseSettingsInfoSchema,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export type UserHandlingInfo = z.infer<typeof userHandlingInfoSchema>;
|
|
53
|
+
export type DatabaseSettingsInfo = z.infer<typeof databaseSettingsInfoSchema>;
|
|
54
|
+
export type ConfigInfo = z.infer<typeof configInfoSchema>;
|
|
55
|
+
|
|
56
|
+
export const configInfo: ConfigInfo = loadConfig();
|
|
57
|
+
|
|
58
|
+
export function loadConfig() {
|
|
59
|
+
const configPath = configFile ?? path.resolve('..', 'config.json');
|
|
60
|
+
|
|
61
|
+
if (!fs.existsSync(configPath)) {
|
|
62
|
+
throw new Error(`Config file ${configPath} does not exist.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const configData = fs.readFileSync(configPath, 'utf-8');
|
|
66
|
+
return configInfoSchema.parse(JSON.parse(configData));
|
|
67
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { IncomingMessage } from 'http';
|
|
2
|
+
import * as url from 'url';
|
|
3
|
+
import { JwtPayload, VerifyOptions, decode, verify } from 'jsonwebtoken';
|
|
4
|
+
import 'reflect-metadata';
|
|
5
|
+
import { Subject, firstValueFrom } from 'rxjs';
|
|
6
|
+
import { AuthenticationError, AuthorizationError } from 'type-graphql';
|
|
7
|
+
import { DataSource } from 'typeorm';
|
|
8
|
+
import { getSigningKeys, validationOptions, verifyUserRecord } from './auth';
|
|
9
|
+
import { authCache } from './cache';
|
|
10
|
+
import { userEmailMap } from './config';
|
|
11
|
+
import { UserPayload } from './types';
|
|
12
|
+
|
|
13
|
+
const verifyAsync = async (
|
|
14
|
+
issuer: string,
|
|
15
|
+
options: VerifyOptions,
|
|
16
|
+
token: string
|
|
17
|
+
): Promise<JwtPayload> =>
|
|
18
|
+
new Promise((resolve, reject) => {
|
|
19
|
+
verify(token, getSigningKeys(issuer), options, (err, jwt) => {
|
|
20
|
+
if (jwt && typeof jwt !== 'string' && !err) {
|
|
21
|
+
const payload = jwt.payload ?? jwt;
|
|
22
|
+
|
|
23
|
+
console.log(
|
|
24
|
+
`Valid token: ${payload.name} (${
|
|
25
|
+
payload.email ? payload.email : payload.preferred_username
|
|
26
|
+
})`
|
|
27
|
+
); // temporary fix to check preferred_username if email is not present
|
|
28
|
+
resolve(payload);
|
|
29
|
+
} else {
|
|
30
|
+
console.warn('Invalid token');
|
|
31
|
+
reject(err);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const getUserPayload = async (
|
|
37
|
+
bearerToken: string,
|
|
38
|
+
sessionId = 'default',
|
|
39
|
+
dataSource: DataSource,
|
|
40
|
+
requestDomain?: string
|
|
41
|
+
): Promise<UserPayload> => {
|
|
42
|
+
try {
|
|
43
|
+
const token = bearerToken.replace('Bearer ', '');
|
|
44
|
+
|
|
45
|
+
if (!token) {
|
|
46
|
+
console.warn('No token to validate');
|
|
47
|
+
throw new AuthenticationError('Missing token');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const payload = decode(token);
|
|
51
|
+
if (!payload || typeof payload === 'string') {
|
|
52
|
+
throw new AuthenticationError('Invalid token payload');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!authCache.has(token)) {
|
|
56
|
+
const issuer = payload.iss;
|
|
57
|
+
if (!issuer) {
|
|
58
|
+
console.warn('No issuer claim on token');
|
|
59
|
+
throw new AuthenticationError('Missing issuer claim on token');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
await verifyAsync(issuer, validationOptions[issuer], token);
|
|
63
|
+
authCache.set(token, true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const email = payload?.email
|
|
67
|
+
? userEmailMap[payload?.email] ?? payload?.email
|
|
68
|
+
: payload?.preferred_username; // temporary fix to check preferred_username if email is not present
|
|
69
|
+
const fullName = payload?.name;
|
|
70
|
+
const firstName = payload?.given_name || fullName?.split(' ')[0];
|
|
71
|
+
const lastName = payload?.family_name || fullName?.split(' ')[1] || fullName?.split(' ')[0];
|
|
72
|
+
const userRecord = await verifyUserRecord(email, firstName, lastName, requestDomain, dataSource);
|
|
73
|
+
|
|
74
|
+
if (!userRecord) {
|
|
75
|
+
console.error(`User ${email} not found`);
|
|
76
|
+
throw new AuthorizationError();
|
|
77
|
+
}
|
|
78
|
+
else if (!userRecord.IsActive) {
|
|
79
|
+
console.error(`User ${email} found but inactive`);
|
|
80
|
+
throw new AuthorizationError();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return { userRecord, email, sessionId };
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return {} as UserPayload;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export const contextFunction =
|
|
90
|
+
({ setupComplete$, dataSource }: { setupComplete$: Subject<unknown>; dataSource: DataSource }) =>
|
|
91
|
+
async ({ req }: { req: IncomingMessage }) => {
|
|
92
|
+
await firstValueFrom(setupComplete$); // wait for setup to complete before processing the request
|
|
93
|
+
|
|
94
|
+
const sessionIdRaw = req.headers['x-session-id'];
|
|
95
|
+
const requestDomain = url.parse(req.headers.origin || '')
|
|
96
|
+
const sessionId = sessionIdRaw ? sessionIdRaw.toString() : '';
|
|
97
|
+
const bearerToken = req.headers.authorization ?? '';
|
|
98
|
+
|
|
99
|
+
const userPayload = await getUserPayload(bearerToken, sessionId, dataSource, requestDomain?.hostname ? requestDomain.hostname : undefined);
|
|
100
|
+
|
|
101
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
102
|
+
console.log((req as any).body?.operationName);
|
|
103
|
+
|
|
104
|
+
return { dataSource, userPayload };
|
|
105
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { FieldMapper, MapperKind, getDirective, mapSchema } from '@graphql-tools/utils';
|
|
2
|
+
import { GraphQLFieldResolver, defaultFieldResolver } from 'graphql';
|
|
3
|
+
import { AuthorizationError, Directive } from 'type-graphql';
|
|
4
|
+
import { AppContext, DirectiveBuilder } from '../types';
|
|
5
|
+
|
|
6
|
+
const DIRECTIVE_NAME = 'Public';
|
|
7
|
+
|
|
8
|
+
export function Public(): PropertyDecorator & MethodDecorator & ClassDecorator;
|
|
9
|
+
export function Public(): PropertyDecorator | MethodDecorator | ClassDecorator {
|
|
10
|
+
return (targetOrPrototype, propertyKey, descriptor) =>
|
|
11
|
+
Directive(`@${DIRECTIVE_NAME}`)(targetOrPrototype, propertyKey, descriptor);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const publicDirective: DirectiveBuilder = {
|
|
15
|
+
typeDefs: `directive @${DIRECTIVE_NAME} on FIELD_DEFINITION`,
|
|
16
|
+
transformer: (schema) => {
|
|
17
|
+
const fieldMapper: FieldMapper = (fieldConfig) => {
|
|
18
|
+
const directive = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0];
|
|
19
|
+
if (directive) {
|
|
20
|
+
return fieldConfig;
|
|
21
|
+
} else {
|
|
22
|
+
// `@Public` directive not present, so will require auth
|
|
23
|
+
const { resolve = defaultFieldResolver } = fieldConfig;
|
|
24
|
+
const directiveResolver: GraphQLFieldResolver<unknown, AppContext> = async (
|
|
25
|
+
source,
|
|
26
|
+
args,
|
|
27
|
+
context,
|
|
28
|
+
info
|
|
29
|
+
) => {
|
|
30
|
+
// eslint-disable-next-line
|
|
31
|
+
if (!context?.userPayload?.userRecord?.IsActive) {
|
|
32
|
+
throw new AuthorizationError();
|
|
33
|
+
}
|
|
34
|
+
return await resolve(source, args, context, info);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return { ...fieldConfig, resolve: directiveResolver };
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
return mapSchema(schema, { [MapperKind.OBJECT_FIELD]: fieldMapper });
|
|
41
|
+
},
|
|
42
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './Public';
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import dotenv from 'dotenv';
|
|
2
|
+
|
|
3
|
+
dotenv.config();
|
|
4
|
+
|
|
5
|
+
import { expressMiddleware } from '@apollo/server/express4';
|
|
6
|
+
import { mergeSchemas } from '@graphql-tools/schema';
|
|
7
|
+
import { Metadata } from '@memberjunction/core';
|
|
8
|
+
import { setupSQLServerClient, SQLServerProviderConfigData } from '@memberjunction/sqlserver-dataprovider';
|
|
9
|
+
import { json } from 'body-parser';
|
|
10
|
+
import cors from 'cors';
|
|
11
|
+
import express from 'express';
|
|
12
|
+
import { globSync } from 'fast-glob';
|
|
13
|
+
import { useServer } from 'graphql-ws/lib/use/ws';
|
|
14
|
+
import { createServer } from 'node:http';
|
|
15
|
+
import 'reflect-metadata';
|
|
16
|
+
import { ReplaySubject } from 'rxjs';
|
|
17
|
+
import { BuildSchemaOptions, buildSchemaSync, GraphQLTimestamp } from 'type-graphql';
|
|
18
|
+
import { DataSource } from 'typeorm';
|
|
19
|
+
import { WebSocketServer } from 'ws';
|
|
20
|
+
import buildApolloServer from './apolloServer';
|
|
21
|
+
import { configInfo, graphqlPort, graphqlRootPath, mj_core_schema, websiteRunFromPackage } from './config';
|
|
22
|
+
import { contextFunction, getUserPayload } from './context';
|
|
23
|
+
import { publicDirective } from './directives';
|
|
24
|
+
import orm from './orm';
|
|
25
|
+
|
|
26
|
+
const cacheRefreshInterval = configInfo.databaseSettings.metadataCacheRefreshInterval;
|
|
27
|
+
|
|
28
|
+
export { configInfo } from './config';
|
|
29
|
+
export { NewUserBase } from './auth/newUsers';
|
|
30
|
+
export { MaxLength } from 'class-validator';
|
|
31
|
+
export * from './types';
|
|
32
|
+
export * from './directives';
|
|
33
|
+
export * from 'type-graphql';
|
|
34
|
+
|
|
35
|
+
export const serve = async (resolverPaths: Array<string>) => {
|
|
36
|
+
const dataSource = new DataSource(orm);
|
|
37
|
+
const setupComplete$ = new ReplaySubject(1);
|
|
38
|
+
dataSource
|
|
39
|
+
.initialize()
|
|
40
|
+
.then(async () => {
|
|
41
|
+
const config = new SQLServerProviderConfigData(dataSource, '', mj_core_schema, cacheRefreshInterval);
|
|
42
|
+
await setupSQLServerClient(config); // datasource is already initialized, so we can setup the client right away
|
|
43
|
+
const md = new Metadata();
|
|
44
|
+
console.log(`Data Source has been initialized. ${md?.Entities ? md.Entities.length : 0} entities loaded.`);
|
|
45
|
+
|
|
46
|
+
setupComplete$.next(true);
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
console.error('Error during Data Source initialization', err);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const paths = resolverPaths.flatMap((path) => globSync(path));
|
|
53
|
+
const dynamicModules = await Promise.all(paths.map((modulePath) => import(modulePath.replace(/\.[jt]s$/, ''))));
|
|
54
|
+
const resolvers = dynamicModules.flatMap((module) =>
|
|
55
|
+
Object.values(module).filter((value) => typeof value === 'function')
|
|
56
|
+
) as BuildSchemaOptions['resolvers'];
|
|
57
|
+
|
|
58
|
+
const schema = publicDirective.transformer(
|
|
59
|
+
mergeSchemas({
|
|
60
|
+
schemas: [
|
|
61
|
+
buildSchemaSync({
|
|
62
|
+
resolvers,
|
|
63
|
+
validate: false,
|
|
64
|
+
scalarsMap: [{ type: Date, scalar: GraphQLTimestamp }],
|
|
65
|
+
emitSchemaFile: websiteRunFromPackage !== 1,
|
|
66
|
+
}),
|
|
67
|
+
],
|
|
68
|
+
typeDefs: [publicDirective.typeDefs],
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const app = express();
|
|
73
|
+
const httpServer = createServer(app);
|
|
74
|
+
|
|
75
|
+
const webSocketServer = new WebSocketServer({ server: httpServer, path: graphqlRootPath });
|
|
76
|
+
const serverCleanup = useServer(
|
|
77
|
+
{
|
|
78
|
+
schema,
|
|
79
|
+
context: async ({ connectionParams }) => {
|
|
80
|
+
const userPayload = await getUserPayload(String(connectionParams?.Authorization), undefined, dataSource);
|
|
81
|
+
return { userPayload };
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
webSocketServer
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const apolloServer = buildApolloServer({ schema }, { httpServer, serverCleanup });
|
|
88
|
+
await apolloServer.start();
|
|
89
|
+
|
|
90
|
+
app.use(
|
|
91
|
+
graphqlRootPath,
|
|
92
|
+
cors<cors.CorsRequest>(),
|
|
93
|
+
json(),
|
|
94
|
+
expressMiddleware(apolloServer, {
|
|
95
|
+
context: contextFunction({ setupComplete$, dataSource }),
|
|
96
|
+
})
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
await new Promise<void>((resolve) => httpServer.listen({ port: graphqlPort }, resolve));
|
|
100
|
+
console.log(`🚀 Server ready at http://localhost:${graphqlPort}/`);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default serve;
|
package/src/orm.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { configInfo, dbDatabase, dbHost, dbPassword, dbPort, dbUsername } from './config';
|
|
3
|
+
import { DataSourceOptions } from 'typeorm';
|
|
4
|
+
|
|
5
|
+
const orm: DataSourceOptions = {
|
|
6
|
+
type: 'mssql',
|
|
7
|
+
entities: [path.resolve(__dirname, './generated/**')],
|
|
8
|
+
migrations: [path.resolve(__dirname, './migrations/**')],
|
|
9
|
+
logging: false,
|
|
10
|
+
host: dbHost,
|
|
11
|
+
port: dbPort,
|
|
12
|
+
username: dbUsername,
|
|
13
|
+
password: dbPassword,
|
|
14
|
+
database: dbDatabase,
|
|
15
|
+
synchronize: false,
|
|
16
|
+
requestTimeout: configInfo.databaseSettings.requestTimeout,
|
|
17
|
+
connectionTimeout: configInfo.databaseSettings.connectionTimeout,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export default orm;
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { GraphQLSchema } from 'graphql';
|
|
2
|
+
import { DataSource, QueryRunner } from 'typeorm';
|
|
3
|
+
|
|
4
|
+
export type UserPayload = {
|
|
5
|
+
email: string;
|
|
6
|
+
userRecord: any;
|
|
7
|
+
sessionId: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type AppContext = {
|
|
11
|
+
dataSource: DataSource;
|
|
12
|
+
userPayload: UserPayload;
|
|
13
|
+
queryRunner?: QueryRunner;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DirectiveBuilder = {
|
|
17
|
+
typeDefs: string;
|
|
18
|
+
transformer: (schema: GraphQLSchema) => GraphQLSchema;
|
|
19
|
+
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "commonjs",
|
|
4
|
+
"target": "es2020",
|
|
5
|
+
"outDir": "dist",
|
|
6
|
+
"rootDir": "src",
|
|
7
|
+
"sourceMap": true,
|
|
8
|
+
"experimentalDecorators": true,
|
|
9
|
+
"resolveJsonModule": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"lib": ["es2020", "esnext.asynciterable"],
|
|
12
|
+
"moduleResolution": "node",
|
|
13
|
+
"removeComments": true,
|
|
14
|
+
"noImplicitAny": true,
|
|
15
|
+
"strictNullChecks": true,
|
|
16
|
+
"strictFunctionTypes": true,
|
|
17
|
+
"noImplicitThis": true,
|
|
18
|
+
"noUnusedLocals": false,
|
|
19
|
+
"noUnusedParameters": false,
|
|
20
|
+
"noImplicitReturns": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
"allowSyntheticDefaultImports": true,
|
|
23
|
+
"emitDecoratorMetadata": true
|
|
24
|
+
},
|
|
25
|
+
"exclude": ["node_modules"],
|
|
26
|
+
"include": ["./src/**/*.ts"]
|
|
27
|
+
}
|