├── .gitignore ├── LICENSE ├── README.md ├── client ├── .browserslistrc ├── .eslintrc.js ├── .npmrc ├── babel.config.js ├── cypress.json ├── index.d.ts ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── src │ ├── App.tsx │ ├── http.ts │ ├── index.html │ ├── index.tsx │ ├── pages │ │ ├── ErrorPage.tsx │ │ ├── Home.tsx │ │ ├── Login.tsx │ │ └── Register.tsx │ ├── routes.ts │ ├── session.tsx │ └── styles.css ├── tailwind.config.js ├── tests │ ├── hello │ │ └── hello.spec.ts │ └── tsconfig.json ├── tsconfig.dev.json ├── tsconfig.json └── webpack.config.js ├── prettier.config.js ├── renovate.json └── server ├── .env.example ├── .eslintrc.js ├── .npmrc ├── index.d.ts ├── package-lock.json ├── package.json ├── prettier.config.js ├── sql └── 001_init.sql ├── src ├── features │ └── auth │ │ ├── AuthMiddleware.ts │ │ ├── AuthRouter.ts │ │ ├── LoginService.ts │ │ ├── RegisterService.ts │ │ ├── User.ts │ │ └── UserRepo.ts ├── index.ts └── infrastructure │ ├── Bcrypt.ts │ ├── DB.ts │ ├── Env.ts │ ├── Error.ts │ ├── Id.ts │ ├── Jwt.ts │ └── Logger.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | server/src/infrastructure/Secrets.ts 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Stanislav Iliev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TS React + Express Starter 2 | 3 | Starter app for a full-stack TypeScript application with auth 4 | 5 | ## Shared 6 | 7 | - [x] Type checking (TypeScript) 8 | - [x] Autoformatting (Prettier) 9 | - [x] Linting (Eslint) 10 | - [x] Dependencies kept up-to-date (Renovate) 11 | - [x] FP best practices (Purify) 12 | 13 | ## Front End 14 | 15 | - [x] Rendering (React) 16 | - [x] Client-side routing (React-router) 17 | - [x] Bundling (Webpack) 18 | - [x] Transpilation with progressive polyfilling (Babel) 19 | - [x] E2E Testing (Cypress) 20 | - [x] Styling (Tailwind, PostCSS, Autoprefixer, modern-modernize) 21 | - [ ] i18n - Languages + units + dates, Keyboard navigation, Screen readers (???) 22 | - [ ] PWA with Offline available (???) 23 | - [ ] Dark mode (???) 24 | 25 | ## Back End 26 | 27 | - [x] Serving (Express) 28 | - [x] Persistent Storage (PostgreSQL) 29 | - [x] Logging (Winston) 30 | - [ ] Auth - Sessions, SSO, Password recovery (???) 31 | - [ ] Security - HSTS, CSRF, CSP (???) 32 | 33 | ### How to setup 34 | 35 | 1. Create `.env` file in the `server` folder by example 36 | 2. Change `name` and `description` in both package.json files 37 | -------------------------------------------------------------------------------- /client/.browserslistrc: -------------------------------------------------------------------------------- 1 | defaults -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended', 10 | 'plugin:@typescript-eslint/recommended' 11 | ], 12 | parser: '@typescript-eslint/parser', 13 | parserOptions: { 14 | ecmaFeatures: { 15 | jsx: true 16 | }, 17 | ecmaVersion: 2021, 18 | sourceType: 'module' 19 | }, 20 | plugins: ['react', 'react-hooks', '@typescript-eslint'], 21 | rules: { 22 | 'no-fallthrough': 'off', 23 | 'react-hooks/rules-of-hooks': 'error', 24 | 'react/jsx-uses-react': 'off', 25 | 'react/react-in-jsx-scope': 'off', 26 | 'react/prop-types': 'off', 27 | '@typescript-eslint/explicit-module-boundary-types': 'off', 28 | '@typescript-eslint/no-non-null-assertion': 'off', 29 | '@typescript-eslint/no-unused-vars': [ 30 | 'error', 31 | { 32 | argsIgnorePattern: '^_' 33 | } 34 | ] 35 | }, 36 | settings: { 37 | react: { 38 | version: 'detect' 39 | } 40 | }, 41 | ignorePatterns: ['**/*.js'] 42 | } 43 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = api => { 2 | api.cache(true) 3 | 4 | return { 5 | presets: [ 6 | [ 7 | '@babel/env', 8 | { 9 | useBuiltIns: 'entry', 10 | corejs: '3.0.0' 11 | } 12 | ], 13 | '@babel/typescript', 14 | ['@babel/react', { runtime: 'automatic' }] 15 | ], 16 | plugins: ['@babel/transform-runtime'] 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:8080", 3 | "video": false, 4 | "fixturesFolder": false, 5 | "pluginsFile": false, 6 | "supportFile": false, 7 | "integrationFolder": "tests" 8 | } -------------------------------------------------------------------------------- /client/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.ttf' 2 | declare module '*.jpg' 3 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "webpack serve", 8 | "build": "webpack --mode production", 9 | "compile": "tsc --noEmit", 10 | "lint": "eslint ./src/**/*.{ts,tsx}", 11 | "test": "cypress run" 12 | }, 13 | "engines": { 14 | "node": "16.17.0", 15 | "npm": "8.18.0" 16 | }, 17 | "author": "", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "@babel/core": "7.18.13", 21 | "@babel/plugin-transform-runtime": "7.18.10", 22 | "@babel/preset-env": "7.18.10", 23 | "@babel/preset-react": "7.18.6", 24 | "@babel/preset-typescript": "7.18.6", 25 | "@babel/types": "7.18.13", 26 | "@types/webpack": "5.28.0", 27 | "@typescript-eslint/eslint-plugin": "5.34.0", 28 | "@typescript-eslint/parser": "5.34.0", 29 | "babel-loader": "8.2.5", 30 | "css-loader": "6.7.1", 31 | "cypress": "10.6.0", 32 | "eslint": "8.22.0", 33 | "eslint-plugin-react": "7.30.1", 34 | "eslint-plugin-react-hooks": "4.6.0", 35 | "html-webpack-plugin": "5.5.0", 36 | "postcss": "8.4.16", 37 | "postcss-loader": "7.0.1", 38 | "prettier": "2.7.1", 39 | "style-loader": "3.3.1", 40 | "typescript": "4.7.4", 41 | "url-loader": "4.1.1", 42 | "webpack": "5.74.0", 43 | "webpack-cli": "4.10.0", 44 | "webpack-dev-server": "4.10.0" 45 | }, 46 | "dependencies": { 47 | "@types/react": "18.0.17", 48 | "@types/react-dom": "18.0.6", 49 | "@types/react-router-config": "5.0.6", 50 | "@types/react-router-dom": "5.3.3", 51 | "autoprefixer": "10.4.8", 52 | "axios": "0.27.2", 53 | "jwt-decode": "3.1.2", 54 | "react": "18.2.0", 55 | "react-dom": "18.2.0", 56 | "react-query": "4.0.0", 57 | "react-router": "6.3.0", 58 | "react-router-config": "5.1.1", 59 | "react-router-dom": "6.3.0", 60 | "tailwindcss": "3.1.8" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [require('tailwindcss'), require('autoprefixer')] 3 | } 4 | -------------------------------------------------------------------------------- /client/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../prettier.config.js') 2 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { renderRoutes } from 'react-router-config' 2 | import { BrowserRouter } from 'react-router-dom' 3 | import { QueryClient, QueryClientProvider } from 'react-query' 4 | import { routes } from './routes' 5 | import { SessionProvider } from './session' 6 | import './styles.css' 7 | 8 | const client = new QueryClient() 9 | 10 | export const App = () => ( 11 | 12 | 13 | {renderRoutes(routes)} 14 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /client/src/http.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios' 2 | import jwtDecode, { JwtPayload } from 'jwt-decode' 3 | import { getAccessToken, setAccessToken } from './session' 4 | 5 | export interface ApiError extends AxiosError { 6 | response: AxiosResponse<{ code: string }> 7 | } 8 | 9 | const refreshAccessTokenIfNeeded = async () => { 10 | const accessToken = getAccessToken() 11 | 12 | if (!accessToken) { 13 | return 14 | } 15 | 16 | const exp = jwtDecode(accessToken).exp! 17 | 18 | if (Date.now() > exp * 1000) { 19 | const refreshTokenResponse = await axios({ 20 | method: 'POST', 21 | url: process.env.API_ROOT! + '/refresh-token', 22 | withCredentials: true 23 | }) 24 | 25 | setAccessToken(refreshTokenResponse.data.accessToken) 26 | } 27 | } 28 | 29 | export const request = async ( 30 | config: AxiosRequestConfig 31 | ): Promise> => { 32 | await refreshAccessTokenIfNeeded() 33 | 34 | return axios({ 35 | ...config, 36 | url: process.env.API_ROOT! + config.url, 37 | headers: { 38 | ...config.headers, 39 | Authorization: `Bearer ${getAccessToken()}` 40 | }, 41 | withCredentials: true 42 | }) 43 | } 44 | -------------------------------------------------------------------------------- /client/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 |
13 | 14 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { render } from 'react-dom' 3 | import { App } from './App' 4 | import 'tailwindcss/tailwind.css' 5 | 6 | render( 7 | 8 | 9 | , 10 | document.querySelector('main') 11 | ) 12 | 13 | if (process.env.NODE_ENV === 'production') { 14 | window.onerror = () => window.location.assign('/error') 15 | } 16 | -------------------------------------------------------------------------------- /client/src/pages/ErrorPage.tsx: -------------------------------------------------------------------------------- 1 | export const ErrorPage = () =>
Error
2 | -------------------------------------------------------------------------------- /client/src/pages/Home.tsx: -------------------------------------------------------------------------------- 1 | import { useSession } from '../session' 2 | 3 | export const Home = () => { 4 | const { user, logout } = useSession() 5 | 6 | return ( 7 |
8 |
9 |
ChitChat
10 |

You have a new message!

11 | {user ? JSON.stringify(user) : 'not logged in'} 12 |
13 | 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /client/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useMutation } from 'react-query' 3 | import { ApiError, request } from '../http' 4 | import { useSession } from '../session' 5 | 6 | const login = (data: { username: string; password: string }) => 7 | request<{ accessToken: string }>({ method: 'POST', url: '/login', data }) 8 | 9 | export const Login = () => { 10 | const [username, setUsername] = useState('') 11 | const [password, setPassword] = useState('') 12 | const [error, setError] = useState() 13 | const { login: processLogin } = useSession() 14 | 15 | const { mutateAsync: loginMutation } = useMutation(login, { 16 | onSuccess: res => processLogin(res.data.accessToken), 17 | onError: (err: ApiError) => setError(err.response.data.code) 18 | }) 19 | 20 | return ( 21 |
22 |
Login
23 | setUsername(e.target.value)} 28 | > 29 | setPassword(e.target.value)} 34 | > 35 |
36 | {error && JSON.stringify(error)} 37 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /client/src/pages/Register.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useMutation } from 'react-query' 3 | import { useHistory } from 'react-router' 4 | import { ApiError, request } from '../http' 5 | 6 | const register = (data: { 7 | username: string 8 | password: string 9 | email: string 10 | }) => request({ method: 'POST', url: '/register', data }) 11 | 12 | export const Register = () => { 13 | const history = useHistory() 14 | const [username, setUsername] = useState('') 15 | const [email, setEmail] = useState('') 16 | const [password, setPassword] = useState('') 17 | const [error, setError] = useState(null) 18 | 19 | const { mutateAsync: registerMutation } = useMutation(register, { 20 | onSuccess: () => history.push('/login'), 21 | onError: (err: ApiError) => setError(err.response.data.code) 22 | }) 23 | 24 | return ( 25 |
26 |
Register
27 | setUsername(e.target.value)} 32 | > 33 | setPassword(e.target.value)} 38 | > 39 | setEmail(e.target.value)} 44 | > 45 |
46 | {error} 47 | 50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /client/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { ErrorPage } from './pages/ErrorPage' 2 | import { Home } from './pages/Home' 3 | import { Login } from './pages/Login' 4 | import { RouteConfig } from 'react-router-config' 5 | import { Register } from './pages/Register' 6 | 7 | export const routes: RouteConfig[] = [ 8 | { 9 | path: '/', 10 | exact: true, 11 | component: Home 12 | }, 13 | { 14 | path: '/error', 15 | exact: true, 16 | component: ErrorPage 17 | }, 18 | { 19 | path: '/login', 20 | exact: true, 21 | component: Login 22 | }, 23 | { 24 | path: '/register', 25 | exact: true, 26 | component: Register 27 | } 28 | ] 29 | 30 | export const hiddenAfterLoginRoutes = ['/login'] 31 | -------------------------------------------------------------------------------- /client/src/session.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from 'react' 2 | import { useHistory, useLocation } from 'react-router' 3 | import { useMutation, useQuery } from 'react-query' 4 | import { request } from './http' 5 | import { hiddenAfterLoginRoutes } from './routes' 6 | 7 | let accessToken = '' 8 | export const setAccessToken = (token: string) => (accessToken = token) 9 | export const getAccessToken = () => accessToken 10 | 11 | interface Session { 12 | user?: User 13 | login: (accessToken: string) => void 14 | logout: () => void 15 | } 16 | 17 | interface User { 18 | username: string 19 | createdOn: string 20 | } 21 | 22 | const SessionContext = createContext(null!) 23 | 24 | const fetchUser = async () => { 25 | const refreshTokenResponse = await request<{ accessToken: string }>({ 26 | method: 'POST', 27 | url: '/refresh-token' 28 | }) 29 | 30 | setAccessToken(refreshTokenResponse.data.accessToken) 31 | 32 | return request({ url: '/me' }).then(res => res.data) 33 | } 34 | 35 | const logout = () => request({ method: 'POST', url: '/logout' }) 36 | 37 | export const SessionProvider: React.FC = ({ children }) => { 38 | const history = useHistory() 39 | const location = useLocation() 40 | 41 | const { mutateAsync: logoutMutation } = useMutation(logout) 42 | 43 | const user = useQuery('user', fetchUser, { 44 | retry: false, 45 | refetchOnWindowFocus: false, 46 | onSuccess: () => { 47 | if (hiddenAfterLoginRoutes.includes(location.pathname)) { 48 | history.push('/') 49 | } 50 | } 51 | }) 52 | 53 | if (user.isFetching) { 54 | return null 55 | } 56 | 57 | return ( 58 | 74 | {children} 75 | 76 | ) 77 | } 78 | 79 | export const useSession = () => { 80 | const session = useContext(SessionContext) 81 | 82 | return session 83 | } 84 | -------------------------------------------------------------------------------- /client/src/styles.css: -------------------------------------------------------------------------------- 1 | main { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./src/**/*.html', './src/**/*.js'], 3 | darkMode: false, // or 'media' or 'class' 4 | theme: { 5 | extend: {} 6 | }, 7 | variants: { 8 | extend: {} 9 | }, 10 | plugins: [] 11 | } 12 | -------------------------------------------------------------------------------- /client/tests/hello/hello.spec.ts: -------------------------------------------------------------------------------- 1 | test('dasda', () => { 2 | it('dasda', () => { 3 | expect(true).to.equal(true) 4 | }) 5 | }) 6 | -------------------------------------------------------------------------------- /client/tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": false, 4 | "baseUrl": "../node_modules", 5 | "target": "es6", 6 | "lib": ["DOM", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020", "ESnext"], 7 | "types": ["cypress"] 8 | }, 9 | "include": ["**/*.ts"] 10 | } -------------------------------------------------------------------------------- /client/tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "react-jsxdev" 5 | }, 6 | } -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | "lib": ["DOM", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "ES2020", "ESnext"], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | "jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | // "outDir": "dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 68 | "noUncheckedIndexedAccess": true 69 | }, 70 | "exclude": ["tests"] 71 | } 72 | -------------------------------------------------------------------------------- /client/webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack') 2 | const path = require('path') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | 5 | module.exports = { 6 | entry: './src/index.tsx', 7 | output: { 8 | path: path.resolve(__dirname, 'dist'), 9 | filename: 'bundle.js' 10 | }, 11 | resolve: { 12 | extensions: ['.ts', '.tsx', '.js', '.json'] 13 | }, 14 | module: { 15 | rules: [ 16 | { test: /\.(ts|js)x?$/, exclude: /node_modules/, loader: 'babel-loader' }, 17 | { test: /\.css$/, use: ['style-loader', 'css-loader', 'postcss-loader'] }, 18 | { test: /\.(woff|woff2|ttf|jpg)$/, loader: 'url-loader' } 19 | ] 20 | }, 21 | plugins: [ 22 | new HtmlWebpackPlugin({ 23 | template: './src/index.html' 24 | }), 25 | new webpack.DefinePlugin({ 26 | 'process.env.API_ROOT': process.env.API_ROOT || `'http://localhost:8081'`, 27 | 'process.env.NODE_ENV': process.env.NODE_ENV || `'development'` 28 | }) 29 | ], 30 | devServer: { 31 | historyApiFallback: true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 80, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, 6 | singleQuote: true, 7 | quoteProps: 'as-needed', 8 | jsxSingleQuote: false, 9 | trailingComma: 'none', 10 | bracketSpacing: true, 11 | arrowParens: 'avoid' 12 | } 13 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":pinAllExceptPeerDependencies" 5 | ], 6 | "ignorePaths": [], 7 | "automerge": true, 8 | "requiredStatusChecks": null 9 | } 10 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | ACCESS_TOKEN_SECRET= 2 | REFRESH_TOKEN_SECRET= 3 | DB_NAME= 4 | DB_USER= 5 | DB_PASSWORD= 6 | APP_NAME= -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true 6 | }, 7 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 8 | parser: '@typescript-eslint/parser', 9 | parserOptions: { 10 | ecmaVersion: 2021, 11 | sourceType: 'module' 12 | }, 13 | plugins: ['@typescript-eslint'], 14 | rules: { 15 | 'no-fallthrough': 'off', 16 | '@typescript-eslint/explicit-module-boundary-types': 'off', 17 | '@typescript-eslint/no-non-null-assertion': 'off', 18 | '@typescript-eslint/no-unused-vars': [ 19 | 'error', 20 | { 21 | argsIgnorePattern: '^_' 22 | } 23 | ] 24 | }, 25 | ignorePatterns: ['**/*.js'] 26 | } 27 | -------------------------------------------------------------------------------- /server/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true -------------------------------------------------------------------------------- /server/index.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Express { 2 | export interface Request { 3 | env: import('./src/infrastructure/Env').Env 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-fullstack-starter", 3 | "version": "1.0.0", 4 | "description": "Starter app for a full-stack TypeScript application with auth", 5 | "scripts": { 6 | "start": "tsc && node dist/index.js", 7 | "lint": "eslint ./src/**/*.{ts,tsx}", 8 | "compile": "tsc --noEmit" 9 | }, 10 | "engines": { 11 | "node": "16.17.0", 12 | "npm": "8.18.0" 13 | }, 14 | "license": "ISC", 15 | "devDependencies": { 16 | "@typescript-eslint/eslint-plugin": "5.34.0", 17 | "@typescript-eslint/parser": "5.34.0", 18 | "eslint": "8.22.0", 19 | "prettier": "2.7.1", 20 | "typescript": "4.7.4" 21 | }, 22 | "dependencies": { 23 | "@types/bcrypt": "5.0.0", 24 | "@types/cookie-parser": "1.4.3", 25 | "@types/cors": "2.8.13", 26 | "@types/express": "4.17.13", 27 | "@types/jsonwebtoken": "8.5.9", 28 | "@types/morgan": "1.9.3", 29 | "@types/ms": "0.7.31", 30 | "@types/node": "16.11.54", 31 | "@types/pg": "8.6.5", 32 | "@types/uuid": "8.3.4", 33 | "bcrypt": "5.0.1", 34 | "cookie-parser": "1.4.6", 35 | "cors": "2.8.5", 36 | "dotenv": "16.0.1", 37 | "express": "4.18.1", 38 | "jsonwebtoken": "8.5.1", 39 | "morgan": "1.10.0", 40 | "ms": "2.1.3", 41 | "pg": "8.8.0", 42 | "postgres-migrations": "5.3.0", 43 | "purify-ts": "1.3.0", 44 | "ts-custom-error": "3.2.0", 45 | "uuid": "8.3.2", 46 | "winston": "3.8.1" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../prettier.config.js') 2 | -------------------------------------------------------------------------------- /server/sql/001_init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE users ( 2 | id bigserial PRIMARY KEY, 3 | username text NOT NULL UNIQUE, 4 | email text NOT NULL UNIQUE, 5 | "password" text NOT NULL, 6 | created_on timestamptz NOT NULL DEFAULT now() 7 | ); -------------------------------------------------------------------------------- /server/src/features/auth/AuthMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response, NextFunction } from 'express' 2 | import { Nothing } from 'purify-ts/Maybe' 3 | import { MaybeAsync } from 'purify-ts/MaybeAsync' 4 | import { 5 | getAccessTokenFromRequest, 6 | verifyAccessToken 7 | } from '../../infrastructure/Jwt' 8 | import { findUserByUsername } from './UserRepo' 9 | 10 | export const optionalUser = ( 11 | req: Request, 12 | _: Response, 13 | next: NextFunction 14 | ): void => { 15 | MaybeAsync.liftMaybe(getAccessTokenFromRequest(req)) 16 | .chain(async authHeader => verifyAccessToken(authHeader).toMaybe()) 17 | .chain(({ username }) => 18 | findUserByUsername(username, req.env.pool).orDefault(Nothing) 19 | ) 20 | .ifJust(user => { 21 | req.env.user = user 22 | }) 23 | .run() 24 | .then(() => next()) 25 | } 26 | 27 | export const requireUser = ( 28 | req: Request, 29 | res: Response, 30 | next: NextFunction 31 | ): void => { 32 | optionalUser(req, res, () => { 33 | if (!req.env.user) { 34 | res.status(401).send() 35 | } else { 36 | next() 37 | } 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /server/src/features/auth/AuthRouter.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { toPublicUser } from './User' 3 | import { login as processLogin } from './LoginService' 4 | import { register as processRegister } from './RegisterService' 5 | import { processError } from '../../infrastructure/Error' 6 | import { 7 | generateAccessToken, 8 | generateRefreshToken, 9 | verifyRefreshToken 10 | } from '../../infrastructure/Jwt' 11 | import ms from 'ms' 12 | 13 | const setRefreshToken = (res: Response, token: string) => { 14 | res.cookie('rtid', token, { 15 | httpOnly: true, 16 | path: '/refresh-token', 17 | /** Must be same as the refresh token exp! */ 18 | expires: new Date(Date.now() + ms('30d')) 19 | }) 20 | } 21 | 22 | export const authRoutes = { 23 | me(req: Request, res: Response): void { 24 | res.json(toPublicUser(req.env.user)) 25 | }, 26 | 27 | login(req: Request, res: Response): void { 28 | processLogin(req.env, req.body) 29 | .ifLeft(processError(res)) 30 | .ifRight(({ accessToken, refreshToken }) => { 31 | setRefreshToken(res, refreshToken) 32 | res.json({ accessToken }) 33 | }) 34 | .run() 35 | }, 36 | 37 | register(req: Request, res: Response): void { 38 | processRegister(req.env, req.body) 39 | .ifLeft(processError(res)) 40 | .ifRight(() => { 41 | res.status(200).send() 42 | }) 43 | .run() 44 | }, 45 | 46 | refreshToken(req: Request, res: Response): void { 47 | verifyRefreshToken(req.cookies.rtid) 48 | .ifLeft(() => res.status(401).send()) 49 | .ifRight(({ username }) => { 50 | setRefreshToken(res, generateRefreshToken(username)) 51 | res.json({ accessToken: generateAccessToken(username) }) 52 | }) 53 | }, 54 | 55 | logout(_: Request, res: Response): void { 56 | setRefreshToken(res, '') 57 | res.status(200).send() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /server/src/features/auth/LoginService.ts: -------------------------------------------------------------------------------- 1 | import { EitherAsync } from 'purify-ts/EitherAsync' 2 | import { Codec, string, GetType } from 'purify-ts/Codec' 3 | import { findUserByUsername } from './UserRepo' 4 | import { Env } from '../../infrastructure/Env' 5 | import { 6 | generateAccessToken, 7 | generateRefreshToken 8 | } from '../../infrastructure/Jwt' 9 | import { comparePasswords } from '../../infrastructure/Bcrypt' 10 | import { Left, Right } from 'purify-ts/Either' 11 | import { CustomError } from 'ts-custom-error' 12 | import { ApplicationError, InvalidRequest } from '../../infrastructure/Error' 13 | 14 | class UserNotFound extends CustomError implements ApplicationError { 15 | status = 400 16 | code = 'UserNotFound' 17 | log = false 18 | } 19 | 20 | const LoginBody = Codec.interface({ 21 | username: string, 22 | password: string 23 | }) 24 | 25 | type LoginBody = GetType 26 | 27 | export const login = (env: Env, rawBody: unknown) => 28 | EitherAsync.liftEither( 29 | LoginBody.decode(rawBody).mapLeft(_ => new InvalidRequest()) 30 | ) 31 | .chain(body => 32 | findUserByUsername(body.username, env.pool) 33 | .chain(async maybeUser => maybeUser.toEither(new UserNotFound())) 34 | .chain(user => 35 | comparePasswords( 36 | user.password, 37 | body.password 38 | ).chain(async isSamePassword => 39 | isSamePassword ? Right(user) : Left(new UserNotFound()) 40 | ) 41 | ) 42 | ) 43 | .map(user => ({ 44 | accessToken: generateAccessToken(user.username), 45 | refreshToken: generateRefreshToken(user.username) 46 | })) 47 | -------------------------------------------------------------------------------- /server/src/features/auth/RegisterService.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg' 2 | import { Codec, GetType, string } from 'purify-ts/Codec' 3 | import { Left, Right } from 'purify-ts/Either' 4 | import { EitherAsync } from 'purify-ts/EitherAsync' 5 | import { Maybe } from 'purify-ts/Maybe' 6 | import { CustomError } from 'ts-custom-error' 7 | import { hashPassword } from '../../infrastructure/Bcrypt' 8 | import { Env } from '../../infrastructure/Env' 9 | import { 10 | ApplicationError, 11 | InvalidRequest, 12 | ValidationFailed 13 | } from '../../infrastructure/Error' 14 | import { 15 | findUserByEmail, 16 | findUserByUsername, 17 | insertUser, 18 | InsertUserDTO 19 | } from './UserRepo' 20 | 21 | class UserAlreadyExists extends CustomError implements ApplicationError { 22 | status = 400 23 | code = 'UserAlreadyExists' 24 | log = false 25 | } 26 | 27 | const RegisterBody = Codec.interface({ 28 | username: string, 29 | email: string, 30 | password: string 31 | }) 32 | 33 | type RegisterBody = GetType 34 | 35 | export const register = (env: Env, rawBody: unknown) => 36 | EitherAsync.liftEither( 37 | RegisterBody.decode(rawBody).mapLeft(_ => new InvalidRequest()) 38 | ) 39 | .chain(async body => validateBody(body).toEither(new ValidationFailed())) 40 | .chain(dto => tryToInsertUser(dto, env.pool).map(_ => dto)) 41 | .map(_ => undefined) 42 | 43 | const tryToInsertUser = (dto: InsertUserDTO, pool: Pool) => 44 | findUserByUsername(dto.username, pool) 45 | .alt(findUserByEmail(dto.email, pool)) 46 | .chain(async maybeUser => 47 | maybeUser.caseOf({ 48 | Just: _ => Left(new UserAlreadyExists()), 49 | Nothing: () => Right(maybeUser) 50 | }) 51 | ) 52 | .chain(() => hashPassword(dto.password)) 53 | .chain(hashedPassword => 54 | insertUser({ ...dto, password: hashedPassword }, pool) 55 | ) 56 | 57 | const validateBody = (body: RegisterBody): Maybe => 58 | Maybe.of(body) 59 | .filter(x => x.username.length >= 4) 60 | .filter(x => x.username.length < 99) 61 | .filter(x => x.email.includes('@')) 62 | .filter(x => x.password.length >= 6) 63 | .map(x => ({ username: x.username, password: x.password, email: x.email })) 64 | -------------------------------------------------------------------------------- /server/src/features/auth/User.ts: -------------------------------------------------------------------------------- 1 | import { Id } from '../../infrastructure/Id' 2 | 3 | export interface User { 4 | id: Id 5 | username: string 6 | email: string 7 | password: string 8 | created_on: string 9 | } 10 | 11 | export interface PublicUser { 12 | username: string 13 | createdOn: string 14 | } 15 | 16 | export const toPublicUser = (user: User): PublicUser => ({ 17 | username: user.username, 18 | createdOn: user.created_on 19 | }) 20 | -------------------------------------------------------------------------------- /server/src/features/auth/UserRepo.ts: -------------------------------------------------------------------------------- 1 | import { Pool } from 'pg' 2 | import { DBError, withConn } from '../../infrastructure/DB' 3 | import { Maybe } from 'purify-ts/Maybe' 4 | import { User } from './User' 5 | import { EitherAsync } from 'purify-ts/EitherAsync' 6 | import { List } from 'purify-ts/List' 7 | 8 | export interface InsertUserDTO { 9 | username: string 10 | email: string 11 | password: string 12 | } 13 | 14 | export const insertUser = ( 15 | dto: InsertUserDTO, 16 | pool: Pool 17 | ): EitherAsync => 18 | withConn(pool, async conn => { 19 | await conn.query( 20 | 'INSERT INTO users (username, email, password) VALUES ($1, $2, $3)', 21 | [dto.username, dto.email, dto.password] 22 | ) 23 | }) 24 | 25 | export const findUserByUsername = ( 26 | username: string, 27 | pool: Pool 28 | ): EitherAsync> => 29 | withConn(pool, conn => 30 | conn 31 | .query('SELECT * FROM users WHERE username = $1 LIMIT 1', [username]) 32 | .then(res => List.at(0, res.rows)) 33 | ) 34 | 35 | export const findUserByEmail = ( 36 | email: string, 37 | pool: Pool 38 | ): EitherAsync> => 39 | withConn(pool, conn => 40 | conn 41 | .query('SELECT * FROM users WHERE email = $1 LIMIT 1', [email]) 42 | .then(res => List.at(0, res.rows)) 43 | ) 44 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | import express from 'express' 3 | import morgan from 'morgan' 4 | import cors from 'cors' 5 | import cookieParser from 'cookie-parser' 6 | import { createDbPool } from './infrastructure/DB' 7 | import { requireUser } from './features/auth/AuthMiddleware' 8 | import { authRoutes } from './features/auth/AuthRouter' 9 | import { initializeEnv } from './infrastructure/Env' 10 | 11 | dotenv.config() 12 | 13 | // https://github.com/microsoft/TypeScript/issues/41831 14 | const prerequisites = [createDbPool(), Promise.resolve()] as const 15 | 16 | Promise.all(prerequisites).then(([pool]) => { 17 | const app = express() 18 | const port = process.env.PORT || 8081 19 | 20 | app 21 | .use(morgan('dev')) 22 | .use( 23 | cors({ 24 | credentials: true, 25 | origin: 'http://localhost:8080' 26 | }) 27 | ) 28 | .use(express.json()) 29 | .use(cookieParser()) 30 | .get('/health', (_, res) => { 31 | if (pool) { 32 | res.status(200).json({ status: 'healthy' }) 33 | } else { 34 | res.status(503).json({ status: 'unavailable' }) 35 | } 36 | }) 37 | .use(initializeEnv(pool)) 38 | .get('/me', requireUser, authRoutes.me) 39 | .post('/login', authRoutes.login) 40 | .post('/logout', requireUser, authRoutes.logout) 41 | .post('/register', authRoutes.register) 42 | .post('/refresh-token', authRoutes.refreshToken) 43 | 44 | app.listen(port, () => { 45 | console.log(`App started successfully on ${port}!`) 46 | }) 47 | }) 48 | -------------------------------------------------------------------------------- /server/src/infrastructure/Bcrypt.ts: -------------------------------------------------------------------------------- 1 | import { compare, hash } from 'bcrypt' 2 | import { EitherAsync } from 'purify-ts/EitherAsync' 3 | import { CustomError } from 'ts-custom-error' 4 | import { v4 as uuidv4 } from 'uuid' 5 | import { ApplicationError } from './Error' 6 | 7 | class BcryptError extends CustomError implements ApplicationError { 8 | status = 500 9 | code = uuidv4() 10 | log = true 11 | } 12 | 13 | export const comparePasswords = ( 14 | hashedPassword: string, 15 | attempt: string 16 | ): EitherAsync => 17 | EitherAsync(() => compare(attempt, hashedPassword)).mapLeft( 18 | err => new BcryptError(err.message) 19 | ) 20 | 21 | export const hashPassword = ( 22 | password: string 23 | ): EitherAsync => 24 | EitherAsync(() => hash(password, 10)).mapLeft( 25 | err => new BcryptError(err.message) 26 | ) 27 | -------------------------------------------------------------------------------- /server/src/infrastructure/DB.ts: -------------------------------------------------------------------------------- 1 | import { Pool, PoolClient } from 'pg' 2 | import { v4 as uuidv4 } from 'uuid' 3 | import { migrate } from 'postgres-migrations' 4 | import { EitherAsync } from 'purify-ts/EitherAsync' 5 | import { CustomError } from 'ts-custom-error' 6 | import { ApplicationError } from './Error' 7 | import { logger } from './Logger' 8 | 9 | export class DBError extends CustomError implements ApplicationError { 10 | status = 500 11 | code = uuidv4() 12 | log = true 13 | } 14 | 15 | export const createDbPool = async (): Promise => { 16 | const pool = new Pool({ 17 | database: process.env.DB_NAME, 18 | user: process.env.DB_USER, 19 | password: process.env.DB_PASSWORD, 20 | host: 'localhost', 21 | port: 5432 22 | }) 23 | 24 | try { 25 | const client = await pool.connect() 26 | 27 | try { 28 | await migrate({ client }, 'sql') 29 | } finally { 30 | await client.release() 31 | } 32 | } catch { 33 | logger.error('Failed to connect to db') 34 | return null 35 | } 36 | 37 | return pool 38 | } 39 | 40 | export const withConn = ( 41 | pool: Pool, 42 | f: (conn: PoolClient) => Promise 43 | ): EitherAsync => 44 | EitherAsync(async () => { 45 | const client = await pool.connect() 46 | 47 | try { 48 | return await f(client) 49 | } catch (e) { 50 | throw new DBError(e.message) 51 | } finally { 52 | await client.release() 53 | } 54 | }) 55 | -------------------------------------------------------------------------------- /server/src/infrastructure/Env.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../features/auth/User' 2 | import { Pool } from 'pg' 3 | import { Request, Response, NextFunction } from 'express' 4 | 5 | export interface Env { 6 | user: User 7 | pool: Pool 8 | } 9 | 10 | export const initializeEnv = (pool: Pool | null) => ( 11 | req: Request, 12 | _: Response, 13 | next: NextFunction 14 | ): void => { 15 | req.env = { pool } as never 16 | next() 17 | } 18 | -------------------------------------------------------------------------------- /server/src/infrastructure/Error.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | import { CustomError } from 'ts-custom-error' 3 | import { logger } from './Logger' 4 | import { v4 as uuidv4 } from 'uuid' 5 | 6 | export interface ApplicationError extends Error { 7 | /** What HTTP status code to respond with */ 8 | status: number 9 | /** 10 | * Error code to return in the response, 11 | * used to conceal implementation details (error messages, stack traces) 12 | * while still providing a code that can be traced to specific logs 13 | * */ 14 | code: string 15 | /** Whether the error should be logged, true for unexpected errors, false for bussiness logic errors */ 16 | log: boolean 17 | } 18 | 19 | export const processError = (res: Response) => ( 20 | err: ApplicationError 21 | ): void => { 22 | if (!err.code && !err.status) { 23 | err = new UnexpectedError(err.message) 24 | } 25 | 26 | if (err.log) { 27 | logger.error(err) 28 | } 29 | 30 | res.status(err.status).json({ code: err.code }) 31 | } 32 | 33 | // General errors 34 | 35 | class UnexpectedError extends CustomError implements ApplicationError { 36 | status = 500 37 | code = uuidv4() 38 | log = true 39 | } 40 | 41 | export class InvalidRequest extends CustomError implements ApplicationError { 42 | status = 500 43 | code = 'InvalidRequest' 44 | log = false 45 | } 46 | 47 | export class ValidationFailed extends CustomError implements ApplicationError { 48 | status = 500 49 | code = 'ValidationFailed' 50 | log = false 51 | } 52 | -------------------------------------------------------------------------------- /server/src/infrastructure/Id.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 2 | export type Id = number 3 | -------------------------------------------------------------------------------- /server/src/infrastructure/Jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { Request } from 'express' 3 | import { Either } from 'purify-ts/Either' 4 | import { CustomError } from 'ts-custom-error' 5 | import { v4 as uuidv4 } from 'uuid' 6 | import { ApplicationError } from './Error' 7 | import { Maybe } from 'purify-ts/Maybe' 8 | 9 | class JwtError extends CustomError implements ApplicationError { 10 | status = 401 11 | code = uuidv4() 12 | log = true 13 | } 14 | 15 | export const generateAccessToken = (username: string): string => 16 | jwt.sign({ username }, process.env.ACCESS_TOKEN_SECRET!, { 17 | algorithm: 'HS256', 18 | issuer: process.env.APP_NAME, 19 | expiresIn: '15m' 20 | }) 21 | 22 | export const generateRefreshToken = (username: string): string => 23 | jwt.sign({ username }, process.env.REFRESH_TOKEN_SECRET!, { 24 | algorithm: 'HS256', 25 | issuer: process.env.APP_NAME, 26 | expiresIn: '30d' 27 | }) 28 | 29 | export const verifyAccessToken = ( 30 | token: string 31 | ): Either => 32 | Either.encase( 33 | () => jwt.verify(token, process.env.ACCESS_TOKEN_SECRET!) as never 34 | ).mapLeft(err => new JwtError(err.message)) 35 | 36 | export const verifyRefreshToken = ( 37 | token: string 38 | ): Either => 39 | Either.encase( 40 | () => jwt.verify(token, process.env.REFRESH_TOKEN_SECRET!) as never 41 | ).mapLeft(err => new JwtError(err.message)) 42 | 43 | export const getAccessTokenFromRequest = (req: Request): Maybe => 44 | Maybe.fromNullable(req.header('Authorization')).chainNullable(x => 45 | x.split(' ').pop() 46 | ) 47 | -------------------------------------------------------------------------------- /server/src/infrastructure/Logger.ts: -------------------------------------------------------------------------------- 1 | import winston, { format } from 'winston' 2 | 3 | export const logger = winston.createLogger({ 4 | format: format.combine(format.timestamp(), format.json()), 5 | transports: [new winston.transports.Console()] 6 | }) 7 | 8 | if (process.env.NODE_ENV === 'production') { 9 | logger.add(new winston.transports.File({ filename: 'error.log' })) 10 | } 11 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Basic Options */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | "target": "ES2019", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 9 | // "lib": [], /* Specify library files to be included in the compilation. */ 10 | // "allowJs": true, /* Allow javascript files to be compiled. */ 11 | // "checkJs": true, /* Report errors in .js files. */ 12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 15 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 16 | // "outFile": "./", /* Concatenate and emit output to single file. */ 17 | "outDir": "dist", /* Redirect output structure to the directory. */ 18 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 19 | // "composite": true, /* Enable project compilation */ 20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 21 | // "removeComments": true, /* Do not emit comments to output. */ 22 | // "noEmit": true, /* Do not emit outputs. */ 23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 26 | 27 | /* Strict Type-Checking Options */ 28 | "strict": true, /* Enable all strict type-checking options. */ 29 | "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 36 | 37 | /* Additional Checks */ 38 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 40 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 41 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 42 | 43 | /* Module Resolution Options */ 44 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 45 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 46 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 47 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 48 | // "typeRoots": [], /* List of folders to include type definitions from. */ 49 | // "types": [], /* Type declaration files to be included in compilation. */ 50 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 51 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 52 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 53 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 54 | 55 | /* Source Map Options */ 56 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 59 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 60 | 61 | /* Experimental Options */ 62 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 63 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 64 | 65 | /* Advanced Options */ 66 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 67 | "forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */ 68 | "noUncheckedIndexedAccess": true 69 | } 70 | } 71 | --------------------------------------------------------------------------------