Merge pull request #1743 from coralproject/next-passport

[next] Passport Integration
This commit is contained in:
Kim Gardner
2018-08-02 11:41:01 +01:00
committed by GitHub
71 changed files with 3079 additions and 395 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ module.exports = {
appPostCssConfig: resolveApp("config/postcss.config.js"),
appJestConfig: resolveApp("config/jest.config.js"),
appLoaders: resolveApp("loaders"),
appDist: resolveApp("dist"),
appDist: resolveApp("dist/static"),
appPublic: resolveApp("public"),
appPackageJson: resolveApp("package.json"),
appSrc: resolveApp("src"),
+8 -2
View File
@@ -10,6 +10,12 @@ import "./env";
const config: Config = {
rootDir: path.resolve(__dirname, "../src"),
watchers: {
compileSchema: {
paths: ["core/server/**/*.graphql"],
executor: new CommandExecutor("npm run compile:schema", {
runOnInit: true,
}),
},
compileRelayStream: {
paths: [
"core/client/stream/**/*.ts",
@@ -49,7 +55,7 @@ const config: Config = {
},
defaultSet: "client",
sets: {
server: ["runServer"],
server: ["compileSchema", "runServer"],
client: [
"runServer",
"runWebpackDevServer",
@@ -57,7 +63,7 @@ const config: Config = {
"compileRelayStream",
],
docz: ["runDocz", "compileCSSTypes"],
compile: ["compileCSSTypes", "compileRelayStream"],
compile: ["compileSchema", "compileCSSTypes", "compileRelayStream"],
},
};
+5 -5
View File
@@ -39,7 +39,7 @@ if (env.stringified["process.env"].NODE_ENV !== '"production"') {
// because of this bug https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/763.
// TODO: Repalce with mini-css-extract-plugin once it supports HMR.
// https://github.com/webpack-contrib/mini-css-extract-plugin
const cssFilename = "static/css/[name].[md5:contenthash:hex:20].css";
const cssFilename = "assets/css/[name].[md5:contenthash:hex:20].css";
// ExtractTextPlugin expects the build output to be flat.
// (See https://github.com/webpack-contrib/extract-text-webpack-plugin/issues/27)
@@ -72,8 +72,8 @@ module.exports = {
// Generated JS file names (with nested folders).
// There will be one main bundle, and one file per asynchronous chunk.
// We don't currently advertise code splitting but Webpack supports it.
filename: "static/js/[name].[chunkhash:8].js",
chunkFilename: "static/js/[name].[chunkhash:8].chunk.js",
filename: "assets/js/[name].[chunkhash:8].js",
chunkFilename: "assets/js/[name].[chunkhash:8].chunk.js",
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: publicPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
@@ -210,7 +210,7 @@ module.exports = {
loader: require.resolve("url-loader"),
options: {
limit: 10000,
name: "static/media/[name].[hash:8].[ext]",
name: "assets/media/[name].[hash:8].[ext]",
},
},
// Process JS with Babel.
@@ -299,7 +299,7 @@ module.exports = {
exclude: [/\.(js|jsx|mjs|ts|tsx)$/, /\.html$/, /\.json$/],
loader: require.resolve("file-loader"),
options: {
name: "static/media/[name].[hash:8].[ext]",
name: "assets/media/[name].[hash:8].[ext]",
},
},
],
+287 -81
View File
@@ -1537,6 +1537,12 @@
"integrity": "sha512-D7VxhADdZbDJ0HjUTMnSQ5xIGb4H2yWpg8k9Sf1T08zfFiQYlaxM8LZydpR4FQ2E6LZJX8IlabNZ5io4vdChwg==",
"dev": true
},
"@types/bcryptjs": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.1.tgz",
"integrity": "sha512-CVJ8ExtzUQJzLJbEk/lWrHD3MTvstTodjWidcH23gCii5WSD0z1TPSLqSdtbn5eCDw+DxfKgoUALi+loe8ftXA==",
"dev": true
},
"@types/bluebird": {
"version": "3.5.21",
"resolved": "https://registry.npmjs.org/@types/bluebird/-/bluebird-3.5.21.tgz",
@@ -1547,7 +1553,6 @@
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz",
"integrity": "sha512-a2+YeUjPkztKJu5aIF2yArYFQQp8d51wZ7DavSHjFuY1mqVgidGyzEQ41JIVNy82fXj8yPgy2vJmfIywgESW6w==",
"dev": true,
"requires": {
"@types/connect": "*",
"@types/node": "*"
@@ -1607,7 +1612,6 @@
"version": "3.4.32",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.32.tgz",
"integrity": "sha512-4r8qa0quOvh7lGD0pre62CAb1oni1OO6ecJLGCezTmhQ8Fz50Arx9RUszryR8KlgK6avuSXvviL6yWyViQABOg==",
"dev": true,
"requires": {
"@types/node": "*"
}
@@ -1658,31 +1662,45 @@
"@types/events": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-1.2.0.tgz",
"integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA==",
"dev": true
"integrity": "sha512-KEIlhXnIutzKwRbQkGWb/I4HFqBuUykAdHgDED6xqwXJfONCjF5VoE0cXEiurh3XauygxzeDzgtXUqvLkxFzzA=="
},
"@types/express": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.16.0.tgz",
"integrity": "sha512-TtPEYumsmSTtTetAPXlJVf3kEqb6wZK0bZojpJQrnD/djV4q1oB6QQ8aKvKqwNPACoe02GNiy5zDzcYivR5Z2w==",
"dev": true,
"requires": {
"@types/body-parser": "*",
"@types/express-serve-static-core": "*",
"@types/serve-static": "*"
}
},
"@types/express-jwt": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/@types/express-jwt/-/express-jwt-0.0.34.tgz",
"integrity": "sha1-/b7kxq9cCiRu8qkz9VGZc8dxfwI=",
"requires": {
"@types/express": "*",
"@types/express-unless": "*"
}
},
"@types/express-serve-static-core": {
"version": "4.16.0",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.0.tgz",
"integrity": "sha512-lTeoCu5NxJU4OD9moCgm0ESZzweAx0YqsAcab6OB0EB3+As1OaHtKnaGJvcngQxYsi9UNv0abn4/DRavrRxt4w==",
"dev": true,
"requires": {
"@types/events": "*",
"@types/node": "*",
"@types/range-parser": "*"
}
},
"@types/express-unless": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/@types/express-unless/-/express-unless-0.0.32.tgz",
"integrity": "sha512-6YpJyFNlDDnPnRjMOvJCoDYlSDDmG/OEEUsPk7yhNkL4G9hUYtgab6vi1CcWsGSSSM0CsvNlWTG+ywAGnvF03g==",
"requires": {
"@types/express": "*"
}
},
"@types/graphql": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/@types/graphql/-/graphql-0.13.3.tgz",
@@ -1723,6 +1741,15 @@
"parse5": "^4.0.0"
}
},
"@types/jsonwebtoken": {
"version": "7.2.8",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-7.2.8.tgz",
"integrity": "sha512-XENN3YzEB8D6TiUww0O8SRznzy1v+77lH7UmuN54xq/IHIsyWjWOzZuFFTtoiRuaE782uAoRwBe/wwow+vQXZw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/lodash": {
"version": "4.14.111",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.111.tgz",
@@ -1738,8 +1765,7 @@
"@types/mime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.0.tgz",
"integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA==",
"dev": true
"integrity": "sha512-A2TAGbTFdBw9azHbpVd+/FkdW2T6msN1uct1O9bH3vTerEHKZhTXJUQXy+hNq1B0RagfU8U+KBdqiZpxjhOUQA=="
},
"@types/mongodb": {
"version": "3.1.1",
@@ -1755,18 +1781,58 @@
"@types/node": {
"version": "10.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-10.5.2.tgz",
"integrity": "sha512-m9zXmifkZsMHZBOyxZWilMwmTlpC8x5Ty360JKTiXvlXZfBWYpsg9ZZvP/Ye+iZUh+Q+MxDLjItVTWIsfwz+8Q==",
"dev": true
"integrity": "sha512-m9zXmifkZsMHZBOyxZWilMwmTlpC8x5Ty360JKTiXvlXZfBWYpsg9ZZvP/Ye+iZUh+Q+MxDLjItVTWIsfwz+8Q=="
},
"@types/oauth": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.0.tgz",
"integrity": "sha512-1oouefxKPGiDkb5m6lNxDkFry3PItCOJ+tlNtEn/gRvWShb2Rb3y0pccOIGwN/AwHUpwsuwlRwSpg7aoCN3bQQ==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/passport": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.5.tgz",
"integrity": "sha512-Ow5akVXwEZlOPCWGbEGy0GX4ocdwKz7JJH1K+BMd/BSOxmJTo2obH2AKbsgcncQvw5z7AGopdIu1Ap/j9sMRnQ==",
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/@types/passport/-/passport-0.4.6.tgz",
"integrity": "sha512-P7TxrdpAze3nvHghYPeLlHkYcFDiIkRBbp7xYz2ehX9zmi1yr/qWQMTpXsMxN5w3ESJpMzn917inK4giASaDcQ==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/passport-local": {
"version": "1.0.33",
"resolved": "https://registry.npmjs.org/@types/passport-local/-/passport-local-1.0.33.tgz",
"integrity": "sha512-+rn6ZIxje0jZ2+DAiWFI8vGG7ZFKB0hXx2cUdMmudSWsigSq6ES7Emso46r4HJk0qCgrZVfI8sJiM7HIYf4SbA==",
"dev": true,
"requires": {
"@types/express": "*",
"@types/passport": "*",
"@types/passport-strategy": "*"
}
},
"@types/passport-oauth2": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.5.tgz",
"integrity": "sha512-q/pT4RKkiHU1W20P2qUAtVmua3bVF1b8Tulag/niDISS+8CGrRthmQxvPhkIVtJb68ssCxn8ck+eagInGuKHDQ==",
"dev": true,
"requires": {
"@types/express": "*",
"@types/oauth": "*",
"@types/passport": "*"
}
},
"@types/passport-strategy": {
"version": "0.2.33",
"resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.33.tgz",
"integrity": "sha512-tmj//XbNqCWmD+PJ/KnxAouircAmMGLN9IHBO3utH5DXuHHHYN4ZG53DRrQBjlZMiS/1b5IP38U2ay1GfbcQrQ==",
"dev": true,
"requires": {
"@types/express": "*",
"@types/passport": "*"
}
},
"@types/query-string": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.1.0.tgz",
@@ -1776,8 +1842,7 @@
"@types/range-parser": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.2.tgz",
"integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw==",
"dev": true
"integrity": "sha512-HtKGu+qG1NPvYe1z7ezLsyIaXYyi8SoAVqWDZgDQ8dLrsZvSzUNCwZyfX33uhWxL/SU0ZDQZ3nwZ0nimt507Kw=="
},
"@types/react": {
"version": "16.4.2",
@@ -1848,7 +1913,6 @@
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.13.2.tgz",
"integrity": "sha512-/BZ4QRLpH/bNYgZgwhKEh+5AsboDBcUdlBYgzoLX0fpj3Y2gp6EApyOlM3bK53wQS/OE1SrdSYBAbux2D1528Q==",
"dev": true,
"requires": {
"@types/express-serve-static-core": "*",
"@types/mime": "*"
@@ -2618,8 +2682,7 @@
"asn1": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz",
"integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=",
"dev": true
"integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y="
},
"asn1.js": {
"version": "4.10.1",
@@ -2661,8 +2724,7 @@
"assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
"dev": true
"integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU="
},
"assign-symbols": {
"version": "1.0.0",
@@ -2705,8 +2767,7 @@
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k="
},
"atob": {
"version": "2.1.1",
@@ -2739,14 +2800,12 @@
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
"integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
"dev": true
"integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg="
},
"aws4": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.7.0.tgz",
"integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w==",
"dev": true
"integrity": "sha512-32NDda82rhwD9/JBCCkB+MRYDp0oSvlo2IL6rQWA10PQi7tDUM3eqMSltXmY+Oyl/7N3P3qNtAlv7X0d9bI28w=="
},
"b3b": {
"version": "0.0.1",
@@ -4585,12 +4644,16 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
"dev": true,
"optional": true,
"requires": {
"tweetnacl": "^0.14.3"
}
},
"bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha1-mrVie5PmBiH/fNrF2pczAn3x0Ms="
},
"big.js": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz",
@@ -4892,6 +4955,11 @@
"isarray": "^1.0.0"
}
},
"buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk="
},
"buffer-from": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz",
@@ -5114,8 +5182,7 @@
"caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
"dev": true
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
"ccount": {
"version": "1.0.3",
@@ -5523,8 +5590,7 @@
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
"integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
"dev": true
"integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ="
},
"coa": {
"version": "1.0.4",
@@ -5613,7 +5679,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz",
"integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
@@ -5856,8 +5921,7 @@
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"cosmiconfig": {
"version": "3.1.0",
@@ -6628,7 +6692,6 @@
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
"integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
"dev": true,
"requires": {
"assert-plus": "^1.0.0"
}
@@ -6837,8 +6900,7 @@
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
},
"delegate": {
"version": "3.2.0",
@@ -7917,12 +7979,19 @@
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.1.tgz",
"integrity": "sha1-D8c6ntXw1Tw4GTOYUj735UN3dQU=",
"dev": true,
"optional": true,
"requires": {
"jsbn": "~0.1.0"
}
},
"ecdsa-sig-formatter": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz",
"integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=",
"requires": {
"safe-buffer": "^5.0.1"
}
},
"ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8532,8 +8601,7 @@
"extend": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.1.tgz",
"integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ=",
"dev": true
"integrity": "sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ="
},
"extend-shallow": {
"version": "3.0.2",
@@ -8647,8 +8715,7 @@
"extsprintf": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
"dev": true
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU="
},
"facepaint": {
"version": "1.2.1",
@@ -9012,14 +9079,12 @@
"forever-agent": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
"integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
"dev": true
"integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE="
},
"form-data": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz",
"integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "1.0.6",
@@ -9707,7 +9772,6 @@
"version": "0.1.7",
"resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
"integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
"dev": true,
"requires": {
"assert-plus": "^1.0.0"
}
@@ -10049,6 +10113,15 @@
"cross-fetch": "2.0.0"
}
},
"graphql-schema-typescript": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/graphql-schema-typescript/-/graphql-schema-typescript-1.2.1.tgz",
"integrity": "sha512-ipZh3Epm/Kqcy6MF5FM6uxwCMFok07q+6qyxFOa7ViRufcjzH9Y3nECmECH5WgqRGl2wR6TmskbZd5qJJrGpoA==",
"dev": true,
"requires": {
"yargs": "^11.0.0"
}
},
"graphql-subscriptions": {
"version": "0.5.8",
"resolved": "https://registry.npmjs.org/graphql-subscriptions/-/graphql-subscriptions-0.5.8.tgz",
@@ -10208,14 +10281,12 @@
"har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
"integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
"dev": true
"integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI="
},
"har-validator": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz",
"integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=",
"dev": true,
"requires": {
"ajv": "^5.1.0",
"har-schema": "^2.0.0"
@@ -10225,7 +10296,6 @@
"version": "5.5.2",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz",
"integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=",
"dev": true,
"requires": {
"co": "^4.6.0",
"fast-deep-equal": "^1.0.0",
@@ -10236,14 +10306,12 @@
"fast-deep-equal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz",
"integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=",
"dev": true
"integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ="
},
"json-schema-traverse": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz",
"integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=",
"dev": true
"integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A="
}
}
},
@@ -10622,7 +10690,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
"integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
"dev": true,
"requires": {
"assert-plus": "^1.0.0",
"jsprim": "^1.2.2",
@@ -11499,8 +11566,7 @@
"is-typedarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
"integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
"dev": true
"integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo="
},
"is-utf8": {
"version": "0.2.1",
@@ -11595,8 +11661,7 @@
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
"dev": true
"integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo="
},
"istanbul-api": {
"version": "1.3.1",
@@ -13489,7 +13554,6 @@
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
"dev": true,
"optional": true
},
"jsdom": {
@@ -13553,8 +13617,7 @@
"json-schema": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz",
"integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=",
"dev": true
"integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM="
},
"json-schema-traverse": {
"version": "0.4.1",
@@ -13565,8 +13628,7 @@
"json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
"dev": true
"integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus="
},
"json3": {
"version": "3.3.2",
@@ -13596,11 +13658,33 @@
"integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=",
"dev": true
},
"jsonwebtoken": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.3.0.tgz",
"integrity": "sha512-oge/hvlmeJCH+iIz1DwcO7vKPkNGJHhgkspk8OH3VKlw+mbi42WtD4ig1+VXRln765vxptAv+xT26Fd3cteqag==",
"requires": {
"jws": "^3.1.5",
"lodash.includes": "^4.3.0",
"lodash.isboolean": "^3.0.3",
"lodash.isinteger": "^4.0.4",
"lodash.isnumber": "^3.0.3",
"lodash.isplainobject": "^4.0.6",
"lodash.isstring": "^4.0.1",
"lodash.once": "^4.0.0",
"ms": "^2.1.1"
},
"dependencies": {
"ms": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz",
"integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg=="
}
}
},
"jsprim": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz",
"integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=",
"dev": true,
"requires": {
"assert-plus": "1.0.0",
"extsprintf": "1.3.0",
@@ -13614,6 +13698,38 @@
"integrity": "sha512-mJVp13Ix6gFo3SBAy9U/kL+oeZqzlYYYLQBwXVBlVzIsZwBqGREnOro24oC/8s8aox+rJhtZ2DiQof++IrkA+g==",
"dev": true
},
"jwa": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-1.1.6.tgz",
"integrity": "sha512-tBO/cf++BUsJkYql/kBbJroKOgHWEigTKBAjjBEmrMGYd1QMBC74Hr4Wo2zCZw6ZrVhlJPvoMrkcOnlWR/DJfw==",
"requires": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.10",
"safe-buffer": "^5.0.1"
}
},
"jwks-rsa": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-1.3.0.tgz",
"integrity": "sha512-9q+d5VffK/FvFAjuXoddrq7zQybFSINV4mcwJJExGKXGyjWWpTt3vsn/aX33aB0heY02LK0qSyicdtRK0gVTig==",
"requires": {
"@types/express-jwt": "0.0.34",
"debug": "^2.2.0",
"limiter": "^1.1.0",
"lru-memoizer": "^1.6.0",
"ms": "^2.0.0",
"request": "^2.73.0"
}
},
"jws": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/jws/-/jws-3.1.5.tgz",
"integrity": "sha512-GsCSexFADNQUr8T5HPJvayTjvPIfoyJPtLQBwn5a4WZQchcrPMPMAWcC1AzJVRDKyD6ZPROPAxgv6rfHViO4uQ==",
"requires": {
"jwa": "^1.1.5",
"safe-buffer": "^5.0.1"
}
},
"keygrip": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.2.tgz",
@@ -13855,6 +13971,11 @@
"type-check": "~0.3.2"
}
},
"limiter": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.3.tgz",
"integrity": "sha512-zrycnIMsLw/3ZxTbW7HCez56rcFGecWTx5OZNplzcXUUmJLmoYArC6qdJzmAN5BWiNXGcpjhF9RQ1HSv5zebEw=="
},
"load-cfg": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.5.6.tgz",
@@ -13980,6 +14101,11 @@
"path-exists": "^3.0.0"
}
},
"lock": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/lock/-/lock-0.1.4.tgz",
"integrity": "sha1-/sfervF+fDoKVeHaBCgD4l2RdF0="
},
"lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.10.tgz",
@@ -14061,11 +14187,41 @@
"integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=",
"dev": true
},
"lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8="
},
"lodash.isboolean": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
"integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY="
},
"lodash.isempty": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.isempty/-/lodash.isempty-4.4.0.tgz",
"integrity": "sha1-b4bL7di+TsmHvpqvM8loTbGzHn4="
},
"lodash.isinteger": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
"integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M="
},
"lodash.isnumber": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
"integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w="
},
"lodash.isplainobject": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs="
},
"lodash.isstring": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
"integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE="
},
"lodash.iteratee": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.iteratee/-/lodash.iteratee-4.7.0.tgz",
@@ -14088,6 +14244,11 @@
"resolved": "https://registry.npmjs.org/lodash.noop/-/lodash.noop-3.0.1.tgz",
"integrity": "sha1-OBiPTWUKOkdCWEObluxFsyYXEzw="
},
"lodash.once": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w="
},
"lodash.partial": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lodash.partial/-/lodash.partial-4.2.1.tgz",
@@ -14245,6 +14406,28 @@
"yallist": "^2.1.2"
}
},
"lru-memoizer": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-1.12.0.tgz",
"integrity": "sha1-7+ZXBsyKnMZT+A8NWm6jitlQ41I=",
"requires": {
"lock": "~0.1.2",
"lodash": "^4.17.4",
"lru-cache": "~4.0.0",
"very-fast-args": "^1.1.0"
},
"dependencies": {
"lru-cache": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.0.2.tgz",
"integrity": "sha1-HRdnnAac2l0ECZGgnbwsDbN35V4=",
"requires": {
"pseudomap": "^1.0.1",
"yallist": "^2.0.0"
}
}
}
},
"luxon": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-1.3.1.tgz",
@@ -15176,11 +15359,15 @@
"integrity": "sha512-Zt6HRR6RcJkuj5/N9zeE7FN6YitRW//hK2wTOwX274IBphbY3Zf5+yn5mZ9v/SzAOTMjQNxZf9KkmPLWn0cV4g==",
"dev": true
},
"oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
"integrity": "sha1-vR/vr2hslrdUda7VGWQS/2DPucE="
},
"oauth-sign": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz",
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=",
"dev": true
"integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM="
},
"object-assign": {
"version": "4.1.1",
@@ -15638,6 +15825,25 @@
"pause": "0.0.1"
}
},
"passport-local": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-local/-/passport-local-1.0.0.tgz",
"integrity": "sha1-H+YyaMkudWBmJkN+O5BmYsFbpu4=",
"requires": {
"passport-strategy": "1.x.x"
}
},
"passport-oauth2": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.4.0.tgz",
"integrity": "sha1-9i+BWDy+EmCb585vFguTlaJ7hq0=",
"requires": {
"oauth": "0.9.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
}
},
"passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
@@ -18178,8 +18384,7 @@
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=",
"dev": true
"integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM="
},
"psl": {
"version": "1.1.28",
@@ -19664,7 +19869,6 @@
"version": "2.87.0",
"resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz",
"integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==",
"dev": true,
"requires": {
"aws-sign2": "~0.7.0",
"aws4": "^1.6.0",
@@ -19691,14 +19895,12 @@
"punycode": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz",
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=",
"dev": true
"integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4="
},
"tough-cookie": {
"version": "2.3.4",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz",
"integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==",
"dev": true,
"requires": {
"punycode": "^1.4.1"
}
@@ -19954,8 +20156,7 @@
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"samsam": {
"version": "1.3.0",
@@ -20571,7 +20772,6 @@
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz",
"integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=",
"dev": true,
"requires": {
"asn1": "~0.2.3",
"assert-plus": "^1.0.0",
@@ -21586,7 +21786,6 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
"integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
"dev": true,
"requires": {
"safe-buffer": "^5.0.1"
}
@@ -21595,7 +21794,6 @@
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
"dev": true,
"optional": true
},
"type-check": {
@@ -21904,6 +22102,11 @@
}
}
},
"uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
"integrity": "sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I="
},
"ulid": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-2.3.0.tgz",
@@ -22397,13 +22600,17 @@
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
"dev": true,
"requires": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"very-fast-args": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/very-fast-args/-/very-fast-args-1.1.0.tgz",
"integrity": "sha1-4W0dH6+KbllqJGQh/ZCneWPQs5Y="
},
"vfile": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-2.3.0.tgz",
@@ -23229,8 +23436,7 @@
"yallist": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz",
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=",
"dev": true
"integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI="
},
"yargs": {
"version": "11.0.0",
+25 -12
View File
@@ -3,46 +3,53 @@
"version": "5.0.0",
"description": "A better commenting experience from Mozilla, The Washington Post, and The New York Times.",
"scripts": {
"start": "node dist/index.js",
"test": "node scripts/test.js --env=jsdom",
"build": "npm-run-all compile --parallel build:*",
"build:client": "node ./scripts/build.js",
"build:server": "tsc -p ./src/tsconfig.json",
"watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts",
"compile": "npm-run-all --parallel compile:*",
"compile:css-types": "tcm src/core/client/",
"compile:relay-stream": "ts-node ./scripts/compileRelay --src ./src/core/client/stream --schema tenant",
"start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts",
"start:webpackDevServer": "node ./scripts/start.js",
"compile:schema": "node ./scripts/generateSchemaTypes.js",
"docz": "docz",
"lint-fix": "npm run lint:server -- --fix && npm run lint:client -- --fix && npm run lint:scripts -- --fix",
"lint": "npm-run-all --parallel lint:*",
"lint:server": "tslint --project ./src/tsconfig.json",
"lint:client": "tslint --project ./src/core/client/tsconfig.json",
"lint:scripts": "tslint --project ./tsconfig.json",
"docz": "docz"
"lint:server": "tslint --project ./src/tsconfig.json",
"lint": "npm-run-all --parallel lint:*",
"start:development": "cross-env NODE_ENV=development ts-node --project ./src/tsconfig.json -r tsconfig-paths/register ./src/index.ts",
"start:webpackDevServer": "node ./scripts/start.js",
"start": "node dist/index.js",
"test": "node scripts/test.js --env=jsdom",
"watch": "cross-env NODE_ENV=development ts-node ./scripts/watcher/bin/watcher.ts --config ./config/watcher.ts"
},
"author": "",
"license": "Apache-2.0",
"dependencies": {
"apollo-server-express": "^1.3.6",
"bcryptjs": "^2.4.3",
"bunyan": "^1.8.12",
"convict": "^4.3.1",
"dataloader": "^1.4.0",
"dotenv": "^6.0.0",
"dotenv-expand": "^4.2.0",
"dotenv": "^6.0.0",
"dotize": "^0.2.0",
"express": "^4.16.3",
"express-static-gzip": "^0.3.2",
"express": "^4.16.3",
"fs-extra": "^6.0.1",
"graphql": "^0.13.2",
"graphql-config": "^2.0.1",
"graphql-redis-subscriptions": "^1.5.0",
"graphql-tools": "^3.0.5",
"graphql": "^0.13.2",
"ioredis": "^3.2.2",
"joi": "^13.4.0",
"jsonwebtoken": "^8.3.0",
"jwks-rsa": "^1.3.0",
"lodash": "^4.17.10",
"luxon": "^1.3.1",
"mongodb": "^3.1.1",
"passport-local": "^1.0.0",
"passport-oauth2": "^1.4.0",
"passport-strategy": "^1.0.0",
"passport": "^0.4.0",
"performance-now": "^2.1.0",
"subscriptions-transport-ws": "^0.9.12",
@@ -55,6 +62,7 @@
"@babel/polyfill": "7.0.0-beta.49",
"@babel/preset-env": "7.0.0-beta.49",
"@babel/preset-react": "7.0.0-beta.49",
"@types/bcryptjs": "^2.4.1",
"@types/bunyan": "^1.8.4",
"@types/chokidar": "^1.7.5",
"@types/classnames": "^2.2.4",
@@ -70,11 +78,15 @@
"@types/jest": "^23.1.5",
"@types/joi": "^13.0.8",
"@types/jsdom": "^11.0.6",
"@types/jsonwebtoken": "^7.2.7",
"@types/lodash": "^4.14.111",
"@types/luxon": "^0.5.3",
"@types/mongodb": "^3.1.1",
"@types/node": "^10.5.2",
"@types/passport": "^0.4.5",
"@types/passport": "^0.4.6",
"@types/passport-local": "^1.0.33",
"@types/passport-oauth2": "^1.4.5",
"@types/passport-strategy": "^0.2.33",
"@types/query-string": "^6.1.0",
"@types/react-dom": "^16.0.6",
"@types/react-relay": "github:coralproject/patched#types/react-relay",
@@ -112,6 +124,7 @@
"fluent-langneg": "^0.1.0",
"fluent-react": "^0.7.0",
"graphql-playground-middleware-express": "^1.7.2",
"graphql-schema-typescript": "^1.2.1",
"html-webpack-plugin": "^3.2.0",
"jest": "^23.4.1",
"jest-junit": "^5.1.0",
+90
View File
@@ -0,0 +1,90 @@
const { Linter, Configuration } = require("tslint");
const { generateTSTypesAsString } = require("graphql-schema-typescript");
const { getGraphQLConfig } = require("graphql-config");
const path = require("path");
const fs = require("fs");
function lintAndWrite(files) {
const linter = new Linter({ fix: true });
for (const { fileName, types } of files) {
const configuration = Configuration.findConfiguration(null, fileName)
.results;
linter.lint(fileName, types, configuration);
}
}
function getFileName(name) {
return path.join(
__dirname,
"../src/core/server/graph",
name,
"schema/__generated__/types.ts"
);
}
async function main() {
const config = getGraphQLConfig(__dirname);
const projects = config.getProjects();
const files = [
{
name: "tenant",
fileName: getFileName("tenant"),
config: {
contextType: "TenantContext",
importStatements: [
'import { Cursor } from "talk-server/models/connection";',
'import TenantContext from "talk-server/graph/tenant/context";',
],
customScalarType: { Cursor: "Cursor", Time: "string" },
},
},
{
name: "management",
fileName: getFileName("management"),
config: {
contextType: "ManagementContext",
importStatements: [
'import ManagementContext from "talk-server/graph/management/context";',
],
},
},
];
for (const file of files) {
// Load the graph schema.
const schema = projects[file.name].getSchema();
// Create the generated directory.
const dir = path.dirname(file.fileName);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir);
}
// Create the types for this file.
file.types = await generateTSTypesAsString(schema, {
tabSpaces: 2,
typePrefix: "GQL",
strictNulls: true,
...file.config,
});
}
// Send the files off to the linter to be linted and written.
lintAndWrite(files);
return files;
}
main()
.then(files => {
for (const { fileName } of files) {
// tslint:disable-next-line:no-console
console.log(`Generated ${fileName}`);
}
})
.catch(err => {
// tslint:disable-next-line:no-console
console.error(err);
});
@@ -9,7 +9,7 @@ import Username from "./Username";
export interface CommentProps {
author: {
username: string;
username: string | null;
} | null;
body: string | null;
createdAt: string;
@@ -19,7 +19,8 @@ const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article">
<TopBar>
{props.author && <Username>{props.author.username}</Username>}
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Typography>{props.body}</Typography>
@@ -19,3 +19,18 @@ it("renders username and body", () => {
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
author: {
username: null,
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
},
};
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders body only 1`] = `
<Comment
author={
Object {
"username": null,
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
/>
`;
exports[`renders username and body 1`] = `
<Comment
author={
@@ -0,0 +1,77 @@
import { RequestHandler } from "express";
import Joi from "joi";
import { Db } from "mongodb";
import { handleSuccessfulLogin } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SignupBody {
username: string;
password: string;
email: string;
displayName?: string;
}
const SignupBodySchema = Joi.object().keys({
username: Joi.string().trim(),
password: Joi.string().trim(),
email: Joi.string().trim(),
});
export interface SignupOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export const signupHandler = (options: SignupOptions): RequestHandler => async (
req: Request,
res,
next
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// Tenant is guaranteed at this point.
const tenant = req.tenant!;
// Check to ensure that the local integration has been enabled.
if (!tenant.auth.integrations.local.enabled) {
// TODO: replace with better error.
return next(new Error("integration is disabled"));
}
// Get the fields from the body. Validate will throw an error if the body
// does not conform to the specification.
const { username, password, email }: SignupBody = validate(
SignupBodySchema,
req.body
);
// Configure with profile.
const profile: LocalProfile = {
id: email,
type: "local",
};
// Create the new user.
const user = await upsert(options.db, tenant, {
email,
username,
password,
profiles: [profile],
// New users signing up via local auth will have the commenter role to
// start with.
role: GQLUSER_ROLE.COMMENTER,
});
// Send off to the passport handler.
return handleSuccessfulLogin(user, options.signingConfig, req, res, next);
} catch (err) {
return next(err);
}
};
+19 -7
View File
@@ -3,14 +3,14 @@ import http from "http";
import { Redis } from "ioredis";
import { Db } from "mongodb";
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
import { createPassport } from "talk-server/app/middleware/passport";
import { JWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import { Config } from "talk-server/config";
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
import { Schemas } from "talk-server/graph/schemas";
import {
access as accessLogger,
error as errorLogger,
} from "./middleware/logging";
import { accessLogger, errorLogger } from "./middleware/logging";
import serveStatic from "./middleware/serveStatic";
import { createRouter } from "./router";
@@ -20,6 +20,7 @@ export interface AppOptions {
mongo: Db;
redis: Redis;
schemas: Schemas;
signingConfig: JWTSigningConfig;
}
/**
@@ -32,13 +33,24 @@ export async function createApp(options: AppOptions): Promise<Express> {
// Logging
parent.use(accessLogger);
// Create some services for the router.
const passport = createPassport({
db: options.mongo,
signingConfig: options.signingConfig,
});
// Mount the router.
parent.use(
await createRouter(options, {
passport,
})
);
// Static Files
parent.use(serveStatic);
// Mount the router.
parent.use(await createRouter(options));
// Error Handling
parent.use(notFoundMiddleware);
parent.use(errorLogger);
return parent;
+6
View File
@@ -0,0 +1,6 @@
import { ErrorRequestHandler } from "express";
export const apiErrorHandler: ErrorRequestHandler = (err, req, res, next) => {
// TODO: handle better when we improve errors.
res.status(500).json({ error: err.message });
};
+5 -4
View File
@@ -1,8 +1,9 @@
import { ErrorRequestHandler, RequestHandler } from "express";
import now from "performance-now";
import logger from "../../logger";
export const access: RequestHandler = (req, res, next) => {
import logger from "talk-server/logger";
export const accessLogger: RequestHandler = (req, res, next) => {
const startTime = now();
const end = res.end;
res.end = (chunk: any, encodingOrCb?: any, cb?: any) => {
@@ -37,7 +38,7 @@ export const access: RequestHandler = (req, res, next) => {
next();
};
export const error: ErrorRequestHandler = (err, req, res, next) => {
logger.error({ err }, "http error");
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
logger.error(err, "http error");
next(err);
};
@@ -0,0 +1,5 @@
import { RequestHandler } from "express";
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
next(new Error("not found"));
};
@@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`createJWTSigningConfig parses a RSA certiciate 1`] = `
"-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV
EnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+
MHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U
C9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2
nQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV
1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS
2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw
fdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD
KrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB
GdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7
0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ
kPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi
55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN
oLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v
lbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6
9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK
8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY
SvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1
rFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr
xqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb
za9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7
1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0
pYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f
RaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt
ySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=
-----END RSA PRIVATE KEY-----"
`;
@@ -1,13 +1,115 @@
import { NextFunction, RequestHandler, Response } from "express";
import { Db } from "mongodb";
import passport, { Authenticator } from "passport";
import {
createJWTStrategy,
JWTSigningConfig,
SigningTokenOptions,
signTokenString,
} from "talk-server/app/middleware/passport/jwt";
import { createLocalStrategy } from "talk-server/app/middleware/passport/local";
import { createOIDCStrategy } from "talk-server/app/middleware/passport/oidc";
import { createSSOStrategy } from "talk-server/app/middleware/passport/sso";
import { User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
export type VerifyCallback = (
err?: Error | null,
user?: User | null,
info?: { message: string }
) => void;
export interface PassportOptions {
db: Db;
signingConfig: JWTSigningConfig;
}
export function createPassport(opts: PassportOptions): passport.Authenticator {
export function createPassport({
db,
signingConfig,
}: PassportOptions): passport.Authenticator {
// Create the authenticator.
const auth = new Authenticator();
// Use the OIDC Strategy.
auth.use(createOIDCStrategy({ db }));
// Use the LocalStrategy.
auth.use(createLocalStrategy({ db }));
// Use the SSOStrategy.
auth.use(createSSOStrategy({ db }));
// Use the JWTStrategy.
auth.use(createJWTStrategy({ db, signingConfig }));
return auth;
}
export async function handleSuccessfulLogin(
user: User,
signingConfig: JWTSigningConfig,
req: Request,
res: Response,
next: NextFunction
) {
try {
// Grab the tenant from the request.
const { tenant } = req;
const options: SigningTokenOptions = {};
if (tenant) {
// Attach the tenant's id to the issued token as a `iss` claim.
options.issuer = tenant.id;
// TODO: (wyattjoh) evaluate the possibility when we have multiple
// integrations per type to use the integration id as the audience.
}
// Grab the token.
const token = await signTokenString(signingConfig, user, options);
// Set the cache control headers.
res.header("Cache-Control", "private, no-cache, no-store, must-revalidate");
res.header("Expires", "-1");
res.header("Pragma", "no-cache");
// Send back the details!
res.json({ token });
} catch (err) {
return next(err);
}
}
/**
* wrapAuthn will wrap a authenticators authenticate method with one that
* will return a valid login token for a valid login by a compatible strategy.
*
* @param authenticator the base authenticator instance
* @param signingConfig used to sign the tokens that are issued.
* @param name the name of the authenticator to use
* @param options any options to be passed to the authenticate call
*/
export const wrapAuthn = (
authenticator: passport.Authenticator,
signingConfig: JWTSigningConfig,
name: string,
options?: any
): RequestHandler => (req: Request, res, next) =>
authenticator.authenticate(
name,
{ ...options, session: false },
(err: Error | null, user: User | null) => {
if (err) {
return next(err);
}
if (!user) {
// TODO: (wyattjoh) replace with better error.
return next(new Error("no user on request"));
}
handleSuccessfulLogin(user, signingConfig, req, res, next);
}
)(req, res, next);
@@ -0,0 +1,84 @@
import sinon from "sinon";
import {
createJWTSigningConfig,
extractJWTFromRequest,
parseAuthHeader,
} from "talk-server/app/middleware/passport/jwt";
import { Config } from "talk-server/config";
import { Request } from "talk-server/types/express";
describe("parseAuthHeader", () => {
it("parses valid headers", () => {
const parsed = {
scheme: "bearer",
value: "token",
};
expect(parseAuthHeader("Bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
expect(parseAuthHeader("bearer token")).toEqual(parsed);
});
it("parses invalid headers", () => {
expect(parseAuthHeader("this-is-a-wrong-header")).toEqual(null);
expect(parseAuthHeader("bearerthis-is-a-wrong-header")).toEqual(null);
});
});
describe("extractJWTFromRequest", () => {
it("extracts the token from header", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns("Bearer token"),
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
req.get.reset();
req.get.returns(null);
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
it("extracts the token from query string", () => {
const req = {
get: sinon
.stub()
.withArgs("authorization")
.returns(null),
query: { access_token: "token" },
};
expect(extractJWTFromRequest((req as any) as Request)).toEqual("token");
expect(req.get.calledOnce).toBeTruthy();
delete req.query.access_token;
req.get.reset();
expect(extractJWTFromRequest((req as any) as Request)).toEqual(null);
expect(req.get.calledOnce).toBeTruthy();
});
});
describe("createJWTSigningConfig", () => {
it("parses a RSA certiciate", () => {
const input = `-----BEGIN RSA PRIVATE KEY-----\\nMIIEpQIBAAKCAQEAyxR2DVlvkQRquggUQTpHN+PxDs2iOiItGgn6u4+faUCdgGEV\\nEnmG69//3lAZHnEQN9rkZS3/20zc41mTJnO7dslJbB316vWUSIwYcVY/VC9DTbk+\\nMHWZd94p5hOB8PoY2vEGA53KiyWLqQC5FWE3u7cz7eYTr9/eRPDTc15IzohLXd5U\\nC9EbO5ebho2CvWrBfrLozM5Kidp8r3Jp+A0o3kfJ/kRDDn/BmG6pM0TohWZFYMs2\\nnQaGg+of9tcafgAs7hZAgBrrcc/jke6+MKxpC8algik79nMk7s7prxF1Z9EbAeQV\\n1ssL2VgsjvGAHIV+Arckl6QJbVDvQXNAM0PqbQIDAQABAoIBAQCoG6D5vf5P8nMS\\n2ltB/6cyyfsjgO/45Y+mTXqERwj0DOwUeMkDyRv6KCxb8LxKade+FPIaG7D/7amw\\nfdcE7qrRUyD3YfnPbUk5oNcfAwFbg+BX969WWBMZmgvfDGj1fWKT4w9ScQ1YkFUD\\nKrkLzLVhK+/N0Dad0VjiguTXTMZCSDFOY9fO8HRF6EA3aewEPeEY62J6rSjGXvWB\\nGdW+FNvf/uRr36xGHNqiOP837pdVUppjgDyVsORnMfFtYMyWyxS2XD5r8gRwcRg7\\n0nz6bLM53DjKweO+Yl+pIVPFAyXL0pwzQDlnjShsCzyzjA9lJftkQwbcMWopeegJ\\nkPLmiq4VAoGBAOqDmySNx8vmWWMOaXKFuH6Gqu/Nd7gBHxZ73wvsEmvV52xwa0oi\\n55h+v6P1YEaNZQWXDFsvILoOUHr2kwZY+Du/MC7tgqpj+Fu3h7UHslulJRE3A+sN\\noLbHjZuwm3wwsatpHdyEYOGg0HIGWXi+9pDT/1gy8g3L2Gf0X6rfkBBXAoGBAN2v\\nlbii0+HvZ2y0D0P6NfUJ6cQDrSyuTe7UW6OVYjBjrVAk8+bhnQ4eKd9edCnUDqu6\\n9C8ZSrqR6VBeItbt8y+5ZCRcrigxd2VdH8rL9g6idD9RPnSbHx7Al8DxSUv25xMK\\n8Z/ZOAvuCmwDfdleycNDoTawKqLtWBzUEntLs5DbAoGAPlTKiJWylAxel8h92HWY\\nSvDqQCChgGOz6prz9sxBPS42e4kJy0OpwMt3jlGqzDXKswipvRayoSEq3PPqshY1\\nrFOtr9trDnTRzzbhuAkaq+ciCghQX0pY/BvgFJCFUyXyIzgmOrVotq+yl4v+fexr\\nxqTCSqQH2AjlNQQr5VPUi7MCgYEAsNbbMXE6YlXug+lS8CANoM3qm4FvSGA3LNhb\\nza9hp0YsP+1qXvgEp/lp35RiR+ewWE+HcHbVhOTWYFTnp9ojDyPtfZAtIUTsgIB7\\n1vNC8kOnRccSckQ32/k4VSJlHOL1S9yECMZnjiSyTZ2va5HQkyJE3PJE4LlCe6S0\\npYQq1tcCgYEAoJDeSeAPqi5NIu+MWNUWzw4vo5raKyHrJi+cTvKyM/2zJFHvBc5f\\nRaxkcIAOmIDoVdFgy6APY/0DnDnpqT1kMagUaxZjG9PLFIDds5DRaL99m+S7l8mt\\nySX/MbmhQHYWpVf2nL6pmfPuP4Ih6tbKIUUGA3wZXYYZ5r+pZFG1IrA=\\n-----END RSA PRIVATE KEY-----`;
const config = {
get: sinon.stub(),
};
config.get.withArgs("signing_secret").returns(input);
config.get.withArgs("signing_algorithm").returns("RS256");
const signingConfig = createJWTSigningConfig((config as any) as Config);
expect(signingConfig.algorithm).toEqual("RS256");
expect(signingConfig.secret.toString()).toMatchSnapshot();
});
});
@@ -0,0 +1,204 @@
import jwt, { SignOptions } from "jsonwebtoken";
import uuid from "uuid";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { Config } from "talk-server/config";
import { retrieveUser, User } from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const authHeaderRegex = /(\S+)\s+(\S+)/;
export function parseAuthHeader(header: string) {
const matches = header.match(authHeaderRegex);
if (!matches || matches.length < 3) {
return null;
}
return {
scheme: matches[1].toLowerCase(),
value: matches[2],
};
}
export function extractJWTFromRequest(req: Request) {
const header = req.get("authorization");
if (header) {
const parts = parseAuthHeader(header);
if (parts && parts.scheme === "bearer") {
return parts.value;
}
}
const token: string | undefined | false = req.query && req.query.access_token;
if (token) {
return token;
}
return null;
}
export enum AsymmetricSigningAlgorithm {
RS256 = "RS256",
RS384 = "RS384",
RS512 = "RS512",
ES256 = "ES256",
ES384 = "ES384",
ES512 = "ES512",
}
export enum SymmetricSigningAlgorithm {
HS256 = "HS256",
HS384 = "HS384",
HS512 = "HS512",
}
export type JWTSigningAlgorithm =
| AsymmetricSigningAlgorithm
| SymmetricSigningAlgorithm;
export interface JWTSigningConfig {
secret: Buffer;
algorithm: JWTSigningAlgorithm;
}
export function createAsymmetricSigningConfig(
algorithm: AsymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
// Secrets have their newlines encoded with newline litterals.
secret: Buffer.from(secret.replace(/\\n/g, "\n")),
algorithm,
};
}
export function createSymmetricSigningConfig(
algorithm: SymmetricSigningAlgorithm,
secret: string
): JWTSigningConfig {
return {
secret: new Buffer(secret),
algorithm,
};
}
function isSymmetricSigningAlgorithm(
algorithm: string | SymmetricSigningAlgorithm
): algorithm is SymmetricSigningAlgorithm {
return algorithm in SymmetricSigningAlgorithm;
}
function isAsymmetricSigningAlgorithm(
algorithm: string | AsymmetricSigningAlgorithm
): algorithm is AsymmetricSigningAlgorithm {
return algorithm in AsymmetricSigningAlgorithm;
}
/**
* Parses the config and provides the signing config.
*
* @param config the server configuration
*/
export function createJWTSigningConfig(config: Config): JWTSigningConfig {
const secret = config.get("signing_secret");
const algorithm = config.get("signing_algorithm");
if (isSymmetricSigningAlgorithm(algorithm)) {
return createSymmetricSigningConfig(algorithm, secret);
} else if (isAsymmetricSigningAlgorithm(algorithm)) {
return createAsymmetricSigningConfig(algorithm, secret);
}
// TODO: (wyattjoh) return better error.
throw new Error("invalid algorithm specified");
}
export type SigningTokenOptions = Pick<SignOptions, "audience" | "issuer">;
export async function signTokenString(
{ algorithm, secret }: JWTSigningConfig,
user: User,
options: SigningTokenOptions
) {
return jwt.sign({}, secret, {
...options,
jwtid: uuid.v4(),
algorithm,
expiresIn: "1 day", // TODO: (wyattjoh) evaluate allowing configuration?
subject: user.id,
});
}
export interface JWTToken {
jti: string;
sub: string;
exp: number;
iss?: string;
}
export interface JWTStrategyOptions {
signingConfig: JWTSigningConfig;
db: Db;
}
export class JWTStrategy extends Strategy {
private signingConfig: JWTSigningConfig;
private db: Db;
public name: string;
constructor({ signingConfig, db }: JWTStrategyOptions) {
super();
this.name = "jwt";
this.signingConfig = signingConfig;
this.db = db;
}
public authenticate(req: Request) {
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// There was no token on the request, so there was no user, so let's mark
// that the strategy was succesfull.
return this.success(null, null);
}
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
jwt.verify(
token,
// Use the secret specified in the configuration.
this.signingConfig.secret,
{
// We need to verify that the token is for the specified tenant.
issuer: tenant.id,
// Use the algorithm specified in the configuration.
algorithms: [this.signingConfig.algorithm],
},
async (err: Error | undefined, { sub }: JWTToken) => {
if (err) {
return this.fail(err, 401);
}
try {
// Find the user.
const user = await retrieveUser(this.db, tenant.id, sub);
// Return them! The user may be null, but that's ok here.
this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createJWTStrategy(options: JWTStrategyOptions) {
return new JWTStrategy(options);
}
@@ -0,0 +1,60 @@
import { Db } from "mongodb";
import { Strategy as LocalStrategy } from "passport-local";
import { VerifyCallback } from "talk-server/app/middleware/passport";
import {
retrieveUserWithProfile,
verifyUserPassword,
} from "talk-server/models/user";
import { Request } from "talk-server/types/express";
const verifyFactory = (db: Db) => async (
req: Request,
email: string,
password: string,
done: VerifyCallback
) => {
try {
// TODO: rate limit based on the IP address and user agent.
// The tenant is guaranteed at this point.
const tenant = req.tenant!;
// Get the user from the database.
const user = await retrieveUserWithProfile(db, tenant.id, {
id: email,
type: "local",
});
if (!user) {
// The user didn't exist.
return done(null, null);
}
// Verify the password.
const passwordVerified = await verifyUserPassword(user, password);
if (!passwordVerified) {
// TODO: return better error
return done(new Error("invalid password"));
}
return done(null, user);
} catch (err) {
return done(err);
}
};
export interface LocalStrategyOptions {
db: Db;
}
export function createLocalStrategy({ db }: LocalStrategyOptions) {
return new LocalStrategy(
{
usernameField: "email",
passwordField: "password",
session: false,
passReqToCallback: true,
},
verifyFactory(db)
);
}
@@ -0,0 +1,87 @@
import {
OIDCDisplayNameIDTokenSchema,
OIDCIDTokenSchema,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
describe("OIDCIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
it("allows an empty email_verified", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
};
expect(validate(OIDCIDTokenSchema, token)).toEqual({
...token,
email_verified: false,
});
});
it("allows an empty picture", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
};
expect(validate(OIDCIDTokenSchema, token)).toEqual(token);
});
});
describe("OIDCDisplayNameIDTokenSchema", () => {
it("allows a valid payload", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: true,
name: "name",
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty name", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
nickname: "nickname",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
it("allows an empty nickname", () => {
const token = {
sub: "sub",
iss: "iss",
aud: "aud",
email: "email",
email_verified: false,
name: "name",
};
expect(validate(OIDCDisplayNameIDTokenSchema, token)).toEqual(token);
});
});
@@ -0,0 +1,375 @@
import Joi from "joi";
import jwt from "jsonwebtoken";
import jwks, { JwksClient } from "jwks-rsa";
import { Db } from "mongodb";
import { Strategy as OAuth2Strategy, VerifyCallback } from "passport-oauth2";
import { Strategy } from "passport-strategy";
import { validate } from "talk-server/app/request/body";
import { reconstructURL } from "talk-server/app/url";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration, Tenant } from "talk-server/models/tenant";
import { OIDCProfile, retrieveUserWithProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface Params {
id_token?: string;
}
/**
* OIDCIDToken describes the set of claims that are present in a ID Token. This
* interface confirms with the ID Token specification as defined:
* https://openid.net/specs/openid-connect-core-1_0.html#IDToken
*/
export interface OIDCIDToken {
aud: string;
iss: string;
sub: string;
exp: number; // TODO: use this as the source for how long an OIDC user can be logged in for
email?: string;
email_verified?: boolean;
picture?: string;
name?: string;
nickname?: string;
}
export interface StrategyItem {
strategy: OAuth2Strategy;
jwksClient?: JwksClient;
}
export interface OIDCStrategyOptions {
db: Db;
}
export function isOIDCToken(token: OIDCIDToken | object): token is OIDCIDToken {
if (
(token as OIDCIDToken).iss &&
(token as OIDCIDToken).sub &&
(token as OIDCIDToken).email
) {
return true;
}
return false;
}
/**
* keyFunc will provide the secret based on the given jwkw client.
*
* @param client the jwks client for the specific request being made
*/
const signingKeyFactory = (client: jwks.JwksClient): jwt.KeyFunction => (
{ kid },
callback
) => {
if (!kid) {
// TODO: return better error.
return callback(new Error("no kid in id_token"));
}
// Get the signing key from the jwks provider.
client.getSigningKey(kid, (err, key) => {
if (err) {
// TODO: wrap error?
return callback(err);
}
// Grab the signingKey out of the provided key.
const signingKey = key.publicKey || key.rsaPublicKey;
callback(null, signingKey);
});
};
function getEnabledIntegration(tenant: Tenant) {
const integration = tenant.auth.integrations.oidc;
if (!integration) {
// TODO: return a better error.
throw new Error("integration not found");
}
// Handle when the integration is enabled/disabled.
if (!integration.enabled) {
// TODO: return a better error.
throw new Error("integration not enabled");
}
return integration;
}
export const OIDCIDTokenSchema = Joi.object()
.keys({
sub: Joi.string(),
iss: Joi.string(),
aud: Joi.string(),
email: Joi.string(),
email_verified: Joi.boolean().default(false),
picture: Joi.string().default(undefined),
})
.optionalKeys(["picture", "email_verified"]);
export const OIDCDisplayNameIDTokenSchema = OIDCIDTokenSchema.keys({
name: Joi.string().default(undefined),
nickname: Joi.string().default(undefined),
}).optionalKeys(["name", "nickname"]);
export async function findOrCreateOIDCUser(
db: Db,
tenant: Tenant,
token: OIDCIDToken
) {
// Unpack/validate the token content.
const {
sub,
iss,
aud,
email,
email_verified,
picture,
name,
nickname,
}: OIDCIDToken = validate(
tenant.auth.integrations.oidc!.displayNameEnable
? OIDCDisplayNameIDTokenSchema
: OIDCIDTokenSchema,
token
);
// Construct the profile that will be used to query for the user.
const profile: OIDCProfile = {
type: "oidc",
id: sub,
issuer: iss,
audience: aud,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: implement rules.
// Default the displayName. When it is disabled, Joi will strip the
// displayName fields from the token, so it will fallback to undefined.
const displayName = nickname || name || undefined;
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username: null,
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
email_verified,
avatar: picture,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* OIDC_SCOPE is the set of scopes requested for users signing up via OIDC.
*/
const OIDC_SCOPE = "openid email profile";
// FIXME: attach strategy to cache updates of the tenants
export default class OIDCStrategy extends Strategy {
public name: string;
private db: Db;
private cache: Map<string, StrategyItem>;
constructor({ db }: OIDCStrategyOptions) {
super();
this.name = "oidc";
this.cache = new Map();
this.db = db;
}
private lookupJWKSClient(
req: Request,
tenantID: string,
oidc: OIDCAuthIntegration
) {
let entry = this.cache.get(tenantID);
if (!entry) {
const strategy = this.createStrategy(req, oidc);
// Create the entry.
entry = {
strategy,
};
// We don't reset the entry in the cache here because if we just created
// it, we'll be creating the jwksClient anyways, so we'll update it there.
}
if (!entry.jwksClient) {
// Create the new JWKS client.
const jwksClient = jwks({
jwksUri: oidc.jwksURI,
});
// Set the jwksClient on the entry.
entry.jwksClient = jwksClient;
// Update the cached entry.
this.cache.set(tenantID, entry);
}
return entry.jwksClient;
}
private userAuthenticatedCallback = (
req: Request,
accessToken: string, // ignore the access token, we don't use it.
refreshToken: string, // ignore the refresh token, we don't use it.
params: Params,
profile: any, // we don't look inside the profile (yet).
done: VerifyCallback
) => {
// Try to lookup user given their id provided in the `sub` claim of the
// `id_token`.
const { id_token } = params;
if (!id_token) {
// TODO: return better error.
return done(new Error("no id_token in params"));
}
// Grab the tenant out of the request, as we need some more details.
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
return done(new Error("tenant not found"));
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
let integration: OIDCAuthIntegration;
try {
integration = getEnabledIntegration(tenant);
} catch (err) {
// TODO: wrap error?
return done(err);
}
// Grab the JWKSClient.
const client = this.lookupJWKSClient(req, tenant.id, integration);
// Verify that the id_token is valid or not.
jwt.verify(
id_token,
signingKeyFactory(client),
{
issuer: integration.issuer,
},
async (err, decoded) => {
if (err) {
// TODO: wrap error?
return done(err);
}
try {
const user = await findOrCreateOIDCUser(
this.db,
tenant,
decoded as OIDCIDToken
);
return done(null, user);
} catch (err) {
return done(err);
}
}
);
};
private createStrategy(
req: Request,
integration: OIDCAuthIntegration
): OAuth2Strategy {
const { clientID, clientSecret, authorizationURL, tokenURL } = integration;
// Construct the callbackURL from the request.
const callbackURL = reconstructURL(req, "/api/tenant/auth/oidc/callback");
// Create a new OAuth2Strategy, where we pass the verify callback bound to
// this OIDCStrategy instance.
return new OAuth2Strategy(
{
passReqToCallback: true,
clientID,
clientSecret,
authorizationURL,
tokenURL,
callbackURL,
},
this.userAuthenticatedCallback
);
}
private async lookupStrategy(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: return a better error.
throw new Error("tenant not found");
}
// Get the integration from the tenant. If needed, it will be used to create
// a new strategy.
const integration = getEnabledIntegration(tenant);
// Try to get the Tenant's cached integrations.
let entry = this.cache.get(tenant.id);
if (!entry) {
// Create the strategy.
const strategy = this.createStrategy(req, integration);
// Reset the entry.
entry = {
strategy,
};
// Update the cached integrations value.
this.cache.set(tenant.id, entry);
}
return entry.strategy;
}
public async authenticate(req: Request) {
try {
// Lookup the strategy.
const strategy = await this.lookupStrategy(req);
if (!strategy) {
throw new Error("strategy not found");
}
// Augment the strategy with the request method bindings.
strategy.error = this.error.bind(this);
strategy.fail = this.fail.bind(this);
strategy.pass = this.pass.bind(this);
strategy.redirect = this.redirect.bind(this);
strategy.success = this.success.bind(this);
// Authenticate with the strategy, binding the current context to the method
// to provide it with the augmented passport handlers. We also request the
// 'openid' scope so we can get an id_token back.
strategy.authenticate(req, {
scope: OIDC_SCOPE,
session: false,
});
} catch (err) {
return this.error(err);
}
}
}
export function createOIDCStrategy({ db }: OIDCStrategyOptions) {
return new OIDCStrategy({ db });
}
@@ -0,0 +1,83 @@
import {
isSSOToken,
SSODisplayNameUserProfileSchema,
SSOUserProfileSchema,
} from "talk-server/app/middleware/passport/sso";
import { validate } from "talk-server/app/request/body";
describe("isSSOToken", () => {
it("understands valid sso tokens", () => {
const token = { user: { id: "id", email: "email", username: "username" } };
expect(isSSOToken(token)).toBeTruthy();
});
it("understands invalid sso tokens", () => {
expect(isSSOToken({ user: { id: "id", email: "email" } })).toBeFalsy();
expect(
isSSOToken({ user: { id: "id", username: "username" } })
).toBeFalsy();
expect(
isSSOToken({ user: { email: "email", username: "username" } })
).toBeFalsy();
expect(isSSOToken({})).toBeFalsy();
});
});
describe("SSOUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
};
expect(validate(SSOUserProfileSchema, profile)).toEqual(profile);
});
});
describe("SSODisplayNameUserProfileSchema", () => {
it("allows a valid payload", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty avatar", () => {
const profile = {
id: "id",
email: "email",
username: "username",
displayName: "displayName",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
it("allows an empty displayName", () => {
const profile = {
id: "id",
email: "email",
username: "username",
avatar: "avatar",
};
expect(validate(SSODisplayNameUserProfileSchema, profile)).toEqual(profile);
});
});
@@ -0,0 +1,225 @@
import Joi from "joi";
import jwt, { KeyFunctionCallback } from "jsonwebtoken";
import { Db } from "mongodb";
import { Strategy } from "passport-strategy";
import { extractJWTFromRequest } from "talk-server/app/middleware/passport/jwt";
import {
findOrCreateOIDCUser,
isOIDCToken,
OIDCIDToken,
} from "talk-server/app/middleware/passport/oidc";
import { validate } from "talk-server/app/request/body";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { retrieveUserWithProfile, SSOProfile } from "talk-server/models/user";
import { upsert } from "talk-server/services/users";
import { Request } from "talk-server/types/express";
export interface SSOStrategyOptions {
db: Db;
}
export interface SSOUserProfile {
id: string;
email: string;
username: string;
avatar?: string;
displayName?: string;
}
export interface SSOToken {
user: SSOUserProfile;
}
export const SSOUserProfileSchema = Joi.object()
.keys({
id: Joi.string(),
email: Joi.string(),
username: Joi.string(),
avatar: Joi.string().default(undefined),
})
.optionalKeys(["avatar"]);
export const SSODisplayNameUserProfileSchema = SSOUserProfileSchema.keys({
displayName: Joi.string().default(undefined),
}).optionalKeys(["displayName"]);
export async function findOrCreateSSOUser(
db: Db,
tenant: Tenant,
token: SSOToken
) {
if (!token.user) {
// TODO: (wyattjoh) replace with better error.
throw new Error("token is malformed, missing user claim");
}
// Unpack/validate the token content.
const { id, email, username, displayName, avatar }: SSOUserProfile = validate(
tenant.auth.integrations.sso!.displayNameEnable
? SSODisplayNameUserProfileSchema
: SSOUserProfileSchema,
token.user
);
const profile: SSOProfile = {
type: "sso",
id,
};
// Try to lookup user given their id provided in the `sub` claim.
let user = await retrieveUserWithProfile(db, tenant.id, profile);
if (!user) {
// FIXME: (wyattjoh) implement rules! Not all users should be able to create an account via this method.
// Create the new user, as one didn't exist before!
user = await upsert(db, tenant, {
username,
// When the displayName is disabled on the tenant, the displayName will
// never be set (or even stored in the database).
displayName,
role: GQLUSER_ROLE.COMMENTER,
email,
avatar,
profiles: [profile],
});
}
// TODO: (wyattjoh) possibly update the user profile if the remaining details mismatch?
return user;
}
/**
* isSSOUserProfile will check if the given profile is a SSOUserProfile.
*
* @param profile the profile to check for the type
*/
export function isSSOUserProfile(
profile: SSOUserProfile | object
): profile is SSOUserProfile {
return (
typeof (profile as SSOUserProfile).id !== "undefined" &&
typeof (profile as SSOUserProfile).email !== "undefined" &&
typeof (profile as SSOUserProfile).username !== "undefined"
);
}
export function isSSOToken(token: SSOToken | object): token is SSOToken {
return (
typeof (token as SSOToken).user === "object" &&
isSSOUserProfile((token as SSOToken).user)
);
}
export default class SSOStrategy extends Strategy {
public name: string;
private db: Db;
constructor({ db }: SSOStrategyOptions) {
super();
this.name = "sso";
this.db = db;
}
/**
* retrieves the integration's secret to be used to verify the token.
*/
private getSigningSecretGetter = (tenant: Tenant) => async (
headers: { kid?: string },
done: KeyFunctionCallback
) => {
const integration = tenant.auth.integrations.sso;
if (!integration) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not found"));
}
if (!integration.enabled) {
// TODO: (wyattjoh) return a better error.
return done(new Error("integration not enabled"));
}
// TODO: (wyattjoh) do something with the kid... Lookup the secret or verify it matches what we have?
return done(null, integration.key);
};
/**
* findOrCreateUser will interpret the token and use the correct strategy for
* retrieving/creating the user.
*
* @param tenant the tenant for the new/returning user
* @param token the token that was unpacked and validated from the sso strategy
*/
private async findOrCreateUser(
tenant: Tenant,
token: OIDCIDToken | SSOToken
) {
if (isOIDCToken(token)) {
// The token provided for SSO contains an issuer claim. We're assuming
// that this request is associated with an OpenID Connect provider.
return findOrCreateOIDCUser(this.db, tenant, token);
}
// Check to see if this token is a SSO Token or not, if it isn't error out.
if (!isSSOToken(token)) {
// TODO: (wyattjoh) return a better error.
throw new Error("token is invalid");
}
// The token provided does not confirm to the OpenID Connect provider
// spec, but id does conform to a SSOToken so we should expect the token to
// contain the user profile.
return findOrCreateSSOUser(this.db, tenant, token);
}
public authenticate(req: Request) {
const { tenant } = req;
if (!tenant) {
// TODO: (wyattjoh) return a better error.
return this.error(new Error("tenant not found"));
}
// Lookup the token.
const token = extractJWTFromRequest(req);
if (!token) {
// TODO: (wyattjoh) return a better error.
return this.fail(new Error("no token on request"), 400);
}
// Perform the JWT validation.
jwt.verify(
token,
this.getSigningSecretGetter(tenant),
{
// Force the use of the HS256 algorithm. We can explore switching this
// out in the future..
algorithms: ["HS256"], // TODO: (wyattjoh) investigate replacing algorithm.
},
async (err: Error | undefined, decoded: OIDCIDToken | SSOToken) => {
if (err) {
// TODO: (wyattjoh) wrap error?
return this.error(err);
}
try {
// Find or create the user based on the decoded token.
const user = await this.findOrCreateUser(tenant, decoded);
// The user was found or created!
return this.success(user, null);
} catch (err) {
return this.error(err);
}
}
);
}
}
export function createSSOStrategy(options: SSOStrategyOptions) {
return new SSOStrategy(options);
}
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`throws an error for missing fields 1`] = `"child \\"d\\" fails because [\\"d\\" is required]"`;
+32
View File
@@ -0,0 +1,32 @@
import Joi from "joi";
import { validate } from "talk-server/app/request/body";
it("strips out unknown fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({});
expect(validate(schema, payload)).toEqual({});
});
it("allows valid fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
expect(validate(schema, payload)).toEqual({ a: 1 });
});
it("allows valid fields from extended schema", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ a: Joi.number() });
const extendedSchema = schema.keys({ b: Joi.number() });
expect(validate(extendedSchema, payload)).toEqual({ a: 1, b: 2 });
});
it("throws an error for missing fields", () => {
const payload = { a: 1, b: 2, c: 3 };
const schema = Joi.object().keys({ d: Joi.number() });
expect(() => validate(schema, payload)).toThrowErrorMatchingSnapshot();
});
+24
View File
@@ -0,0 +1,24 @@
import Joi from "joi";
/**
* validate will strip unknown fields and perform validation against it. It will
* throw any error encountered.
*
* @param schema the Joi schema to validate against
* @param body the body to parse and strip of unknown fields
*/
export const validate = (schema: Joi.SchemaLike, body: any) => {
// Extract the schema from the request.
const { value, error: err } = Joi.validate(body, schema, {
stripUnknown: true,
presence: "required",
abortEarly: false,
});
if (err) {
// TODO: wrap error?
throw err;
}
return value;
};
+67 -17
View File
@@ -1,5 +1,10 @@
import express from "express";
import passport from "passport";
import { signupHandler } from "talk-server/app/handlers/auth/local";
import { apiErrorHandler } from "talk-server/app/middleware/error";
import { errorLogger } from "talk-server/app/middleware/logging";
import { wrapAuthn } from "talk-server/app/middleware/passport";
import tenantMiddleware from "talk-server/app/middleware/tenant";
import managementGraphMiddleware from "talk-server/graph/management/middleware";
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
@@ -7,7 +12,7 @@ import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
import { AppOptions } from "./index";
import playground from "./middleware/playground";
async function createManagementRouter(opts: AppOptions) {
async function createManagementRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Management API
@@ -15,51 +20,96 @@ async function createManagementRouter(opts: AppOptions) {
"/graphql",
express.json(),
await managementGraphMiddleware(
opts.schemas.management,
opts.config,
opts.mongo
app.schemas.management,
app.config,
app.mongo
)
);
return router;
}
async function createTenantRouter(opts: AppOptions) {
async function createTenantRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Tenant identification middleware.
router.use(tenantMiddleware({ db: opts.mongo }));
router.use(tenantMiddleware({ db: app.mongo }));
// Setup Passport middleware.
router.use(options.passport.initialize());
// Setup auth routes.
router.use("/auth", createNewAuthRouter(app, options));
// Tenant API
router.use(
"/graphql",
express.json(),
await tenantGraphMiddleware(opts.schemas.tenant, opts.config, opts.mongo)
// Any users may submit their GraphQL requests with authentication, this
// middleware will unpack their user into the request.
options.passport.authenticate("jwt", { session: false }),
await tenantGraphMiddleware(app.schemas.tenant, app.config, app.mongo)
);
return router;
}
async function createAPIRouter(opts: AppOptions) {
// Create a router.
function createNewAuthRouter(app: AppOptions, options: RouterOptions) {
const router = express.Router();
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(opts));
// Configure the management routes.
router.use("/management", await createManagementRouter(opts));
// Mount the passport routes.
router.post(
"/local",
express.json(),
wrapAuthn(options.passport, app.signingConfig, "local")
);
router.post(
"/local/signup",
express.json(),
signupHandler({ db: app.mongo, signingConfig: app.signingConfig })
);
router.post("/sso", wrapAuthn(options.passport, app.signingConfig, "sso"));
router.get("/oidc", wrapAuthn(options.passport, app.signingConfig, "oidc"));
router.get(
"/oidc/callback",
wrapAuthn(options.passport, app.signingConfig, "oidc")
);
return router;
}
export async function createRouter(opts: AppOptions) {
async function createAPIRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(opts));
// Configure the tenant routes.
router.use("/tenant", await createTenantRouter(app, options));
if (opts.config.get("env") === "development") {
// Configure the management routes.
router.use("/management", await createManagementRouter(app, options));
// General API error handler.
router.use(errorLogger);
router.use(apiErrorHandler);
return router;
}
export interface RouterOptions {
/**
* passport is the instance of the Authenticator that can be used to create
* and mount new authentication middleware.
*/
passport: passport.Authenticator;
}
export async function createRouter(app: AppOptions, options: RouterOptions) {
// Create a router.
const router = express.Router();
router.use("/api", await createAPIRouter(app, options));
if (app.config.get("env") === "development") {
// Tenant GraphiQL
router.get(
"/tenant/graphiql",
+12
View File
@@ -0,0 +1,12 @@
import { Request } from "talk-server/types/express";
import { URL } from "url";
export function reconstructURL(req: Request, path: string = "/"): string {
const scheme = req.secure ? "https" : "http";
const host = req.get("host");
const base = `${scheme}://${host}`;
const url = new URL(path, base);
return url.href;
}
+22 -10
View File
@@ -1,11 +1,6 @@
import convict from "convict";
import dotenv from "dotenv";
import Joi from "joi";
// Apply all the configuration provided in the .env file if it isn't already in
// the environment.
dotenv.config();
// Add custom format for the mongo uri scheme.
convict.addFormat({
name: "mongo-uri",
@@ -60,12 +55,29 @@ const config = convict({
env: "REDIS",
arg: "redis",
},
secret: {
doc: "The secret used to sign and verify JWTs",
signing_secret: {
doc: "",
format: "*",
default: null,
env: "SECRET",
arg: "secret",
default: "keyboard cat", // TODO: (wyattjoh) evaluate best solution
env: "SIGNING_SECRET",
arg: "signingSecret",
},
signing_algorithm: {
doc: "",
format: [
"HS256",
"HS384",
"HS512",
"RS256",
"RS384",
"RS512",
"ES256",
"ES384",
"ES512",
],
default: "HS256",
env: "SIGNING_ALGORITHM",
arg: "signingAlgorithm",
},
logging_level: {
doc: "The logging level to print to the console",
+13
View File
@@ -0,0 +1,13 @@
import { User } from "talk-server/models/user";
export interface CommonContextOptions {
user?: User;
}
export default class CommonContext {
public user?: User;
constructor({ user }: CommonContextOptions) {
this.user = user;
}
}
@@ -0,0 +1,39 @@
import { DirectiveResolverFn } from "graphql-tools";
import CommonContext from "talk-server/graph/common/context";
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
export interface AuthDirectiveArgs {
roles?: GQLUSER_ROLE[];
userIDField?: string;
}
const auth: DirectiveResolverFn<
Record<string, string | undefined>,
CommonContext
> = (next, src, { roles, userIDField }: AuthDirectiveArgs, { user }) => {
// If there is a user on the request.
if (user) {
// If the role and user owner checks are disabled, then allow them based on
// their authenticated status.
if (!roles && !userIDField) {
return next();
}
// And the user has the expected role.
if (roles && roles.includes(user.role)) {
// Let the request continue.
return next();
}
// Or the item is owned by the specific user.
if (userIDField && src[userIDField] && src[userIDField] === user.id) {
return next();
}
}
// TODO: return better error.
throw new Error("not authorized");
};
export default auth;
@@ -0,0 +1,136 @@
import { Kind } from "graphql";
import { DateTime } from "luxon";
import Cursor from "./cursor";
describe("parseLiteral", () => {
it("parses a date from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "2018-07-16T18:34:26.744Z",
})
).toBeInstanceOf(Date);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "this-should-fail",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "",
})
).toEqual(null);
});
it("parses a number from a string", () => {
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "null",
})
).toEqual(null);
expect(
Cursor.parseLiteral({
kind: Kind.STRING,
value: "0",
})
).toEqual(0);
});
it("parses a number from a number", () => {
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "20",
})
).toEqual(20);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "0",
})
).toEqual(0);
expect(
Cursor.parseLiteral({
kind: Kind.INT,
value: "",
})
).toEqual(null);
});
it("does not parse unknown kinds", () => {
expect(
Cursor.parseLiteral({
kind: Kind.FLOAT,
value: "0.0",
})
).toEqual(null);
});
});
describe("serialize", () => {
it("renders native dates correctly", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.serialize(date)).toEqual(expected);
expect(Cursor.serialize({})).toEqual(null);
});
it("renders luxon dates correctly", () => {
const date = DateTime.fromJSDate(new Date());
const expected = date.toISO();
expect(Cursor.serialize(date)).toEqual(expected);
});
it("renders numbers correctly", () => {
let value = 50;
let expected = "50";
expect(Cursor.serialize(value)).toEqual(expected);
value = 0;
expected = "0";
expect(Cursor.serialize(value)).toEqual(expected);
expect(Cursor.serialize(null)).toEqual(null);
});
});
describe("parseValue", () => {
it("parses the string value of a Date", () => {
const date = new Date();
const expected = date.toISOString();
expect(Cursor.parseValue(expected)).toBeInstanceOf(Date);
});
it("parses the string value of a number", () => {
expect(Cursor.parseValue("0")).toEqual(0);
});
it("handles invalid properties", () => {
expect(Cursor.parseValue(null)).toEqual(null);
expect(Cursor.parseValue(2)).toEqual(null);
});
});
@@ -1,11 +1,15 @@
import { GraphQLScalarType } from "graphql";
import { Kind } from "graphql/language";
import { DateTime } from "luxon";
import { Cursor } from "talk-server/models/connection";
function parseIntegerCursor(value: string): number | null {
try {
const cursor = parseInt(value, 10);
if (isNaN(cursor)) {
return null;
}
return cursor;
} catch (err) {
+4 -1
View File
@@ -1,13 +1,16 @@
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
export interface ManagementContextOptions {
db: Db;
}
export default class ManagementContext {
export default class ManagementContext extends CommonContext {
public db: Db;
constructor({ db }: ManagementContextOptions) {
super({});
this.db = db;
}
}
@@ -1,5 +1,5 @@
import Cursor from "../../common/scalars/cursor";
import { GQLResolver } from "talk-server/graph/management/schema/__generated__/types";
export default {
Cursor,
};
const Resolvers: GQLResolver = {};
export default Resolvers;
@@ -1,6 +1,8 @@
import { IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import resolvers from "talk-server/graph/management/resolvers";
export default function getManagementSchema() {
return loadSchema("management", resolvers);
return loadSchema("management", resolvers as IResolvers);
}
@@ -7,27 +7,22 @@ Time represented as an ISO8601 string.
"""
scalar Time
"""
Cursor represents a paginating cursor.
"""
scalar Cursor
################################################################################
## Tenant
################################################################################
type Tenant {
id: ID!
id: ID!
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationName is the name of the organization.
"""
organizationName: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
"""
organizationContactEmail is the email of the organization.
"""
organizationContactEmail: String
}
################################################################################
@@ -35,5 +30,5 @@ type Tenant {
################################################################################
type Query {
tenant(id: ID!): Tenant
tenant(id: ID!): Tenant
}
+5 -2
View File
@@ -1,4 +1,5 @@
import { Db } from "mongodb";
import CommonContext from "talk-server/graph/common/context";
import { Tenant } from "talk-server/models/tenant";
import { User } from "talk-server/models/user";
import loaders from "./loaders";
@@ -10,14 +11,16 @@ export interface TenantContextOptions {
user?: User;
}
export default class TenantContext {
export default class TenantContext extends CommonContext {
public loaders: ReturnType<typeof loaders>;
public mutators: ReturnType<typeof mutators>;
public db: Db;
public tenant: Tenant;
public user?: User;
public tenant: Tenant;
constructor({ user, tenant, db }: TenantContextOptions) {
super({ user });
this.tenant = tenant;
this.user = user;
this.loaders = loaders(this);
@@ -1,8 +1,16 @@
import DataLoader from "dataloader";
import TenantContext from "talk-server/graph/tenant/context";
import { Asset, retrieveManyAssets } from "talk-server/models/asset";
import {
Asset,
FindOrCreateAssetInput,
retrieveManyAssets,
} from "talk-server/models/asset";
import { findOrCreate } from "talk-server/services/assets";
export default (ctx: TenantContext) => ({
findOrCreate: (input: FindOrCreateAssetInput) =>
findOrCreate(ctx.db, ctx.tenant, input),
asset: new DataLoader<string, Asset | null>(ids =>
retrieveManyAssets(ctx.db, ctx.tenant.id, ids)
),
@@ -1,18 +1,48 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import {
ConnectionInput,
retrieveAssetConnection,
retrieveMany,
retrieveRepliesConnection,
AssetToCommentsArgs,
CommentToRepliesArgs,
GQLCOMMENT_SORT,
} from "talk-server/graph/tenant/schema/__generated__/types";
import {
retrieveCommentAssetConnection,
retrieveCommentRepliesConnection,
retrieveManyComments,
} from "talk-server/models/comment";
export default (ctx: Context) => ({
comment: new DataLoader((ids: string[]) =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyComments(ctx.db, ctx.tenant.id, ids)
),
forAsset: (assetID: string, input: ConnectionInput) =>
retrieveAssetConnection(ctx.db, ctx.tenant.id, assetID, input),
forParent: (assetID: string, parentID: string, input: ConnectionInput) =>
retrieveRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, input),
forAsset: (
assetID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: AssetToCommentsArgs
) =>
retrieveCommentAssetConnection(ctx.db, ctx.tenant.id, assetID, {
first,
orderBy,
after,
}),
forParent: (
assetID: string,
parentID: string,
// Apply the graph schema defaults at the loader.
{
first = 10,
orderBy = GQLCOMMENT_SORT.CREATED_AT_DESC,
after,
}: CommentToRepliesArgs
) =>
retrieveCommentRepliesConnection(ctx.db, ctx.tenant.id, assetID, parentID, {
first,
orderBy,
after,
}),
});
@@ -1,9 +1,9 @@
import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import { retrieveMany, User } from "talk-server/models/user";
import { retrieveManyUsers, User } from "talk-server/models/user";
export default (ctx: Context) => ({
user: new DataLoader<string, User | null>(ids =>
retrieveMany(ctx.db, ctx.tenant.id, ids)
retrieveManyUsers(ctx.db, ctx.tenant.id, ids)
),
});
@@ -1,12 +1,12 @@
import TenantContext from "talk-server/graph/tenant/context";
import { CreateCommentInput } from "talk-server/graph/tenant/resolvers/mutation";
import { GQLCreateCommentInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { Comment } from "talk-server/models/comment";
import { create } from "talk-server/services/comments";
export default (ctx: TenantContext) => ({
create: (input: CreateCommentInput): Promise<Comment> => {
create: (input: GQLCreateCommentInput): Promise<Comment> => {
// FIXME: remove tenant + user !
return create(ctx.db, ctx.tenant!.id, {
return create(ctx.db, ctx.tenant, {
author_id: ctx.user!.id,
asset_id: input.assetID,
body: input.body,
@@ -1,10 +1,11 @@
import Context from "talk-server/graph/tenant/context";
import { GQLAssetTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Asset } from "talk-server/models/asset";
import { ConnectionInput } from "talk-server/models/comment";
export default {
comments: async (asset: Asset, input: ConnectionInput, ctx: Context) =>
const Asset: GQLAssetTypeResolver<Asset> = {
comments: (asset, input, ctx) =>
ctx.loaders.Comments.forAsset(asset.id, input),
// TODO: implement this.
isClosed: () => false,
};
export default Asset;
@@ -0,0 +1,14 @@
import { GQLAuthIntegrationsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { AuthIntegration, AuthIntegrations } from "talk-server/models/tenant";
const disabled: AuthIntegration = { enabled: false };
const AuthIntegrations: GQLAuthIntegrationsTypeResolver<AuthIntegrations> = {
local: auth => auth.local || disabled,
sso: auth => auth.sso || disabled,
oidc: auth => auth.oidc || disabled,
google: auth => auth.google || disabled,
facebook: auth => auth.facebook || disabled,
};
export default AuthIntegrations;
@@ -0,0 +1,8 @@
import { GQLAuthSettingsTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Auth } from "talk-server/models/tenant";
const AuthSettings: GQLAuthSettingsTypeResolver<Auth> = {
integrations: auth => auth.integrations,
};
export default AuthSettings;
@@ -1,11 +1,12 @@
import Context from "talk-server/graph/tenant/context";
import { Comment, ConnectionInput } from "talk-server/models/comment";
import { GQLCommentTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Comment } from "talk-server/models/comment";
export default {
createdAt: async (comment: Comment, _: any, ctx: Context) =>
comment.created_at,
author: async (comment: Comment, _: any, ctx: Context) =>
const Comment: GQLCommentTypeResolver<Comment> = {
createdAt: comment => comment.created_at,
author: (comment, input, ctx) =>
ctx.loaders.Users.user.load(comment.author_id),
replies: async (comment: Comment, input: ConnectionInput, ctx: Context) =>
replies: (comment, input, ctx) =>
ctx.loaders.Comments.forParent(comment.asset_id, comment.id, input),
};
export default Comment;
@@ -0,0 +1,10 @@
import { GQLFacebookAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { FacebookAuthIntegration } from "talk-server/models/tenant";
const FacebookAuthIntegration: GQLFacebookAuthIntegrationTypeResolver<
FacebookAuthIntegration
> = {
config: auth => auth,
};
export default FacebookAuthIntegration;
@@ -0,0 +1,10 @@
import { GQLGoogleAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { GoogleAuthIntegration } from "talk-server/models/tenant";
const GoogleAuthIntegration: GQLGoogleAuthIntegrationTypeResolver<
GoogleAuthIntegration
> = {
config: auth => auth,
};
export default GoogleAuthIntegration;
@@ -1,13 +1,33 @@
import Cursor from "../../common/scalars/cursor";
import Asset from "./asset";
import Comment from "./comment";
import Mutation from "./mutation";
import Query from "./query";
import Cursor from "talk-server/graph/common/scalars/cursor";
import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export default {
import Asset from "./asset";
import AuthIntegrations from "./auth_integrations";
import AuthSettings from "./auth_settings";
import Comment from "./comment";
import FacebookAuthIntegration from "./facebook_auth_integration";
import GoogleAuthIntegration from "./google_auth_integration";
import LocalAuthIntegration from "./local_auth_integration";
import Mutation from "./mutation";
import OIDCAuthIntegration from "./oidc_auth_integration";
import Profile from "./profile";
import Query from "./query";
import SSOAuthIntegration from "./sso_auth_integration";
const Resolvers: GQLResolver = {
Asset,
AuthIntegrations,
AuthSettings,
Comment,
FacebookAuthIntegration,
GoogleAuthIntegration,
LocalAuthIntegration,
OIDCAuthIntegration,
SSOAuthIntegration,
Cursor,
Query,
Mutation,
Profile,
Query,
};
export default Resolvers;
@@ -0,0 +1,8 @@
import { GQLLocalAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { LocalAuthIntegration } from "talk-server/models/tenant";
const LocalAuthIntegration: GQLLocalAuthIntegrationTypeResolver<
LocalAuthIntegration
> = {};
export default LocalAuthIntegration;
@@ -1,23 +1,7 @@
import { ClientMutationProps } from "talk-server/graph/common/resolvers/mutation";
import TenantContext from "talk-server/graph/tenant/context";
import { Comment } from "talk-server/models/comment";
import { GQLMutationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export interface CreateCommentInput extends ClientMutationProps {
assetID: string;
parentID?: string;
body: string;
}
export interface CreateCommentPayload extends ClientMutationProps {
comment: Comment;
}
const Mutation = {
createComment: async (
source: void,
input: CreateCommentInput,
ctx: TenantContext
): Promise<CreateCommentPayload> => ({
const Mutation: GQLMutationTypeResolver<void> = {
createComment: async (source, { input }, ctx) => ({
comment: await ctx.mutators.Comment.create(input),
clientMutationId: input.clientMutationId,
}),
@@ -0,0 +1,10 @@
import { GQLOIDCAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { OIDCAuthIntegration } from "talk-server/models/tenant";
const OIDCAuthIntegration: GQLOIDCAuthIntegrationTypeResolver<
OIDCAuthIntegration
> = {
config: auth => auth,
};
export default OIDCAuthIntegration;
@@ -0,0 +1,21 @@
import { GQLProfileTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { Profile } from "talk-server/models/user";
const resolveType: GQLProfileTypeResolver<Profile> = profile => {
switch (profile.type) {
case "local":
return "LocalProfile";
case "oidc":
return "OIDCProfile";
case "sso":
return "SSOProfile";
default:
// TODO: replace with better error.
throw new Error("invalid profile type");
}
};
export default {
__resolveType: resolveType,
};
@@ -1,10 +1,9 @@
import TenantContext from "talk-server/graph/tenant/context";
import { GQLQueryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
export default {
asset: async (
source: void,
{ id }: { id: string; url: string },
ctx: TenantContext
) => ctx.loaders.Assets.asset.load(id),
settings: async (parent: any, args: any, ctx: TenantContext) => ctx.tenant,
const Query: GQLQueryTypeResolver<void> = {
asset: (source, args, ctx) => ctx.loaders.Assets.findOrCreate(args),
settings: (source, args, ctx) => ctx.tenant,
me: (source, args, ctx) => ctx.user,
};
export default Query;
@@ -0,0 +1,10 @@
import { GQLSSOAuthIntegrationTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import { SSOAuthIntegration } from "talk-server/models/tenant";
const SSOAuthIntegration: GQLSSOAuthIntegrationTypeResolver<
SSOAuthIntegration
> = {
config: auth => auth,
};
export default SSOAuthIntegration;
+9 -1
View File
@@ -1,6 +1,14 @@
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import auth from "talk-server/graph/common/directives/auth";
import resolvers from "talk-server/graph/tenant/resolvers";
export default function getTenantSchema() {
return loadSchema("tenant", resolvers);
const schema = loadSchema("tenant", resolvers as IResolvers);
// Attach the directive resolvers.
attachDirectiveResolvers(schema, { auth });
return schema;
}
@@ -1,3 +1,16 @@
################################################################################
## Custom Directives
################################################################################
"""
auth is a directive that will enforce authorization rules on the schema
definition. It will restrict the viewer of the field based on roles or if the
`userIDField` is specified, it will see if the current users ID equals the field
specified. This allows users that own a specific resource (like a comment, or a
flag) see their own content, but restrict it to everyone else.
"""
directive @auth(roles: [USER_ROLE!], userIDField: String) on FIELD_DEFINITION
################################################################################
## Custom Scalar Types
################################################################################
@@ -31,9 +44,9 @@ enum MODERATION_MODE {
}
"""
Wordlist describes all the available wordlists.
WordlistSettings describes all the available wordlists.
"""
type Wordlist {
type WordlistSettings {
"""
banned words will by default reject the comment if it is found.
"""
@@ -45,12 +58,129 @@ type Wordlist {
suspect: [String!]!
}
# Settings stores the global settings for a given installation.
################################################################################
## AuthSettings
################################################################################
##########################
## LocalAuthIntegration
##########################
type LocalAuthIntegration {
enabled: Boolean!
}
##########################
## SSOAuthIntegration
##########################
type SSOAuthIntegrationConfig {
key: String!
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type SSOAuthIntegration {
enabled: Boolean!
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## OIDCAuthIntegration
##########################
type OIDCAuthIntegrationConfig {
clientID: String!
clientSecret: String!
authorizationURL: String!
tokenURL: String!
"""
displayNameEnable when enabled, will allow Users to set and view their
displayName's.
"""
displayNameEnable: Boolean!
}
type OIDCAuthIntegrationOptions {
name: String!
}
type OIDCAuthIntegration {
enabled: Boolean!
options: OIDCAuthIntegrationOptions
config: SSOAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## GoogleAuthIntegration
##########################
type GoogleAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type GoogleAuthIntegration {
enabled: Boolean!
config: GoogleAuthIntegrationConfig @auth(roles: [ADMIN])
}
##########################
## FacebookAuthIntegration
##########################
type FacebookAuthIntegrationConfig {
clientID: String!
clientSecret: String!
}
type FacebookAuthIntegration {
enabled: Boolean!
config: FacebookAuthIntegrationConfig @auth(roles: [ADMIN])
}
type AuthIntegrations {
local: LocalAuthIntegration!
sso: SSOAuthIntegration!
oidc: OIDCAuthIntegration!
google: GoogleAuthIntegration!
facebook: FacebookAuthIntegration!
}
"""
AuthSettings contains all the settings related to authentication and
authorization.
"""
type AuthSettings {
"""
integrations are the set of configurations for the variations of
authentication solutions.
"""
integrations: AuthIntegrations!
}
################################################################################
## Settings
################################################################################
"""
Settings stores the global settings for a given Tenant.
"""
type Settings {
"""
domain is the domain that is associated with this Tenant.
"""
domain: String @auth(roles: [ADMIN])
"""
moderation is the moderation mode for all Asset's on the site.
"""
moderation: MODERATION_MODE!
moderation: MODERATION_MODE @auth(roles: [ADMIN])
"""
Enables a requirement for email confirmation before a user can login.
@@ -87,13 +217,13 @@ type Settings {
"""
premodLinksEnable will put all comments that contain links into premod.
"""
premodLinksEnable: Boolean!
premodLinksEnable: Boolean @auth(roles: [ADMIN])
"""
autoCloseStream when true will auto close the stream when the `closeTimeout`
amount of seconds have been reached.
"""
autoCloseStream: Boolean!
autoCloseStream: Boolean! @auth(roles: [ADMIN])
"""
customCssUrl is the URL of the custom CSS used to display on the frontend.
@@ -152,18 +282,79 @@ type Settings {
"""
wordlist will return a given list of words.
"""
wordlist: Wordlist!
wordlist: WordlistSettings @auth(roles: [ADMIN, MODERATOR])
"""
domains will return a given list of whitelisted domains.
"""
domains: [String!]!
domains: [String!] @auth(roles: [ADMIN]) @auth(roles: [ADMIN])
"""
auth contains all the settings related to authentication and authorization.
"""
auth: AuthSettings!
}
################################################################################
## User
################################################################################
enum USER_ROLE {
COMMENTER
MODERATOR
ADMIN
}
enum USER_USERNAME_STATUS {
"""
UNSET is used when the username can be changed, and does not necessarily
require moderator action to become active. This can be used when the user
signs up with a social login and has the option of setting their own
username.
"""
UNSET
"""
SET is used when the username has been set for the first time, but cannot
change without the username being rejected by a moderator and that moderator
agreeing that the username should be allowed to change.
"""
SET
"""
APPROVED is used when the username was changed, and subsequently approved by
said moderator.
"""
APPROVED
"""
REJECTED is used when the username was changed, and subsequently rejected by
said moderator.
"""
REJECTED
"""
CHANGED is used after a user has changed their username after it was
rejected.
"""
CHANGED
}
type LocalProfile {
id: String!
}
type OIDCProfile {
id: String!
provider: String!
}
type SSOProfile {
id: String!
}
union Profile = LocalProfile | OIDCProfile | SSOProfile
"""
User is someone that leaves Comments, and logs in.
"""
@@ -176,7 +367,22 @@ type User {
"""
username is the name of the User visible to other Users.
"""
username: String!
username: String
"""
displayName is provided optionally when enabled and available.
"""
displayName: String
"""
profiles is the array of profiles assigned to the user.
"""
profiles: [Profile!] @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
role is the current role of the User.
"""
role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
}
################################################################################
@@ -391,9 +597,9 @@ type Query {
assets(cursor: Cursor, limit: Int = 10): AssetsConnection
"""
asset is the Asset specified by its ID.
asset is the Asset specified by its ID/URL.
"""
asset(id: ID!): Asset
asset(id: ID, url: String): Asset
"""
me is the current logged in User.
@@ -463,7 +669,7 @@ type Mutation {
"""
createComment will create a Comment as the current logged in User.
"""
createComment(input: CreateCommentInput!): CreateCommentPayload
createComment(input: CreateCommentInput!): CreateCommentPayload @auth
}
################################################################################
+6
View File
@@ -1,9 +1,11 @@
import express, { Express } from "express";
import http from "http";
import { createJWTSigningConfig } from "talk-server/app/middleware/passport/jwt";
import getManagementSchema from "talk-server/graph/management/schema";
import { Schemas } from "talk-server/graph/schemas";
import getTenantSchema from "talk-server/graph/tenant/schema";
import { attachSubscriptionHandlers, createApp, listenAndServe } from "./app";
import config, { Config } from "./config";
import logger from "./logger";
@@ -63,6 +65,9 @@ class Server {
// Setup Redis.
const redis = await createRedisClient(config);
// Create the signing config.
const signingConfig = createJWTSigningConfig(this.config);
// Create the Talk App, branching off from the parent app.
const app: Express = await createApp({
parent,
@@ -70,6 +75,7 @@ class Server {
redis,
config: this.config,
schemas: this.schemas,
signingConfig,
});
// Start the application and store the resulting http.Server.
+4 -1
View File
@@ -1,5 +1,8 @@
import bunyan from "bunyan";
const logger = bunyan.createLogger({ name: "talk" });
const logger = bunyan.createLogger({
name: "talk",
serializers: bunyan.stdSerializers,
});
export default logger;
+100 -30
View File
@@ -1,10 +1,10 @@
import dotize from "dotize";
import { defaults } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit } from "talk-common/types";
import { TenantResource } from "talk-server/models/tenant";
import uuid from "uuid";
import Query from "./query";
function collection(db: Db) {
return db.collection<Readonly<Asset>>("assets");
@@ -27,48 +27,103 @@ export interface Asset extends TenantResource {
created_at: Date;
}
export type CreateAssetInput = Pick<Asset, "id" | "url">;
export interface UpsertAssetInput {
id?: string;
url: string;
}
export async function createAsset(
export async function upsertAsset(
db: Db,
tenantID: string,
input: CreateAssetInput
{ id, url }: UpsertAssetInput
) {
const now = new Date();
// Construct the filter.
const query = new Query<Asset>(collection(db)).where({
tenant_id: tenantID,
});
if (input.id) {
query.where({ id: input.id });
} else {
query.where({ url: input.url });
}
// TODO: verify that the url for the given Asset is whitelisted by the tenant.
// Craft the update object.
// Create the asset, optionally sourcing the id from the input, additionally
// porting in the tenant_id.
const update: { $setOnInsert: Asset } = {
$setOnInsert: defaults(input, {
id: uuid.v4(),
tenant_id: tenantID,
created_at: now,
}),
$setOnInsert: defaults(
{
url,
tenant_id: tenantID,
created_at: now,
},
{ id },
{
id: uuid.v4(),
}
),
};
// Perform the upsert operation.
const result = await collection(db).findOneAndUpdate(query.filter, update, {
// Create the object if it doesn't already exist.
upsert: true,
// False to return the updated document instead of the original
// document.
returnOriginal: false,
});
// Perform the find and update operation to try and find and or create the
// asset.
const { value: asset } = await collection(db).findOneAndUpdate(
{ url },
update,
{
// Create the object if it doesn't already exist.
upsert: true,
return result.value || null;
// False to return the updated document instead of the original
// document.
returnOriginal: false,
}
);
if (!asset) {
return null;
}
if (!asset.scraped) {
// TODO: create scrape job to collect asset metadata
}
return asset;
}
export interface FindOrCreateAssetInput {
id?: string;
url?: string;
}
export async function findOrCreateAsset(
db: Db,
tenantID: string,
{ id, url }: FindOrCreateAssetInput
) {
if (id) {
if (url) {
// The URL was specified, this is an upsert operation.
return upsertAsset(db, tenantID, {
id,
url,
});
}
// The URL was not specified, this is a lookup operation.
return retrieveAsset(db, tenantID, id);
}
// The ID was not specified, this is an upsert operation. Check to see that
// the URL exists.
if (!url) {
throw new Error("cannot upsert an asset without the url");
}
return upsertAsset(db, tenantID, { url });
}
export async function retrieveAssetByURL(
db: Db,
tenantID: string,
url: string
) {
return collection(db).findOne({ url, tenant_id: tenantID });
}
export async function retrieveAsset(db: Db, tenantID: string, id: string) {
return await collection(db).findOne({ id, tenant_id: tenantID });
return collection(db).findOne({ id, tenant_id: tenantID });
}
export async function retrieveManyAssets(
@@ -86,6 +141,21 @@ export async function retrieveManyAssets(
return ids.map(id => assets.find(asset => asset.id === id) || null);
}
export async function retrieveManyAssetsByURL(
db: Db,
tenantID: string,
urls: string[]
) {
const cursor = await collection(db).find({
url: { $in: urls },
tenant_id: tenantID,
});
const assets = await cursor.toArray();
return urls.map(url => assets.find(asset => asset.url === url) || null);
}
export type UpdateAssetInput = Omit<
Partial<Asset>,
"id" | "tenant_id" | "url" | "created_at"
+28 -36
View File
@@ -1,6 +1,12 @@
import { merge } from "lodash";
import { Db } from "mongodb";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import {
GQLCOMMENT_SORT,
GQLCOMMENT_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ActionCounts } from "talk-server/models/actions";
import {
Connection,
@@ -11,7 +17,6 @@ import {
} from "talk-server/models/connection";
import Query from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
import uuid from "uuid";
function collection(db: Db) {
return db.collection<Readonly<Comment>>("comments");
@@ -23,35 +28,25 @@ export interface BodyHistoryItem {
}
export interface StatusHistoryItem {
status: CommentStatus; // TODO: migrate field
status: GQLCOMMENT_STATUS; // TODO: migrate field
assigned_by?: string;
created_at: Date;
}
export enum CommentStatus {
ACCEPTED = "ACCEPTED",
REJECTED = "REJECTED",
PREMOD = "PREMOD",
SYSTEM_WITHHELD = "SYSTEM_WITHHELD",
NONE = "NONE",
}
export interface Comment extends TenantResource {
readonly id: string;
parent_id?: string;
parent_id: string | null;
author_id: string;
asset_id: string;
body: string;
body_history: BodyHistoryItem[];
status: CommentStatus;
status: GQLCOMMENT_STATUS;
status_history: StatusHistoryItem[];
action_counts: ActionCounts;
reply_count: number;
created_at: Date;
deleted_at?: Date;
metadata?: {
[_: string]: any;
};
metadata?: Record<string, any>;
}
export type CreateCommentInput = Omit<
@@ -64,7 +59,7 @@ export type CreateCommentInput = Omit<
| "status_history"
>;
export async function create(
export async function createComment(
db: Db,
tenantID: string,
input: CreateCommentInput
@@ -110,11 +105,15 @@ export async function create(
return comment;
}
export async function retrieve(db: Db, tenantID: string, id: string) {
export async function retrieveComment(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
}
export async function retrieveMany(db: Db, tenantID: string, ids: string[]) {
export async function retrieveManyComments(
db: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(db).find({
id: {
$in: ids,
@@ -127,16 +126,9 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) {
return ids.map(id => comments.find(comment => comment.id === id) || null);
}
export enum CommentSort {
CREATED_AT_DESC = "CREATED_AT_DESC",
CREATED_AT_ASC = "CREATED_AT_ASC",
REPLIES_DESC = "REPLIES_DESC",
RESPECT_DESC = "RESPECT_DESC",
}
export interface ConnectionInput {
first: number;
orderBy: CommentSort;
orderBy: GQLCOMMENT_SORT;
after?: Cursor;
}
@@ -144,11 +136,11 @@ function cursorGetterFactory(
input: ConnectionInput
): NodeToCursorTransformer<Comment> {
switch (input.orderBy) {
case CommentSort.CREATED_AT_DESC:
case CommentSort.CREATED_AT_ASC:
case GQLCOMMENT_SORT.CREATED_AT_DESC:
case GQLCOMMENT_SORT.CREATED_AT_ASC:
return comment => comment.created_at;
case CommentSort.REPLIES_DESC:
case CommentSort.RESPECT_DESC:
case GQLCOMMENT_SORT.REPLIES_DESC:
case GQLCOMMENT_SORT.RESPECT_DESC:
return (_, index) =>
(input.after ? (input.after as number) : 0) + index + 1;
}
@@ -162,7 +154,7 @@ function cursorGetterFactory(
* @param parentID the parent id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveRepliesConnection(
export async function retrieveCommentRepliesConnection(
db: Db,
tenantID: string,
assetID: string,
@@ -188,7 +180,7 @@ export async function retrieveRepliesConnection(
* @param assetID the Asset id for the comment to retrieve
* @param input connection configuration
*/
export async function retrieveAssetConnection(
export async function retrieveCommentAssetConnection(
db: Db,
tenantID: string,
assetID: string,
@@ -254,25 +246,25 @@ async function retrieveConnection(
function applyInputToQuery(input: ConnectionInput, query: Query<Comment>) {
switch (input.orderBy) {
case CommentSort.CREATED_AT_DESC:
case GQLCOMMENT_SORT.CREATED_AT_DESC:
query.orderBy({ created_at: -1 });
if (input.after) {
query.where({ created_at: { $lt: input.after as Date } });
}
break;
case CommentSort.CREATED_AT_ASC:
case GQLCOMMENT_SORT.CREATED_AT_ASC:
query.orderBy({ created_at: 1 });
if (input.after) {
query.where({ created_at: { $gt: input.after as Date } });
}
break;
case CommentSort.REPLIES_DESC:
case GQLCOMMENT_SORT.REPLIES_DESC:
query.orderBy({ reply_count: -1, created_at: -1 });
if (input.after) {
query.after(input.after as number);
}
break;
case CommentSort.RESPECT_DESC:
case GQLCOMMENT_SORT.RESPECT_DESC:
query.orderBy({ "action_counts.respect": -1, created_at: -1 });
if (input.after) {
query.after(input.after as number);
+115 -7
View File
@@ -1,9 +1,14 @@
import dotize from "dotize";
import { merge } from "lodash";
import { Db } from "mongodb";
import { Sub } from "talk-common/types";
import uuid from "uuid";
import { Sub } from "talk-common/types";
import {
GQLMODERATION_MODE,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
function collection(db: Db) {
return db.collection<Readonly<Tenant>>("tenants");
}
@@ -17,11 +22,104 @@ export interface Wordlist {
suspect: string[];
}
export enum Moderation {
PRE = "PRE",
POST = "POST",
// AuthIntegrations.
export interface EmailDomainRuleCondition {
// emailDomain is the domain name component of the email addresses that should
// match for this condition.
emailDomain: string;
// emailVerifiedRequired stipulates that this rule only applies when the user
// account has been marked as having their email address already verified.
emailVerifiedRequired: boolean;
}
// RoleRule describes the role assignment for when a user logs into Talk, how
// they can have their account automatically upgraded to a specific role when
// the domain for their email matches the one provided.
export interface RoleRule extends Partial<EmailDomainRuleCondition> {
// role is the specific GQLUSER_ROLE that should be assigned to the newly created
// user depending on their email address.
role: GQLUSER_ROLE;
}
export interface AuthRules {
// roles allow the configuration of automatic role assignment based on the
// user's email address.
roles?: RoleRule[];
// restrictTo when populated, will restrict which users can login using this
// integration. If a user successfully logs in using the OIDCStrategy, but
// does not match the following rules, the user will not be created.
restrictTo?: EmailDomainRuleCondition[];
}
export interface AuthIntegration {
enabled: boolean;
}
export interface DisplayNameAuthIntegration {
displayNameEnable: boolean;
}
// SSOAuthIntegration is an AuthIntegration that provides a secret to the admins
// of a tenant, where they can sign a SSO payload with it to provide to the
// embed to allow single sign on.
export interface SSOAuthIntegration
extends AuthIntegration,
DisplayNameAuthIntegration {
key: string;
}
// OIDCAuthIntegration provides a way to store Open ID Connect credentials. This
// will be used in the admin to provide staff logins for users.
export interface OIDCAuthIntegration
extends AuthIntegration,
DisplayNameAuthIntegration {
clientID: string;
clientSecret: string;
issuer: string;
authorizationURL: string;
jwksURI: string;
tokenURL: string;
}
export interface FacebookAuthIntegration extends AuthIntegration {
clientID: string;
clientSecret: string;
}
export interface GoogleAuthIntegration extends AuthIntegration {
clientID: string;
clientSecret: string;
}
export type LocalAuthIntegration = AuthIntegration;
// AuthIntegrations describes all of the possible auth integration configurations.
export interface AuthIntegrations {
// local is the auth integration for the local auth.
local: LocalAuthIntegration;
// sso is the external auth integration for the single sign on auth.
sso?: SSOAuthIntegration;
// sso is the external auth integration for the OpenID Connect auth.
oidc?: OIDCAuthIntegration;
// sso is the external auth integration for the Google auth.
google?: GoogleAuthIntegration;
// sso is the external auth integration for the Facebook auth.
facebook?: FacebookAuthIntegration;
}
export interface Auth {
integrations: AuthIntegrations;
}
// Tenant definition.
export interface Tenant {
readonly id: string;
@@ -29,7 +127,7 @@ export interface Tenant {
// specific tenant that the API request pertains to.
domain: string;
moderation: Moderation;
moderation: GQLMODERATION_MODE;
requireEmailConfirmation: boolean;
infoBoxEnable: boolean;
infoBoxContent?: string;
@@ -57,6 +155,9 @@ export interface Tenant {
// domains is the set of whitelisted domains.
domains: string[];
// Set of configured authentication integrations.
auth: Auth;
}
/**
@@ -81,7 +182,7 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
id: uuid.v4(),
// Default to post moderation.
moderation: Moderation.POST,
moderation: GQLMODERATION_MODE.POST,
// Email confirmation is default off.
requireEmailConfirmation: false,
@@ -99,6 +200,13 @@ export async function createTenant(db: Db, input: CreateTenantInput) {
suspect: [],
banned: [],
},
auth: {
integrations: {
local: {
enabled: true,
},
},
},
};
// Create the new Tenant by merging it together with the defaults.
@@ -114,7 +222,7 @@ export async function retrieveTenantByDomain(db: Db, domain: string) {
return collection(db).findOne({ domain });
}
export async function retrieve(db: Db, id: string) {
export async function retrieveTenant(db: Db, id: string) {
return collection(db).findOne({ id });
}
+135 -62
View File
@@ -1,112 +1,107 @@
import bcrypt from "bcryptjs";
import { merge } from "lodash";
import { Db } from "mongodb";
import { Omit, Sub } from "talk-common/types";
import { ActionCounts } from "talk-server/models/actions";
import { TenantResource } from "talk-server/models/tenant";
import uuid from "uuid";
import { Omit, Sub } from "talk-common/types";
import {
GQLUSER_ROLE,
GQLUSER_USERNAME_STATUS,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { ActionCounts } from "talk-server/models/actions";
import { FilterQuery } from "talk-server/models/query";
import { TenantResource } from "talk-server/models/tenant";
function collection(db: Db) {
return db.collection<Readonly<User>>("users");
}
export interface Profile {
readonly id: string;
provider: string;
export interface LocalProfile {
type: "local";
id: string;
}
export interface OIDCProfile {
type: "oidc";
id: string;
issuer: string;
audience: string;
}
export interface SSOProfile {
type: "sso";
id: string;
}
export type Profile = LocalProfile | OIDCProfile | SSOProfile;
export interface Token {
readonly id: string;
name: string;
active: boolean;
}
export enum UserUsernameStatus {
// UNSET is used when the username can be changed, and does not necessarily
// require moderator action to become active. This can be used when the user
// signs up with a social login and has the option of setting their own
// username.
UNSET = "UNSET",
// SET is used when the username has been set for the first time, but cannot
// change without the username being rejected by a moderator and that moderator
// agreeing that the username should be allowed to change.
SET = "SET",
// APPROVED is used when the username was changed, and subsequently approved by
// said moderator.
APPROVED = "APPROVED",
// REJECTED is used when the username was changed, and subsequently rejected by
// said moderator.
REJECTED = "REJECTED",
// CHANGED is used after a user has changed their username after it was
// rejected.
CHANGED = "CHANGED",
}
export enum UserRole {
ADMIN = "ADMIN",
MODERATOR = "MODERATOR",
STAFF = "STAFF",
COMMENTER = "COMMENTER",
}
export interface UserStatusHistory<T> {
status: T; // TODO: migrate field
status: T;
assigned_by?: string;
reason?: string; // TODO: migrate field
reason?: string;
created_at: Date;
}
export interface UserStatusItem<T> {
status: T; // TODO: migrate field
status: T;
history: Array<UserStatusHistory<T>>;
}
export interface UserStatus {
username: UserStatusItem<UserUsernameStatus>;
username: UserStatusItem<GQLUSER_USERNAME_STATUS>;
banned: UserStatusItem<boolean>;
suspension: UserStatusItem<Date | null>;
}
export interface User extends TenantResource {
readonly id: string;
username: string;
username: string | null;
displayName?: string;
password?: string;
avatar?: string;
email?: string;
email_verified?: boolean;
profiles: Profile[];
tokens: Token[];
role: UserRole;
role: GQLUSER_ROLE;
status: UserStatus;
action_counts: ActionCounts;
ignored_users: string[]; // TODO: migrate field
ignored_users: string[];
created_at: Date;
}
export type CreateUserInput = Omit<
export type UpsertUserInput = Omit<
User,
| "id"
| "tenant_id"
| "tokens"
| "status"
| "role"
| "action_counts"
| "ignored_users"
| "created_at"
>;
export async function create(db: Db, tenantID: string, input: CreateUserInput) {
export async function upsertUser(
db: Db,
tenantID: string,
input: UpsertUserInput
) {
const now = new Date();
// // Pull out some useful properties from the input.
// const { body, status } = input;
// Create a new ID for the user.
const id = uuid.v4();
// default are the properties set by the application when a new user is
// created.
const defaults: Sub<User, CreateUserInput> = {
id: uuid.v4(),
const defaults: Sub<User, UpsertUserInput> = {
id,
tenant_id: tenantID,
role: UserRole.COMMENTER,
tokens: [],
action_counts: {},
ignored_users: [],
@@ -120,27 +115,84 @@ export async function create(db: Db, tenantID: string, input: CreateUserInput) {
history: [],
},
username: {
status: UserUsernameStatus.SET,
status: input.username
? GQLUSER_USERNAME_STATUS.SET
: GQLUSER_USERNAME_STATUS.UNSET,
history: [],
},
},
created_at: now,
};
let hashedPassword;
if (input.password) {
// Hash the user's password with bcrypt.
hashedPassword = await bcrypt.hash(input.password, 10);
}
// Merge the defaults and the input together.
const user: Readonly<User> = merge({}, defaults, input);
const user: Readonly<User> = merge({}, defaults, input, {
// Specified last in the merge call, it will override any existing password
// entry if it is defined.
password: hashedPassword,
});
// Insert it into the database.
await collection(db).insertOne(user);
// Create a query that will utilize a findOneAndUpdate to facilitate an upsert
// operation to ensure no user has the same profile and/or email address. If
// any user is found to have the same profile as any of the profiles specified
// in the new user object, then we should error here.
const filter = createUpsertUserFilter(user);
return user;
// Create the upsert/update operation.
const update: { $setOnInsert: Readonly<User> } = {
$setOnInsert: user,
};
// Insert it into the database. This may throw an error.
const result = await collection(db).findOneAndUpdate(filter, update, {
// We are using this to create a user, so we need to upsert it.
upsert: true,
// False to return the updated document instead of the original document.
// This lets us detect if the document was updated or not.
returnOriginal: false,
});
// Check to see if this was a new user that was upserted, or one was found
// that matched existing records. We are sure here that the record exists
// because we're returning the updated document and performing an upsert
// operation.
if (result.value!.id !== id) {
// TODO: return better error.
throw new Error("user already found");
}
return result.value!;
}
export async function retrieve(db: Db, tenantID: string, id: string) {
const createUpsertUserFilter = (user: Readonly<User>) => {
const query: FilterQuery<User> = {
// Query by the profiles if the user is being created with one.
$or: user.profiles.map(profile => ({ profiles: { $elemMatch: profile } })),
};
if (user.email) {
// Query by the email address if the user is being created with one.
query.$or.push({ email: user.email });
}
return query;
};
export async function retrieveUser(db: Db, tenantID: string, id: string) {
return collection(db).findOne({ id, tenant_id: tenantID });
}
export async function retrieveMany(db: Db, tenantID: string, ids: string[]) {
export async function retrieveManyUsers(
db: Db,
tenantID: string,
ids: string[]
) {
const cursor = await collection(db).find({
id: {
$in: ids,
@@ -153,11 +205,24 @@ export async function retrieveMany(db: Db, tenantID: string, ids: string[]) {
return ids.map(id => users.find(comment => comment.id === id) || null);
}
export async function updateRole(
export async function retrieveUserWithProfile(
db: Db,
tenantID: string,
profile: Profile
) {
return collection(db).findOne({
tenant_id: tenantID,
profiles: {
$elemMatch: profile,
},
});
}
export async function updateUserRole(
db: Db,
tenantID: string,
id: string,
role: UserRole
role: GQLUSER_ROLE
) {
const result = await collection(db).findOneAndUpdate(
{ id, tenant_id: tenantID },
@@ -167,3 +232,11 @@ export async function updateRole(
return result.value || null;
}
export async function verifyUserPassword(user: User, password: string) {
if (user.password) {
return bcrypt.compare(password, user.password);
}
return false;
}
+21
View File
@@ -0,0 +1,21 @@
import { Db } from "mongodb";
import {
findOrCreateAsset,
FindOrCreateAssetInput,
} from "talk-server/models/asset";
import { Tenant } from "talk-server/models/tenant";
export type FindOrCreateAsset = FindOrCreateAssetInput;
export async function findOrCreate(
db: Db,
tenant: Tenant,
input: FindOrCreateAsset
) {
// TODO: check to see if the tenant has enabled lazy asset creation.
const asset = await findOrCreateAsset(db, tenant.id, input);
return asset;
}
+6 -13
View File
@@ -1,26 +1,19 @@
import { Db } from "mongodb";
import { Omit } from "talk-common/types";
import {
Comment,
CommentStatus,
create as createComment,
CreateCommentInput,
} from "talk-server/models/comment";
import { GQLCOMMENT_STATUS } from "talk-server/graph/tenant/schema/__generated__/types";
import { createComment, CreateCommentInput } from "talk-server/models/comment";
import { Tenant } from "talk-server/models/tenant";
export type CreateComment = Omit<
CreateCommentInput,
"status" | "action_counts"
>;
export async function create(
db: Db,
tenantID: string,
input: CreateComment
): Promise<Comment> {
export async function create(db: Db, tenant: Tenant, input: CreateComment) {
// TODO: run the comment through the moderation phases.
const comment = await createComment(db, tenantID, {
status: CommentStatus.ACCEPTED,
const comment = await createComment(db, tenant.id, {
status: GQLCOMMENT_STATUS.ACCEPTED,
action_counts: {},
...input,
});
+12
View File
@@ -0,0 +1,12 @@
import { Db } from "mongodb";
import { Tenant } from "talk-server/models/tenant";
import { upsertUser, UpsertUserInput } from "talk-server/models/user";
export type UpsertUser = UpsertUserInput;
export async function upsert(db: Db, tenant: Tenant, input: UpsertUser) {
const user = await upsertUser(db, tenant.id, input);
return user;
}
+6
View File
@@ -1,3 +1,9 @@
import dotenv from "dotenv";
// Apply all the configuration provided in the .env file if it isn't already in
// the environment.
dotenv.config();
import express from "express";
import logger from "talk-server/logger";
+1 -1
View File
@@ -9,7 +9,7 @@
"outDir": "../dist",
// See https://github.com/prismagraphql/graphql-request/issues/26 for why we
// have to include "dom" here.
"lib": ["es6", "esnext.asynciterable", "dom"],
"lib": ["es2017", "es6", "esnext.asynciterable", "dom"],
"baseUrl": "./",
"paths": {
"talk-server/*": ["./core/server/*"],
+19
View File
@@ -0,0 +1,19 @@
import { VerifyOptions, VerifyCallback } from "jsonwebtoken";
declare module "jsonwebtoken" {
export type KeyFunctionCallback = (
err: Error | null,
secretOrPublicKey?: string | Buffer
) => void;
export type KeyFunction = (
headers: { kid?: string },
callback: KeyFunctionCallback
) => void;
export function verify(
token: string,
secretOrPublicKey: string | Buffer | KeyFunction,
options?: VerifyOptions,
callback?: VerifyCallback
): void;
}
+16
View File
@@ -0,0 +1,16 @@
declare module "webfinger" {
export interface WebfingerOptions {
webfingerOnly?: boolean;
}
export interface WebfingerCallback {
(err: Error, jrd: { [key: string]: any }): void;
}
export function webfinger(
resource: string,
res: string,
options: WebfingerOptions,
callback: WebfingerCallback
): void;
}
+1 -7
View File
@@ -15,12 +15,6 @@
"noErrorTruncation": true,
"lib": ["es6", "esnext.asynciterable"]
},
"include": [
"./src/**/.*.js",
"./scripts/**/*",
"./config/**/*",
"./test/**/*",
"*.js"
],
"include": ["./src/**/.*.js", "./scripts/**/*", "./config/**/*", "*.js"],
"exclude": ["node_modules"]
}