mirror of
https://github.com/wassname/talk.git
synced 2026-06-27 17:50:42 +08:00
[next] Error and Logging Improvements (#2152)
* feat: added locale support for Tenant * feat: added secret scrubbing to logs * chore: cleanup logger * chore: logger improvements * feat: re-introduce scoped pretty logger * feat: added initial error support * refactor: replace trace-error.TraceError with talk.InternalError * fix: fixed error logging * refactor: replaced Error with VError * fix: repaired issue with error management on api * fix: patched bug with not found handler * feat: added translations * feat: added location path to invalid entries * refactor: refactored error handling on graph * fix: moved indexing operations to master node * refactor: added throw for when the message isn't found in testing * fix: removed duplicate log * fix: fixed naming on environment variable
This commit is contained in:
@@ -16,4 +16,9 @@ module.exports = {
|
||||
"^talk-common/(.*)$": "<rootDir>/src/core/common/$1",
|
||||
},
|
||||
moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"],
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
useBabelrc: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+10
-7
@@ -23,13 +23,16 @@ gulp.task("server:schema", () => generateTypescriptTypes());
|
||||
|
||||
gulp.task("server:scripts", () =>
|
||||
gulp
|
||||
.src([
|
||||
"./src/**/*.ts",
|
||||
"./src/**/.*.ts",
|
||||
// Exclude client files from this, that's for webpack.
|
||||
"!./src/core/client/**/*.ts",
|
||||
"!./src/core/client/**/.*.ts",
|
||||
])
|
||||
.src(
|
||||
[
|
||||
"./src/**/*.ts",
|
||||
"./src/**/.*.ts",
|
||||
// Exclude client files from this, that's for webpack.
|
||||
"!./src/core/client/**/*.ts",
|
||||
"!./src/core/client/**/.*.ts",
|
||||
],
|
||||
{ base: "src" }
|
||||
)
|
||||
.pipe(sourcemaps.init())
|
||||
.pipe(tsProject())
|
||||
.pipe(
|
||||
|
||||
Generated
+65
-38
@@ -1620,6 +1620,11 @@
|
||||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@coralproject/bunyan-prettystream": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/bunyan-prettystream/-/bunyan-prettystream-0.1.4.tgz",
|
||||
"integrity": "sha512-Bl38cx/rS/oRqb0gvzT/mtA3BNWdLwqLEyI5Jdp8mA1CG+pOL7NQZeLEz8OIRI/ie6xBnjT7CoVKscjfmpX7nw=="
|
||||
},
|
||||
"@coralproject/rte": {
|
||||
"version": "0.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@coralproject/rte/-/rte-0.10.13.tgz",
|
||||
@@ -2064,15 +2069,6 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/bunyan-prettystream": {
|
||||
"version": "0.1.31",
|
||||
"resolved": "https://registry.npmjs.org/@types/bunyan-prettystream/-/bunyan-prettystream-0.1.31.tgz",
|
||||
"integrity": "sha512-NE7fq2ZcX7OSMK+VhTNJkVEHlo+hm0uVXpuLeH1ifGm52Qwuo/kLD2GHo7UcEXMFu3duKver/AFo8C4TME93zw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/case-sensitive-paths-webpack-plugin": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.1.2.tgz",
|
||||
@@ -2641,6 +2637,21 @@
|
||||
"integrity": "sha512-yxzBCIjE3lp9lYjfBbIK/LRCoXgCLLbIIBIje7eNCcUIIR2CZZtyX5uto2hVoMSMqLrsRrT6mwwUEd0yFgOwpA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/source-map-support": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/source-map-support/-/source-map-support-0.4.1.tgz",
|
||||
"integrity": "sha512-eoyZxYGwaeHq5zCVeoNgY1dQy6dVdm1b7K9k1FRnWkf997Tji3NLBoLAjK5WCobeh1Qs6Q5KUV1rZCmHvzaDBw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/stack-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-1.0.1.tgz",
|
||||
"integrity": "sha512-l42BggppR6zLmpfU6fq9HEa2oGPEI8yrSPL3GITjfRInppYFahObbIQOQK3UGxEnyQpltZLaPe75046NOZQikw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/tapable": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.4.tgz",
|
||||
@@ -2691,6 +2702,12 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/verror": {
|
||||
"version": "1.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/verror/-/verror-1.10.3.tgz",
|
||||
"integrity": "sha512-7Jz0MPsW2pWg5dJfEp9nJYI0RDCYfgjg2wIo5HfQ8vOJvUq0/BxT7Mv2wNQvkKBmV9uT++6KF3reMnLmh/0HrA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/vinyl": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/vinyl/-/vinyl-2.0.2.tgz",
|
||||
@@ -6251,8 +6268,7 @@
|
||||
"buffer-from": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz",
|
||||
"integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-c5mRlguI/Pe2dSZmpER62rSCu0ryKmWddzRYsuXc50U2/g8jMOulc31VZMa4mYx31U5xsmSOpDCgH88Vl9cDGQ=="
|
||||
},
|
||||
"buffer-indexof": {
|
||||
"version": "1.1.1",
|
||||
@@ -6347,11 +6363,6 @@
|
||||
"safe-json-stringify": "~1"
|
||||
}
|
||||
},
|
||||
"bunyan-prettystream": {
|
||||
"version": "0.1.3",
|
||||
"resolved": "https://registry.npmjs.org/bunyan-prettystream/-/bunyan-prettystream-0.1.3.tgz",
|
||||
"integrity": "sha1-bDtxMmb2rTIAfHtqsemYokU0nZg="
|
||||
},
|
||||
"busboy": {
|
||||
"version": "0.2.14",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-0.2.14.tgz",
|
||||
@@ -11378,7 +11389,7 @@
|
||||
"integrity": "sha1-ETOUSrJHeINHOZVZaIPg05z4hc8=",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"intl-pluralrules": "github:projectfluent/IntlPluralRules#94cb0fa1c23ad943bc5aafef43cea132fa51d68b"
|
||||
"intl-pluralrules": "github:projectfluent/IntlPluralRules#module"
|
||||
}
|
||||
},
|
||||
"fluent-langneg": {
|
||||
@@ -11672,7 +11683,8 @@
|
||||
"ansi-regex": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz",
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8="
|
||||
"integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=",
|
||||
"optional": true
|
||||
},
|
||||
"aproba": {
|
||||
"version": "1.2.0",
|
||||
@@ -11693,12 +11705,14 @@
|
||||
"balanced-match": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz",
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
|
||||
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=",
|
||||
"optional": true
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
@@ -11713,17 +11727,20 @@
|
||||
"code-point-at": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz",
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c="
|
||||
"integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=",
|
||||
"optional": true
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
|
||||
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=",
|
||||
"optional": true
|
||||
},
|
||||
"console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4="
|
||||
"integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=",
|
||||
"optional": true
|
||||
},
|
||||
"core-util-is": {
|
||||
"version": "1.0.2",
|
||||
@@ -11840,7 +11857,8 @@
|
||||
"inherits": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz",
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4="
|
||||
"integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=",
|
||||
"optional": true
|
||||
},
|
||||
"ini": {
|
||||
"version": "1.3.5",
|
||||
@@ -11852,6 +11870,7 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz",
|
||||
"integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"number-is-nan": "^1.0.0"
|
||||
}
|
||||
@@ -11866,6 +11885,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz",
|
||||
"integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
@@ -11873,12 +11893,14 @@
|
||||
"minimist": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz",
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0="
|
||||
"integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=",
|
||||
"optional": true
|
||||
},
|
||||
"minipass": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-2.2.4.tgz",
|
||||
"integrity": "sha512-hzXIWWet/BzWhYs2b+u7dRHlruXhwdgvlTMDKC6Cb1U7ps6Ac6yQlR39xsbjWJE377YTCtKwIXIpJ5oP+j5y8g==",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"safe-buffer": "^5.1.1",
|
||||
"yallist": "^3.0.0"
|
||||
@@ -11897,6 +11919,7 @@
|
||||
"version": "0.5.1",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz",
|
||||
"integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"minimist": "0.0.8"
|
||||
}
|
||||
@@ -11977,7 +12000,8 @@
|
||||
"number-is-nan": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0="
|
||||
"integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=",
|
||||
"optional": true
|
||||
},
|
||||
"object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -11989,6 +12013,7 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
@@ -12074,7 +12099,8 @@
|
||||
"safe-buffer": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz",
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg=="
|
||||
"integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==",
|
||||
"optional": true
|
||||
},
|
||||
"safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
@@ -12110,6 +12136,7 @@
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz",
|
||||
"integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"code-point-at": "^1.0.0",
|
||||
"is-fullwidth-code-point": "^1.0.0",
|
||||
@@ -12129,6 +12156,7 @@
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz",
|
||||
"integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=",
|
||||
"optional": true,
|
||||
"requires": {
|
||||
"ansi-regex": "^2.0.0"
|
||||
}
|
||||
@@ -12172,12 +12200,14 @@
|
||||
"wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
|
||||
"integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=",
|
||||
"optional": true
|
||||
},
|
||||
"yallist": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.2.tgz",
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k="
|
||||
"integrity": "sha1-hFK0u36Dx8GI2AQcGoN8dz1ti7k=",
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25201,10 +25231,9 @@
|
||||
}
|
||||
},
|
||||
"source-map-support": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.6.tgz",
|
||||
"integrity": "sha512-N4KXEz7jcKqPf2b2vZF11lQIz9W5ZMuUcIOGj243lduidkf2fjkVKJS9vNxVWn3u/uxX38AcE8U9nnH9FPcq+g==",
|
||||
"dev": true,
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.10.tgz",
|
||||
"integrity": "sha512-YfQ3tQFTK/yzlGJuX8pTwa4tifQj4QS2Mj7UegOu8jAz59MqIiMGPXxQhVQiIMNzayuUSF/jEuVnfFF5JqybmQ==",
|
||||
"requires": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"source-map": "^0.6.0"
|
||||
@@ -25213,8 +25242,7 @@
|
||||
"source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -25387,10 +25415,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"stack-utils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.1.tgz",
|
||||
"integrity": "sha1-1PM6tU6OOHeLDKXP07OvsS22hiA=",
|
||||
"dev": true
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.2.tgz",
|
||||
"integrity": "sha512-MTX+MeG5U994cazkjd/9KNAapsHnibjMLnfXodlkXw76JEea0UiNzrqidzo1emMwk7w5Qhc9jd4Bn9TBb1MFwA=="
|
||||
},
|
||||
"stackframe": {
|
||||
"version": "1.0.4",
|
||||
|
||||
+8
-3
@@ -47,12 +47,12 @@
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@coralproject/bunyan-prettystream": "^0.1.4",
|
||||
"akismet-api": "^4.2.0",
|
||||
"apollo-server-express": "^2.1.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bull": "^3.4.4",
|
||||
"bunyan": "^1.8.12",
|
||||
"bunyan-prettystream": "^0.1.3",
|
||||
"cheerio": "^1.0.0-rc.2",
|
||||
"consolidate": "0.14.0",
|
||||
"content-security-policy-builder": "^2.0.0",
|
||||
@@ -103,11 +103,14 @@
|
||||
"performance-now": "^2.1.0",
|
||||
"permit": "^0.2.4",
|
||||
"react-relay-network-modern": "^2.4.0",
|
||||
"source-map-support": "^0.5.10",
|
||||
"stack-utils": "^1.0.2",
|
||||
"striptags": "^3.1.1",
|
||||
"subscriptions-transport-ws": "^0.9.12",
|
||||
"throng": "^4.0.0",
|
||||
"tlds": "^1.203.1",
|
||||
"uuid": "^3.3.2"
|
||||
"uuid": "^3.3.2",
|
||||
"verror": "^1.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.2.0",
|
||||
@@ -121,7 +124,6 @@
|
||||
"@types/bcryptjs": "^2.4.1",
|
||||
"@types/bull": "^3.3.16",
|
||||
"@types/bunyan": "^1.8.4",
|
||||
"@types/bunyan-prettystream": "^0.1.31",
|
||||
"@types/case-sensitive-paths-webpack-plugin": "^2.1.2",
|
||||
"@types/cheerio": "^0.22.8",
|
||||
"@types/chokidar": "^1.7.5",
|
||||
@@ -173,9 +175,12 @@
|
||||
"@types/relay-runtime": "^1.3.6",
|
||||
"@types/sane": "^2.0.0",
|
||||
"@types/sinon": "^5.0.1",
|
||||
"@types/source-map-support": "^0.4.1",
|
||||
"@types/stack-utils": "^1.0.1",
|
||||
"@types/throng": "^4.0.2",
|
||||
"@types/tlds": "^1.199.0",
|
||||
"@types/uuid": "^3.4.3",
|
||||
"@types/verror": "^1.10.3",
|
||||
"@types/vinyl": "^2.0.2",
|
||||
"@types/webpack": "^4.4.7",
|
||||
"@types/webpack-bundle-analyzer": "^2.13.0",
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
export enum ERROR_CODES {
|
||||
/**
|
||||
* STORY_URL_NOT_PERMITTED is used when the given Story being created or
|
||||
* updated does not have a URL that is permitted by the Tenant.
|
||||
*/
|
||||
STORY_URL_NOT_PERMITTED = "STORY_URL_NOT_PERMITTED",
|
||||
|
||||
/**
|
||||
* TOKEN_NOT_FOUND is used when a Token is referenced by ID but can not be
|
||||
* found to be associated with the given User.
|
||||
*/
|
||||
TOKEN_NOT_FOUND = "TOKEN_NOT_FOUND",
|
||||
|
||||
/**
|
||||
* DUPLICATE_STORY_URL is used when trying to create a Story with the same URL
|
||||
* as another Story.
|
||||
*/
|
||||
DUPLICATE_STORY_URL = "DUPLICATE_STORY_URL",
|
||||
|
||||
/**
|
||||
* EMAIL_ALREADY_SET is used when trying to set the email address on a User
|
||||
* when the User already has an email address associated with their account.
|
||||
*/
|
||||
EMAIL_ALREADY_SET = "EMAIL_ALREADY_SET",
|
||||
|
||||
/**
|
||||
* EMAIL_NOT_SET is used when performing an operation that requires that the
|
||||
* email address be set on the User, and it is not.
|
||||
*/
|
||||
EMAIL_NOT_SET = "EMAIL_NOT_SET",
|
||||
|
||||
/**
|
||||
* TENANT_NOT_FOUND is used when the domain being queried does not correspond
|
||||
* to a Tenant.
|
||||
*/
|
||||
TENANT_NOT_FOUND = "TENANT_NOT_FOUND",
|
||||
|
||||
/**
|
||||
* INTERNAL_ERROR is returned when a situation occurs that is not user facing,
|
||||
* such as an unexpected index violation, or a database connection error.
|
||||
*/
|
||||
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||
|
||||
/**
|
||||
* DUPLICATE_USER is returned when a user was attempted to be created twice.
|
||||
* This can occur when a User creates an account with one method, then
|
||||
* attempts to create another user account with another method yielding the
|
||||
* same email address.
|
||||
*/
|
||||
DUPLICATE_USER = "DUPLICATE_USER",
|
||||
|
||||
/**
|
||||
* TOKEN_INVALID is returned when the provided token has an invalid format.
|
||||
*/
|
||||
TOKEN_INVALID = "TOKEN_INVALID",
|
||||
|
||||
/**
|
||||
* DUPLICATE_USERNAME is returned when a user attempts to create an account
|
||||
* with the same username as another user.
|
||||
*/
|
||||
DUPLICATE_USERNAME = "DUPLICATE_USERNAME",
|
||||
|
||||
/**
|
||||
* DUPLICATE_EMAIL is returned when a user attempts to create an account
|
||||
* with the same email address as another user.
|
||||
*/
|
||||
DUPLICATE_EMAIL = "DUPLICATE_EMAIL",
|
||||
|
||||
/**
|
||||
* LOCAL_PROFILE_ALREADY_SET is returned when the user attempts to associate a
|
||||
* local profile when the user already has one.
|
||||
*/
|
||||
LOCAL_PROFILE_ALREADY_SET = "LOCAL_PROFILE_ALREADY_SET",
|
||||
|
||||
/**
|
||||
* LOCAL_PROFILE_NOT_SET is returned when the user attempts to perform an
|
||||
* action which requires a local profile to be associated with the user.
|
||||
*/
|
||||
LOCAL_PROFILE_NOT_SET = "LOCAL_PROFILE_NOT_SET",
|
||||
|
||||
/**
|
||||
* USERNAME_ALREADY_SET is returned when the user attempts to set a username
|
||||
* via the set operations when they already have a username associated with
|
||||
* their account.
|
||||
*/
|
||||
USERNAME_ALREADY_SET = "USERNAME_ALREADY_SET",
|
||||
|
||||
/**
|
||||
* USERNAME_CONTAINS_INVALID_CHARACTERS is returned when the user attempts to
|
||||
* associate a new username that contains invalid characters.
|
||||
*/
|
||||
USERNAME_CONTAINS_INVALID_CHARACTERS = "USERNAME_CONTAINS_INVALID_CHARACTERS",
|
||||
|
||||
/**
|
||||
* USERNAME_EXCEEDS_MAX_LENGTH is returned when the user attempts to associate
|
||||
* a new username that exceeds the maximum length.
|
||||
*/
|
||||
USERNAME_EXCEEDS_MAX_LENGTH = "USERNAME_EXCEEDS_MAX_LENGTH",
|
||||
|
||||
/**
|
||||
* USERNAME_TOO_SHORT is returned when the user attempts to associate a new
|
||||
* username that is too short.
|
||||
*/
|
||||
USERNAME_TOO_SHORT = "USERNAME_TOO_SHORT",
|
||||
|
||||
/**
|
||||
* PASSWORD_TOO_SHORT is returned when the user attempts to associate a new
|
||||
* password but it is too short.
|
||||
*/
|
||||
PASSWORD_TOO_SHORT = "PASSWORD_TOO_SHORT",
|
||||
|
||||
/**
|
||||
* DISPLAY_NAME_EXCEEDS_MAX_LENGTH is returned when the user attempts to
|
||||
* associate a new display name that exceeds the maximum length.
|
||||
*/
|
||||
DISPLAY_NAME_EXCEEDS_MAX_LENGTH = "DISPLAY_NAME_EXCEEDS_MAX_LENGTH",
|
||||
|
||||
/**
|
||||
* EMAIL_INVALID_FORMAT is returned when when the user attempts to associate a
|
||||
* new email address that is not a valid email address.
|
||||
*/
|
||||
EMAIL_INVALID_FORMAT = "EMAIL_INVALID_FORMAT",
|
||||
|
||||
/**
|
||||
* EMAIL_EXCEEDS_MAX_LENGTH is returned when when the user attempts to
|
||||
* associate a new email address and it exceeds the maximum length.
|
||||
*/
|
||||
EMAIL_EXCEEDS_MAX_LENGTH = "EMAIL_EXCEEDS_MAX_LENGTH",
|
||||
|
||||
/**
|
||||
* USER_NOT_FOUND is returned when the user being looked up via an ID does not
|
||||
* exist in the database.
|
||||
*/
|
||||
USER_NOT_FOUND = "USER_NOT_FOUND",
|
||||
|
||||
/**
|
||||
* NOT_FOUND is returned when attempting to access a resource that does not
|
||||
* exist.
|
||||
*/
|
||||
NOT_FOUND = "NOT_FOUND",
|
||||
|
||||
/**
|
||||
* TENANT_INSTALLED_ALREADY is returned when attempting to install a Tenant
|
||||
* when the Tenant is already setup when in single-tenant mode.
|
||||
*/
|
||||
TENANT_INSTALLED_ALREADY = "TENANT_INSTALLED_ALREADY",
|
||||
|
||||
/**
|
||||
* USER_NOT_ENTITLED is returned when a user attempts to perform an action that
|
||||
* they are not entitled to.
|
||||
*/
|
||||
USER_NOT_ENTITLED = "USER_NOT_ENTITLED",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* LanguageCode is the type represented by the internally identifiable types for
|
||||
* the different languages that can be supported.
|
||||
*/
|
||||
export type LanguageCode = "en-US" | "es" | "de";
|
||||
|
||||
/**
|
||||
* LOCALES is an array of supported language codes that can be accessed as a
|
||||
* value.
|
||||
*/
|
||||
export const LOCALES: LanguageCode[] = ["en-US", "es", "de"];
|
||||
@@ -3,8 +3,10 @@ import { Redis } from "ioredis";
|
||||
import Joi from "joi";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales";
|
||||
import { Omit } from "talk-common/types";
|
||||
import { validate } from "talk-server/app/request/body";
|
||||
import { Config } from "talk-server/config";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { LocalProfile } from "talk-server/models/user";
|
||||
import { install, InstallTenant } from "talk-server/services/tenant";
|
||||
@@ -13,26 +15,33 @@ import { upsert, UpsertUser } from "talk-server/services/users";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface TenantInstallBody {
|
||||
tenant: Omit<InstallTenant, "domain">;
|
||||
tenant: Omit<InstallTenant, "domain" | "locale"> & {
|
||||
locale: LanguageCode | null;
|
||||
};
|
||||
user: Required<Pick<UpsertUser, "username" | "email"> & { password: string }>;
|
||||
}
|
||||
|
||||
const TenantInstallBodySchema = Joi.object().keys({
|
||||
tenant: Joi.object().keys({
|
||||
organizationName: Joi.string().trim(),
|
||||
organizationURL: Joi.string()
|
||||
.trim()
|
||||
.uri(),
|
||||
organizationContactEmail: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
domains: Joi.array().items(
|
||||
Joi.string()
|
||||
tenant: Joi.object()
|
||||
.keys({
|
||||
organizationName: Joi.string().trim(),
|
||||
organizationURL: Joi.string()
|
||||
.trim()
|
||||
.uri()
|
||||
),
|
||||
}),
|
||||
.uri(),
|
||||
organizationContactEmail: Joi.string()
|
||||
.trim()
|
||||
.lowercase()
|
||||
.email(),
|
||||
domains: Joi.array().items(
|
||||
Joi.string()
|
||||
.trim()
|
||||
.uri()
|
||||
),
|
||||
locale: Joi.string()
|
||||
.default(null)
|
||||
.valid(LOCALES),
|
||||
})
|
||||
.optionalKeys("locale"),
|
||||
user: Joi.object().keys({
|
||||
username: Joi.string().trim(),
|
||||
password: Joi.string(),
|
||||
@@ -47,12 +56,14 @@ export interface TenantInstallHandlerOptions {
|
||||
cache: TenantCache;
|
||||
redis: Redis;
|
||||
mongo: Db;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export const tenantInstallHandler = ({
|
||||
mongo,
|
||||
redis,
|
||||
cache,
|
||||
config,
|
||||
}: TenantInstallHandlerOptions): RequestHandler => async (
|
||||
req: Request,
|
||||
res,
|
||||
@@ -62,16 +73,25 @@ export const tenantInstallHandler = ({
|
||||
// Validate that the payload passed in was correct, it will throw if the
|
||||
// payload is invalid.
|
||||
const {
|
||||
tenant: tenantInput,
|
||||
tenant: { locale: tenantLocale, ...tenantInput },
|
||||
user: userInput,
|
||||
}: TenantInstallBody = validate(TenantInstallBodySchema, req.body);
|
||||
|
||||
// Default the locale to the default locale if not provided.
|
||||
let locale = tenantLocale;
|
||||
if (!locale) {
|
||||
locale = config.get("default_locale") as LanguageCode;
|
||||
}
|
||||
|
||||
// Install will throw if it can not create a Tenant, or it has already been
|
||||
// installed.
|
||||
const tenant = await install(mongo, redis, cache, {
|
||||
...tenantInput,
|
||||
// Infer the Tenant domain via the hostname parameter.
|
||||
domain: req.hostname,
|
||||
// Add the locale that we had to default to the default locale from the
|
||||
// config.
|
||||
locale,
|
||||
});
|
||||
|
||||
// Pull the user details out of the input for the user.
|
||||
@@ -95,7 +115,6 @@ export const tenantInstallHandler = ({
|
||||
// Send back the Tenant.
|
||||
return res.sendStatus(204);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) maybe wrap the error?
|
||||
return next(err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,13 +7,14 @@ import nunjucks from "nunjucks";
|
||||
import path from "path";
|
||||
|
||||
import { cacheHeadersMiddleware } from "talk-server/app/middleware/cacheHeaders";
|
||||
import { errorHandler } from "talk-server/app/middleware/error";
|
||||
import { HTMLErrorHandler } from "talk-server/app/middleware/error";
|
||||
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
|
||||
import { createPassport } from "talk-server/app/middleware/passport";
|
||||
import { Config } from "talk-server/config";
|
||||
import { handleSubscriptions } from "talk-server/graph/common/subscriptions/middleware";
|
||||
import { Schemas } from "talk-server/graph/schemas";
|
||||
import { TaskQueue } from "talk-server/queue";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { AugmentedRedis } from "talk-server/services/redis";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
@@ -31,6 +32,7 @@ export interface AppOptions {
|
||||
schemas: Schemas;
|
||||
signingConfig: JWTSigningConfig;
|
||||
tenantCache: TenantCache;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +68,7 @@ export async function createApp(options: AppOptions): Promise<Express> {
|
||||
// Error Handling
|
||||
parent.use(notFoundMiddleware);
|
||||
parent.use(errorLogger);
|
||||
parent.use(errorHandler);
|
||||
parent.use(HTMLErrorHandler(options.i18n));
|
||||
|
||||
return parent;
|
||||
}
|
||||
@@ -100,14 +102,26 @@ function configureApplication(options: AppOptions) {
|
||||
function setupViews(options: AppOptions) {
|
||||
const { parent } = options;
|
||||
|
||||
// configure the default views directory.
|
||||
const views = path.join(__dirname, "..", "..", "..", "..", "dist", "static");
|
||||
// Configure the default views directories.
|
||||
const views = [
|
||||
// Load the templates compiled by Webpack.
|
||||
path.resolve(
|
||||
path.join(__dirname, "..", "..", "..", "..", "dist", "static")
|
||||
),
|
||||
// Load the templates generated by the server.
|
||||
path.join(__dirname, "views"),
|
||||
];
|
||||
parent.set("views", views);
|
||||
|
||||
// Reconfigure nunjucks.
|
||||
(cons.requires as any).nunjucks = nunjucks.configure(views, {
|
||||
// In development, we should enable file watch mode.
|
||||
// In development, we should enable file watch mode, and prevent file
|
||||
// caching.
|
||||
watch: options.config.get("env") === "development",
|
||||
noCache: options.config.get("env") === "development",
|
||||
// Trim blocks of whitespace.
|
||||
trimBlocks: true,
|
||||
lstripBlocks: true,
|
||||
});
|
||||
|
||||
// assign the nunjucks engine to .njk and .html files.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Db } from "mongodb";
|
||||
import { Config } from "talk-server/config";
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import { TaskQueue } from "talk-server/queue";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { AugmentedRedis } from "talk-server/services/redis";
|
||||
import { Request } from "talk-server/types/express";
|
||||
@@ -14,6 +15,7 @@ export interface TenantContextMiddlewareOptions {
|
||||
queue: TaskQueue;
|
||||
config: Config;
|
||||
signingConfig: JWTSigningConfig;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export const tenantContext = ({
|
||||
@@ -22,6 +24,7 @@ export const tenantContext = ({
|
||||
queue,
|
||||
config,
|
||||
signingConfig,
|
||||
i18n,
|
||||
}: TenantContextMiddlewareOptions): RequestHandler => (
|
||||
req: Request,
|
||||
res,
|
||||
@@ -52,6 +55,7 @@ export const tenantContext = ({
|
||||
tenantCache: cache.tenant,
|
||||
queue,
|
||||
signingConfig,
|
||||
i18n,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,61 @@
|
||||
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 });
|
||||
import { InternalError, TalkError } from "talk-server/errors";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
/**
|
||||
* wrapError ensures that the error being propagated is a TalkError.
|
||||
*
|
||||
* @param err the error to be wrapped
|
||||
*/
|
||||
const wrapError = (err: Error) =>
|
||||
err instanceof TalkError
|
||||
? err
|
||||
: new InternalError(err, "wrapped internal error");
|
||||
|
||||
/**
|
||||
* serializeError will return a serialized error that can be returned via the
|
||||
* API response.
|
||||
*
|
||||
* @param err the TalkError that should be serialized
|
||||
* @param bundles the translation bundles
|
||||
* @param tenant the optional tenant to use when selecting the language
|
||||
*/
|
||||
const serializeError = (err: TalkError, req: Request, bundles: I18n) => {
|
||||
// Get the translation bundle.
|
||||
let bundle = bundles.getDefaultBundle();
|
||||
if (req.talk && req.talk.tenant) {
|
||||
bundle = bundles.getBundle(req.talk.tenant.locale);
|
||||
}
|
||||
|
||||
return {
|
||||
error: err.serializeExtensions(bundle),
|
||||
};
|
||||
};
|
||||
|
||||
export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
|
||||
// TODO: handle better when we improve errors.
|
||||
if (err.message === "not found") {
|
||||
// TODO: handle better when we improve errors.
|
||||
res.status(404).send(err.message);
|
||||
} else {
|
||||
// TODO: handle better when we improve errors.
|
||||
res.status(500).send(err.message);
|
||||
}
|
||||
export const JSONErrorHandler = (bundles: I18n): ErrorRequestHandler => (
|
||||
err,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
// Wrap the error if it needs to be wrapped.
|
||||
err = wrapError(err);
|
||||
|
||||
// Send the response via JSON.
|
||||
res.status(err.status).json(serializeError(err, req, bundles));
|
||||
};
|
||||
|
||||
export const HTMLErrorHandler = (bundles: I18n): ErrorRequestHandler => (
|
||||
err,
|
||||
req,
|
||||
res,
|
||||
next
|
||||
) => {
|
||||
// Wrap the error if it needs to be wrapped.
|
||||
err = wrapError(err);
|
||||
|
||||
// Send the response via HTML.
|
||||
res.status(err.status).render("error", serializeError(err, req, bundles));
|
||||
};
|
||||
|
||||
@@ -39,10 +39,7 @@ export const accessLogger: RequestHandler = (req, res, next) => {
|
||||
};
|
||||
|
||||
export const errorLogger: ErrorRequestHandler = (err, req, res, next) => {
|
||||
// TODO: handle better when we improve errors.
|
||||
if (err.message !== "not found") {
|
||||
logger.error({ err }, "http error");
|
||||
}
|
||||
logger.error({ err }, "http error");
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { RequestHandler } from "express";
|
||||
|
||||
import { NotFoundError } from "talk-server/errors";
|
||||
|
||||
export const notFoundMiddleware: RequestHandler = (req, res, next) => {
|
||||
// FIXME: (wyattjoh) send an error that won't log as crazily as this one does.
|
||||
next(new Error("not found"));
|
||||
next(new NotFoundError(req.method, req.originalUrl));
|
||||
};
|
||||
|
||||
@@ -16,9 +16,9 @@ import { Config } from "talk-server/config";
|
||||
import logger from "talk-server/logger";
|
||||
import { User } from "talk-server/models/user";
|
||||
import {
|
||||
blacklistJWT,
|
||||
extractJWTFromRequest,
|
||||
JWTSigningConfig,
|
||||
revokeJWT,
|
||||
SigningTokenOptions,
|
||||
signTokenString,
|
||||
} from "talk-server/services/jwt";
|
||||
@@ -97,8 +97,8 @@ export async function handleLogout(redis: Redis, req: Request, res: Response) {
|
||||
const validFor = exp - Date.now() / 1000;
|
||||
if (validFor > 0) {
|
||||
// Invalidate the token, the expiry is in the future and it needs to be
|
||||
// blacklisted.
|
||||
await blacklistJWT(redis, jti, validFor);
|
||||
// revoked.
|
||||
await revokeJWT(redis, jti, validFor);
|
||||
}
|
||||
|
||||
return res.sendStatus(204);
|
||||
@@ -233,7 +233,29 @@ export const wrapAuthn = (
|
||||
return next(new Error("no user on request"));
|
||||
}
|
||||
|
||||
// Pass the login off to be signed.
|
||||
handleSuccessfulLogin(user, signingConfig, req, res, next);
|
||||
}
|
||||
)(req, res, next);
|
||||
};
|
||||
|
||||
/**
|
||||
* authenticate will wrap the authenticator to forward any error to the error
|
||||
* handler from ExpressJS.
|
||||
*
|
||||
* @param authenticator the authenticator to use
|
||||
*/
|
||||
export const authenticate = (
|
||||
authenticator: passport.Authenticator
|
||||
): RequestHandler => (req, res, next) =>
|
||||
authenticator.authenticate(
|
||||
"jwt",
|
||||
{ session: false },
|
||||
(err: Error | null, user: User | null) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
)(req, res, next);
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
SSOToken,
|
||||
SSOVerifier,
|
||||
} from "talk-server/app/middleware/passport/strategies/verifiers/sso";
|
||||
import { TenantNotFoundError, TokenInvalidError } from "talk-server/errors";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { User } from "talk-server/models/user";
|
||||
import {
|
||||
@@ -71,8 +72,7 @@ export class JWTStrategy extends Strategy {
|
||||
private async verify(tokenString: string, tenant: Tenant) {
|
||||
const token: Token = jwt.decode(tokenString);
|
||||
if (!token || typeof token === "string") {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("token could not be decoded");
|
||||
throw new TokenInvalidError(tokenString, "token could not be decoded");
|
||||
}
|
||||
|
||||
// TODO: add OIDC support.
|
||||
@@ -94,8 +94,10 @@ export class JWTStrategy extends Strategy {
|
||||
}
|
||||
|
||||
// No verifier could be found.
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("no suitable jwt verifier could be found");
|
||||
throw new TokenInvalidError(
|
||||
tokenString,
|
||||
"no suitable jwt verifier could be found"
|
||||
);
|
||||
}
|
||||
|
||||
public async authenticate(req: Request) {
|
||||
@@ -109,8 +111,7 @@ export class JWTStrategy extends Strategy {
|
||||
|
||||
const { tenant } = req.talk!;
|
||||
if (!tenant) {
|
||||
// TODO: (wyattjoh) log this error, and return a better one?
|
||||
return this.error(new Error("tenant not found"));
|
||||
return this.error(new TenantNotFoundError(req.hostname));
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -121,8 +122,7 @@ export class JWTStrategy extends Strategy {
|
||||
|
||||
return this.success(user, null);
|
||||
} catch (err) {
|
||||
// TODO: (wyattjoh) log this error
|
||||
return this.fail(err);
|
||||
return this.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,13 +6,13 @@ import now from "performance-now";
|
||||
import logger from "talk-server/logger";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { retrieveUser } from "talk-server/models/user";
|
||||
import { checkBlacklistJWT, JWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { checkJWTRevoked, JWTSigningConfig } from "talk-server/services/jwt";
|
||||
|
||||
export interface JWTToken {
|
||||
/**
|
||||
* jti is the Token identifier. With normal login tokens, this is a randomly
|
||||
* generated uuid, which is added to a blacklist when the User "logs out". For
|
||||
* Personal Access Tokens, this is the Token identifier.
|
||||
* generated uuid, which is added to a revoke list when the User "logs out".
|
||||
* For Personal Access Tokens, this is the Token identifier.
|
||||
*/
|
||||
jti: string;
|
||||
|
||||
@@ -102,11 +102,11 @@ export class JWTVerifier {
|
||||
logger.trace({ responseTime }, "jwt verification complete");
|
||||
|
||||
// Check to see if this is a Personal Access Token, these tokens cannot be
|
||||
// blacklisted.
|
||||
// revoked.
|
||||
if (!token.pat) {
|
||||
// Check to see if the token has been blacklisted, as these tokens can be
|
||||
// Check to see if the token has been revoked, as these tokens can be
|
||||
// revoked.
|
||||
await checkBlacklistJWT(this.redis, token.jti);
|
||||
await checkJWTRevoked(this.redis, token.jti);
|
||||
}
|
||||
|
||||
// Find the user.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TenantNotFoundError } from "talk-server/errors";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { RequestHandler } from "talk-server/types/express";
|
||||
|
||||
@@ -11,6 +12,14 @@ export const tenantMiddleware = ({
|
||||
passNoTenant = false,
|
||||
}: MiddlewareOptions): RequestHandler => async (req, res, next) => {
|
||||
try {
|
||||
// Set Talk on the request.
|
||||
req.talk = {
|
||||
cache: {
|
||||
// Attach the tenant cache to the request.
|
||||
tenant: cache,
|
||||
},
|
||||
};
|
||||
|
||||
// Attach the tenant to the request.
|
||||
const tenant = await cache.retrieveByDomain(req.hostname);
|
||||
if (!tenant) {
|
||||
@@ -18,19 +27,11 @@ export const tenantMiddleware = ({
|
||||
return next();
|
||||
}
|
||||
|
||||
// TODO: send a http.StatusNotFound?
|
||||
return next(new Error("tenant not found"));
|
||||
return next(new TenantNotFoundError(req.hostname));
|
||||
}
|
||||
|
||||
// Set Talk on the request.
|
||||
req.talk = {
|
||||
cache: {
|
||||
// Attach the tenant cache to the request.
|
||||
tenant: cache,
|
||||
},
|
||||
// Attach the tenant to the request.
|
||||
tenant,
|
||||
};
|
||||
// Attach the tenant to the request.
|
||||
req.talk.tenant = tenant;
|
||||
|
||||
// Attach the tenant to the view locals.
|
||||
res.locals.tenant = tenant;
|
||||
|
||||
@@ -3,8 +3,9 @@ import passport from "passport";
|
||||
|
||||
import { AppOptions } from "talk-server/app";
|
||||
import { versionHandler } from "talk-server/app/handlers/api/version";
|
||||
import { apiErrorHandler } from "talk-server/app/middleware/error";
|
||||
import { JSONErrorHandler } from "talk-server/app/middleware/error";
|
||||
import { errorLogger } from "talk-server/app/middleware/logging";
|
||||
import { notFoundMiddleware } from "talk-server/app/middleware/notFound";
|
||||
|
||||
import { createManagementRouter } from "./management";
|
||||
import { createTenantRouter } from "./tenant";
|
||||
@@ -27,11 +28,13 @@ export async function createAPIRouter(app: AppOptions, options: RouterOptions) {
|
||||
// Configure the management routes.
|
||||
router.use("/management", await createManagementRouter(app));
|
||||
|
||||
// Configure the version route.
|
||||
router.get("/version", versionHandler);
|
||||
|
||||
// General API error handler.
|
||||
router.use(notFoundMiddleware);
|
||||
router.use(errorLogger);
|
||||
router.use(apiErrorHandler);
|
||||
router.use(JSONErrorHandler(app.i18n));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -10,11 +10,12 @@ export async function createManagementRouter(app: AppOptions) {
|
||||
router.use(
|
||||
"/graphql",
|
||||
express.json(),
|
||||
await managementGraphMiddleware(
|
||||
app.schemas.management,
|
||||
app.config,
|
||||
app.mongo
|
||||
)
|
||||
await managementGraphMiddleware({
|
||||
schema: app.schemas.management,
|
||||
config: app.config,
|
||||
mongo: app.mongo,
|
||||
i18n: app.i18n,
|
||||
})
|
||||
);
|
||||
|
||||
return router;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RouterOptions } from "talk-server/app/router/types";
|
||||
import tenantGraphMiddleware from "talk-server/graph/tenant/middleware";
|
||||
|
||||
import { tenantContext } from "talk-server/app/middleware/context/tenant";
|
||||
import { authenticate } from "talk-server/app/middleware/passport";
|
||||
import { createNewAuthRouter } from "./auth";
|
||||
|
||||
export async function createTenantRouter(
|
||||
@@ -20,6 +21,7 @@ export async function createTenantRouter(
|
||||
"/install",
|
||||
express.json(),
|
||||
tenantInstallHandler({
|
||||
config: app.config,
|
||||
cache: app.tenantCache,
|
||||
redis: app.redis,
|
||||
mongo: app.mongo,
|
||||
@@ -41,7 +43,7 @@ export async function createTenantRouter(
|
||||
express.json(),
|
||||
// Any users may submit their GraphQL requests with authentication, this
|
||||
// middleware will unpack their user into the request.
|
||||
options.passport.authenticate("jwt", { session: false }),
|
||||
authenticate(options.passport),
|
||||
tenantContext(app),
|
||||
await tenantGraphMiddleware({
|
||||
schema: app.schemas.tenant,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Error</title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="Content-type" content="text/html; charset=utf-8"/>
|
||||
<style type="text/css">
|
||||
body, html { margin: 0; padding: 0; }
|
||||
dl { max-width: 800px; margin: 20px auto; font-size: 23px; }
|
||||
dh { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<dl>
|
||||
<dh>Message</dh>
|
||||
<dd>{{ error.message }}</dd>
|
||||
<dh>Code</dh>
|
||||
<dd>{{ error.code }}</dd>
|
||||
<dh>ID</dh>
|
||||
<dd>{{ error.id }}</dd>
|
||||
</dl>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -2,6 +2,8 @@ import convict from "convict";
|
||||
import Joi from "joi";
|
||||
import os from "os";
|
||||
|
||||
import { LOCALES } from "talk-common/helpers/i18n/locales";
|
||||
|
||||
// Add custom format for the mongo uri scheme.
|
||||
convict.addFormat({
|
||||
name: "mongo-uri",
|
||||
@@ -49,6 +51,13 @@ const config = convict({
|
||||
default: "development",
|
||||
env: "NODE_ENV",
|
||||
},
|
||||
default_locale: {
|
||||
doc:
|
||||
"Specify the default locale to use for all requests without a locale specified",
|
||||
format: LOCALES,
|
||||
default: "en-US",
|
||||
env: "LOCALE",
|
||||
},
|
||||
enable_graphiql: {
|
||||
doc: "When true, this will enable the GraphiQL routes",
|
||||
format: Boolean,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { VError } from "verror";
|
||||
|
||||
import { DuplicateUserError, InternalError, TalkError } from ".";
|
||||
|
||||
it("has the right inheritance chain", () => {
|
||||
const err = new DuplicateUserError();
|
||||
|
||||
expect(err).toBeInstanceOf(DuplicateUserError);
|
||||
expect(err).toBeInstanceOf(TalkError);
|
||||
expect(err).toBeInstanceOf(VError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
|
||||
expect(err.name).toEqual("DuplicateUserError");
|
||||
});
|
||||
|
||||
it("provides an accurate stack", () => {
|
||||
const err = new InternalError(
|
||||
new Error("this is a test"),
|
||||
"this is the reason"
|
||||
);
|
||||
|
||||
expect(err).toBeInstanceOf(InternalError);
|
||||
expect(err).toBeInstanceOf(TalkError);
|
||||
expect(err).toBeInstanceOf(VError);
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
expect(err.stack).toBeDefined();
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
// tslint:disable:max-classes-per-file
|
||||
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
import uuid from "uuid";
|
||||
import { VError } from "verror";
|
||||
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { translate } from "talk-server/services/i18n";
|
||||
|
||||
import { ERROR_TRANSLATIONS } from "./translations";
|
||||
|
||||
/**
|
||||
* TalkErrorTypes associates a class of errors with a specific code.
|
||||
*/
|
||||
export type TalkErrorTypes = "invalid_request_error";
|
||||
|
||||
/**
|
||||
* TalkErrorExtensions is the different extension data that is associated with
|
||||
* a given error. This data is surfaced in the GraphQL, REST error response as
|
||||
* well as via logs.
|
||||
*/
|
||||
export interface TalkErrorExtensions {
|
||||
/**
|
||||
* id identifies this specific error that was thrown, allowing offline tracing
|
||||
* to occur.
|
||||
*/
|
||||
readonly id: string;
|
||||
|
||||
/**
|
||||
* code is the identifier specific to this Error. No other TalkError should
|
||||
* share the same code.
|
||||
*/
|
||||
readonly code: ERROR_CODES;
|
||||
|
||||
/**
|
||||
* type represents the class of errors that this error is associated with.
|
||||
*/
|
||||
readonly type: TalkErrorTypes;
|
||||
|
||||
/**
|
||||
* message is the (optionally translated) message that can be shown to users.
|
||||
*/
|
||||
readonly message: string;
|
||||
|
||||
/**
|
||||
* param, if set, references the fieldSpec to which the error is related to.
|
||||
* If for example an error occurred during email processing, this field could
|
||||
* be `input.email` to denote the specific input field that caused the error.
|
||||
*/
|
||||
param?: string;
|
||||
}
|
||||
|
||||
export interface TalkErrorContext {
|
||||
/**
|
||||
* pub stores information that is used by the translation framework
|
||||
* to provide context to the error being emitted to pass publicly. Sensitive
|
||||
* information should not be passed via this method.
|
||||
*/
|
||||
pub: Record<string, any>;
|
||||
|
||||
/**
|
||||
* pvt stores information that is logged out by the logging
|
||||
* framework to provide context for bug reporting software in the event that
|
||||
* the error is unexpected.
|
||||
*/
|
||||
pvt: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* TalkErrorOptions describes the options used to create a TalkError.
|
||||
*/
|
||||
export interface TalkErrorOptions {
|
||||
/**
|
||||
* code is the identifier specific to this Error. No other TalkError should
|
||||
* share the same code.
|
||||
*/
|
||||
code: ERROR_CODES;
|
||||
|
||||
/**
|
||||
* context stores the public and private details about the error.
|
||||
*/
|
||||
context?: Partial<TalkErrorContext>;
|
||||
|
||||
/**
|
||||
* status is the number sent via the REST error responses. GraphQL responses
|
||||
* do not involve this number.
|
||||
*/
|
||||
status?: number;
|
||||
|
||||
/**
|
||||
* type represents the class of errors that this error is associated with.
|
||||
*/
|
||||
type?: TalkErrorTypes;
|
||||
|
||||
/**
|
||||
* cause is the error that provides the root cause of the underlying error
|
||||
* that is thrown.
|
||||
*/
|
||||
cause?: Error;
|
||||
|
||||
/**
|
||||
* param, if set, references the fieldSpec to which the error is related to.
|
||||
* If for example an error occurred during email processing, this field could
|
||||
* be `input.email` to denote the specific input field that caused the error.
|
||||
*/
|
||||
param?: string;
|
||||
}
|
||||
|
||||
export class TalkError extends VError {
|
||||
/**
|
||||
* id identifies this specific error that was thrown, allowing offline tracing
|
||||
* to occur.
|
||||
*/
|
||||
public readonly id: string;
|
||||
|
||||
/**
|
||||
* code is the identifier specific to this Error. No other TalkError should
|
||||
* share the same code.
|
||||
*/
|
||||
public readonly code: ERROR_CODES;
|
||||
|
||||
/**
|
||||
* status is the number sent via the REST error responses. GraphQL responses
|
||||
* do not involve this number.
|
||||
*/
|
||||
public readonly status: number;
|
||||
|
||||
/**
|
||||
* type represents the class of errors that this error is associated with.
|
||||
*/
|
||||
public readonly type: TalkErrorTypes;
|
||||
|
||||
/**
|
||||
* param, if set, references the fieldSpec to which the error is related to.
|
||||
* If for example an error occurred during email processing, this field could
|
||||
* be `input.email` to denote the specific input field that caused the error.
|
||||
*/
|
||||
public param?: string;
|
||||
|
||||
/**
|
||||
* context stores the public and private details about the error.
|
||||
*/
|
||||
public readonly context: Readonly<TalkErrorContext>;
|
||||
|
||||
constructor({
|
||||
code,
|
||||
context = {},
|
||||
status = 500,
|
||||
type = "invalid_request_error",
|
||||
cause,
|
||||
param,
|
||||
}: TalkErrorOptions) {
|
||||
// Call the super method with the right arguments depending on if we're
|
||||
// supposed to be handling a causal error or not.
|
||||
if (cause) {
|
||||
super(cause, code);
|
||||
} else {
|
||||
super(code);
|
||||
}
|
||||
|
||||
// Rename the error to have the name of the error that this extends.
|
||||
this.name = new.target.name;
|
||||
|
||||
// Assign a unique ID to this error.
|
||||
const id = uuid.v1();
|
||||
this.status = status;
|
||||
|
||||
// Capture the context for the error.
|
||||
const { pub = {}, pvt = {} } = context;
|
||||
this.context = { pub, pvt };
|
||||
|
||||
// Capture the extension parameters.
|
||||
this.id = id;
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.param = param;
|
||||
}
|
||||
|
||||
public serializeExtensions(bundle: FluentBundle): TalkErrorExtensions {
|
||||
const message = translate(
|
||||
bundle,
|
||||
this.code,
|
||||
ERROR_TRANSLATIONS[this.code],
|
||||
this.context.pub
|
||||
);
|
||||
|
||||
return {
|
||||
id: this.id,
|
||||
code: this.code,
|
||||
type: this.type,
|
||||
message,
|
||||
param: this.param,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class StoryURLInvalidError extends TalkError {
|
||||
constructor(properties: { storyURL: string; tenantDomains: string[] }) {
|
||||
super({
|
||||
code: ERROR_CODES.STORY_URL_NOT_PERMITTED,
|
||||
context: { pub: properties },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateUserError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.DUPLICATE_USER });
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailNotSetError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.EMAIL_NOT_SET });
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateStoryURLError extends TalkError {
|
||||
constructor(url: string) {
|
||||
super({ code: ERROR_CODES.DUPLICATE_STORY_URL, context: { pvt: { url } } });
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateUsernameError extends TalkError {
|
||||
constructor(username: string) {
|
||||
super({
|
||||
code: ERROR_CODES.DUPLICATE_USERNAME,
|
||||
context: { pvt: { username } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateEmailError extends TalkError {
|
||||
constructor(email: string) {
|
||||
super({ code: ERROR_CODES.DUPLICATE_EMAIL, context: { pvt: { email } } });
|
||||
}
|
||||
}
|
||||
|
||||
export class UsernameAlreadySetError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.USERNAME_ALREADY_SET });
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailAlreadySetError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.EMAIL_ALREADY_SET });
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProfileNotSetError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.LOCAL_PROFILE_NOT_SET });
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalProfileAlreadySetError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.LOCAL_PROFILE_ALREADY_SET });
|
||||
}
|
||||
}
|
||||
|
||||
export class UsernameContainsInvalidCharactersError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS });
|
||||
}
|
||||
}
|
||||
|
||||
export class UsernameExceedsMaxLengthError extends TalkError {
|
||||
constructor(length: number, max: number) {
|
||||
super({
|
||||
code: ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
|
||||
context: { pub: { length, max } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UsernameTooShortError extends TalkError {
|
||||
constructor(length: number, min: number) {
|
||||
super({
|
||||
code: ERROR_CODES.USERNAME_TOO_SHORT,
|
||||
context: { pub: { length, min } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class DisplayNameExceedsMaxLengthError extends TalkError {
|
||||
constructor(length: number, max: number) {
|
||||
super({
|
||||
code: ERROR_CODES.DISPLAY_NAME_EXCEEDS_MAX_LENGTH,
|
||||
context: { pub: { length, max } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PasswordTooShortError extends TalkError {
|
||||
constructor(length: number, min: number) {
|
||||
super({
|
||||
code: ERROR_CODES.PASSWORD_TOO_SHORT,
|
||||
context: { pub: { length, min } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailInvalidFormatError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.EMAIL_INVALID_FORMAT });
|
||||
}
|
||||
}
|
||||
|
||||
export class EmailExceedsMaxLengthError extends TalkError {
|
||||
constructor(length: number, max: number) {
|
||||
super({
|
||||
code: ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH,
|
||||
context: { pub: { length, max } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenNotFoundError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.TOKEN_NOT_FOUND });
|
||||
}
|
||||
}
|
||||
|
||||
export class TokenInvalidError extends TalkError {
|
||||
constructor(token: string, reason: string) {
|
||||
super({
|
||||
code: ERROR_CODES.TOKEN_INVALID,
|
||||
context: { pub: { token }, pvt: { reason } },
|
||||
status: 401,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserForbiddenError extends TalkError {
|
||||
constructor(reason: string, resource: string, userID: string | null) {
|
||||
super({
|
||||
code: ERROR_CODES.USER_NOT_ENTITLED,
|
||||
context: { pvt: { reason, userID, resource } },
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserNotFoundError extends TalkError {
|
||||
constructor(userID: string) {
|
||||
super({ code: ERROR_CODES.USER_NOT_FOUND, context: { pvt: { userID } } });
|
||||
}
|
||||
}
|
||||
|
||||
export class TenantNotFoundError extends TalkError {
|
||||
constructor(hostname: string) {
|
||||
super({
|
||||
code: ERROR_CODES.TENANT_NOT_FOUND,
|
||||
context: { pub: { hostname } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalError extends TalkError {
|
||||
constructor(cause: Error, reason: string) {
|
||||
super({
|
||||
code: ERROR_CODES.INTERNAL_ERROR,
|
||||
cause,
|
||||
context: { pvt: { reason } },
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends TalkError {
|
||||
constructor(method: string, path: string) {
|
||||
super({
|
||||
code: ERROR_CODES.NOT_FOUND,
|
||||
status: 404,
|
||||
context: { pub: { method, path } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class TenantInstalledAlreadyError extends TalkError {
|
||||
constructor() {
|
||||
super({ code: ERROR_CODES.TENANT_INSTALLED_ALREADY, status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
|
||||
export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
STORY_URL_NOT_PERMITTED: "error-storyURLNotPermitted",
|
||||
TOKEN_NOT_FOUND: "error-tokenNotFound",
|
||||
DUPLICATE_STORY_URL: "error-duplicateStoryURL",
|
||||
EMAIL_ALREADY_SET: "error-emailAlreadySet",
|
||||
EMAIL_NOT_SET: "error-emailNotSet",
|
||||
TENANT_NOT_FOUND: "error-tenantNotFound",
|
||||
DUPLICATE_USER: "error-duplicateUser",
|
||||
DUPLICATE_USERNAME: "error-duplicateUsername",
|
||||
DUPLICATE_EMAIL: "error-duplicateEmail",
|
||||
LOCAL_PROFILE_ALREADY_SET: "error-localProfileAlreadySet",
|
||||
LOCAL_PROFILE_NOT_SET: "error-localProfileNotSet",
|
||||
USERNAME_ALREADY_SET: "error-usernameAlreadySet",
|
||||
USERNAME_CONTAINS_INVALID_CHARACTERS:
|
||||
"error-usernameContainsInvalidCharacters",
|
||||
USERNAME_EXCEEDS_MAX_LENGTH: "error-usernameExceedsMaxLength",
|
||||
USERNAME_TOO_SHORT: "error-usernameTooShort",
|
||||
PASSWORD_TOO_SHORT: "error-passwordTooShort",
|
||||
DISPLAY_NAME_EXCEEDS_MAX_LENGTH: "error-displayNameExceedsMaxLength",
|
||||
EMAIL_INVALID_FORMAT: "error-emailInvalidFormat",
|
||||
EMAIL_EXCEEDS_MAX_LENGTH: "error-emailExceedsMaxLength",
|
||||
USER_NOT_FOUND: "error-userNotFound",
|
||||
NOT_FOUND: "error-notFound",
|
||||
INTERNAL_ERROR: "error-internalError",
|
||||
TOKEN_INVALID: "error-tokenInvalid",
|
||||
TENANT_INSTALLED_ALREADY: "error-tenantInstalledAlready",
|
||||
USER_NOT_ENTITLED: "error-userNotEntitled",
|
||||
};
|
||||
@@ -1,29 +1,43 @@
|
||||
import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
import { Config } from "talk-server/config";
|
||||
import logger from "talk-server/logger";
|
||||
import { User } from "talk-server/models/user";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface CommonContextOptions {
|
||||
user?: User;
|
||||
req?: Request;
|
||||
lang?: LanguageCode;
|
||||
config: Config;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default class CommonContext {
|
||||
public readonly user?: User;
|
||||
public readonly req?: Request;
|
||||
public readonly config: Config;
|
||||
public readonly i18n: I18n;
|
||||
public readonly lang: LanguageCode;
|
||||
|
||||
public readonly logger = logger.child({
|
||||
context: "graph",
|
||||
contextID: uuid.v4(),
|
||||
contextID: uuid.v1(),
|
||||
});
|
||||
|
||||
constructor({ user, req, config }: CommonContextOptions) {
|
||||
constructor({
|
||||
user,
|
||||
req,
|
||||
config,
|
||||
i18n,
|
||||
lang = i18n.getDefaultLang(),
|
||||
}: CommonContextOptions) {
|
||||
this.user = user;
|
||||
this.req = req;
|
||||
this.config = config;
|
||||
this.i18n = i18n;
|
||||
this.lang = lang;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { DirectiveResolverFn } from "graphql-tools";
|
||||
import { memoize } from "lodash";
|
||||
|
||||
import { GraphQLResolveInfo, ResponsePath } from "graphql";
|
||||
import { UserForbiddenError } from "talk-server/errors";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
import {
|
||||
GQLUSER_AUTH_CONDITIONS,
|
||||
@@ -31,6 +33,38 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
|
||||
return conditions.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* calculateLocationKey will reduce the resolve information to determine the
|
||||
* path to where the key that is being accessed.
|
||||
*
|
||||
* @param info the info from the graph request
|
||||
*/
|
||||
function calculateLocationKey(info: Pick<GraphQLResolveInfo, "path">): string {
|
||||
// Guard against invalid input.
|
||||
if (!info || !info.path || !info.path.key) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// Grab the first part of the path.
|
||||
const parts: string[] = [info.path.key.toString()];
|
||||
|
||||
// Grab the parent previous part of the path.
|
||||
let prev: ResponsePath | undefined = info.path.prev;
|
||||
|
||||
// While there is still a previous part of the path, keep looping to find the
|
||||
// all the parts.
|
||||
while (prev && prev.key) {
|
||||
// Push the key into the front of the array.
|
||||
parts.unshift(prev.key.toString());
|
||||
|
||||
// Change the selection to the previous path element.
|
||||
prev = prev.prev;
|
||||
}
|
||||
|
||||
// Join it together with a dotted path.
|
||||
return parts.join(".");
|
||||
}
|
||||
|
||||
const calculateAuthConditionsMemoized = memoize(calculateAuthConditions);
|
||||
|
||||
const auth: DirectiveResolverFn<
|
||||
@@ -40,7 +74,8 @@ const auth: DirectiveResolverFn<
|
||||
next,
|
||||
src,
|
||||
{ roles, userIDField, permit }: AuthDirectiveArgs,
|
||||
{ user }
|
||||
{ user },
|
||||
info
|
||||
) => {
|
||||
// If there is a user on the request.
|
||||
if (user) {
|
||||
@@ -48,8 +83,11 @@ const auth: DirectiveResolverFn<
|
||||
// User, if they do error.
|
||||
const conditions = calculateAuthConditionsMemoized(user);
|
||||
if (!permit && conditions.length > 0) {
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized 1");
|
||||
throw new UserForbiddenError(
|
||||
"authentication conditions not met",
|
||||
calculateLocationKey(info),
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
// If the permit was specified, and some of the conditions for the user
|
||||
@@ -58,8 +96,11 @@ const auth: DirectiveResolverFn<
|
||||
permit &&
|
||||
conditions.some(condition => permit.indexOf(condition) === -1)
|
||||
) {
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized 2");
|
||||
throw new UserForbiddenError(
|
||||
"authentication conditions not met",
|
||||
calculateLocationKey(info),
|
||||
user.id
|
||||
);
|
||||
}
|
||||
|
||||
// If the role and user owner checks are disabled, then allow them based on
|
||||
@@ -80,8 +121,11 @@ const auth: DirectiveResolverFn<
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: return better error.
|
||||
throw new Error("not authorized");
|
||||
throw new UserForbiddenError(
|
||||
"user does not have permission to access the resource",
|
||||
calculateLocationKey(info),
|
||||
user ? user.id : null
|
||||
);
|
||||
};
|
||||
|
||||
export default auth;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { TalkError } from "talk-server/errors";
|
||||
|
||||
/**
|
||||
* mapFieldsetToErrorCodes will wait for any errors to occur with the request,
|
||||
* and then associate the appropriate field that caused the error to the error
|
||||
* itself so it can link context in the UI.
|
||||
*
|
||||
* @param promise the promise to await on for any errors to occur
|
||||
* @param errorMap the map of error codes to associate with a given fieldSet
|
||||
*/
|
||||
export async function mapFieldsetToErrorCodes<T>(
|
||||
promise: Promise<T>,
|
||||
errorMap: Record<string, ERROR_CODES[]>
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await promise;
|
||||
} catch (err) {
|
||||
// If the error is a TalkError...
|
||||
if (err instanceof TalkError) {
|
||||
// Then loop over all the fieldSpecs...
|
||||
for (const param in errorMap) {
|
||||
if (!errorMap.hasOwnProperty(param)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (errorMap[param].some(code => err.code === code)) {
|
||||
err.param = param;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { GraphQLError } from "graphql";
|
||||
import { GraphQLExtension, GraphQLResponse } from "graphql-extensions";
|
||||
import { merge } from "lodash";
|
||||
|
||||
import { InternalError, TalkError } from "talk-server/errors";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
|
||||
function hoistTalkErrorExtensions(ctx: CommonContext, err: GraphQLError): void {
|
||||
if (!err.originalError) {
|
||||
// Only errors that have an originalError need to be hoisted.
|
||||
return;
|
||||
}
|
||||
|
||||
// Grab or wrap the originalError so that it's a TalkError.
|
||||
const originalError: TalkError =
|
||||
err.originalError instanceof TalkError
|
||||
? err.originalError
|
||||
: new InternalError(err.originalError, "wrapped internal error");
|
||||
|
||||
// Get the translation bundle.
|
||||
const bundle = ctx.i18n.getBundle(ctx.lang);
|
||||
|
||||
// Translate the extensions.
|
||||
const extensions = originalError.serializeExtensions(bundle);
|
||||
|
||||
// Hoist the message from the original error into the message of the base
|
||||
// error.
|
||||
err.message = extensions.message;
|
||||
|
||||
// Re-hoist the extensions.
|
||||
merge(err.extensions, extensions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* enrichAndLogError will enrich and then log out the error.
|
||||
*
|
||||
* @param ctx the GraphQL context for the request
|
||||
* @param err the error that occurred
|
||||
*/
|
||||
export function enrichError(
|
||||
ctx: CommonContext,
|
||||
err: GraphQLError
|
||||
): GraphQLError {
|
||||
if (err.extensions) {
|
||||
// Delete the exception field from the error extension, we never need to
|
||||
// provide that data.
|
||||
if (err.extensions.exception) {
|
||||
delete err.extensions.exception;
|
||||
}
|
||||
|
||||
if (err.originalError) {
|
||||
// Hoist the extensions onto the error.
|
||||
hoistTalkErrorExtensions(ctx, err);
|
||||
}
|
||||
}
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
export class ErrorWrappingExtension implements GraphQLExtension<CommonContext> {
|
||||
public willSendResponse(o: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: CommonContext;
|
||||
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
|
||||
if (o.graphqlResponse.errors) {
|
||||
return {
|
||||
...o,
|
||||
graphqlResponse: {
|
||||
...o.graphqlResponse,
|
||||
errors: o.graphqlResponse.errors.map(err =>
|
||||
enrichError(o.context, err)
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-24
@@ -1,4 +1,3 @@
|
||||
import { formatApolloErrors } from "apollo-server-errors";
|
||||
import {
|
||||
DocumentNode,
|
||||
ExecutionArgs,
|
||||
@@ -14,17 +13,11 @@ import now from "performance-now";
|
||||
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
|
||||
export function logError(ctx: CommonContext, err: GraphQLError) {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
}
|
||||
|
||||
export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
private logError = (ctx: CommonContext) => (err: Error) => {
|
||||
if (err instanceof GraphQLError) {
|
||||
ctx.logger.error({ err: err.originalError }, "graphql error");
|
||||
} else {
|
||||
ctx.logger.error({ err }, "graphql query error");
|
||||
}
|
||||
|
||||
return err;
|
||||
};
|
||||
|
||||
private getOperationMetadata(doc: DocumentNode) {
|
||||
if (doc.kind === "Document") {
|
||||
const operationDefinition = doc.definitions.find(
|
||||
@@ -70,21 +63,14 @@ export class LoggerExtension implements GraphQLExtension<CommonContext> {
|
||||
}
|
||||
}
|
||||
|
||||
public willSendResponse(o: {
|
||||
public willSendResponse(response: {
|
||||
graphqlResponse: GraphQLResponse;
|
||||
context: CommonContext;
|
||||
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
|
||||
if (o.graphqlResponse.errors) {
|
||||
return {
|
||||
...o,
|
||||
graphqlResponse: {
|
||||
...o.graphqlResponse,
|
||||
errors: formatApolloErrors(o.graphqlResponse.errors, {
|
||||
formatter: this.logError(o.context),
|
||||
debug: false,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}): void {
|
||||
if (response.graphqlResponse.errors) {
|
||||
response.graphqlResponse.errors.forEach(err =>
|
||||
logError(response.context, err)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { Config } from "talk-server/config";
|
||||
import { LoggerExtension } from "talk-server/graph/common/middleware/extensions/logger";
|
||||
|
||||
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
|
||||
import { LoggerExtension } from "./extensions/LoggerExtension";
|
||||
|
||||
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
|
||||
const NoIntrospection = (context: ValidationContext) => ({
|
||||
@@ -36,7 +38,7 @@ export const graphqlMiddleware = (
|
||||
debug: false,
|
||||
// Include extensions.
|
||||
extensions: [
|
||||
// Log queries and errors.
|
||||
() => new ErrorWrappingExtension(),
|
||||
() => new LoggerExtension(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
|
||||
import { Config } from "talk-server/config";
|
||||
import { createRedisClient } from "talk-server/services/redis";
|
||||
|
||||
export function createPubSub(config: Config): RedisPubSub {
|
||||
export async function createPubSub(config: Config): Promise<RedisPubSub> {
|
||||
// Create the Redis clients for the PubSub server.
|
||||
const publisher = createRedisClient(config);
|
||||
const subscriber = createRedisClient(config);
|
||||
const publisher = await createRedisClient(config);
|
||||
const subscriber = await createRedisClient(config);
|
||||
|
||||
// Create the new PubSub manager.
|
||||
return new RedisPubSub({
|
||||
|
||||
@@ -2,19 +2,21 @@ import { Db } from "mongodb";
|
||||
|
||||
import { Config } from "talk-server/config";
|
||||
import CommonContext from "talk-server/graph/common/context";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
export interface ManagementContextOptions {
|
||||
mongo: Db;
|
||||
config: Config;
|
||||
i18n: I18n;
|
||||
req?: Request;
|
||||
}
|
||||
|
||||
export default class ManagementContext extends CommonContext {
|
||||
public readonly mongo: Db;
|
||||
|
||||
constructor({ req, mongo, config }: ManagementContextOptions) {
|
||||
super({ req, config });
|
||||
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
|
||||
super({ req, config, i18n });
|
||||
|
||||
this.mongo = mongo;
|
||||
}
|
||||
|
||||
@@ -5,10 +5,23 @@ import { Config } from "talk-server/config";
|
||||
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import ManagementContext from "./context";
|
||||
|
||||
export default (schema: GraphQLSchema, config: Config, mongo: Db) =>
|
||||
export interface ManagementGraphQLMiddlewareOptions {
|
||||
schema: GraphQLSchema;
|
||||
config: Config;
|
||||
mongo: Db;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default ({
|
||||
schema,
|
||||
config,
|
||||
mongo,
|
||||
i18n,
|
||||
}: ManagementGraphQLMiddlewareOptions) =>
|
||||
graphqlMiddleware(config, async (req: Request) => ({
|
||||
schema,
|
||||
context: new ManagementContext({ req, mongo, config }),
|
||||
context: new ManagementContext({ req, mongo, config, i18n }),
|
||||
}));
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AugmentedRedis } from "talk-server/services/redis";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
import { Request } from "talk-server/types/express";
|
||||
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import loaders from "./loaders";
|
||||
import mutators from "./mutators";
|
||||
|
||||
@@ -23,6 +24,7 @@ export interface TenantContextOptions {
|
||||
signingConfig?: JWTSigningConfig;
|
||||
req?: Request;
|
||||
user?: User;
|
||||
i18n: I18n;
|
||||
}
|
||||
|
||||
export default class TenantContext extends CommonContext {
|
||||
@@ -46,8 +48,9 @@ export default class TenantContext extends CommonContext {
|
||||
tenantCache,
|
||||
queue,
|
||||
signingConfig,
|
||||
i18n,
|
||||
}: TenantContextOptions) {
|
||||
super({ user, req, config });
|
||||
super({ user, req, config, i18n, lang: tenant.locale });
|
||||
|
||||
this.tenant = tenant;
|
||||
this.tenantCache = tenantCache;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isNull, omitBy } from "lodash";
|
||||
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { GQLUpdateSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import { regenerateSSOKey, update } from "talk-server/services/tenant";
|
||||
|
||||
@@ -11,8 +11,8 @@ export const Settings = ({
|
||||
tenantCache,
|
||||
tenant,
|
||||
}: TenantContext) => ({
|
||||
update: (input: GQLSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
|
||||
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, tenant, omitBy(input.settings, isNull)),
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { isNull, omitBy } from "lodash";
|
||||
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import {
|
||||
GQLCreateStoryInput,
|
||||
@@ -16,17 +18,33 @@ export const Story = (ctx: TenantContext) => ({
|
||||
create: async (
|
||||
input: GQLCreateStoryInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
omitBy(input.story, isNull)
|
||||
mapFieldsetToErrorCodes(
|
||||
create(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.story.id,
|
||||
input.story.url,
|
||||
omitBy(input.story, isNull)
|
||||
),
|
||||
{
|
||||
"input.story.url": [
|
||||
ERROR_CODES.STORY_URL_NOT_PERMITTED,
|
||||
ERROR_CODES.DUPLICATE_STORY_URL,
|
||||
],
|
||||
}
|
||||
),
|
||||
update: async (
|
||||
input: GQLUpdateStoryInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
|
||||
mapFieldsetToErrorCodes(
|
||||
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
|
||||
{
|
||||
"input.story.url": [
|
||||
ERROR_CODES.STORY_URL_NOT_PERMITTED,
|
||||
ERROR_CODES.DUPLICATE_STORY_URL,
|
||||
],
|
||||
}
|
||||
),
|
||||
merge: async (
|
||||
input: GQLMergeStoriesInput
|
||||
): Promise<Readonly<story.Story> | null> =>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ERROR_CODES } from "talk-common/errors";
|
||||
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
|
||||
import TenantContext from "talk-server/graph/tenant/context";
|
||||
import * as user from "talk-server/models/user";
|
||||
import {
|
||||
@@ -13,6 +15,7 @@ import {
|
||||
updateRole,
|
||||
updateUsername,
|
||||
} from "talk-server/services/users";
|
||||
|
||||
import {
|
||||
GQLCreateTokenInput,
|
||||
GQLDeactivateTokenInput,
|
||||
@@ -31,11 +34,32 @@ export const User = (ctx: TenantContext) => ({
|
||||
setUsername: async (
|
||||
input: GQLSetUsernameInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
|
||||
mapFieldsetToErrorCodes(
|
||||
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
|
||||
{
|
||||
"input.username": [
|
||||
ERROR_CODES.USERNAME_ALREADY_SET,
|
||||
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
|
||||
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
|
||||
ERROR_CODES.USERNAME_TOO_SHORT,
|
||||
ERROR_CODES.DUPLICATE_USERNAME,
|
||||
],
|
||||
}
|
||||
),
|
||||
setEmail: async (
|
||||
input: GQLSetEmailInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
|
||||
mapFieldsetToErrorCodes(
|
||||
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
|
||||
{
|
||||
"input.email": [
|
||||
ERROR_CODES.EMAIL_ALREADY_SET,
|
||||
ERROR_CODES.DUPLICATE_EMAIL,
|
||||
ERROR_CODES.EMAIL_INVALID_FORMAT,
|
||||
ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH,
|
||||
],
|
||||
}
|
||||
),
|
||||
setPassword: async (
|
||||
input: GQLSetPasswordInput
|
||||
): Promise<Readonly<user.User> | null> =>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
import { LOCALES } from "./LOCALES";
|
||||
|
||||
it("does not contain duplicate entries", () => {
|
||||
const seen: Partial<Record<LanguageCode, true>> = {};
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value in seen).toBeFalsy();
|
||||
seen[value] = true;
|
||||
}
|
||||
});
|
||||
|
||||
it("contains the correct mappings to the BCP 47 format", () => {
|
||||
for (const key in LOCALES) {
|
||||
if (!LOCALES.hasOwnProperty(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = LOCALES[key as GQLLOCALES];
|
||||
expect(value).toEqual(key.replace(/_/, "-"));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
|
||||
import { GQLLOCALES } from "../schema/__generated__/types";
|
||||
|
||||
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
|
||||
en_US: "en-US",
|
||||
es: "es",
|
||||
de: "de",
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
updateSettings: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.update(input.settings),
|
||||
settings: await ctx.mutators.Settings.update(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
createCommentReaction: async (source, { input }, ctx) => ({
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
|
||||
import {
|
||||
addResolveFunctionsToSchema,
|
||||
attachDirectiveResolvers,
|
||||
IEnumResolver,
|
||||
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";
|
||||
import { LOCALES } from "talk-server/graph/tenant/resolvers/LOCALES";
|
||||
|
||||
export default function getTenantSchema() {
|
||||
const schema = loadSchema("tenant", resolvers as IResolvers);
|
||||
@@ -10,5 +16,15 @@ export default function getTenantSchema() {
|
||||
// Attach the directive resolvers.
|
||||
attachDirectiveResolvers(schema, { auth });
|
||||
|
||||
// Attach the GraphQL enum fields.
|
||||
addResolveFunctionsToSchema({
|
||||
schema,
|
||||
resolvers: {
|
||||
// For some reason, the resolver doesn't quite work without coercing the
|
||||
// type.
|
||||
LOCALES: LOCALES as IEnumResolver,
|
||||
},
|
||||
});
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -825,6 +825,16 @@ type ReactionConfiguration {
|
||||
## Settings
|
||||
################################################################################
|
||||
|
||||
"""
|
||||
LOCALES list all the supported locales in a modified BCP 47 format, where the
|
||||
hyphen is replaced by an underscore.
|
||||
"""
|
||||
enum LOCALES {
|
||||
en_US
|
||||
es
|
||||
de
|
||||
}
|
||||
|
||||
"""
|
||||
Settings stores the global settings for a given Tenant.
|
||||
"""
|
||||
@@ -840,10 +850,15 @@ type Settings {
|
||||
domain: String! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
domains will return a given list of whitelisted domains.
|
||||
domains will return a given list of permitted domains.
|
||||
"""
|
||||
domains: [String!] @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
locale is the specified locale for this Tenant.
|
||||
"""
|
||||
locale: LOCALES!
|
||||
|
||||
"""
|
||||
moderation is the moderation mode for all Stories on the site.
|
||||
"""
|
||||
|
||||
+21
-15
@@ -2,6 +2,7 @@ import express, { Express } from "express";
|
||||
import http from "http";
|
||||
import { Db } from "mongodb";
|
||||
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
import {
|
||||
attachSubscriptionHandlers,
|
||||
createApp,
|
||||
@@ -13,6 +14,7 @@ import { Schemas } from "talk-server/graph/schemas";
|
||||
import getTenantSchema from "talk-server/graph/tenant/schema";
|
||||
import logger from "talk-server/logger";
|
||||
import { createQueue, TaskQueue } from "talk-server/queue";
|
||||
import { I18n } from "talk-server/services/i18n";
|
||||
import { createJWTSigningConfig } from "talk-server/services/jwt";
|
||||
import { createMongoDB } from "talk-server/services/mongodb";
|
||||
import { ensureIndexes } from "talk-server/services/mongodb/indexes";
|
||||
@@ -23,10 +25,6 @@ export interface ServerOptions {
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
export interface ServerConnectOptions {
|
||||
isWorker?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server provides an interface to create, start, and manage a Talk Server.
|
||||
*/
|
||||
@@ -79,7 +77,7 @@ class Server {
|
||||
};
|
||||
}
|
||||
|
||||
public async connect({ isWorker }: ServerConnectOptions = {}) {
|
||||
public async connect() {
|
||||
// Guard against double connecting.
|
||||
if (this.connected) {
|
||||
throw new Error("server has already connected");
|
||||
@@ -89,15 +87,8 @@ class Server {
|
||||
// Setup MongoDB.
|
||||
this.mongo = await createMongoDB(config);
|
||||
|
||||
// If the instance being connected is not a worker process, then create the
|
||||
// database indexes if it isn't disabled.
|
||||
if (!isWorker && !this.config.get("disable_mongodb_autoindexing")) {
|
||||
// Setup the database indexes.
|
||||
await ensureIndexes(this.mongo);
|
||||
}
|
||||
|
||||
// Setup Redis.
|
||||
this.redis = createRedisClient(config);
|
||||
this.redis = await createRedisClient(config);
|
||||
|
||||
// Create the TenantCache.
|
||||
this.tenantCache = new TenantCache(
|
||||
@@ -110,7 +101,7 @@ class Server {
|
||||
await this.tenantCache.primeAll();
|
||||
|
||||
// Create the Job Queue.
|
||||
this.queue = createQueue({
|
||||
this.queue = await createQueue({
|
||||
config: this.config,
|
||||
mongo: this.mongo,
|
||||
tenantCache: this.tenantCache,
|
||||
@@ -118,7 +109,7 @@ class Server {
|
||||
}
|
||||
|
||||
/**
|
||||
* process will start the job processors.
|
||||
* process will start the job processors and ancillary operations.
|
||||
*/
|
||||
public async process() {
|
||||
// Guard against double connecting.
|
||||
@@ -127,6 +118,12 @@ class Server {
|
||||
}
|
||||
this.processing = true;
|
||||
|
||||
// Create the database indexes if it isn't disabled.
|
||||
if (!this.config.get("disable_mongodb_autoindexing")) {
|
||||
// Setup the database indexes.
|
||||
await ensureIndexes(this.mongo);
|
||||
}
|
||||
|
||||
this.queue.mailer.process();
|
||||
this.queue.scraper.process();
|
||||
}
|
||||
@@ -151,6 +148,14 @@ class Server {
|
||||
// Create the signing config.
|
||||
const signingConfig = createJWTSigningConfig(this.config);
|
||||
|
||||
// Get the default locale. This is asserted here because the LanguageCode
|
||||
// is verified via Convict, but not typed, so this resolves that.
|
||||
const defaultLocale = this.config.get("default_locale") as LanguageCode;
|
||||
|
||||
// Create and load the translations.
|
||||
const i18n = new I18n(defaultLocale);
|
||||
await i18n.load();
|
||||
|
||||
// Create the Talk App, branching off from the parent app.
|
||||
const app: Express = await createApp({
|
||||
parent,
|
||||
@@ -161,6 +166,7 @@ class Server {
|
||||
queue: this.queue,
|
||||
config: this.config,
|
||||
schemas: this.schemas,
|
||||
i18n,
|
||||
});
|
||||
|
||||
// Start the application and store the resulting http.Server.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
error-storyURLNotPermitted =
|
||||
The specified story URL does not exist in the permitted domains list.
|
||||
error-duplicateStoryURL = The specified story URL already exists.
|
||||
error-tenantNotFound = Tenant hostname ({$hostname}) not found
|
||||
error-userNotFound = User ({$userID}) not found
|
||||
error-notFound = Unrecognized request URL ({$method} {$path}).
|
||||
error-tokenInvalid = Invalid API Token provided: {$token}
|
||||
|
||||
error-tokenNotFound = Specified token does not exist.
|
||||
error-emailAlreadySet = Email address has already been set.
|
||||
error-emailNotSet = Email address has not been set yet.
|
||||
error-duplicateUser =
|
||||
Specified user already exists with a different login method.
|
||||
error-duplicateUsername = Specified username has already been taken.
|
||||
error-duplicateEmail = Specified email address is already in use.
|
||||
error-localProfileAlreadySet =
|
||||
Specified account already has a password set.
|
||||
error-localProfileNotSet =
|
||||
Specified account does not have a password set.
|
||||
error-usernameAlreadySet = Specified account already has their username set.
|
||||
error-usernameContainsInvalidCharacters =
|
||||
Provided username contains invalid characters.
|
||||
error-usernameExceedsMaxLength =
|
||||
Username exceeds maximum length of {$max} characters.
|
||||
error-usernameTooShort =
|
||||
Username must have at least {$min} characters.
|
||||
error-passwordTooShort =
|
||||
Password must have at least {$min} characters.
|
||||
error-displayNameExceedsMaxLength =
|
||||
Display Name exceeds maximum length of {$max} characters.
|
||||
error-emailInvalidFormat =
|
||||
Provided email address does not appear to be a valid email.
|
||||
error-emailExceedsMaxLength =
|
||||
Email address exceeds maximum length of {$max} characters.
|
||||
error-internalError = Internal Error
|
||||
error-tenantInstalledAlready = Tenant has already been installed already.
|
||||
error-userNotEntitled = You are not authorized to access that resource.
|
||||
@@ -1,33 +0,0 @@
|
||||
import bunyan, { LogLevelString, stdSerializers as serializers } from "bunyan";
|
||||
import PrettyStream from "bunyan-prettystream";
|
||||
import cluster from "cluster";
|
||||
|
||||
import config from "talk-server/config";
|
||||
|
||||
function getStreams() {
|
||||
// If we aren't in production mode, use the pretty stream printer.
|
||||
if (config.get("env") !== "production") {
|
||||
const pretty = new PrettyStream();
|
||||
pretty.pipe(process.stdout);
|
||||
|
||||
return [{ type: "raw", stream: pretty }];
|
||||
}
|
||||
|
||||
// In production, emit JSON.
|
||||
return [{ stream: process.stdout }];
|
||||
}
|
||||
|
||||
const logger = bunyan.createLogger({
|
||||
name: "talk",
|
||||
|
||||
// Attach the cluster node information to the log entries.
|
||||
clusterNode: cluster.worker ? `worker.${cluster.worker.id}` : "master",
|
||||
|
||||
// Include file references in log entries.
|
||||
src: true,
|
||||
serializers,
|
||||
streams: getStreams(),
|
||||
level: config.get("logging_level") as LogLevelString,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,22 @@
|
||||
import bunyan, { LogLevelString } from "bunyan";
|
||||
import cluster from "cluster";
|
||||
|
||||
import config from "talk-server/config";
|
||||
|
||||
import serializers from "./serializers";
|
||||
import { getStreams } from "./streams";
|
||||
|
||||
const logger = bunyan.createLogger({
|
||||
name: "talk",
|
||||
|
||||
// Attach the cluster node information to the log entries.
|
||||
clusterNode: cluster.worker ? `worker.${cluster.worker.id}` : "master",
|
||||
|
||||
// Include file references in log entries.
|
||||
src: true,
|
||||
serializers,
|
||||
streams: getStreams(),
|
||||
level: config.get("logging_level") as LogLevelString,
|
||||
});
|
||||
|
||||
export default logger;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { stdSerializers } from "bunyan";
|
||||
import { GraphQLError } from "graphql";
|
||||
import StackUtils from "stack-utils";
|
||||
|
||||
import { TalkError, TalkErrorContext } from "talk-server/errors";
|
||||
|
||||
interface SerializedError {
|
||||
id?: string;
|
||||
message: string;
|
||||
name: string;
|
||||
stack?: string;
|
||||
context?: TalkErrorContext;
|
||||
originalError?: SerializedError;
|
||||
}
|
||||
|
||||
const stackUtils = new StackUtils();
|
||||
|
||||
const errSerializer = (err: Error) => {
|
||||
const obj: SerializedError = {
|
||||
message: err.message,
|
||||
name: err.name,
|
||||
};
|
||||
|
||||
if (err.stack) {
|
||||
// Copy over a cleaned stack.
|
||||
obj.stack = stackUtils.clean(err.stack);
|
||||
}
|
||||
|
||||
if (err instanceof GraphQLError && err.originalError) {
|
||||
// If the error was caused by another error, integrate it.
|
||||
obj.originalError = errSerializer(err.originalError);
|
||||
} else if (err instanceof TalkError) {
|
||||
// Copy over the TalkError specific details.
|
||||
obj.id = err.id;
|
||||
obj.context = err.context;
|
||||
|
||||
// If the error was caused by another error, integrate it.
|
||||
const cause = err.cause();
|
||||
if (cause) {
|
||||
obj.originalError = errSerializer(cause);
|
||||
}
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
export default {
|
||||
...stdSerializers,
|
||||
err: errSerializer,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Transform, TransformCallback } from "stream";
|
||||
|
||||
export class SecretStream extends Transform {
|
||||
private static keys = "(key|token|clientID|clientSecret|password)";
|
||||
private static pattern = new RegExp(
|
||||
`"(${SecretStream.keys})":"([^"]*)"`,
|
||||
"ig"
|
||||
);
|
||||
|
||||
private static replace(chunk: string): string {
|
||||
return chunk.replace(SecretStream.pattern, `"$1":"[Sensitive]"`);
|
||||
}
|
||||
|
||||
public _transform(
|
||||
chunk: string | object | Buffer,
|
||||
encoding: string,
|
||||
callback: TransformCallback
|
||||
) {
|
||||
try {
|
||||
if (typeof chunk === "string") {
|
||||
this.push(SecretStream.replace(chunk));
|
||||
} else if (Buffer.isBuffer(chunk)) {
|
||||
this.push(SecretStream.replace(chunk.toString()));
|
||||
} else {
|
||||
this.push(SecretStream.replace(JSON.stringify(chunk)));
|
||||
}
|
||||
} catch (err) {
|
||||
// tslint:disable-next-line:no-console
|
||||
console.error(err);
|
||||
}
|
||||
|
||||
callback();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import config from "talk-server/config";
|
||||
|
||||
import PrettyStream from "@coralproject/bunyan-prettystream";
|
||||
import { SecretStream } from "./SecretStream";
|
||||
|
||||
export function getStreams() {
|
||||
// Create a new secret stream.
|
||||
const secret = new SecretStream();
|
||||
|
||||
// If we aren't in production mode, use the pretty stream printer.
|
||||
if (config.get("env") !== "production") {
|
||||
const pretty = new PrettyStream();
|
||||
|
||||
// Pipe the secret stream to pretty.
|
||||
secret.pipe(pretty);
|
||||
|
||||
// Pipe the pretty stream to stdout.
|
||||
pretty.pipe(process.stdout);
|
||||
|
||||
return [{ type: "raw", stream: pretty }];
|
||||
}
|
||||
|
||||
// Pipe the stream to stdout.
|
||||
secret.pipe(process.stdout);
|
||||
|
||||
// In production, emit JSON.
|
||||
return [{ type: "stream", stream: secret }];
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import uuid from "uuid";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { dotize } from "talk-common/utils/dotize";
|
||||
import { DuplicateStoryURLError } from "talk-server/errors";
|
||||
import { GQLStoryMetadata } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { createIndexFactory } from "talk-server/models/helpers/query";
|
||||
import { ModerationSettings } from "talk-server/models/settings";
|
||||
@@ -185,8 +186,7 @@ export async function createStory(
|
||||
// Evaluate the error, if it is in regards to violating the unique index,
|
||||
// then return a duplicate Story error.
|
||||
if (err instanceof MongoError && err.code === 11000) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("story with this url already exists");
|
||||
throw new DuplicateStoryURLError(url);
|
||||
}
|
||||
|
||||
throw err;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isNull, omitBy } from "lodash";
|
||||
import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { LanguageCode } from "talk-common/helpers/i18n/locales";
|
||||
import { DeepPartial, Omit, Sub } from "talk-common/types";
|
||||
import { dotize, DotizeOptions } from "talk-common/utils/dotize";
|
||||
import { GQLMODERATION_MODE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
@@ -38,6 +39,11 @@ export interface Tenant extends Settings {
|
||||
// domains is the list of domains that are allowed to have the iframe load on.
|
||||
domains: string[];
|
||||
|
||||
/**
|
||||
* locale is the specified locale for this Tenant.
|
||||
*/
|
||||
locale: LanguageCode;
|
||||
|
||||
organizationName: string;
|
||||
organizationURL: string;
|
||||
organizationContactEmail: string;
|
||||
@@ -61,10 +67,11 @@ export async function createTenantIndexes(mongo: Db) {
|
||||
export type CreateTenantInput = Pick<
|
||||
Tenant,
|
||||
| "domain"
|
||||
| "domains"
|
||||
| "locale"
|
||||
| "organizationName"
|
||||
| "organizationURL"
|
||||
| "organizationContactEmail"
|
||||
| "domains"
|
||||
>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,16 @@ import { Db } from "mongodb";
|
||||
import uuid from "uuid";
|
||||
|
||||
import { Omit, Sub } from "talk-common/types";
|
||||
import {
|
||||
DuplicateEmailError,
|
||||
DuplicateUserError,
|
||||
DuplicateUsernameError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
TokenNotFoundError,
|
||||
UsernameAlreadySetError,
|
||||
UserNotFoundError,
|
||||
} from "talk-server/errors";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import {
|
||||
createIndexFactory,
|
||||
@@ -180,8 +190,7 @@ export async function upsertUser(
|
||||
// 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");
|
||||
throw new DuplicateUserError();
|
||||
}
|
||||
|
||||
return result.value!;
|
||||
@@ -259,8 +268,7 @@ export async function updateUserRole(
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -271,7 +279,7 @@ export async function verifyUserPassword(user: User, password: string) {
|
||||
({ type }) => type === "local"
|
||||
) as LocalProfile | undefined;
|
||||
if (!profile) {
|
||||
throw new Error("no local profile exists for this user");
|
||||
throw new LocalProfileNotSetError();
|
||||
}
|
||||
|
||||
return bcrypt.compare(password, profile.password);
|
||||
@@ -310,8 +318,7 @@ export async function updateUserPassword(
|
||||
if (!result.value) {
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -319,12 +326,10 @@ export async function updateUserPassword(
|
||||
profile => profile.type === "local" && profile.id === user.email
|
||||
)
|
||||
) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user does not have a local profile");
|
||||
throw new LocalProfileNotSetError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value || null;
|
||||
@@ -354,8 +359,7 @@ export async function setUserUsername(
|
||||
lowercaseUsername,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate username found");
|
||||
throw new DuplicateUsernameError(username);
|
||||
}
|
||||
|
||||
// The username wasn't found, so add it to the user.
|
||||
@@ -381,17 +385,14 @@ export async function setUserUsername(
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
if (user.username) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has username");
|
||||
throw new UsernameAlreadySetError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -420,8 +421,7 @@ export async function updateUserUsername(
|
||||
lowercaseUsername,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate username found");
|
||||
throw new DuplicateUsernameError(username);
|
||||
}
|
||||
|
||||
// The username wasn't found, so add it to the user.
|
||||
@@ -446,12 +446,10 @@ export async function updateUserUsername(
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -495,12 +493,10 @@ export async function updateUserDisplayName(
|
||||
// Try to get the current user to discover what happened.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -530,8 +526,7 @@ export async function setUserEmail(
|
||||
email,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate email found");
|
||||
throw new DuplicateEmailError(email);
|
||||
}
|
||||
|
||||
// The email wasn't found, so try to update the User.
|
||||
@@ -556,17 +551,14 @@ export async function setUserEmail(
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has email");
|
||||
throw new UsernameAlreadySetError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -595,8 +587,7 @@ export async function updateUserEmail(
|
||||
email,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate email found");
|
||||
throw new DuplicateEmailError(email);
|
||||
}
|
||||
|
||||
// The email wasn't found, so try to update the User.
|
||||
@@ -620,12 +611,10 @@ export async function updateUserEmail(
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -669,12 +658,10 @@ export async function updateUserAvatar(
|
||||
// Try to get the current user to discover what happened.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -707,8 +694,7 @@ export async function setUserLocalProfile(
|
||||
id: email,
|
||||
});
|
||||
if (user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("duplicate profile found");
|
||||
throw new DuplicateEmailError(email);
|
||||
}
|
||||
|
||||
// Hash the password.
|
||||
@@ -745,17 +731,14 @@ export async function setUserLocalProfile(
|
||||
// Try to get the current user to discover what happened.
|
||||
user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
if (user.profiles.some(({ type }) => type === "local")) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user already has local profile");
|
||||
throw new LocalProfileAlreadySetError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("unexpected error occurred");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
@@ -789,8 +772,7 @@ export async function createUserToken(
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -824,18 +806,15 @@ export async function deactivateUserToken(
|
||||
if (!result.value) {
|
||||
const user = await retrieveUser(mongo, tenantID, userID);
|
||||
if (!user) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("user not found");
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// Check to see if the User had that Token in the first place.
|
||||
if (!user.tokens.find(t => t.id === id)) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("token not found on user");
|
||||
throw new TokenNotFoundError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("could not remove the token for an unknown reason");
|
||||
throw new Error("an unexpected error occured");
|
||||
}
|
||||
|
||||
// We have to typecast here because we know at this point that the record does
|
||||
|
||||
@@ -11,10 +11,12 @@ import {
|
||||
import { createRedisClient } from "talk-server/services/redis";
|
||||
import TenantCache from "talk-server/services/tenant/cache";
|
||||
|
||||
const createQueueOptions = (config: Config): Queue.QueueOptions => {
|
||||
const client = createRedisClient(config);
|
||||
const subscriber = createRedisClient(config);
|
||||
const blockingClient = createRedisClient(config);
|
||||
const createQueueOptions = async (
|
||||
config: Config
|
||||
): Promise<Queue.QueueOptions> => {
|
||||
const client = await createRedisClient(config);
|
||||
const subscriber = await createRedisClient(config);
|
||||
const blockingClient = await createRedisClient(config);
|
||||
|
||||
// Return the options that can be used by the Queue.
|
||||
return {
|
||||
@@ -51,10 +53,10 @@ export interface TaskQueue {
|
||||
scraper: Task<ScraperData>;
|
||||
}
|
||||
|
||||
export function createQueue(options: QueueOptions): TaskQueue {
|
||||
export async function createQueue(options: QueueOptions): Promise<TaskQueue> {
|
||||
// Create the processor queue options. This holds references to the Redis
|
||||
// clients that are shared per queue.
|
||||
const queueOptions = createQueueOptions(options.config);
|
||||
const queueOptions = await createQueueOptions(options.config);
|
||||
|
||||
// Attach process functions to the various tasks in the queue.
|
||||
const mailer = createMailerTask(queueOptions, options);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { FluentBundle } from "fluent/compat";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
|
||||
import { LanguageCode, LOCALES } from "talk-common/helpers/i18n/locales";
|
||||
import config from "talk-server/config";
|
||||
|
||||
/**
|
||||
* isLanguageCode will return true if the string is a `LanguageCode`.
|
||||
*
|
||||
* @param locale the string that is being tested if it's a `LanguageCode`
|
||||
*/
|
||||
function isLanguageCode(locale: string): locale is LanguageCode {
|
||||
return LOCALES.some(code => code === locale);
|
||||
}
|
||||
|
||||
// pathToLocales is the path where the server stores the locales.
|
||||
const pathToLocales = path.join(__dirname, "..", "..", "locales");
|
||||
|
||||
export class I18n {
|
||||
private bundles: Partial<Record<LanguageCode, FluentBundle>> = {};
|
||||
private defaultLang: LanguageCode;
|
||||
|
||||
constructor(defaultLocale: LanguageCode) {
|
||||
this.defaultLang = defaultLocale;
|
||||
}
|
||||
|
||||
/**
|
||||
* load will read all the translations located in the server locales folder.
|
||||
*/
|
||||
public async load() {
|
||||
// Load all the locales from the server locales folder.
|
||||
|
||||
// Load all the locales from the locales folders.
|
||||
const folders = await fs.readdir(pathToLocales);
|
||||
|
||||
// Load all the translation files for each of the folders.
|
||||
for (const folder of folders) {
|
||||
// Parse out the language code.
|
||||
const locale = path.basename(folder);
|
||||
if (!isLanguageCode(locale)) {
|
||||
throw new Error(`invalid language code: ${locale}`);
|
||||
}
|
||||
|
||||
// Now we have a language code.
|
||||
const bundle = new FluentBundle(locale);
|
||||
|
||||
// Load all the translations in the folder.
|
||||
const files = await fs.readdir(path.join(pathToLocales, folder));
|
||||
|
||||
for (const file of files) {
|
||||
const messages = await fs.readFile(
|
||||
path.join(pathToLocales, folder, file),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
bundle.addMessages(messages);
|
||||
}
|
||||
|
||||
this.bundles[locale] = bundle;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* getBundle will return a bundle keyed on the language.
|
||||
*
|
||||
* @param lang the locale to get the bundle for
|
||||
*/
|
||||
public getBundle(lang: LanguageCode): FluentBundle {
|
||||
const bundle = this.bundles[lang];
|
||||
if (!bundle) {
|
||||
throw new Error(`bundle for language "${lang}" not found`);
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultLang will return the default language.
|
||||
*/
|
||||
public getDefaultLang(): Readonly<LanguageCode> {
|
||||
return this.defaultLang;
|
||||
}
|
||||
|
||||
/**
|
||||
* getDefaultBundle will return the default bundle to use.
|
||||
*/
|
||||
public getDefaultBundle(): FluentBundle {
|
||||
return this.getBundle(this.getDefaultLang());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* translate will attempt a translation but fallback to the defaultValue if it
|
||||
* can't be translated.
|
||||
*
|
||||
* @param bundle the bundle to use for translations
|
||||
* @param defaultValue the default value if the message or translation isn't
|
||||
* available
|
||||
* @param id the ID for the translation
|
||||
* @param args the args to be used in the translation
|
||||
* @param errors the errors to for the translation bundle
|
||||
*/
|
||||
export function translate(
|
||||
bundle: FluentBundle,
|
||||
defaultValue: string,
|
||||
id: string,
|
||||
args?: object,
|
||||
errors?: string[]
|
||||
): string {
|
||||
const message = bundle.getMessage(id);
|
||||
if (!message) {
|
||||
if (config.get("env") === "test") {
|
||||
throw new Error(`the message for ${id} is missing`);
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const value = bundle.format(message, args, errors);
|
||||
if (!value) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { I18n } from ".";
|
||||
|
||||
it("loads the translations without error", async () => {
|
||||
const translation = new I18n("en-US");
|
||||
await translation.load();
|
||||
expect(translation.getBundle("en-US")).toBeDefined();
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export function createAsymmetricSigningConfig(
|
||||
): JWTSigningConfig {
|
||||
return {
|
||||
// Secrets have their newlines encoded with newline literals.
|
||||
secret: Buffer.from(secret.replace(/\\n/g, "\n")),
|
||||
secret: Buffer.from(secret.replace(/\\n/g, "\n"), "utf8"),
|
||||
algorithm,
|
||||
};
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export function createSymmetricSigningConfig(
|
||||
secret: string
|
||||
): JWTSigningConfig {
|
||||
return {
|
||||
secret: new Buffer(secret),
|
||||
secret: Buffer.from(secret, "utf8"),
|
||||
algorithm,
|
||||
};
|
||||
}
|
||||
@@ -121,27 +121,23 @@ export function extractJWTFromRequest(req: Request) {
|
||||
return permit.check(req) || null;
|
||||
}
|
||||
|
||||
function generateJTIBlacklistKey(jti: string) {
|
||||
// jtib: JTI Blacklist namespace.
|
||||
return `jtib:${jti}`;
|
||||
function generateJTIRevokedKey(jti: string) {
|
||||
// jtir: JTI Revoked namespace.
|
||||
return `jtir:${jti}`;
|
||||
}
|
||||
|
||||
export async function blacklistJWT(
|
||||
redis: Redis,
|
||||
jti: string,
|
||||
validFor: number
|
||||
) {
|
||||
export async function revokeJWT(redis: Redis, jti: string, validFor: number) {
|
||||
await redis.setex(
|
||||
generateJTIBlacklistKey(jti),
|
||||
generateJTIRevokedKey(jti),
|
||||
Math.ceil(validFor),
|
||||
Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
export async function checkBlacklistJWT(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIBlacklistKey(jti));
|
||||
export async function checkJWTRevoked(redis: Redis, jti: string) {
|
||||
const expiredAtString = await redis.get(generateJTIRevokedKey(jti));
|
||||
if (expiredAtString) {
|
||||
// TODO: (wyattjoh) return a better error.
|
||||
throw new Error("JWT exists in blacklist");
|
||||
throw new Error("JWT was revoked");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { Db, MongoClient } from "mongodb";
|
||||
|
||||
import { Config } from "talk-server/config";
|
||||
import { InternalError } from "talk-server/errors";
|
||||
|
||||
export async function createMongoClient(config: Config): Promise<MongoClient> {
|
||||
return MongoClient.connect(
|
||||
config.get("mongodb"),
|
||||
{ useNewUrlParser: true }
|
||||
);
|
||||
try {
|
||||
return await MongoClient.connect(
|
||||
config.get("mongodb"),
|
||||
{ useNewUrlParser: true }
|
||||
);
|
||||
} catch (err) {
|
||||
throw new InternalError(err, "could not connect to mongodb");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,8 @@ import RedisClient, { Pipeline, Redis } from "ioredis";
|
||||
|
||||
import { Omit } from "talk-common/types";
|
||||
import { Config } from "talk-server/config";
|
||||
import { InternalError } from "talk-server/errors";
|
||||
import logger from "talk-server/logger";
|
||||
|
||||
export interface AugmentedRedisCommands {
|
||||
mhincrby(key: string, ...args: any[]): Promise<void>;
|
||||
@@ -15,6 +17,11 @@ export type AugmentedRedis = Omit<Redis, "pipeline"> &
|
||||
};
|
||||
|
||||
function configureRedisClient(redis: Redis) {
|
||||
// Attach to the error event.
|
||||
redis.on("error", (err: Error) => {
|
||||
logger.error({ err }, "an error occurred with redis");
|
||||
});
|
||||
|
||||
// mhincrby will increment many hash values.
|
||||
redis.defineCommand("mhincrby", {
|
||||
numberOfKeys: 1,
|
||||
@@ -31,11 +38,22 @@ function configureRedisClient(redis: Redis) {
|
||||
*
|
||||
* @param config application configuration.
|
||||
*/
|
||||
export function createRedisClient(config: Config): AugmentedRedis {
|
||||
const redis = new RedisClient(config.get("redis"), {});
|
||||
export async function createRedisClient(
|
||||
config: Config
|
||||
): Promise<AugmentedRedis> {
|
||||
try {
|
||||
const redis = new RedisClient(config.get("redis"), {
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
// Configure the redis client for use with the custom commands.
|
||||
configureRedisClient(redis);
|
||||
// Configure the redis client for use with the custom commands.
|
||||
configureRedisClient(redis);
|
||||
|
||||
return redis as AugmentedRedis;
|
||||
// Connect the redis client.
|
||||
await redis.connect();
|
||||
|
||||
return redis as AugmentedRedis;
|
||||
} catch (err) {
|
||||
throw new InternalError(err, "could not connect to redis");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
isURLSecure,
|
||||
prefixSchemeIfRequired,
|
||||
} from "talk-server/app/url";
|
||||
import { StoryURLInvalidError } from "talk-server/errors";
|
||||
import logger from "talk-server/logger";
|
||||
import {
|
||||
countTotalActionCounts,
|
||||
@@ -53,11 +54,10 @@ export async function findOrCreate(
|
||||
// If the URL is provided, and the url is not on a allowed domain, then refuse
|
||||
// to create the Asset.
|
||||
if (input.url && !isURLPermitted(tenant, input.url)) {
|
||||
logger.warn(
|
||||
{ story_url: input.url, tenant_domains: tenant.domains },
|
||||
"provided story url was not in the list of permitted tenant domains, story not found"
|
||||
);
|
||||
return null;
|
||||
throw new StoryURLInvalidError({
|
||||
storyURL: input.url,
|
||||
tenantDomains: tenant.domains,
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: check to see if the tenant has enabled lazy story creation, if they haven't, switch to find only.
|
||||
@@ -189,11 +189,7 @@ export async function create(
|
||||
) {
|
||||
// Ensure that the given URL is allowed.
|
||||
if (!isURLPermitted(tenant, storyURL)) {
|
||||
logger.warn(
|
||||
{ storyURL, tenantDomains: tenant.domains },
|
||||
"provided story url was not in the list of permitted tenant domains, story not created"
|
||||
);
|
||||
return null;
|
||||
throw new StoryURLInvalidError({ storyURL, tenantDomains: tenant.domains });
|
||||
}
|
||||
|
||||
// Create the story in the database.
|
||||
@@ -217,11 +213,10 @@ export async function update(
|
||||
) {
|
||||
// Ensure that the given URL is allowed.
|
||||
if (input.url && !isURLPermitted(tenant, input.url)) {
|
||||
logger.warn(
|
||||
{ storyURL: input.url, tenantDomains: tenant.domains },
|
||||
"provided story url was not in the list of permitted tenant domains, story not updated"
|
||||
);
|
||||
return null;
|
||||
throw new StoryURLInvalidError({
|
||||
storyURL: input.url,
|
||||
tenantDomains: tenant.domains,
|
||||
});
|
||||
}
|
||||
|
||||
return updateStory(mongo, tenant.id, storyID, input);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "talk-server/models/tenant";
|
||||
|
||||
import { discover } from "talk-server/app/middleware/passport/strategies/oidc/discover";
|
||||
import { TenantInstalledAlreadyError } from "talk-server/errors";
|
||||
import logger from "talk-server/logger";
|
||||
import TenantCache from "./cache";
|
||||
|
||||
@@ -44,26 +45,22 @@ export async function install(
|
||||
input: InstallTenant
|
||||
) {
|
||||
if (await isInstalled(cache)) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error(
|
||||
"tenant already setup, setup multi-tenant mode if you want to install more than one tenant"
|
||||
);
|
||||
throw new TenantInstalledAlreadyError();
|
||||
}
|
||||
|
||||
// TODO: (wyattjoh) perform any pending migrations.
|
||||
|
||||
// TODO: (wyattjoh) setup database indexes.
|
||||
|
||||
logger.info({ tenant: input }, "installing tenant");
|
||||
|
||||
// Create the Tenant.
|
||||
const tenant = await createTenant(mongo, input);
|
||||
|
||||
// Update the tenant cache.
|
||||
await cache.update(redis, tenant);
|
||||
|
||||
logger.info(
|
||||
{ tenantID: tenant.id, tenantDomain: tenant.domain },
|
||||
"a tenant has been installed"
|
||||
);
|
||||
logger.info({ tenant }, "a tenant has been installed");
|
||||
|
||||
return tenant;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,21 @@ import {
|
||||
USERNAME_MIN_LENGTH,
|
||||
USERNAME_REGEX,
|
||||
} from "talk-common/helpers/validate";
|
||||
import {
|
||||
DisplayNameExceedsMaxLengthError,
|
||||
EmailAlreadySetError,
|
||||
EmailExceedsMaxLengthError,
|
||||
EmailInvalidFormatError,
|
||||
EmailNotSetError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
PasswordTooShortError,
|
||||
TokenNotFoundError,
|
||||
UsernameAlreadySetError,
|
||||
UsernameContainsInvalidCharactersError,
|
||||
UsernameExceedsMaxLengthError,
|
||||
UsernameTooShortError,
|
||||
} from "talk-server/errors";
|
||||
import { GQLUSER_ROLE } from "talk-server/graph/tenant/schema/__generated__/types";
|
||||
import { Tenant } from "talk-server/models/tenant";
|
||||
import {
|
||||
@@ -26,6 +41,7 @@ import {
|
||||
UpsertUserInput,
|
||||
User,
|
||||
} from "talk-server/models/user";
|
||||
|
||||
import { JWTSigningConfig, signPATString } from "../jwt";
|
||||
|
||||
/**
|
||||
@@ -40,15 +56,18 @@ function validateUsername(tenant: Tenant, username: string) {
|
||||
// TODO: replace these static regex/length with database options in the Tenant eventually
|
||||
|
||||
if (!USERNAME_REGEX.test(username)) {
|
||||
throw new Error("username contained illegal characters");
|
||||
throw new UsernameContainsInvalidCharactersError();
|
||||
}
|
||||
|
||||
if (username.length > USERNAME_MAX_LENGTH) {
|
||||
throw new Error("username exceeded maximum length");
|
||||
throw new UsernameExceedsMaxLengthError(
|
||||
username.length,
|
||||
USERNAME_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
if (username.length < USERNAME_MIN_LENGTH) {
|
||||
throw new Error("username is too short");
|
||||
throw new UsernameTooShortError(username.length, USERNAME_MIN_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +83,10 @@ function validateDisplayName(tenant: Tenant, displayName: string) {
|
||||
// TODO: replace these static regex/length with database options in the Tenant eventually
|
||||
|
||||
if (displayName.length > DISPLAY_NAME_MAX_LENGTH) {
|
||||
throw new Error("displayName exceeded maximum length");
|
||||
throw new DisplayNameExceedsMaxLengthError(
|
||||
displayName.length,
|
||||
DISPLAY_NAME_MAX_LENGTH
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +101,12 @@ function validateDisplayName(tenant: Tenant, displayName: string) {
|
||||
function validatePassword(tenant: Tenant, password: string) {
|
||||
// TODO: replace these static length with database options in the Tenant eventually
|
||||
if (password.length < PASSWORD_MIN_LENGTH) {
|
||||
throw new Error("password is too short");
|
||||
throw new PasswordTooShortError(password.length, PASSWORD_MIN_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
const EMAIL_MAX_LENGTH = 100;
|
||||
|
||||
/**
|
||||
* validateEmail will validate that the email is valid. Current implementation
|
||||
* uses a length statically, future versions will expose this as configuration.
|
||||
@@ -91,9 +115,13 @@ function validatePassword(tenant: Tenant, password: string) {
|
||||
* @param email the email to be tested
|
||||
*/
|
||||
function validateEmail(tenant: Tenant, email: string) {
|
||||
// TODO: replace these static length with database options in the Tenant eventually
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
throw new Error("email is in an invalid format");
|
||||
throw new EmailInvalidFormatError();
|
||||
}
|
||||
|
||||
// TODO: replace these static length with database options in the Tenant eventually
|
||||
if (email.length > EMAIL_MAX_LENGTH) {
|
||||
throw new EmailExceedsMaxLengthError(email.length, EMAIL_MAX_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +151,6 @@ export async function upsert(mongo: Db, tenant: Tenant, input: UpsertUser) {
|
||||
validatePassword(tenant, localProfile.password);
|
||||
|
||||
if (input.email !== localProfile.id) {
|
||||
// TODO: (wyattjoh) return better error.
|
||||
throw new Error("email addresses don't match profile");
|
||||
}
|
||||
}
|
||||
@@ -150,7 +177,7 @@ export async function setUsername(
|
||||
) {
|
||||
// We require that the username is not defined in order to use this method.
|
||||
if (user.username) {
|
||||
throw new Error("username already associated with user");
|
||||
throw new UsernameAlreadySetError();
|
||||
}
|
||||
|
||||
validateUsername(tenant, username);
|
||||
@@ -176,7 +203,7 @@ export async function setEmail(
|
||||
// We requires that the email address is not defined in order to use this
|
||||
// method.
|
||||
if (user.email) {
|
||||
throw new Error("email address already associated with user");
|
||||
throw new EmailAlreadySetError();
|
||||
}
|
||||
|
||||
validateEmail(tenant, email);
|
||||
@@ -204,13 +231,13 @@ export async function setPassword(
|
||||
) {
|
||||
// We require that the email address for the user be defined for this method.
|
||||
if (!user.email) {
|
||||
throw new Error("no email address associated with user");
|
||||
throw new EmailNotSetError();
|
||||
}
|
||||
|
||||
// We also don't allow this method to be used by users that already have a
|
||||
// local profile.
|
||||
if (user.profiles.some(({ type }) => type === "local")) {
|
||||
throw new Error("user already has local profile");
|
||||
throw new LocalProfileAlreadySetError();
|
||||
}
|
||||
|
||||
validatePassword(tenant, password);
|
||||
@@ -237,7 +264,7 @@ export async function updatePassword(
|
||||
) {
|
||||
// We require that the email address for the user be defined for this method.
|
||||
if (!user.email) {
|
||||
throw new Error("no email address associated with user");
|
||||
throw new EmailNotSetError();
|
||||
}
|
||||
|
||||
// We also don't allow this method to be used by users that don't have a local
|
||||
@@ -245,7 +272,7 @@ export async function updatePassword(
|
||||
if (
|
||||
!user.profiles.some(({ id, type }) => type === "local" && id === user.email)
|
||||
) {
|
||||
throw new Error("user does not have a local profile");
|
||||
throw new LocalProfileNotSetError();
|
||||
}
|
||||
|
||||
validatePassword(tenant, password);
|
||||
@@ -302,8 +329,7 @@ export async function deactivateToken(
|
||||
id: string
|
||||
) {
|
||||
if (!user.tokens.find(t => t.id === id)) {
|
||||
// TODO: (wyattjoh) return better error
|
||||
throw new Error("token not found on user");
|
||||
throw new TokenNotFoundError();
|
||||
}
|
||||
|
||||
return deactivateUserToken(mongo, tenant.id, user.id, id);
|
||||
|
||||
+20
-9
@@ -1,9 +1,21 @@
|
||||
import dotenv from "dotenv";
|
||||
import sourceMapSupport from "source-map-support";
|
||||
|
||||
// Configure the source map support so stack traces will reference the source
|
||||
// files rather than the transpiled code.
|
||||
sourceMapSupport.install();
|
||||
|
||||
// Apply all the configuration provided in the .env file if it isn't already in
|
||||
// the environment.
|
||||
dotenv.config();
|
||||
|
||||
// Makes the script crash on unhandled rejections instead of silently
|
||||
// ignoring them. In the future, promise rejections that are not handled will
|
||||
// terminate the Node.js process with a non-zero exit code.
|
||||
process.on("unhandledRejection", err => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
import express from "express";
|
||||
import throng from "throng";
|
||||
|
||||
@@ -19,13 +31,11 @@ async function worker(server: Server) {
|
||||
try {
|
||||
logger.debug("started server worker");
|
||||
|
||||
// Connect the server to databases.
|
||||
await server.connect({ isWorker: true });
|
||||
|
||||
// Start the server.
|
||||
await server.start(app);
|
||||
} catch (err) {
|
||||
logger.error({ err }, "can not start server in worker mode");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,13 +45,13 @@ async function master(server: Server) {
|
||||
logger.debug({ workerCount }, "spawning workers to handle traffic");
|
||||
|
||||
try {
|
||||
// Connect the server to databases.
|
||||
await server.connect();
|
||||
logger.debug("started server master");
|
||||
|
||||
// Process jobs.
|
||||
await server.process();
|
||||
} catch (err) {
|
||||
logger.error({ err }, "can not start server in master mode");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,16 +66,16 @@ async function bootstrap() {
|
||||
// Determine the number of workers.
|
||||
const workerCount = server.config.get("concurrency");
|
||||
|
||||
// Connect the server to databases.
|
||||
await server.connect();
|
||||
|
||||
if (workerCount === 1) {
|
||||
logger.debug(
|
||||
{ workerCount },
|
||||
"not utilizing cluster as concurrency level is 1"
|
||||
);
|
||||
|
||||
// Connect the server to databases.
|
||||
await server.connect({});
|
||||
|
||||
// Process jobs.
|
||||
// Start processing jobs.
|
||||
await server.process();
|
||||
|
||||
// Start the server.
|
||||
@@ -80,6 +90,7 @@ async function bootstrap() {
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err }, "can not bootstrap server");
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
declare module "@coralproject/bunyan-prettystream" {
|
||||
// Type definitions for @coralproject/bunyan-prettystream
|
||||
// Project: https://www.npmjs.com/package/@coralproject/bunyan-prettystream
|
||||
// Definitions by: Jason Swearingen <https://github.com/jasonswearingen>, Vadim Macagon <https://github.com/enlight>
|
||||
// Definitions: https://github.com/DefinitelyTyped/DefinitelyTyped
|
||||
|
||||
import stream from "stream";
|
||||
|
||||
export default class PrettyStream extends stream.Writable {
|
||||
constructor(options?: { mode?: string; useColor?: boolean });
|
||||
public pipe<T extends NodeJS.WritableStream>(
|
||||
destination: T,
|
||||
options?: { end?: boolean }
|
||||
): T;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user