├── docs ├── extensions │ └── api-examples.js ├── api │ ├── root-examples.md │ ├── rows-auth-examples.md │ ├── auth-examples.md │ ├── tables-examples.md │ └── rows-examples.md ├── logo.png ├── soul-studio.png ├── auth-db-diagram.png ├── ws-examples.md ├── self-hosting.md ├── api-examples.md ├── ws │ └── rows-examples.md ├── extensions-examples.md └── auth.md ├── .eslintignore ├── src ├── constants │ ├── roles.js │ ├── auth.js │ ├── index.js │ ├── api.js │ ├── tables.js │ └── messages.js ├── controllers │ ├── auth │ │ ├── index.js │ │ ├── common.js │ │ ├── tables.js │ │ ├── token.js │ │ └── user.js │ ├── index.js │ ├── index.test.js │ ├── tables.test.js │ ├── tables.js │ ├── rows.test.js │ └── auth.test.js ├── services │ ├── index.js │ ├── authService.js │ ├── tableService.js │ └── rowService.js ├── tests │ ├── globalTeardown.js │ ├── globalSetup.js │ ├── index.js │ └── testData.js ├── routes │ ├── index.js │ ├── auth.js │ ├── tables.js │ └── rows.js ├── middlewares │ ├── logger.js │ ├── broadcast.js │ ├── validation.js │ ├── auth.js │ └── api.js ├── db │ ├── index.js │ └── schema.js ├── schemas │ ├── index.js │ ├── auth.js │ ├── rows.js │ └── tables.js ├── commands.js ├── server.js ├── extensions.js ├── websocket.js ├── utils │ └── index.js ├── cli.js ├── index.js ├── config │ └── index.js └── swagger │ └── index.js ├── .bruno ├── environments │ └── local.bru ├── auth │ ├── folder.bru │ └── obtain-access-token.bru ├── rows │ ├── folder.bru │ └── list-rows-with-ordering.bru ├── tables │ ├── folder.bru │ └── list-tables.bru └── bruno.json ├── assets ├── images │ ├── soul-RA-app.png │ └── soul-standalone.png └── css │ └── style.scss ├── .husky └── pre-commit ├── .github ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ └── npm-publish.yml ├── .env.sample ├── .eslintrc.js ├── .editorconfig ├── CONTRIBUTING.md ├── LICENSE ├── .gitignore ├── package.json ├── .all-contributorsrc ├── CODE_OF_CONDUCT.md └── README.md /docs/extensions/api-examples.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/api/root-examples.md: -------------------------------------------------------------------------------- 1 | ## Root 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | *.test.js 2 | _extensions/ 3 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevahidal/soul/HEAD/docs/logo.png -------------------------------------------------------------------------------- /src/constants/roles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | DEFAULT_ROLE: 'default', 3 | }; 4 | -------------------------------------------------------------------------------- /.bruno/environments/local.bru: -------------------------------------------------------------------------------- 1 | vars { 2 | base_url: http://localhost:8000 3 | } 4 | -------------------------------------------------------------------------------- /docs/soul-studio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevahidal/soul/HEAD/docs/soul-studio.png -------------------------------------------------------------------------------- /docs/auth-db-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevahidal/soul/HEAD/docs/auth-db-diagram.png -------------------------------------------------------------------------------- /.bruno/auth/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: auth 3 | seq: 2 4 | } 5 | 6 | auth { 7 | mode: inherit 8 | } 9 | -------------------------------------------------------------------------------- /.bruno/rows/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: rows 3 | seq: 1 4 | } 5 | 6 | auth { 7 | mode: inherit 8 | } 9 | -------------------------------------------------------------------------------- /assets/images/soul-RA-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevahidal/soul/HEAD/assets/images/soul-RA-app.png -------------------------------------------------------------------------------- /.bruno/tables/folder.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: tables 3 | seq: 3 4 | } 5 | 6 | auth { 7 | mode: inherit 8 | } 9 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | npx lint-staged 6 | -------------------------------------------------------------------------------- /assets/images/soul-standalone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thevahidal/soul/HEAD/assets/images/soul-standalone.png -------------------------------------------------------------------------------- /.bruno/bruno.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "1", 3 | "name": "soul", 4 | "type": "collection", 5 | "ignore": ["node_modules", ".git"] 6 | } 7 | -------------------------------------------------------------------------------- /.bruno/tables/list-tables.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: list-tables 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: {{base_url}}/api/tables 9 | body: none 10 | auth: inherit 11 | } 12 | 13 | settings { 14 | encodeUrl: true 15 | timeout: 0 16 | } 17 | -------------------------------------------------------------------------------- /src/controllers/auth/index.js: -------------------------------------------------------------------------------- 1 | const users = require('./user'); 2 | const token = require('./token'); 3 | const tables = require('./tables'); 4 | const { checkAuthConfigs } = require('./common'); 5 | 6 | module.exports = { ...users, ...token, ...tables, checkAuthConfigs }; 7 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | 3 | const rowService = require('./rowService')(db); 4 | const tableService = require('./tableService')(db); 5 | const authService = require('./authService')(db); 6 | 7 | module.exports = { rowService, tableService, authService }; 8 | -------------------------------------------------------------------------------- /src/tests/globalTeardown.js: -------------------------------------------------------------------------------- 1 | const { dropTestDatabase } = require('.'); 2 | 3 | const globalTearDown = () => { 4 | console.log('Test suite finished'); 5 | console.log('Dropping test database...'); 6 | dropTestDatabase(); 7 | }; 8 | 9 | module.exports = globalTearDown; 10 | -------------------------------------------------------------------------------- /src/routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const controllers = require('../controllers/index'); 4 | 5 | const router = express.Router(); 6 | 7 | router.get('/', controllers.root); 8 | router.get('/health', controllers.health); 9 | 10 | module.exports = router; 11 | -------------------------------------------------------------------------------- /src/constants/auth.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | SALT_ROUNDS: 10, 3 | ACCESS_TOKEN_SUBJECT: 'accessToken', 4 | REFRESH_TOKEN_SUBJECT: 'refreshToken', 5 | REVOKED_REFRESH_TOKENS_REMOVAL_TIME_RANGE: 3 * 24 * 60 * 60 * 1000, // 3 days * 24 hours * 60 minutes * 60 seconds * 1000 milliseconds = 6 | }; 7 | -------------------------------------------------------------------------------- /src/constants/index.js: -------------------------------------------------------------------------------- 1 | const dbConstants = require('./tables'); 2 | const apiConstants = require('./api'); 3 | const constantRoles = require('./roles'); 4 | const responseMessages = require('./messages'); 5 | const authConstants = require('./auth'); 6 | 7 | module.exports = { 8 | dbConstants, 9 | apiConstants, 10 | constantRoles, 11 | responseMessages, 12 | authConstants, 13 | }; 14 | -------------------------------------------------------------------------------- /src/middlewares/logger.js: -------------------------------------------------------------------------------- 1 | // including the method, url, status code and response time 2 | const logger = (req, res, next) => { 3 | const start = Date.now(); 4 | res.on('finish', () => { 5 | const delta = Date.now() - start; 6 | console.log(`${req.method} ${req.url} ${res.statusCode} ${delta}ms`); 7 | }); 8 | next(); 9 | }; 10 | 11 | module.exports = { 12 | logger, 13 | }; 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Please see the documentation for all configuration options: 2 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "npm" # See documentation for possible values 7 | directory: "/" # Location of package manifests 8 | schedule: 9 | interval: "weekly" 10 | -------------------------------------------------------------------------------- /.bruno/auth/obtain-access-token.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: obtain-access-token 3 | type: http 4 | seq: 1 5 | } 6 | 7 | post { 8 | url: {{base_url}}/api/auth/token/obtain 9 | body: json 10 | auth: inherit 11 | } 12 | 13 | body:json { 14 | { 15 | "fields": { 16 | "username": "john", 17 | "password": "johns_passowrd" 18 | } 19 | } 20 | } 21 | 22 | settings { 23 | encodeUrl: true 24 | timeout: 0 25 | } 26 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | CORE_PORT=8000 2 | 3 | NODE_ENV=development 4 | 5 | CORS_ORIGIN_WHITELIST=http://localhost:3000,http://127.0.0.1:3000 6 | AUTH=false 7 | 8 | RATE_LIMIT_ENABLED=false 9 | RATE_LIMIT_WINDOW=1000 10 | RATE_LIMIT_MAX=10 11 | 12 | DB=foobar.db 13 | 14 | 15 | ACCESS_TOKEN_EXPIRATION_TIME=10H 16 | REFRESH_TOKEN_EXPIRATION_TIME=2D 17 | 18 | INITIAL_USER_USERNAME 19 | INITIAL_USER_PASSWORD 20 | TOKEN_SECRET 21 | 22 | START_WITH_STUDIO=false 23 | -------------------------------------------------------------------------------- /src/middlewares/broadcast.js: -------------------------------------------------------------------------------- 1 | const { websocketSubscribers } = require('../websocket'); 2 | 3 | const broadcast = (req) => { 4 | const data = req.broadcast; 5 | const { name: tableName } = req.params; 6 | 7 | const subscribers = websocketSubscribers?.get(tableName); 8 | 9 | if (subscribers) { 10 | subscribers.forEach(({ ws }) => { 11 | ws.send(JSON.stringify(data)); 12 | }); 13 | } 14 | }; 15 | 16 | module.exports = { 17 | broadcast, 18 | }; 19 | -------------------------------------------------------------------------------- /.bruno/rows/list-rows-with-ordering.bru: -------------------------------------------------------------------------------- 1 | meta { 2 | name: list-rows-with-ordering 3 | type: http 4 | seq: 1 5 | } 6 | 7 | get { 8 | url: {{base_url}}/api/tables/:tableName/rows/?_ordering=-CustomerId.LastName&_extend=CustomerId 9 | body: none 10 | auth: inherit 11 | } 12 | 13 | params:query { 14 | _ordering: -CustomerId.LastName 15 | _extend: CustomerId 16 | } 17 | 18 | params:path { 19 | tableName: Invoice 20 | } 21 | 22 | settings { 23 | encodeUrl: true 24 | timeout: 0 25 | } 26 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | commonjs: true, 5 | es2021: true, 6 | jest: true, 7 | }, 8 | extends: ["eslint:recommended", "prettier"], 9 | overrides: [ 10 | { 11 | env: { 12 | node: true, 13 | }, 14 | files: [".eslintrc.{js,cjs}"], 15 | parserOptions: { 16 | sourceType: "script", 17 | }, 18 | }, 19 | ], 20 | parserOptions: { 21 | ecmaVersion: "latest", 22 | }, 23 | rules: {}, 24 | }; 25 | -------------------------------------------------------------------------------- /src/tests/globalSetup.js: -------------------------------------------------------------------------------- 1 | const { 2 | createTestTable, 3 | insertIntoTestTable, 4 | createTestRelatedTable, 5 | insertIntoTestRelatedTable, 6 | } = require('.'); 7 | 8 | const setup = () => { 9 | console.log('Test suite started'); 10 | console.log('Creating test table...'); 11 | createTestTable(); 12 | createTestRelatedTable(); 13 | console.log('Inserting a row into test table...'); 14 | insertIntoTestTable(); 15 | insertIntoTestRelatedTable(); 16 | }; 17 | 18 | module.exports = setup; 19 | -------------------------------------------------------------------------------- /src/db/index.js: -------------------------------------------------------------------------------- 1 | const BS3Database = require('better-sqlite3'); 2 | 3 | const config = require('../config/index'); 4 | 5 | class Database { 6 | constructor(filename, options) { 7 | this.db = new BS3Database(filename, { 8 | verbose: this.getVerbose(), 9 | ...options, 10 | }); 11 | } 12 | 13 | getVerbose() { 14 | if (config.verbose === 'console') { 15 | return console.log; 16 | } else if (config.verbose === null) { 17 | return null; 18 | } 19 | } 20 | 21 | get() { 22 | return this.db; 23 | } 24 | } 25 | 26 | module.exports = new Database(config.db.filename).get(); 27 | -------------------------------------------------------------------------------- /src/constants/api.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | authEndpoints: ['_users', '_roles', '_roles_permissions', '_users_roles'], 3 | baseTableUrl: '/api/tables', 4 | universalAccessEndpoints: ['/api/auth/change-password'], 5 | 6 | DEFAULT_PAGE_LIMIT: 10, 7 | DEFAULT_PAGE_INDEX: 0, 8 | PASSWORD: { 9 | TOO_WEAK: 'Too weak', 10 | WEAK: 'Weak', 11 | }, 12 | 13 | httpVerbs: { 14 | POST: 'POST', 15 | GET: 'GET', 16 | PUT: 'PUT', 17 | DELETE: 'DELETE', 18 | }, 19 | 20 | httpMethodDefinitions: { 21 | POST: 'CREATE', 22 | GET: 'READ', 23 | PUT: 'UPDATE', 24 | DELETE: 'DELETE', 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | @import "https://cdnjs.cloudflare.com/ajax/libs/github-markdown-css/5.5.1/github-markdown.min.css"; 4 | 5 | body { 6 | margin: 0; 7 | } 8 | 9 | @media (prefers-color-scheme: light) { 10 | body { 11 | background-color: #ffffff; 12 | } 13 | } 14 | 15 | @media (prefers-color-scheme: dark) { 16 | body { 17 | background-color: #0d1117; 18 | } 19 | } 20 | 21 | .markdown-body { 22 | box-sizing: border-box; 23 | min-width: 200px; 24 | max-width: 980px; 25 | margin: 0 auto; 26 | padding: 45px; 27 | } 28 | 29 | @media (max-width: 767px) { 30 | .markdown-body { 31 | padding: 15px; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /src/schemas/index.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const transaction = Joi.object({ 4 | query: Joi.object().required(), 5 | params: Joi.object().required(), 6 | body: Joi.object({ 7 | transaction: Joi.array() 8 | .items( 9 | Joi.object({ 10 | statement: Joi.string().required(), 11 | values: Joi.object().required(), 12 | }), 13 | Joi.object({ 14 | query: Joi.string().required(), 15 | }), 16 | ) 17 | .required(), 18 | }).required(), 19 | 20 | cookies: Joi.object({ 21 | refreshToken: Joi.string().optional(), 22 | accessToken: Joi.string().optional(), 23 | }), 24 | }); 25 | 26 | module.exports = { 27 | transaction, 28 | }; 29 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Node.js Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | publish-npm: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 16 18 | registry-url: https://registry.npmjs.org/ 19 | 20 | - run: npm ci 21 | - run: npm publish 22 | env: 23 | NODE_AUTH_TOKEN: ${{secrets.npm_token}} 24 | -------------------------------------------------------------------------------- /src/controllers/auth/common.js: -------------------------------------------------------------------------------- 1 | const { rowService } = require('../../services'); 2 | const { dbConstants } = require('../../constants'); 3 | 4 | const { USERS_TABLE } = dbConstants; 5 | 6 | const isUsernameTaken = (username) => { 7 | const users = rowService.get({ 8 | tableName: USERS_TABLE, 9 | whereString: 'WHERE username=?', 10 | whereStringValues: [username], 11 | }); 12 | 13 | return users.length > 0; 14 | }; 15 | 16 | const checkAuthConfigs = ({ auth, tokenSecret }) => { 17 | if (auth && !tokenSecret) { 18 | throw new Error( 19 | 'You need to provide a token secret either from the CLI or from your environment variables', 20 | ); 21 | } 22 | }; 23 | 24 | module.exports = { isUsernameTaken, checkAuthConfigs }; 25 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://github.com/editorconfig/editorconfig 2 | 3 | # Top-most EditorConfig file 4 | root = true 5 | 6 | [*.{js,jsx,ts,tsx}] 7 | quote_type = single 8 | 9 | [*] 10 | # Set default charset to utf-8 11 | charset = utf-8 12 | # Set default indentation to spaces 13 | indent_style = space 14 | # Linux-style newlines with a newline ending every file 15 | end_of_line = lf 16 | insert_final_newline = true 17 | # Remove whitespace characters preceding newline characters 18 | trim_trailing_whitespace = true 19 | 20 | # Two space indentation for JavaScript files 21 | [*.{js,json}] 22 | indent_size = 2 23 | 24 | # Disable trimming trailing whitespaces so that double space newlines work 25 | [*.md] 26 | trim_trailing_whitespace = false 27 | -------------------------------------------------------------------------------- /src/commands.js: -------------------------------------------------------------------------------- 1 | const { yargs } = require('./cli'); 2 | const { updateSuperuser } = require('./controllers/auth'); 3 | 4 | const { argv } = yargs; 5 | 6 | const runCLICommands = () => { 7 | // if the updatesuperuser command is passed from the CLI execute the updatesuperuser function 8 | if (argv._.includes('updatesuperuser')) { 9 | const { id, password, is_superuser } = argv; 10 | if (!password && is_superuser === undefined) { 11 | console.log( 12 | 'Please provide either the --password or --is_superuser flag when using the updateuser command.', 13 | ); 14 | process.exit(1); 15 | } else { 16 | updateSuperuser({ id, password, is_superuser }); 17 | } 18 | } 19 | }; 20 | 21 | module.exports = { runCLICommands }; 22 | -------------------------------------------------------------------------------- /src/controllers/index.js: -------------------------------------------------------------------------------- 1 | const version = require('../../package.json').version; 2 | 3 | // Root endpoint 4 | const root = async (req, res) => { 5 | /* 6 | #swagger.tags = ['Root'] 7 | #swagger.summary = 'Timestamp' 8 | #swagger.description = 'Endpoint to return server timestamp' 9 | */ 10 | 11 | res.json({ 12 | message: 'Soul is running...', 13 | data: { 14 | version, 15 | timestamp: new Date().toISOString(), 16 | }, 17 | }); 18 | }; 19 | 20 | const health = async (req, res) => { 21 | /* 22 | #swagger.tags = ['Root'] 23 | #swagger.summary = 'Health Check' 24 | #swagger.description = 'Endpoint to return server health status' 25 | */ 26 | 27 | res.send('OK'); 28 | }; 29 | 30 | module.exports = { 31 | root, 32 | health, 33 | }; 34 | -------------------------------------------------------------------------------- /src/routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const controllers = require('../controllers/auth'); 4 | const { validator } = require('../middlewares/validation'); 5 | const schema = require('../schemas/auth'); 6 | const { hasAccess } = require('../middlewares/auth'); 7 | 8 | const router = express.Router(); 9 | 10 | router.post( 11 | '/token/obtain', 12 | validator(schema.obtainAccessToken), 13 | controllers.obtainAccessToken, 14 | ); 15 | 16 | router.get( 17 | '/token/refresh', 18 | validator(schema.refreshAccessToken), 19 | controllers.refreshAccessToken, 20 | ); 21 | 22 | router.put( 23 | '/change-password', 24 | hasAccess, 25 | validator(schema.changePassword), 26 | controllers.changePassword, 27 | ); 28 | 29 | router.get( 30 | '/logout', 31 | validator(schema.removeAccessTokens), 32 | controllers.removeTokens, 33 | ); 34 | 35 | module.exports = router; 36 | -------------------------------------------------------------------------------- /src/routes/tables.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const controllers = require('../controllers/tables'); 4 | const { validator } = require('../middlewares/validation'); 5 | const schema = require('../schemas/tables'); 6 | const { hasAccess } = require('../middlewares/auth'); 7 | const { processTableRequest } = require('../middlewares/api'); 8 | 9 | const router = express.Router(); 10 | 11 | router.get( 12 | '/', 13 | hasAccess, 14 | validator(schema.listTables), 15 | controllers.listTables, 16 | ); 17 | 18 | router.post( 19 | '/', 20 | processTableRequest, 21 | hasAccess, 22 | validator(schema.createTable), 23 | controllers.createTable, 24 | ); 25 | 26 | router.get( 27 | '/:name', 28 | hasAccess, 29 | validator(schema.getTableSchema), 30 | controllers.getTableSchema, 31 | ); 32 | 33 | router.delete( 34 | '/:name', 35 | hasAccess, 36 | validator(schema.deleteTable), 37 | controllers.deleteTable, 38 | ); 39 | 40 | module.exports = router; 41 | -------------------------------------------------------------------------------- /src/controllers/index.test.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest'); 2 | 3 | const app = require('../index'); 4 | const requestWithSupertest = supertest(app); 5 | 6 | describe('Root Endpoints', () => { 7 | it('GET / should return server version and timestamp', async () => { 8 | const res = await requestWithSupertest.get('/api'); 9 | expect(res.status).toEqual(200); 10 | expect(res.type).toEqual(expect.stringContaining('json')); 11 | expect(res.body).toHaveProperty('message'); 12 | expect(res.body).toHaveProperty('data'); 13 | expect(res.body.data).toHaveProperty('version'); 14 | expect(res.body.data).toHaveProperty('timestamp'); 15 | }); 16 | }); 17 | 18 | describe('Health Endpoints', () => { 19 | it('GET /health should return server version and timestamp', async () => { 20 | const res = await requestWithSupertest.get('/api/health'); 21 | expect(res.status).toEqual(200); 22 | expect(res.type).toEqual(expect.stringContaining('text')); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 2. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 3. Increase the version numbers in any examples files and the README.md to the new version that this 15 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 16 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you 17 | do not have permission to do that, you may request the second reviewer to merge it for you. 18 | -------------------------------------------------------------------------------- /src/middlewares/validation.js: -------------------------------------------------------------------------------- 1 | const validator = (schema) => (req, res, next) => { 2 | const { body, params, query, cookies } = req; 3 | const data = { body, params, query, cookies }; 4 | 5 | const { value, error } = schema.validate(data); 6 | 7 | if (error) { 8 | res.status(400).json({ 9 | message: error.message, 10 | error: error.details, 11 | }); 12 | } else { 13 | req.body = value.body; 14 | req.params = value.params; 15 | req.query = value.query; 16 | req.cookies = value.cookies; 17 | 18 | next(); 19 | } 20 | }; 21 | 22 | const customValidator = (schema) => (req) => { 23 | const response = { errorStatus: false, message: '', error: '' }; 24 | 25 | const { body, params, query, cookies } = req; 26 | const data = { body, params, query, cookies }; 27 | 28 | const { error } = schema.validate(data); 29 | 30 | if (error) { 31 | response.errorStatus = true; 32 | response.message = error.message; 33 | response.error = error.details; 34 | } 35 | 36 | return response; 37 | }; 38 | 39 | module.exports = { 40 | validator, 41 | customValidator, 42 | }; 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Vahid Al 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 | -------------------------------------------------------------------------------- /src/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const http = require('http'); 4 | 5 | const app = require('./index'); 6 | const { wss } = require('./websocket'); 7 | const config = require('./config/index'); 8 | 9 | if (config.startWithStudio) { 10 | (async () => { 11 | const { handler: soulStudioHandler } = await import( 12 | 'soul-studio/build/handler.js' 13 | ); 14 | app.use('/studio', soulStudioHandler); 15 | })(); 16 | } 17 | 18 | const server = http.createServer(app); 19 | 20 | const port = config.port; 21 | 22 | const baseURL = `http://localhost:${port}`; 23 | const coreURL = `${baseURL}/api/`; 24 | const studioURL = `${baseURL}/studio/`; 25 | 26 | server.listen(port, () => { 27 | console.log(`Soul is running...`); 28 | console.log(` > Core API at ${coreURL}`); 29 | 30 | if (config.startWithStudio) { 31 | console.log(` > Studio at ${studioURL}`); 32 | require('child_process').exec(`open ${studioURL}`); 33 | } 34 | }); 35 | 36 | server.on('upgrade', (request, socket, head) => { 37 | wss.handleUpgrade(request, socket, head, (socket) => { 38 | wss.emit('connection', socket, request); 39 | }); 40 | }); 41 | 42 | module.exports = {}; 43 | -------------------------------------------------------------------------------- /docs/ws-examples.md: -------------------------------------------------------------------------------- 1 | # Websocket Examples 2 | 3 | Soul realtime right now only support `rows` changes. 4 | 5 | ## Setup Environment 6 | 7 | To follow the below examples we need to download a sample database, install Soul CLI and also a websocket client to test. 8 | (Please note that you can use any websocket client you want, e.g. Postman, Insomnia, etc.) 9 | 10 | ### Download Sample Database 11 | 12 | ```bash 13 | wget https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite # Download sample sqlite database 14 | ``` 15 | 16 | ### Install Websocket client 17 | 18 | ```bash 19 | npm i -g wscat # Install websocket client 20 | ``` 21 | 22 | ### Using Soul CLI 23 | 24 | ```bash 25 | npm install -g soul-cli 26 | soul -d ./Chinook_Sqlite.sqlite -p 8000 27 | ``` 28 | 29 |
30 | Or Using Local Development 31 | 32 | ```bash 33 | git clone https://github.com/thevahidal/soul # Clone project 34 | npm install # Install dependencies 35 | npm link # might need `sudo` 36 | soul -d ./Chinook_Sqlite.sqlite -p 8000 37 | ``` 38 | 39 |
40 | 41 | ## Namespaces 42 | 43 | 1. [/ws/tables](ws/rows-examples.md) Examples for Rows websockets 44 | -------------------------------------------------------------------------------- /src/constants/tables.js: -------------------------------------------------------------------------------- 1 | const USERS_TABLE = '_users'; 2 | const ROLES_TABLE = '_roles'; 3 | const USERS_ROLES_TABLE = '_users_roles'; 4 | const ROLES_PERMISSIONS_TABLE = '_roles_permissions'; 5 | const REVOKED_REFRESH_TOKENS_TABLE = '_revoked_refresh_tokens'; 6 | 7 | module.exports = { 8 | // db table names 9 | USERS_TABLE, 10 | ROLES_TABLE, 11 | USERS_ROLES_TABLE, 12 | ROLES_PERMISSIONS_TABLE, 13 | REVOKED_REFRESH_TOKENS_TABLE, 14 | 15 | reservedTableNames: [ 16 | USERS_TABLE, 17 | ROLES_TABLE, 18 | USERS_ROLES_TABLE, 19 | ROLES_PERMISSIONS_TABLE, 20 | ], 21 | 22 | constraints: { 23 | UNIQUE_USERS_ROLE: 'unique_users_role', 24 | UNIQUE_ROLES_TABLE: 'unique_ROLES_TABLE', 25 | }, 26 | 27 | tableFields: { 28 | ID: 'id', 29 | 30 | // _role fields 31 | ROLE_NAME: 'name', 32 | 33 | // _user fields 34 | USERNAME: 'username', 35 | HASHED_PASSWORD: 'hashed_password', 36 | SALT: 'salt', 37 | IS_SUPERUSER: 'is_superuser', 38 | 39 | // _roles_permissions fields 40 | ROLE_ID: 'role_id', 41 | TABLE_NAME: 'table_name', 42 | CREATE: 'create', 43 | READ: 'read', 44 | UPDATE: 'update', 45 | DELETE: 'delete', 46 | 47 | // _users_roles fields 48 | USER_ID: 'user_id', 49 | 50 | //_revoked_refresh_tokens 51 | REFRESH_TOKEN: 'refresh_token', 52 | EXPIRES_AT: 'expires_at', 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/routes/rows.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | 3 | const controllers = require('../controllers/rows'); 4 | const { broadcast } = require('../middlewares/broadcast'); 5 | const { validator } = require('../middlewares/validation'); 6 | const { processRowRequest, processRowResponse } = require('../middlewares/api'); 7 | const { hasAccess } = require('../middlewares/auth'); 8 | const schema = require('../schemas/rows'); 9 | 10 | const router = express.Router(); 11 | 12 | router.get( 13 | '/:name/rows', 14 | hasAccess, 15 | validator(schema.listTableRows), 16 | processRowRequest, 17 | controllers.listTableRows, 18 | processRowResponse, 19 | ); 20 | router.post( 21 | '/:name/rows', 22 | hasAccess, 23 | validator(schema.insertRowInTable), 24 | processRowRequest, 25 | controllers.insertRowInTable, 26 | broadcast, 27 | ); 28 | router.get( 29 | '/:name/rows/:pks', 30 | hasAccess, 31 | validator(schema.getRowInTableByPK), 32 | controllers.getRowInTableByPK, 33 | processRowResponse, 34 | ); 35 | router.put( 36 | '/:name/rows/:pks', 37 | hasAccess, 38 | validator(schema.updateRowInTableByPK), 39 | processRowRequest, 40 | controllers.updateRowInTableByPK, 41 | broadcast, 42 | ); 43 | router.delete( 44 | '/:name/rows/:pks', 45 | hasAccess, 46 | validator(schema.deleteRowInTableByPK), 47 | controllers.deleteRowInTableByPK, 48 | broadcast, 49 | ); 50 | 51 | module.exports = router; 52 | -------------------------------------------------------------------------------- /src/extensions.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | const { extensions: extensionsConfig } = require('./config'); 4 | 5 | const { path: extensionsPath } = extensionsConfig; 6 | 7 | const setupExtensions = async (app, db) => { 8 | if (extensionsPath) { 9 | const extensions = fs.readdirSync(extensionsPath); 10 | extensions.forEach((extension) => { 11 | if (extension === 'api.js') { 12 | const apiExtensions = require(`${extensionsPath}/${extension}`); 13 | 14 | console.log('API extensions loaded'); 15 | 16 | Object.keys(apiExtensions).forEach((key) => { 17 | const api = apiExtensions[key]; 18 | switch (api.method) { 19 | case 'GET': 20 | app.get(api.path, (req, res) => api.handler(req, res, db)); 21 | break; 22 | case 'POST': 23 | app.post(api.path, api.handler); 24 | break; 25 | case 'PUT': 26 | app.put(api.path, api.handler); 27 | break; 28 | case 'DELETE': 29 | app.delete(api.path, api.handler); 30 | break; 31 | 32 | default: 33 | break; 34 | } 35 | console.log(' >', api.path); 36 | }); 37 | console.log('\n'); 38 | } 39 | }); 40 | } else { 41 | console.log('No extensions directory provided'); 42 | } 43 | }; 44 | 45 | module.exports = { 46 | setupExtensions, 47 | }; 48 | -------------------------------------------------------------------------------- /docs/self-hosting.md: -------------------------------------------------------------------------------- 1 | # Hosting Static Content Using Soul 2 | 3 | You can host static content using Soul by utilizing its extensions feature. This allows you to expose your static application through Soul, enabling users to access your content without hosting multiple applications. 4 | 5 | Here are diagrams showing how to access Soul APIs when running standalone and how to expose a static client via extensions. 6 | 7 | **Accessing Soul APIs** 8 | ![soul standalone](../assets/images/soul-standalone.png) 9 | 10 | **Accessing a React Admin Application via Soul** 11 | ![soul RA app](../assets/images/soul-RA-app.png) 12 | 13 | ## Steps 14 | 15 | In this guide, we will demonstrate how to host a static `React Admin` application. 16 | 17 | 1. Create an `_extensions` folder and add an `api.js` file to it.: 18 | ```sh 19 | mkdir _extensions && touch _extensions/api.js 20 | ``` 21 | 2. Add the following code to the `api.js` file: 22 | 23 | ```js 24 | const reactAdminApp = { 25 | method: "GET", 26 | path: "/api/client", 27 | handler: (req, res, db) => { 28 | const clientPath = path.join(__dirname, "../dist", "index.html"); 29 | res.app.use(express.static(path.join(__dirname, "../dist"))); 30 | res.sendFile(clientPath); 31 | }, 32 | }; 33 | ``` 34 | 35 | 3. Build your React Admin client: 36 | 37 | ```sh 38 | npm run build 39 | ``` 40 | 41 | 4. Copy the `dist` folder from your `React Admin` project to the `_extensions` folder: 42 | 43 | ```sh 44 | cp -r dist 45 | ``` 46 | 47 | 5. Run your Soul application: 48 | ```sh 49 | soul -d foobar.db --extensions /path/to/_extensions/ 50 | ``` 51 | 6. To verify that the app is working, open the following URL in your browser: 52 | ``` 53 | http://localhost:/api/client 54 | ``` 55 | -------------------------------------------------------------------------------- /src/websocket.js: -------------------------------------------------------------------------------- 1 | const { WebSocketServer } = require('ws'); 2 | 3 | const db = require('./db/index'); 4 | 5 | const wss = new WebSocketServer({ noServer: true }); 6 | 7 | const websocketSubscribers = new Map(); 8 | 9 | wss.on('connection', function (ws, request) { 10 | const [_path, params] = request?.url?.split('?'); 11 | 12 | if (!_path.startsWith('/ws')) { 13 | ws.close(); 14 | return; 15 | } 16 | 17 | const tableName = _path.replace('/ws/tables/', '').replace('/', ''); 18 | 19 | // if table does not exists close the connection 20 | const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`; 21 | try { 22 | const table = db.prepare(query).get(); 23 | 24 | if (!table) { 25 | ws.send( 26 | JSON.stringify({ 27 | message: `Table ${tableName} does not exist`, 28 | }) 29 | ); 30 | 31 | ws.close(); 32 | return; 33 | } 34 | } catch (error) { 35 | ws.send( 36 | JSON.stringify({ 37 | message: error.message, 38 | }) 39 | ); 40 | ws.close(); 41 | return; 42 | } 43 | 44 | if (!websocketSubscribers.has(tableName)) { 45 | websocketSubscribers.set(tableName, new Set()); 46 | } 47 | 48 | const subscriber = { 49 | ws, 50 | params: new URLSearchParams(params), 51 | }; 52 | 53 | console.log(`New subscriber for table ${tableName}`); 54 | websocketSubscribers.get(tableName).add(subscriber); 55 | 56 | ws.send(JSON.stringify({ message: `Subscribed to table "${tableName}"` })); 57 | 58 | ws.on('message', function (message) { 59 | console.log('received: %s', message); 60 | }); 61 | 62 | ws.on('close', function () { 63 | websocketSubscribers.get(tableName).delete(subscriber); 64 | }); 65 | }); 66 | 67 | module.exports = { 68 | wss, 69 | websocketSubscribers, 70 | }; 71 | -------------------------------------------------------------------------------- /docs/api-examples.md: -------------------------------------------------------------------------------- 1 | # API Examples 2 | 3 | Soul consists of 4 main namespaces: Tables (`/tables/`), Rows (`/tables/{table-name}/rows/`), Auth (`/auth/`), and Root (`/`). In this document, we'll try to go over all of them so you can get familiar with how Soul works. 4 | 5 | ## Setup Environment 6 | 7 | To follow the below examples we need to download a sample database and also install Soul CLI. 8 | 9 | ### Download Sample Database 10 | 11 | ```bash 12 | wget https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite # Download sample sqlite database 13 | ``` 14 | 15 | ### Using Soul CLI 16 | 17 | ```bash 18 | npm install -g soul-cli 19 | soul -d ./Chinook_Sqlite.sqlite -p 8000 20 | ``` 21 | 22 |
23 | Or Using Local Development 24 | 25 | ```bash 26 | git clone https://github.com/thevahidal/soul # Clone project 27 | npm install # Install dependencies 28 | npm link # might need `sudo` 29 | soul -d ./Chinook_Sqlite.sqlite -p 8000 30 | ``` 31 | 32 |
33 | 34 | ## Namespaces 35 | 36 | 1. [/api/tables/](api/tables-examples.md) Examples for Tables endpoints 37 | 2. [/api/{table-name}/rows/](api/rows-examples.md) Examples for Rows endpoints 38 | 3. [/api/auth/](api/auth-examples.md) Examples for Authentication / Authorization endpoints 39 | 1. [/api/{table-name}/rows/](api/rows-auth-examples.md) Examples for Rows endpoints in Auth mode 40 | 4. [/api/](api/root-examples.md) Examples for Root endpoints 41 | 42 | ## Handling Errors 43 | 44 | If an error occurs while processing a request, it will be indicated via the presence of an `error` key and a `message` in the JSON response. For example: 45 | 46 | ```bash 47 | curl --request POST \ 48 | --url http://localhost:8000/api/tables \ 49 | --header 'Content-Type: application/json' \ 50 | --data '{ 51 | "name": "Artist", # note that we already have an `artists` table 52 | "schema": [ 53 | # ... 54 | ] 55 | }' 56 | ``` 57 | 58 | ```json 59 | { 60 | "message": "table artists already exists", 61 | "error": { 62 | "code": "SQLITE_ERROR" 63 | } 64 | } 65 | ``` 66 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcrypt'); 2 | const jwt = require('jsonwebtoken'); 3 | 4 | const { passwordStrength } = require('check-password-strength'); 5 | 6 | const hashPassword = async (password, saltRounds) => { 7 | const salt = await bcrypt.genSalt(saltRounds); 8 | const hashedPassword = await bcrypt.hash(password, saltRounds); 9 | return { hashedPassword, salt }; 10 | }; 11 | 12 | const comparePasswords = async (plainPassword, hashedPassword) => { 13 | const isMatch = await bcrypt.compare(plainPassword, hashedPassword); 14 | return isMatch; 15 | }; 16 | 17 | const checkPasswordStrength = (password) => { 18 | const value = passwordStrength(password).value; 19 | return value; 20 | }; 21 | 22 | const generateToken = async (payload, secret, expiresIn) => { 23 | return jwt.sign(payload, secret, { expiresIn }); 24 | }; 25 | 26 | const decodeToken = async (token, secret) => { 27 | try { 28 | const decoded = jwt.verify(token, secret); 29 | return decoded; 30 | } catch (error) { 31 | throw new Error('Invalid token'); 32 | } 33 | }; 34 | 35 | const toBoolean = (value) => { 36 | if (typeof value === 'boolean') { 37 | return value; 38 | } 39 | 40 | if (typeof value === 'string') { 41 | const lowerCaseValue = value.toLowerCase(); 42 | if (lowerCaseValue === 'true') { 43 | return true; 44 | } else if (lowerCaseValue === 'false') { 45 | return false; 46 | } 47 | } 48 | 49 | if (typeof value === 'number') { 50 | if (value === 1) { 51 | return true; 52 | } else if (value === 0) { 53 | return false; 54 | } 55 | } 56 | 57 | throw new Error('Invalid value. Cannot convert to boolean.'); 58 | }; 59 | 60 | const removeFields = async (rows, fields) => { 61 | const newPayload = rows.map((row) => { 62 | fields.map((field) => { 63 | delete row[field]; 64 | }); 65 | }); 66 | 67 | return newPayload; 68 | }; 69 | 70 | module.exports = { 71 | hashPassword, 72 | comparePasswords, 73 | checkPasswordStrength, 74 | generateToken, 75 | decodeToken, 76 | toBoolean, 77 | removeFields, 78 | }; 79 | -------------------------------------------------------------------------------- /src/constants/messages.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | successMessage: { 3 | SUCCESS: 'Success', 4 | ROW_INSERTED: 'Row Inserted', 5 | PASSWORD_UPDATE_SUCCESS: 'Password updated successfully', 6 | USER_UPDATE_SUCCESS: 'User updated successfully', 7 | INITIAL_USER_CREATED_SUCCESS: 'Initial user created successfully', 8 | LOGOUT_MESSAGE: 'Logout successful', 9 | }, 10 | 11 | errorMessage: { 12 | USERNAME_TAKEN_ERROR: 'This username is taken', 13 | WEAK_PASSWORD_ERROR: 14 | 'This password is weak, it should be at least 8 characters long and contain a combination of lowercase letters, uppercase letters, numbers, and special characters', 15 | DEFAULT_ROLE_NOT_CREATED_ERROR: 16 | 'Please restart soul so a default role can be created', 17 | INVALID_USERNAME_PASSWORD_ERROR: 'Invalid username or password', 18 | INVALID_REFRESH_TOKEN_ERROR: 'Invalid refresh token', 19 | INVALID_ACCESS_TOKEN_ERROR: 'Invalid access token', 20 | USER_NOT_FOUND_ERROR: 'User not found', 21 | INVALID_CURRENT_PASSWORD_ERROR: 'Invalid current password', 22 | NOT_AUTHORIZED_ERROR: 'Not authorized', 23 | PERMISSION_NOT_DEFINED_ERROR: 'Permission not defined for this role', 24 | ROLE_NOT_FOUND_ERROR: 'Role not found for this user', 25 | AUTH_SET_TO_FALSE_ERROR: 26 | 'You can not access this endpoint while AUTH is set to false', 27 | RESERVED_TABLE_NAME_ERROR: 28 | 'The table name is reserved. Please choose a different name for the table.', 29 | SERVER_ERROR: 'Server error', 30 | 31 | INITIAL_USER_USERNAME_NOT_PASSED_ERROR: 32 | 'Error: You should pass the initial users username either from the CLI with the --iuu or from the environment variable using the INITIAL_USER_USERNAME flag', 33 | INITIAL_USER_PASSWORD_NOT_PASSED_ERROR: 34 | 'Error: You should pass the initial users password either from the CLI with the --iup or from the environment variable using the INITIAL_USER_PASSWORD flag', 35 | 36 | USERNAME_REQUIRED_ERROR: 'username is required', 37 | PASSWORD_REQUIRED_ERROR: 'password is required', 38 | }, 39 | 40 | infoMessage: { 41 | INITIAL_USER_ALREADY_CREATED: 'Initial user is already created', 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /src/tests/index.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { unlink } = require('fs/promises'); 3 | const db = require('../db/index'); 4 | const { testNames } = require('./testData'); 5 | 6 | const dropTestTable = (table = 'users') => { 7 | db.prepare(`DROP TABLE IF EXISTS ${table}`).run(); 8 | }; 9 | 10 | const dropTestDatabase = async (path = 'test.db') => { 11 | // delete test database file e.g. test.db 12 | try { 13 | await unlink(path), console.log(`successfully deleted ${path}`); 14 | } catch (error) { 15 | console.error('there was an error:', error); 16 | } 17 | 18 | if (fs.existsSync(path + '-wal')) { 19 | try { 20 | await Promise.allSettled([unlink(path + '-wal'), unlink(path + '-shm')]); 21 | } catch (error) { 22 | console.error('there was an error:', error); 23 | } 24 | } 25 | }; 26 | 27 | const createTestTable = (table = 'users') => { 28 | db.prepare( 29 | `CREATE TABLE ${table} (id INTEGER PRIMARY KEY, firstName TEXT, lastName TEXT, email TEXT, username TEXT, createdAt TEXT)`, 30 | ).run(); 31 | }; 32 | 33 | const createTestRelatedTable = (table = 'posts') => { 34 | db.prepare( 35 | `CREATE TABLE ${table} (id INTEGER PRIMARY KEY, userId INTEGER, title TEXT, content TEXT, createdAt TEXT, FOREIGN KEY(userId) REFERENCES users(id))`, 36 | ).run(); 37 | }; 38 | 39 | const insertIntoTestTable = (table = 'users') => { 40 | const statement = db.prepare( 41 | `INSERT INTO ${table} (firstName, lastName, createdAt) VALUES (?, ?, ?)`, 42 | ); 43 | 44 | for (const user of testNames) { 45 | statement.run(user.firstName, user.lastName, user.createdAt); 46 | } 47 | }; 48 | 49 | const insertIntoTestRelatedTable = (table = 'posts') => { 50 | const statement = db.prepare( 51 | `INSERT INTO ${table} (userId, title, content, createdAt) VALUES (?, ?, ?, ?)`, 52 | ); 53 | 54 | for (let i = 1; i <= testNames.length; i++) { 55 | statement.run( 56 | '' + i, 57 | `Post Title ${i}`, 58 | `This is the content of post ${i}.`, 59 | new Date().toISOString(), 60 | ); 61 | } 62 | }; 63 | 64 | module.exports = { 65 | dropTestTable, 66 | dropTestDatabase, 67 | createTestTable, 68 | createTestRelatedTable, 69 | insertIntoTestTable, 70 | insertIntoTestRelatedTable, 71 | }; 72 | -------------------------------------------------------------------------------- /.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 | 106 | # Sqlite files 107 | *.db 108 | *.db-shm 109 | *.db-wal 110 | 111 | *Chinook* 112 | _extensions/ 113 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soul-cli", 3 | "version": "0.8.2", 4 | "description": "A SQLite REST and Realtime server", 5 | "main": "src/server.js", 6 | "bin": { 7 | "soul": "./src/server.js" 8 | }, 9 | "scripts": { 10 | "start": "node src/server.js", 11 | "dev": "npm run swagger-autogen && cross-env NO_CLI=true nodemon src/server.js", 12 | "cli": "nodemon src/server.js --database foobar.db", 13 | "swagger-autogen": "cross-env NO_CLI=true node src/swagger/index.js", 14 | "test": "cross-env CI=true NODE_ENV=test NO_CLI=true DB=test.db CORE_PORT=8001 jest --testTimeout=10000", 15 | "prepare": "husky install", 16 | "lint": "eslint . --fix --max-warnings=0", 17 | "format": "prettier . --write" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/thevahidal/soul.git" 22 | }, 23 | "author": "Vahid Al @thevahidal", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/thevahidal/soul/issues" 27 | }, 28 | "homepage": "https://github.com/thevahidal/soul#readme", 29 | "dependencies": { 30 | "bcrypt": "^5.1.1", 31 | "better-sqlite3": "^8.1.0", 32 | "body-parser": "^1.20.2", 33 | "check-password-strength": "^2.0.7", 34 | "cookie-parser": "^1.4.6", 35 | "cors": "^2.8.5", 36 | "dotenv": "^16.0.3", 37 | "express": "^4.18.2", 38 | "express-rate-limit": "^6.7.0", 39 | "express-winston": "^4.2.0", 40 | "joi": "^17.8.3", 41 | "jsonwebtoken": "^9.0.2", 42 | "soul-studio": "^0.0.1", 43 | "swagger-ui-express": "^4.6.1", 44 | "winston": "^3.8.2", 45 | "ws": "^8.12.1", 46 | "yargs": "^17.7.1" 47 | }, 48 | "devDependencies": { 49 | "cross-env": "^7.0.3", 50 | "eslint": "^8.54.0", 51 | "eslint-config-prettier": "^9.0.0", 52 | "husky": "^8.0.3", 53 | "jest": "^29.4.3", 54 | "nodemon": "^3.1.3", 55 | "prettier": "3.1.0", 56 | "supertest": "^6.3.3", 57 | "swagger-autogen": "^2.23.1" 58 | }, 59 | "jest": { 60 | "testEnvironment": "node", 61 | "coveragePathIgnorePatterns": [ 62 | "/node_modules/" 63 | ], 64 | "globalSetup": "./src/tests/globalSetup.js", 65 | "globalTeardown": "./src/tests/globalTeardown.js" 66 | }, 67 | "lint-staged": { 68 | "*.js": "eslint --fix", 69 | "*.{js,css,md,html,json}": "prettier --write" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributors": [ 10 | { 11 | "login": "thevahidal", 12 | "name": "Vahid Al", 13 | "avatar_url": "https://avatars.githubusercontent.com/u/20302825?v=4", 14 | "profile": "http://linktr.ee/thevahidal", 15 | "contributions": [ 16 | "code", 17 | "review" 18 | ] 19 | }, 20 | { 21 | "login": "AbegaM", 22 | "name": "Abenezer Melkamu", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/70259638?v=4", 24 | "profile": "https://github.com/AbegaM", 25 | "contributions": [ 26 | "code" 27 | ] 28 | }, 29 | { 30 | "login": "IanMayo", 31 | "name": "Ian Mayo", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/1108513?v=4", 33 | "profile": "https://github.com/IanMayo", 34 | "contributions": [ 35 | "code", 36 | "review" 37 | ] 38 | }, 39 | { 40 | "login": "HanzCEO", 41 | "name": "Hanz", 42 | "avatar_url": "https://avatars.githubusercontent.com/u/40712686?v=4", 43 | "profile": "https://godot.id", 44 | "contributions": [ 45 | "code" 46 | ] 47 | }, 48 | { 49 | "login": "KoenDG", 50 | "name": "Koen De Groote", 51 | "avatar_url": "https://avatars.githubusercontent.com/u/1440619?v=4", 52 | "profile": "https://github.com/KoenDG", 53 | "contributions": [ 54 | "code" 55 | ] 56 | }, 57 | { 58 | "login": "TahaKhanAbdalli", 59 | "name": "Muhammad Taha Khan", 60 | "avatar_url": "https://avatars.githubusercontent.com/u/50602678?v=4", 61 | "profile": "https://github.com/TahaKhanAbdalli", 62 | "contributions": [ 63 | "code" 64 | ] 65 | }, 66 | { 67 | "login": "thoughtsunificator", 68 | "name": "Romain Lebesle", 69 | "avatar_url": "https://avatars.githubusercontent.com/u/85041649?v=4", 70 | "profile": "http://thoughtsunificator.me", 71 | "contributions": [ 72 | "code" 73 | ] 74 | } 75 | ], 76 | "contributorsPerLine": 7, 77 | "skipCi": true, 78 | "repoType": "github", 79 | "repoHost": "https://github.com", 80 | "projectName": "soul", 81 | "projectOwner": "thevahidal" 82 | } 83 | -------------------------------------------------------------------------------- /src/tests/testData.js: -------------------------------------------------------------------------------- 1 | const testNames = [ 2 | { firstName: 'Emily', lastName: 'William', createdAt: '2008-01-08 00:00:00' }, 3 | { firstName: 'Michael', lastName: 'Lee', createdAt: '2009-01-08 00:00:00' }, 4 | { firstName: 'Sarah', lastName: 'Johnson', createdAt: '2010-01-08 00:00:00' }, 5 | { firstName: 'David', lastName: 'Chen', createdAt: '2011-01-08 00:00:00' }, 6 | { 7 | firstName: 'Olivia', 8 | lastName: 'William', 9 | createdAt: '2012-01-08 00:00:00', 10 | }, 11 | { firstName: 'William', lastName: 'Kim', createdAt: '2013-01-08 00:00:00' }, 12 | { firstName: 'Sophia', lastName: 'Singh', createdAt: '2013-02-08 00:00:00' }, 13 | { 14 | firstName: 'James', 15 | lastName: 'Rodriguez', 16 | createdAt: '2013-03-08 00:00:00', 17 | }, 18 | { firstName: 'Ava', lastName: 'Patel', createdAt: '2013-01-04 00:00:00' }, 19 | { 20 | firstName: 'Benjamin', 21 | lastName: 'Garcia', 22 | createdAt: '2015-01-08 00:00:00', 23 | }, 24 | { 25 | firstName: 'Isabella', 26 | lastName: 'Nguyen', 27 | createdAt: '2014-01-08 00:00:00', 28 | }, 29 | { firstName: 'Ethan', lastName: 'Lee', createdAt: '2016-01-08 00:00:00' }, 30 | { firstName: 'Mia', lastName: 'Wilson', createdAt: '2017-01-08 00:00:00' }, 31 | { 32 | firstName: 'Alexander', 33 | lastName: 'William', 34 | createdAt: '2018-01-08 00:00:00', 35 | }, 36 | { 37 | firstName: 'Charlotte', 38 | lastName: 'Hernandez', 39 | createdAt: '2019-01-08 00:00:00', 40 | }, 41 | { firstName: 'Liam', lastName: 'Gonzalez', createdAt: '2020-01-08 00:00:00' }, 42 | { firstName: 'Emma', lastName: 'Gomez', createdAt: '2021-01-08 00:00:00' }, 43 | { firstName: 'Noah', lastName: 'Perez', createdAt: '2021-01-08 00:00:00' }, 44 | { firstName: 'Avery', lastName: 'Ramirez', createdAt: '2023-02-08 00:00:00' }, 45 | { firstName: 'Jacob', lastName: 'Turner', createdAt: '2023-03-08 00:00:00' }, 46 | { 47 | firstName: 'Abigail', 48 | lastName: 'Williams', 49 | createdAt: '2023-02-10 00:00:00', 50 | }, 51 | { firstName: 'Elijah', lastName: 'Hall', createdAt: '2023-04-02 00:00:00' }, 52 | { firstName: 'Mila', lastName: 'Flores', createdAt: '2023-05-13 00:00:00' }, 53 | { 54 | firstName: 'Evelyn', 55 | lastName: 'Morales', 56 | createdAt: '2023-06-05 00:00:00', 57 | }, 58 | { firstName: 'Logan', lastName: 'Collins', createdAt: '2023-06-07 00:00:00' }, 59 | { firstName: null, lastName: 'Flores', createdAt: '2023-06-09 00:00:00' }, 60 | ]; 61 | 62 | const testData = { 63 | strongPassword: 'HeK34#C44DMJ', 64 | strongPassword2: 'Mk22#c9@Cv!K', 65 | weakPassword: '12345678', 66 | invalidUsername: 'invalid_username', 67 | invalidPassword: 'invalid_password', 68 | users: { 69 | user1: { username: 'Jane' }, 70 | user2: { username: 'Mike' }, 71 | user3: { username: 'John' }, 72 | }, 73 | }; 74 | 75 | module.exports = { testNames, testData }; 76 | -------------------------------------------------------------------------------- /docs/ws/rows-examples.md: -------------------------------------------------------------------------------- 1 | ## Rows 2 | 3 | ### 1. Rows realtime changes 4 | 5 | To subscribe to realtime changes (Insert, Update and Delete) in rows of a certain table, 6 | use `tables/` endpoint. 7 | 8 | ```bash 9 | wscat -c ws://localhost:8000/ws/tables/Employee 10 | ``` 11 | 12 | It should respond with the following message: 13 | 14 | ```json 15 | { 16 | "message": "Subscribed to table \"Employee\"" 17 | } 18 | ``` 19 | 20 | Note that it's ready to receive messages. 21 | 22 | Then to test it, in a new terminal, insert a new row in the table `Employee`: 23 | 24 | ```bash 25 | curl --request POST \ 26 | --url http://localhost:8000/api/tables/Employee/rows \ 27 | --header 'Content-Type: application/json' \ 28 | --data '{ 29 | "fields": { 30 | "FirstName": "Damien", 31 | "LastName": "Rice" 32 | } 33 | }' 34 | ``` 35 | 36 | Checkout `wscat` terminal, it should respond with the following message: 37 | 38 | ```json 39 | { 40 | "type": "INSERT", 41 | "data": { 42 | "pk": 10, 43 | "FirstName": "Damien", 44 | "LastName": "Rice" 45 | } 46 | } 47 | ``` 48 | 49 | Then to test update changes, let's change the `FirstName` of the row with `pk` 10: 50 | 51 | ```bash 52 | curl --request PUT \ 53 | --url http://localhost:8000/api/tables/Employee/rows/10 \ 54 | --header 'Content-Type: application/json' \ 55 | --data '{ 56 | "fields": { 57 | "FirstName": "Glen" 58 | } 59 | }' 60 | ``` 61 | 62 | Checkout `wscat` terminal, it should respond with the following message: 63 | 64 | ```json 65 | { 66 | "type": "UPDATE", 67 | "_lookup_field": "EmployeeId", 68 | "data": { 69 | "pks": ["10"], 70 | "FirstName": "Glen" 71 | } 72 | } 73 | ``` 74 | 75 | While we're testing update changes, let's see how it works in bulk update mode: 76 | 77 | ```bash 78 | curl --request PUT \ 79 | --url http://localhost:8000/api/tables/Employee/rows/8,9,10 \ 80 | --header 'Content-Type: application/json' \ 81 | --data '{ 82 | "fields": { 83 | "FirstName": "David" 84 | } 85 | }' 86 | ``` 87 | 88 | Note comma-separated `lookup-values` in the URL. (8,9,10) 89 | 90 | Checkout `wscat` terminal, it should respond with the following message: 91 | 92 | ```json 93 | { 94 | "type": "UPDATE", 95 | "_lookup_field": "EmployeeId", 96 | "data": { 97 | "pks": ["8", "9", "10"], 98 | "FirstName": "David" 99 | } 100 | } 101 | ``` 102 | 103 | Then to test delete changes, let's delete the row with `pk` 10: 104 | 105 | ```bash 106 | curl --request DELETE \ 107 | --url http://localhost:8000/api/tables/Employee/rows/10 108 | ``` 109 | 110 | Checkout `wscat` terminal, it should respond with the following message: 111 | 112 | ```json 113 | { 114 | "type": "DELETE", 115 | "_lookup_field": "EmployeeId", 116 | "data": { 117 | "pks": ["10"] 118 | } 119 | } 120 | ``` 121 | 122 | That's it for realtime changes. 123 | -------------------------------------------------------------------------------- /src/middlewares/auth.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const { decodeToken, toBoolean } = require('../utils/index'); 3 | const { apiConstants, responseMessages } = require('../constants'); 4 | const { authService } = require('../services'); 5 | 6 | const { errorMessage } = responseMessages; 7 | 8 | const hasAccess = async (req, res, next) => { 9 | let payload; 10 | const { name: tableName } = req.params; 11 | const verb = req.method; 12 | const originalURL = req.originalUrl; 13 | 14 | try { 15 | if (config.auth) { 16 | // extract the payload from the token and verify it 17 | try { 18 | payload = await decodeToken( 19 | req.cookies.accessToken, 20 | config.tokenSecret, 21 | ); 22 | req.user = payload; 23 | } catch (error) { 24 | return res 25 | .status(401) 26 | .send({ message: errorMessage.INVALID_ACCESS_TOKEN_ERROR }); 27 | } 28 | 29 | // if the user is a super_user, allow access on the resource 30 | if (toBoolean(payload.isSuperuser)) { 31 | return next(); 32 | } 33 | 34 | // if the endpoint is set to be accessed by any user regardless of there roles, then allow access 35 | if (apiConstants.universalAccessEndpoints.includes(originalURL)) { 36 | return next(); 37 | } 38 | 39 | // if table_name is not passed from the router throw unauthorized error 40 | if (!tableName) { 41 | return res 42 | .status(403) 43 | .send({ message: errorMessage.NOT_AUTHORIZED_ERROR }); 44 | } 45 | 46 | // if the user is not a super user, fetch the permission of the user from the DB 47 | const rolePermissions = authService.getPermissionByRoleIds({ 48 | roleIds: payload.roleIds, 49 | }); 50 | 51 | const resourcePermission = rolePermissions.filter( 52 | (row) => row.table_name === tableName, 53 | ); 54 | 55 | if (resourcePermission.length <= 0) { 56 | return res 57 | .status(403) 58 | .send({ message: errorMessage.PERMISSION_NOT_DEFINED_ERROR }); 59 | } 60 | 61 | // If the user has permission on the table in at least in one of the roles then allow access on the table 62 | let hasPermission = false; 63 | 64 | resourcePermission.some((resource) => { 65 | const httpMethod = 66 | apiConstants.httpMethodDefinitions[verb].toLowerCase(); 67 | 68 | if (toBoolean(resource[httpMethod])) { 69 | hasPermission = true; 70 | return true; 71 | } 72 | }); 73 | 74 | if (hasPermission) { 75 | next(); 76 | } else { 77 | return res 78 | .status(403) 79 | .send({ message: errorMessage.NOT_AUTHORIZED_ERROR }); 80 | } 81 | } else { 82 | next(); 83 | } 84 | } catch (error) { 85 | console.log(error); 86 | res.status(401).send({ message: error.message }); 87 | } 88 | }; 89 | 90 | module.exports = { hasAccess }; 91 | -------------------------------------------------------------------------------- /src/controllers/tables.test.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest'); 2 | 3 | const app = require('../index'); 4 | const { generateToken } = require('../utils'); 5 | const config = require('../config'); 6 | 7 | const requestWithSupertest = supertest(app); 8 | 9 | describe('Tables Endpoints', () => { 10 | it('GET /tables should return a list of all tables', async () => { 11 | const accessToken = await generateToken( 12 | { username: 'John', isSuperuser: true }, 13 | config.tokenSecret, 14 | '1H', 15 | ); 16 | 17 | const res = await requestWithSupertest 18 | .get('/api/tables') 19 | .set('Cookie', [`accessToken=${accessToken}`]); 20 | 21 | expect(res.status).toEqual(200); 22 | expect(res.type).toEqual(expect.stringContaining('json')); 23 | expect(res.body).toHaveProperty('data'); 24 | expect(res.body.data).toEqual(expect.any(Array)); 25 | expect(res.body.data[0]).toHaveProperty('name'); 26 | }); 27 | 28 | it('POST /tables should create a new table and return generated schema', async () => { 29 | const accessToken = await generateToken( 30 | { username: 'John', isSuperuser: true }, 31 | config.tokenSecret, 32 | '1H', 33 | ); 34 | 35 | const res = await requestWithSupertest 36 | .post('/api/tables') 37 | .send({ 38 | name: 'pets', 39 | autoAddCreatedAt: true, 40 | autoAddUpdatedAt: false, 41 | schema: [ 42 | { 43 | name: 'owner', 44 | type: 'INTEGER', 45 | foreignKey: { 46 | table: 'users', 47 | column: 'id', 48 | onDelete: 'CASCADE', 49 | onUpdate: 'CASCADE', 50 | }, 51 | }, 52 | { 53 | name: 'name', 54 | type: 'TEXT', 55 | notNull: true, 56 | }, 57 | { 58 | name: 'petId', 59 | unique: true, 60 | type: 'INTEGER', 61 | }, 62 | ], 63 | }) 64 | .set('Cookie', [`accessToken=${accessToken}`]); 65 | 66 | expect(res.status).toEqual(201); 67 | expect(res.type).toEqual(expect.stringContaining('json')); 68 | expect(res.body).toHaveProperty('data'); 69 | expect(res.body.data).toHaveProperty('name'); 70 | expect(res.body.data).toHaveProperty('schema'); 71 | expect(res.body.data.schema).toEqual(expect.any(Array)); 72 | expect(res.body.data.schema[0]).toHaveProperty('name'); 73 | expect(res.body.data.schema[0]).toHaveProperty('cid'); 74 | }); 75 | 76 | it('GET /tables/:name should return schema of the table', async () => { 77 | const accessToken = await generateToken( 78 | { username: 'John', isSuperuser: true }, 79 | config.tokenSecret, 80 | '1H', 81 | ); 82 | 83 | const res = await requestWithSupertest 84 | .get('/api/tables/users') 85 | .set('Cookie', [`accessToken=${accessToken}`]); 86 | 87 | expect(res.status).toEqual(200); 88 | expect(res.type).toEqual(expect.stringContaining('json')); 89 | expect(res.body).toHaveProperty('data'); 90 | expect(res.body.data).toEqual(expect.any(Array)); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/db/schema.js: -------------------------------------------------------------------------------- 1 | const { dbConstants } = require('../constants'); 2 | 3 | const { tableFields, ROLES_TABLE, USERS_TABLE } = dbConstants; 4 | 5 | module.exports = { 6 | roleSchema: [ 7 | { 8 | name: tableFields.ROLE_NAME, 9 | type: 'TEXT', 10 | primaryKey: false, 11 | notNull: true, 12 | unique: true, 13 | }, 14 | ], 15 | 16 | userSchema: [ 17 | { 18 | name: tableFields.USERNAME, 19 | type: 'TEXT', 20 | primaryKey: false, 21 | notNull: true, 22 | unique: true, 23 | }, 24 | { 25 | name: tableFields.HASHED_PASSWORD, 26 | type: 'TEXT', 27 | primaryKey: false, 28 | notNull: true, 29 | unique: false, 30 | }, 31 | { 32 | name: tableFields.SALT, 33 | type: 'TEXT', 34 | primaryKey: false, 35 | notNull: true, 36 | unique: false, 37 | }, 38 | 39 | { 40 | name: tableFields.IS_SUPERUSER, 41 | type: 'BOOLEAN', 42 | primaryKey: false, 43 | notNull: true, 44 | unique: false, 45 | }, 46 | ], 47 | 48 | rolePermissionSchema: [ 49 | { 50 | name: tableFields.ROLE_ID, 51 | type: 'NUMERIC', 52 | primaryKey: false, 53 | notNull: true, 54 | unique: false, 55 | foreignKey: { table: ROLES_TABLE, column: tableFields.ID }, 56 | }, 57 | 58 | { 59 | name: tableFields.TABLE_NAME, 60 | type: 'TEXT', 61 | primaryKey: false, 62 | notNull: true, 63 | unique: false, 64 | }, 65 | 66 | { 67 | name: tableFields.CREATE, 68 | type: 'BOOLEAN', 69 | primaryKey: false, 70 | notNull: true, 71 | unique: false, 72 | }, 73 | 74 | { 75 | name: tableFields.READ, 76 | type: 'BOOLEAN', 77 | primaryKey: false, 78 | notNull: true, 79 | unique: false, 80 | }, 81 | 82 | { 83 | name: tableFields.UPDATE, 84 | type: 'BOOLEAN', 85 | primaryKey: false, 86 | notNull: true, 87 | unique: false, 88 | }, 89 | 90 | { 91 | name: tableFields.DELETE, 92 | type: 'BOOLEAN', 93 | primaryKey: false, 94 | notNull: true, 95 | unique: false, 96 | }, 97 | ], 98 | 99 | usersRoleSchema: [ 100 | { 101 | name: tableFields.USER_ID, 102 | type: 'NUMERIC', 103 | primaryKey: false, 104 | notNull: true, 105 | unique: false, 106 | foreignKey: { table: USERS_TABLE, column: tableFields.ID }, 107 | }, 108 | 109 | { 110 | name: tableFields.ROLE_ID, 111 | type: 'NUMERIC', 112 | primaryKey: false, 113 | notNull: true, 114 | unique: false, 115 | foreignKey: { table: ROLES_TABLE, column: tableFields.ID }, 116 | }, 117 | ], 118 | 119 | revokedRefreshTokensSchema: [ 120 | { 121 | name: tableFields.REFRESH_TOKEN, 122 | type: 'TEXT', 123 | primaryKey: false, 124 | notNull: true, 125 | unique: false, 126 | }, 127 | 128 | { 129 | name: tableFields.EXPIRES_AT, 130 | type: 'NUMERIC', 131 | primaryKey: false, 132 | notNull: true, 133 | unique: false, 134 | }, 135 | ], 136 | }; 137 | -------------------------------------------------------------------------------- /src/schemas/auth.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const obtainAccessToken = Joi.object({ 4 | query: Joi.object().required(), 5 | params: Joi.object({}).required(), 6 | 7 | body: Joi.object({ 8 | fields: Joi.object({ 9 | username: Joi.string().required(), 10 | password: Joi.string().required(), 11 | }).required(), 12 | }).required(), 13 | 14 | cookies: Joi.object({ 15 | refreshToken: Joi.string().optional(), 16 | accessToken: Joi.string().optional(), 17 | }).unknown(true), 18 | }); 19 | 20 | const refreshAccessToken = Joi.object({ 21 | query: Joi.object().required(), 22 | params: Joi.object({}).required(), 23 | body: Joi.object({}).required(), 24 | cookies: Joi.object({ 25 | refreshToken: Joi.string().required(), 26 | accessToken: Joi.string().optional(), 27 | }) 28 | .unknown(true) 29 | .required(), 30 | }); 31 | 32 | const changePassword = Joi.object({ 33 | query: Joi.object().required(), 34 | params: Joi.object().required(), 35 | 36 | body: Joi.object({ 37 | fields: Joi.object({ 38 | currentPassword: Joi.string().required(), 39 | newPassword: Joi.string().required(), 40 | }).required(), 41 | }).required(), 42 | cookies: Joi.object({ 43 | accessToken: Joi.string().required(), 44 | refreshToken: Joi.string().optional(), 45 | }) 46 | .unknown(true) 47 | .required(), 48 | }); 49 | 50 | const registerUser = Joi.object({ 51 | query: Joi.object().required(), 52 | params: Joi.object({}).required(), 53 | body: Joi.object({ 54 | fields: Joi.object({ 55 | username: Joi.string().required(), 56 | password: Joi.string().required(), 57 | }).required(), 58 | }).required(), 59 | 60 | cookies: Joi.object({ 61 | accessToken: Joi.string().required(), 62 | refreshToken: Joi.string().optional(), 63 | }) 64 | .unknown(true) 65 | .required(), 66 | }); 67 | 68 | const updateRolePermissions = Joi.object({ 69 | query: Joi.object({}).required(), 70 | params: Joi.object({ name: Joi.string(), pks: Joi.string() }).required(), 71 | body: Joi.object({ 72 | fields: Joi.object({ 73 | role_id: Joi.number().required(), 74 | table_name: Joi.string().required(), 75 | create: Joi.number().valid(0, 1).required(), 76 | read: Joi.number().valid(0, 1).required(), 77 | update: Joi.number().valid(0, 1).required(), 78 | delete: Joi.number().valid(0, 1).required(), 79 | }).required(), 80 | }).required(), 81 | 82 | cookies: Joi.object({ 83 | accessToken: Joi.string().required(), 84 | refreshToken: Joi.string().optional(), 85 | }) 86 | .unknown(true) 87 | .required(), 88 | }); 89 | 90 | const removeAccessTokens = Joi.object({ 91 | query: Joi.object().required(), 92 | params: Joi.object({}).required(), 93 | body: Joi.object({}).required(), 94 | cookies: Joi.object({ 95 | refreshToken: Joi.string().required(), 96 | accessToken: Joi.string().required(), 97 | }) 98 | .unknown(true) 99 | .required(), 100 | }); 101 | 102 | module.exports = { 103 | obtainAccessToken, 104 | refreshAccessToken, 105 | changePassword, 106 | registerUser, 107 | updateRolePermissions, 108 | removeAccessTokens, 109 | }; 110 | -------------------------------------------------------------------------------- /src/schemas/rows.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const listTableRows = Joi.object({ 4 | query: Joi.object({ 5 | _page: Joi.number().integer().min(1).default(1), 6 | _limit: Joi.number().integer().min(1).default(10), 7 | _search: Joi.string(), 8 | _ordering: Joi.string().regex(/^[\w.-]+$/), 9 | _schema: Joi.string(), 10 | _extend: Joi.string(), 11 | _filters: Joi.string(), 12 | }).required(), 13 | params: Joi.object({ 14 | name: Joi.string(), 15 | }).required(), 16 | body: Joi.object().required(), 17 | cookies: Joi.object({ 18 | refreshToken: Joi.string().optional(), 19 | accessToken: Joi.string().optional(), 20 | }), 21 | }); 22 | 23 | const insertRowInTable = Joi.object({ 24 | query: Joi.object().required(), 25 | params: Joi.object({ 26 | name: Joi.string() 27 | .regex(/^[\w-]+$/) 28 | .min(3) 29 | .max(30) 30 | .required(), 31 | }).required(), 32 | body: Joi.object({ 33 | fields: Joi.object().required(), 34 | }).required(), 35 | cookies: Joi.object({ 36 | refreshToken: Joi.string().optional(), 37 | accessToken: Joi.string().optional(), 38 | }), 39 | }); 40 | 41 | const getRowInTableByPK = Joi.object({ 42 | query: Joi.object({ 43 | _lookup_field: Joi.string() 44 | .regex(/^[\w-]+$/) 45 | .min(3) 46 | .max(30), 47 | _schema: Joi.string(), 48 | _extend: Joi.string(), 49 | }).required(), 50 | params: Joi.object({ 51 | name: Joi.string() 52 | .regex(/^[\w-]+$/) 53 | .min(3) 54 | .max(30) 55 | .required(), 56 | pks: Joi.string().required(), 57 | }).required(), 58 | body: Joi.object().required(), 59 | cookies: Joi.object({ 60 | refreshToken: Joi.string().optional(), 61 | accessToken: Joi.string().optional(), 62 | }), 63 | }); 64 | 65 | const updateRowInTableByPK = Joi.object({ 66 | query: Joi.object({ 67 | _lookup_field: Joi.string() 68 | .regex(/^[\w-]+$/) 69 | .min(3) 70 | .max(30), 71 | }).required(), 72 | params: Joi.object({ 73 | name: Joi.string() 74 | .regex(/^[\w-]+$/) 75 | .min(3) 76 | .max(30) 77 | .required(), 78 | pks: Joi.string().required(), 79 | }).required(), 80 | body: Joi.object({ 81 | fields: Joi.object().required(), 82 | }).required(), 83 | cookies: Joi.object({ 84 | refreshToken: Joi.string().optional(), 85 | accessToken: Joi.string().optional(), 86 | }), 87 | }); 88 | 89 | const deleteRowInTableByPK = Joi.object({ 90 | query: Joi.object({ 91 | _lookup_field: Joi.string() 92 | .regex(/^[\w-]+$/) 93 | .min(3) 94 | .max(30), 95 | }).required(), 96 | params: Joi.object({ 97 | name: Joi.string() 98 | .regex(/^[\w-]+$/) 99 | .min(3) 100 | .max(30) 101 | .required(), 102 | pks: Joi.string().required(), 103 | }).required(), 104 | body: Joi.object().required(), 105 | cookies: Joi.object({ 106 | refreshToken: Joi.string().optional(), 107 | accessToken: Joi.string().optional(), 108 | }), 109 | }); 110 | 111 | module.exports = { 112 | listTableRows, 113 | insertRowInTable, 114 | getRowInTableByPK, 115 | updateRowInTableByPK, 116 | deleteRowInTableByPK, 117 | }; 118 | -------------------------------------------------------------------------------- /src/controllers/auth/tables.js: -------------------------------------------------------------------------------- 1 | const { tableService, rowService } = require('../../services'); 2 | const { constantRoles, dbConstants } = require('../../constants'); 3 | const schema = require('../../db/schema'); 4 | 5 | const { 6 | USERS_TABLE, 7 | ROLES_TABLE, 8 | USERS_ROLES_TABLE, 9 | ROLES_PERMISSIONS_TABLE, 10 | REVOKED_REFRESH_TOKENS_TABLE, 11 | constraints, 12 | tableFields, 13 | } = dbConstants; 14 | 15 | const createDefaultTables = async () => { 16 | let roleId; 17 | 18 | // check if the default tables are already created 19 | const roleTable = tableService.checkTableExists(ROLES_TABLE); 20 | const usersTable = tableService.checkTableExists(USERS_TABLE); 21 | const rolesPermissionTable = tableService.checkTableExists( 22 | ROLES_PERMISSIONS_TABLE, 23 | ); 24 | const usersRolesTable = tableService.checkTableExists(USERS_ROLES_TABLE); 25 | const revokedRefreshTokensTable = tableService.checkTableExists( 26 | REVOKED_REFRESH_TOKENS_TABLE, 27 | ); 28 | 29 | // create _users table 30 | if (!usersTable) { 31 | tableService.createTable(USERS_TABLE, schema.userSchema); 32 | } 33 | 34 | // create _users_roles table 35 | if (!usersRolesTable) { 36 | tableService.createTable( 37 | USERS_ROLES_TABLE, 38 | 39 | schema.usersRoleSchema, 40 | { 41 | multipleUniqueConstraints: { 42 | name: constraints.UNIQUE_USERS_ROLE, 43 | fields: [tableFields.USER_ID, tableFields.USER_ID], 44 | }, 45 | }, 46 | ); 47 | } 48 | 49 | // create _roles table 50 | if (!roleTable) { 51 | tableService.createTable(ROLES_TABLE, schema.roleSchema); 52 | 53 | // create a default role in the _roles table 54 | const role = rowService.save({ 55 | tableName: ROLES_TABLE, 56 | fields: { name: constantRoles.DEFAULT_ROLE }, 57 | }); 58 | roleId = role.lastInsertRowid; 59 | } 60 | 61 | // create _roles_permissions table 62 | if (!rolesPermissionTable && roleId) { 63 | tableService.createTable( 64 | ROLES_PERMISSIONS_TABLE, 65 | schema.rolePermissionSchema, 66 | { 67 | multipleUniqueConstraints: { 68 | name: constraints.UNIQUE_ROLES_TABLE, 69 | fields: [tableFields.ROLE_ID, tableFields.TABLE_NAME], 70 | }, 71 | }, 72 | ); 73 | 74 | // fetch all DB tables 75 | const tables = tableService.listTables(); 76 | 77 | // add permission for the default role (for each db table) 78 | const permissions = []; 79 | for (const table of tables) { 80 | permissions.push({ 81 | role_id: roleId, 82 | table_name: table.name, 83 | create: 0, 84 | read: 1, 85 | update: 0, 86 | delete: 0, 87 | }); 88 | } 89 | 90 | // store the permissions in the db 91 | rowService.bulkWrite({ 92 | tableName: ROLES_PERMISSIONS_TABLE, 93 | fields: permissions, 94 | }); 95 | } 96 | 97 | // create _revoked_refresh_tokens table 98 | if (!revokedRefreshTokensTable) { 99 | tableService.createTable( 100 | REVOKED_REFRESH_TOKENS_TABLE, 101 | schema.revokedRefreshTokensSchema, 102 | ); 103 | } 104 | }; 105 | 106 | module.exports = { 107 | createDefaultTables, 108 | }; 109 | -------------------------------------------------------------------------------- /src/services/authService.js: -------------------------------------------------------------------------------- 1 | const db = require('../db'); 2 | const rowService = require('./rowService')(db); 3 | 4 | const { constantRoles, dbConstants } = require('../constants'); 5 | 6 | const { 7 | USERS_TABLE, 8 | ROLES_TABLE, 9 | USERS_ROLES_TABLE, 10 | ROLES_PERMISSIONS_TABLE, 11 | REVOKED_REFRESH_TOKENS_TABLE, 12 | tableFields, 13 | } = dbConstants; 14 | 15 | module.exports = (db) => { 16 | return { 17 | getUsersByUsername({ username }) { 18 | const users = rowService.get({ 19 | tableName: USERS_TABLE, 20 | whereString: `WHERE ${tableFields.USERNAME} =?`, 21 | whereStringValues: [username], 22 | }); 23 | 24 | return users; 25 | }, 26 | 27 | getUsersById({ userId }) { 28 | const users = rowService.get({ 29 | tableName: USERS_TABLE, 30 | whereString: `WHERE ${tableFields.ID}=?`, 31 | whereStringValues: [userId], 32 | }); 33 | 34 | return users; 35 | }, 36 | 37 | getAllUsers() { 38 | const users = rowService.get({ 39 | tableName: USERS_TABLE, 40 | whereString: '', 41 | whereStringValues: [], 42 | }); 43 | 44 | return users; 45 | }, 46 | 47 | // TODO: bypass pagination by providing query param for number of rows 48 | getPermissionByRoleIds({ roleIds }) { 49 | const permissions = rowService.get({ 50 | tableName: ROLES_PERMISSIONS_TABLE, 51 | whereString: `WHERE ${tableFields.ROLE_ID} IN (${roleIds.map( 52 | () => '?', 53 | )})`, 54 | whereStringValues: [...roleIds], 55 | limit: 10000, 56 | }); 57 | 58 | return permissions; 59 | }, 60 | 61 | getUserRoleByUserId({ userId }) { 62 | const userRoles = rowService.get({ 63 | tableName: USERS_ROLES_TABLE, 64 | whereString: `WHERE ${tableFields.USER_ID} =?`, 65 | whereStringValues: [userId], 66 | }); 67 | 68 | return userRoles; 69 | }, 70 | 71 | getDefaultRole() { 72 | const defaultRole = rowService.get({ 73 | tableName: ROLES_TABLE, 74 | whereString: `WHERE ${tableFields.ROLE_NAME}=?`, 75 | whereStringValues: [constantRoles.DEFAULT_ROLE], 76 | }); 77 | 78 | return defaultRole; 79 | }, 80 | 81 | saveRevokedRefreshToken({ refreshToken, expiresAt }) { 82 | const { lastInsertRowid } = rowService.save({ 83 | tableName: REVOKED_REFRESH_TOKENS_TABLE, 84 | fields: { 85 | refresh_token: refreshToken, 86 | expires_at: expiresAt, 87 | }, 88 | }); 89 | 90 | return { id: lastInsertRowid }; 91 | }, 92 | 93 | getRevokedRefreshToken({ refreshToken }) { 94 | const token = rowService.get({ 95 | tableName: REVOKED_REFRESH_TOKENS_TABLE, 96 | whereString: `WHERE ${tableFields.REFRESH_TOKEN}=?`, 97 | whereStringValues: [refreshToken], 98 | }); 99 | 100 | return token; 101 | }, 102 | 103 | deleteRevokedRefreshTokens({ lookupField }) { 104 | const query = `DELETE FROM ${REVOKED_REFRESH_TOKENS_TABLE} ${lookupField}`; 105 | const statement = db.prepare(query); 106 | const result = statement.run(); 107 | return result; 108 | }, 109 | }; 110 | }; 111 | -------------------------------------------------------------------------------- /src/middlewares/api.js: -------------------------------------------------------------------------------- 1 | const config = require('../config'); 2 | const { registerUser } = require('../controllers/auth'); 3 | const { 4 | apiConstants, 5 | dbConstants, 6 | responseMessages, 7 | } = require('../constants/'); 8 | const { removeFields } = require('../utils'); 9 | const { customValidator } = require('../middlewares/validation'); 10 | const schema = require('../schemas/auth'); 11 | 12 | const { httpVerbs } = apiConstants; 13 | const { 14 | reservedTableNames, 15 | USERS_TABLE, 16 | ROLES_PERMISSIONS_TABLE, 17 | tableFields, 18 | } = dbConstants; 19 | const { errorMessage } = responseMessages; 20 | 21 | const processRowRequest = async (req, res, next) => { 22 | const resource = req.params.name; 23 | const { method } = req; 24 | 25 | // If the user sends a request to the auth tables while AUTH is set to false, throw an error 26 | if (apiConstants.authEndpoints.includes(resource) && !config.auth) { 27 | return res.status(403).send({ 28 | message: errorMessage.AUTH_SET_TO_FALSE_ERROR, 29 | }); 30 | } 31 | 32 | // Redirect this request to the registerUser controller => POST /api/tables/_users/rows 33 | if (resource === USERS_TABLE && method === httpVerbs.POST) { 34 | return registerUser(req, res); 35 | } 36 | 37 | // Remove some fields for this request and check the username field => PUT /api/tables/_users/rows 38 | if (resource === USERS_TABLE && method === httpVerbs.PUT) { 39 | /** 40 | * remove some user fields from the request like (is_superuser, hashed_password, salt). 41 | * NOTE: password can be updated via the /change-password API and superuser status can be only updated from the CLI 42 | */ 43 | removeFields( 44 | [req.body.fields], 45 | [tableFields.SALT, tableFields.IS_SUPERUSER, tableFields.HASHED_PASSWORD], 46 | ); 47 | } 48 | 49 | // Validate fields for the _roles_permission API on POST and PUT requests 50 | if ( 51 | resource === ROLES_PERMISSIONS_TABLE && 52 | (method === httpVerbs.POST || method === httpVerbs.PUT) 53 | ) { 54 | const validation = customValidator(schema.updateRolePermissions)(req); 55 | 56 | if (validation.errorStatus) { 57 | return res.status(400).json({ 58 | message: validation.message, 59 | error: validation.details, 60 | }); 61 | } 62 | } 63 | 64 | next(); 65 | }; 66 | 67 | const processRowResponse = async (req, res, next) => { 68 | // Extract payload data 69 | const resource = req.params.name; 70 | const status = req.response.status; 71 | const payload = req.response.payload; 72 | 73 | // Remove some fields from the response 74 | if (resource === USERS_TABLE) { 75 | removeFields(payload.data, [tableFields.SALT, tableFields.HASHED_PASSWORD]); 76 | } 77 | 78 | res.status(status).send(payload); 79 | next(); 80 | }; 81 | 82 | const processTableRequest = async (req, res, next) => { 83 | const { method, body, baseUrl } = req; 84 | 85 | // if the user tries to create a table with the reserved table names throw an error. Request => POST /api/tables 86 | if (baseUrl === apiConstants.baseTableUrl && method === httpVerbs.POST) { 87 | if (reservedTableNames.includes(body.name)) { 88 | return res.status(409).send({ 89 | message: errorMessage.RESERVED_TABLE_NAME_ERROR, 90 | }); 91 | } 92 | } 93 | 94 | next(); 95 | }; 96 | 97 | module.exports = { 98 | processRowRequest, 99 | processRowResponse, 100 | processTableRequest, 101 | }; 102 | -------------------------------------------------------------------------------- /src/schemas/tables.js: -------------------------------------------------------------------------------- 1 | const Joi = require('joi'); 2 | 3 | const listTables = Joi.object({ 4 | query: Joi.object({ 5 | _search: Joi.string(), 6 | _ordering: Joi.string(), 7 | }).required(), 8 | params: Joi.object().required(), 9 | body: Joi.object().required(), 10 | cookies: Joi.object({ 11 | refreshToken: Joi.string().optional(), 12 | accessToken: Joi.string().optional(), 13 | }), 14 | }); 15 | 16 | const createTable = Joi.object({ 17 | query: Joi.object().required(), 18 | params: Joi.object().required(), 19 | body: Joi.object({ 20 | name: Joi.string() 21 | .regex(/^[\w-]+$/) 22 | .min(2) 23 | .max(30) 24 | .required(), 25 | autoAddCreatedAt: Joi.boolean().default(true), 26 | autoAddUpdatedAt: Joi.boolean().default(true), 27 | schema: Joi.array() 28 | .items( 29 | Joi.object({ 30 | name: Joi.string() 31 | .regex(/^[\w-]+$/) 32 | .min(2) 33 | .max(30) 34 | .required(), 35 | // type one of sqlite3 types 36 | type: Joi.string() 37 | .valid( 38 | 'TEXT', 39 | 'NUMERIC', 40 | 'INTEGER', 41 | 'REAL', 42 | 'BLOB', 43 | 'BOOLEAN', 44 | 'DATE', 45 | 'DATETIME', 46 | ) 47 | .insensitive() 48 | .required(), 49 | default: Joi.any(), 50 | notNull: Joi.boolean(), 51 | unique: Joi.boolean(), 52 | primaryKey: Joi.boolean(), 53 | foreignKey: Joi.object({ 54 | table: Joi.string() 55 | .regex(/^[\w-]+$/) 56 | .min(2) 57 | .max(30) 58 | .required(), 59 | column: Joi.string() 60 | .regex(/^[\w-]+$/) 61 | .min(2) 62 | .max(30) 63 | .required(), 64 | onDelete: Joi.string() 65 | .valid('CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT') 66 | .insensitive() 67 | .default('CASCADE'), 68 | onUpdate: Joi.string() 69 | .valid('CASCADE', 'SET NULL', 'SET DEFAULT', 'RESTRICT') 70 | .insensitive() 71 | .default('RESTRICT'), 72 | }), 73 | index: Joi.boolean(), 74 | }), 75 | ) 76 | .required(), 77 | }), 78 | cookies: Joi.object({ 79 | refreshToken: Joi.string().optional(), 80 | accessToken: Joi.string().optional(), 81 | }), 82 | }); 83 | 84 | const getTableSchema = Joi.object({ 85 | query: Joi.object().required(), 86 | params: Joi.object({ 87 | name: Joi.string() 88 | .regex(/^[\w-]+$/) 89 | .min(3) 90 | .max(30) 91 | .required(), 92 | }), 93 | body: Joi.object().required(), 94 | cookies: Joi.object({ 95 | refreshToken: Joi.string().optional(), 96 | accessToken: Joi.string().optional(), 97 | }), 98 | }); 99 | 100 | const deleteTable = Joi.object({ 101 | query: Joi.object().required(), 102 | params: Joi.object({ 103 | name: Joi.string() 104 | .regex(/^[\w-]+$/) 105 | .min(3) 106 | .max(30) 107 | .required(), 108 | }), 109 | body: Joi.object().required(), 110 | cookies: Joi.object({ 111 | refreshToken: Joi.string().optional(), 112 | accessToken: Joi.string().optional(), 113 | }), 114 | }); 115 | 116 | module.exports = { 117 | listTables, 118 | createTable, 119 | getTableSchema, 120 | deleteTable, 121 | }; 122 | -------------------------------------------------------------------------------- /docs/api/rows-auth-examples.md: -------------------------------------------------------------------------------- 1 | ## Rows in Auth mode 2 | 3 | ### 1. List Rows of a Table in Auth mode 4 | 5 | To list rows in auth mode we call `/tables//rows/` endpoint with `GET` method and pass the jwt access token via Cookies. 6 | 7 | > Note that your account needs to have access to read this table. 8 | > Access (Authorization) in Soul is handled via "\_roles" table aka Roles. 9 | > If you want to learn about granting permissions proceed to the next example. 10 | 11 | ```bash 12 | curl 'localhost:8000/api/tables/Album/rows/' \ 13 | --cookie 'accessToken=' 14 | ``` 15 | 16 | Response 17 | 18 | ```json 19 | { 20 | "data": [ 21 | { 22 | "AlbumId": 1, 23 | "Title": "For Those About To Rock We Salute You", 24 | "ArtistId": 1 25 | }, 26 | { "AlbumId": 2, "Title": "Balls to the Wall", "ArtistId": 2 } 27 | // ... 28 | ], 29 | "total": 347, 30 | "next": "/tables/Album?page=2", 31 | "previous": null 32 | } 33 | ``` 34 | 35 | #### Cookies 36 | 37 | - `accessToken` the access token that you acquired before 38 | 39 | ### 2. Granting access to users 40 | 41 | Only super users (e.g. `is_superuser=true`) or those with roles that have access to '\_roles' table can grant access to other users. 42 | 43 | > Head over to [README, _Updating Super Users_ section](/README.md#3-updating-super-users) to learn how to promote someone to a super user. 44 | 45 | #### 2.1. Create a new Role 46 | 47 | To create a new Role call `/tables/_roles/rows/` endpoint with `POST` method. 48 | 49 | ```bash 50 | curl --request POST \ 51 | --url http://localhost:8000/api/tables/_roles/rows/ \ 52 | --header 'Content-Type: application/json' \ 53 | --header 'Cookie: accessToken=' \ 54 | --data '{ 55 | "fields": { 56 | "name": "editor" 57 | } 58 | }' 59 | ``` 60 | 61 | Response 62 | 63 | ```json 64 | { 65 | "message": "Row inserted", 66 | "data": { 67 | "changes": 1, 68 | "lastInsertRowid": 2 69 | } 70 | } 71 | ``` 72 | 73 | Now that we have our `editor` Role, we can give it some permissions. Here we want to give it permission to `read` `Album` table. 74 | 75 | #### 2.2. Create permissions for a Role 76 | 77 | To create permissions for a Role call `/tables/_roles_permissions/rows/` endpoint with `POST` method. 78 | 79 | ```bash 80 | curl --request POST \ 81 | --url http://localhost:8000/api/tables/_roles_permissions/rows/ \ 82 | --header 'Content-Type: application/json' \ 83 | --header 'Cookie: accessToken=' \ 84 | --data '{ 85 | "fields": { 86 | "role_id": 2, 87 | "table_name": "Album", 88 | "create": 0, 89 | "read": 1, 90 | "update": 0, 91 | "delete": 0 92 | } 93 | }' 94 | ``` 95 | 96 | Response 97 | 98 | ```json 99 | { 100 | "message": "Row inserted", 101 | "data": { 102 | "changes": 1, 103 | "lastInsertRowid": 6 104 | } 105 | } 106 | ``` 107 | 108 | #### 2.3. Assign Role to a User 109 | 110 | To assign roles to a user call `/tables/_users_roles/rows/` endpoint with `POST` method. 111 | 112 | ```bash 113 | curl --request POST \ 114 | --url http://localhost:8000/api/tables/_users_roles/rows/ \ 115 | --header 'Content-Type: application/json' \ 116 | --header 'Cookie: accessToken=' \ 117 | --data '{ 118 | "fields": { 119 | "user_id": 1, 120 | "role_id": 2 121 | } 122 | }' 123 | ``` 124 | 125 | Response 126 | 127 | ```json 128 | { 129 | "message": "Row inserted", 130 | "data": { 131 | "changes": 1, 132 | "lastInsertRowid": 2 133 | } 134 | } 135 | ``` 136 | 137 | Now that we assigned `editor` role to a user, he / she can read the `Album` table. 138 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | const yargs = require('yargs'); 2 | 3 | const usage = ` 4 | Soul | REST and realtime server for SQLite 5 | Usage: soul [options] 6 | `; 7 | 8 | let options = undefined; 9 | if (process.env.NO_CLI !== 'true') { 10 | options = yargs 11 | .usage(usage) 12 | .option('d', { 13 | alias: 'database', 14 | describe: 'SQLite database file or :memory:', 15 | type: 'string', 16 | demandOption: true, 17 | }) 18 | .option('p', { 19 | alias: 'port', 20 | describe: 'Port to listen on', 21 | type: 'number', 22 | demandOption: false, 23 | }) 24 | .option('r', { 25 | alias: 'rate-limit-enabled', 26 | describe: 'Enable rate limiting', 27 | type: 'boolean', 28 | demandOption: false, 29 | }) 30 | .option('c', { 31 | alias: 'cors', 32 | describe: 'CORS whitelist origins', 33 | type: 'string', 34 | demandOption: false, 35 | }) 36 | .option('V', { 37 | alias: 'verbose', 38 | describe: 'Enable verbose logging', 39 | type: 'string', 40 | demandOption: false, 41 | choices: ['console', null], 42 | }) 43 | .options('e', { 44 | alias: 'extensions', 45 | describe: 'Extensions directory path to load', 46 | type: 'string', 47 | demandOption: false, 48 | }) 49 | .options('env', { 50 | alias: 'envpath', 51 | describe: 'Environment variable file path to load', 52 | type: 'string', 53 | demandOption: false, 54 | }) 55 | .options('a', { 56 | alias: 'auth', 57 | describe: 'Enable authentication and authorization', 58 | type: 'boolean', 59 | demandOption: false, 60 | }) 61 | .options('ts', { 62 | alias: 'tokensecret', 63 | describe: 'JWT secret for the access and refresh tokens', 64 | type: 'string', 65 | demandOption: false, 66 | }) 67 | .options('atet', { 68 | alias: 'accesstokenexpirationtime', 69 | describe: 'JWT expiration time for access token', 70 | type: 'string', 71 | demandOption: false, 72 | }) 73 | .options('rtet', { 74 | alias: 'refreshtokenexpirationtime', 75 | describe: 'JWT expiration time for refresh token', 76 | type: 'string', 77 | demandOption: false, 78 | }) 79 | .options('iuu', { 80 | alias: 'initialuserusername', 81 | describe: 'Initial superuser username', 82 | type: 'string', 83 | demandOption: false, 84 | }) 85 | .options('iup', { 86 | alias: 'initialuserpassword', 87 | describe: 'Initial superuser password', 88 | type: 'string', 89 | demandOption: false, 90 | }) 91 | .options('S', { 92 | alias: 'studio', 93 | describe: 'Start Soul Studio in parallel', 94 | type: 'boolean', 95 | demandOption: false, 96 | }) 97 | .command('updatesuperuser', 'Update a superuser', (yargs) => { 98 | return yargs 99 | .option('id', { 100 | describe: 'The ID of the superuser you want to update', 101 | type: 'number', 102 | demandOption: true, 103 | }) 104 | .option('password', { 105 | describe: 'The new password for the superuser you want to update', 106 | type: 'string', 107 | demandOption: false, 108 | }) 109 | .option('is_superuser', { 110 | describe: 'The role of the superuser you want to update', 111 | type: 'boolean', 112 | demandOption: false, 113 | }); 114 | }) 115 | .help(true).argv; 116 | } 117 | 118 | module.exports = { 119 | yargs, 120 | usage, 121 | options, 122 | }; 123 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const express = require('express'); 4 | const bodyParser = require('body-parser'); 5 | const winston = require('winston'); 6 | const expressWinston = require('express-winston'); 7 | const cors = require('cors'); 8 | const rateLimit = require('express-rate-limit'); 9 | const swaggerUi = require('swagger-ui-express'); 10 | const cookieParser = require('cookie-parser'); 11 | 12 | const config = require('./config/index'); 13 | const db = require('./db/index'); 14 | 15 | const rootRoutes = require('./routes/index'); 16 | const tablesRoutes = require('./routes/tables'); 17 | const rowsRoutes = require('./routes/rows'); 18 | const authRoutes = require('./routes/auth'); 19 | 20 | const swaggerFile = require('./swagger/swagger.json'); 21 | const { setupExtensions } = require('./extensions'); 22 | const { 23 | createDefaultTables, 24 | createInitialUser, 25 | removeRevokedRefreshTokens, 26 | checkAuthConfigs, 27 | } = require('./controllers/auth'); 28 | 29 | const { runCLICommands } = require('./commands'); 30 | const { authConstants } = require('./constants'); 31 | 32 | const app = express(); 33 | 34 | app.use(bodyParser.json()); 35 | app.use(cookieParser()); 36 | 37 | // Activate wal mode 38 | db.exec('PRAGMA journal_mode = WAL'); 39 | 40 | // Enable CORS 41 | let corsOrigin = config.cors.origin; 42 | 43 | if (corsOrigin.includes('*')) { 44 | corsOrigin = '*'; 45 | } 46 | 47 | const corsOptions = { 48 | origin: corsOrigin, 49 | credentials: true, 50 | allowedHeaders: ['Content-Type', 'Authorization'], 51 | }; 52 | 53 | app.use(cors(corsOptions)); 54 | 55 | // Log requests 56 | if (config.verbose !== null) { 57 | app.use( 58 | expressWinston.logger({ 59 | transports: [new winston.transports.Console()], 60 | format: winston.format.combine( 61 | winston.format.colorize(), 62 | 63 | winston.format.json(), 64 | ), 65 | meta: false, 66 | msg: 'HTTP {{req.method}} {{req.url}}', 67 | expressFormat: true, 68 | 69 | colorize: false, 70 | }), 71 | ); 72 | } 73 | 74 | if (config.rateLimit.enabled) { 75 | const limiter = rateLimit({ 76 | windowMs: config.rateLimit.windowMs, 77 | max: config.rateLimit.max, // Limit each IP to {max} requests per `window` 78 | standardHeaders: true, // Return rate limit info in the `RateLimit*` headers 79 | legacyHeaders: false, // Disable the `XRateLimit*` headers 80 | }); 81 | 82 | // Apply the rate limiting middleware to all requests 83 | app.use(limiter); 84 | } 85 | 86 | // If Auth mode is activated but if the tokenSecret value is undefined then throw an error 87 | checkAuthConfigs({ auth: config.auth, tokenSecret: config.tokenSecret }); 88 | 89 | // If Auth mode is activated then create auth tables in the DB & create a super user if there are no users in the DB 90 | if (config.auth) { 91 | createDefaultTables(); 92 | createInitialUser(); 93 | } else { 94 | console.warn( 95 | 'Warning: Soul is running in open mode without authentication or authorization for API endpoints. Please be aware that your API endpoints will not be secure.', 96 | ); 97 | } 98 | 99 | // remove revoked refresh tokens every X days 100 | setInterval( 101 | removeRevokedRefreshTokens, 102 | authConstants.REVOKED_REFRESH_TOKENS_REMOVAL_TIME_RANGE, 103 | ); 104 | 105 | // If the user has passed custom CLI commands run the command and exit to avoid running the server 106 | runCLICommands(); 107 | 108 | app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerFile)); 109 | app.use('/api', rootRoutes); 110 | app.use('/api/tables', tablesRoutes); 111 | app.use('/api/tables', rowsRoutes); 112 | 113 | app.use('/api/auth', authRoutes); 114 | 115 | setupExtensions(app, db); 116 | 117 | module.exports = app; 118 | -------------------------------------------------------------------------------- /docs/extensions-examples.md: -------------------------------------------------------------------------------- 1 | # Extensions Examples 2 | 3 | Soul extensions are a way to extend the functionality of Soul. Extensions are written in JavaScript and can be used to add new endpoints, modify existing endpoints, or add new functionality to Soul. 4 | 5 | ## Types of Extensions 6 | 7 | - API Extensions: Add new endpoints to Soul 8 | 9 | 10 | ## Setup Environment 11 | 12 | To follow the below examples we need to download a sample database and also install Soul CLI. 13 | 14 | ### Download Sample Database 15 | 16 | ```bash 17 | wget https://raw.githubusercontent.com/lerocha/chinook-database/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite # Download sample sqlite database 18 | ``` 19 | 20 | ### Using Soul CLI 21 | ```bash 22 | npm install -g soul-cli 23 | soul -d ./Chinook_Sqlite.sqlite -p 8000 -e "/absolute/path/to/_extensions/" 24 | ``` 25 | 26 |
27 | Or Using Local Development 28 | 29 | ```bash 30 | git clone https://github.com/thevahidal/soul # Clone project 31 | npm install # Install dependencies 32 | npm link # might need `sudo` 33 | soul -d ./Chinook_Sqlite.sqlite -p 8000 -e "/absolute/path/to/_extensions/" 34 | ``` 35 |
36 | 37 | 38 | ## Creating an API extension 39 | 40 | To create an extension, create a new folder named `_extensions`. Then create a file named `api.js` inside it. This file will contain the extension code. 41 | 42 | ```js 43 | const hello = { 44 | method: 'GET', 45 | path: '/api/hello-soul', 46 | handler: (req, res, db) => { 47 | res.status(200).json({ 48 | message: 'Hello Soul!' 49 | }); 50 | }, 51 | }; 52 | 53 | const timestamp = { 54 | method: 'GET', 55 | path: '/api/timestamp', 56 | handler: (req, res, db) => { 57 | res.status(200).json({ 58 | timestamp: Date.now(), 59 | }); 60 | }, 61 | }; 62 | 63 | const greetings = { 64 | method: 'POST', 65 | path: '/api/greetings/:name', 66 | handler: (req, res, db) => { 67 | const { name } = req.params; 68 | const { greeting } = req.body; 69 | res.status(200).json({ 70 | message: `${greeting} ${name}!`, 71 | }); 72 | }, 73 | } 74 | 75 | const searchTables = { 76 | method: 'GET', 77 | path: '/api/search-tables', 78 | handler: (req, res, db) => { 79 | const { q } = req.query; 80 | const sql = ` 81 | SELECT name FROM sqlite_master 82 | WHERE type='table' 83 | AND name LIKE $searchQuery 84 | `; 85 | try { 86 | const tables = db.prepare(sql).all({ 87 | searchQuery: `%${q}%`, 88 | }); 89 | res.status(200).json({ 90 | tables, 91 | }); 92 | } catch (error) { 93 | res.status(500).json({ 94 | error: error.message, 95 | }); 96 | } 97 | }, 98 | }; 99 | 100 | module.exports = { 101 | hello, 102 | timestamp, 103 | greetings, 104 | searchTables, 105 | }; 106 | 107 | ``` 108 | 109 | Alright, now we can test if the extension is working: 110 | 111 | ```bash 112 | curl http://localhost:8000/api/hello-soul 113 | ``` 114 | 115 | It should return: 116 | 117 | ```json 118 | { 119 | "message": "Hello Soul!" 120 | } 121 | ``` 122 | 123 | And the same for the `timestamp` endpoint: 124 | 125 | ```bash 126 | curl http://localhost:8000/api/timestamp 127 | ``` 128 | 129 | It should return: 130 | 131 | ```json 132 | { 133 | "timestamp": 1620000000000 134 | } 135 | ``` 136 | 137 | And `greetings` endpoint: 138 | 139 | ```bash 140 | curl -X POST -H "Content-Type: application/json" -d '{"greeting": "Hello"}' http://localhost:8000/api/greetings/John 141 | ``` 142 | 143 | It should return: 144 | 145 | ```json 146 | { 147 | "message": "Hello John!" 148 | } 149 | ``` 150 | 151 | And `list-tables` endpoint: 152 | 153 | ```bash 154 | curl http://localhost:8000/api/search-tables?q=al 155 | ``` 156 | 157 | It should return: 158 | 159 | ```json 160 | { 161 | "tables": [ 162 | { 163 | "name": "Album" 164 | } 165 | ] 166 | } 167 | ``` 168 | -------------------------------------------------------------------------------- /docs/api/auth-examples.md: -------------------------------------------------------------------------------- 1 | ## Authentication / Authorization 2 | 3 | These endpoints are for Soul in the Auth mode. For that you need to enable auth mode by setting the `AUTH=true` environment variable or using the `-a` argument when starting a Soul server. 4 | 5 | ### 1. Obtain an access token 6 | 7 | To obtain an access token call `/auth/token/obtain/` endpoint with `POST` method. 8 | 9 | ```bash 10 | curl -v --request POST \ 11 | --url http://localhost:8000/api/auth/token/obtain \ 12 | --header 'Content-Type: application/json' \ 13 | --data '{ 14 | "fields": { 15 | "username": "damien", 16 | "password": "strongpass" 17 | } 18 | }' 19 | ``` 20 | 21 | Response 22 | 23 | ``` 24 | ... 25 | < Set-Cookie: accessToken=; Path=/; HttpOnly 26 | < Set-Cookie: refreshToken=; Path=/; HttpOnly 27 | ... 28 | ``` 29 | 30 | > You can see that when login is successful, Soul sets two cookies one for the access token and the other for the refresh token. 31 | 32 | ```json 33 | { 34 | "message": "Success", 35 | "data": { 36 | "userId": 1 37 | } 38 | } 39 | ``` 40 | 41 | #### Body Params 42 | 43 | - `fields` containing `username` and `password` e.g. 44 | 45 | ```json 46 | "fields": { 47 | "username": "damien", 48 | "password": "strongpass" 49 | } 50 | ``` 51 | 52 | > Here's how the jwt access token payload looks like: 53 | 54 | ```json 55 | { 56 | "subject": "accessToken", 57 | "username": "damien", 58 | "userId": 1, 59 | "isSuperuser": "false", 60 | "roleIds": [1], 61 | "iat": 1717427688, 62 | "exp": 1717463688 63 | } 64 | ``` 65 | 66 | ### 2. Refresh token 67 | 68 | To refresh and obtain a new access token call `/auth/token/refresh/` endpoint with `GET` method. 69 | 70 | ```bash 71 | curl -v http://localhost:8000/api/auth/token/refresh \ 72 | --cookie 'refreshToken=' 73 | ``` 74 | 75 | Response 76 | 77 | ``` 78 | ... 79 | < Set-Cookie: accessToken=; Path=/; HttpOnly 80 | < Set-Cookie: refreshToken=; Path=/; HttpOnly 81 | ... 82 | ``` 83 | 84 | > There you get a new token pair 85 | 86 | ```json 87 | { 88 | "message": "Success", 89 | "data": { 90 | "userId": 1 91 | } 92 | } 93 | ``` 94 | 95 | #### Cookies 96 | 97 | - `refreshToken` the refresh token that you acquired before 98 | 99 | ### 3. Change password 100 | 101 | To change your account password call `/auth/change-password/` endpoint with `PUT` method. 102 | 103 | ```bash 104 | curl --request PUT \ 105 | --url http://localhost:8000/api/auth/change-password \ 106 | --header 'Content-Type: application/json' \ 107 | --data '{ 108 | "fields": { 109 | "currentPassword": "strongpass", 110 | "newPassword": "anotherstrongpass" 111 | } 112 | }' \ 113 | --cookie 'accessToken=' 114 | ``` 115 | 116 | Response 117 | 118 | ```json 119 | { 120 | "message": "Password updated successfully", 121 | "data": { 122 | "id": 1, 123 | "username": "damien" 124 | } 125 | } 126 | ``` 127 | 128 | #### Body Params 129 | 130 | - `fields` containing `currentPassword` and `newPassword` e.g. 131 | 132 | ```json 133 | "fields": { 134 | "currentPassword": "strongpass", 135 | "newPassword": "anotherstrongpass" 136 | } 137 | ``` 138 | 139 | #### Cookies 140 | 141 | - `accessToken` the access token that you acquired before 142 | 143 | ### 4. Logout 144 | 145 | In order to logout from your account e.g. remove access and refresh cookies and also revoke your refresh token (access token lifetime is very short and doesn't need to be revoked) call `/auth/logout/` endpoint with `GET` method. 146 | 147 | ```bash 148 | curl http://localhost:8000/api/auth/logout \ 149 | --cookie 'accessToken=' \ 150 | --cookie 'refreshToken=' 151 | ``` 152 | 153 | Response 154 | 155 | ```json 156 | { 157 | "message": "Logout successful" 158 | } 159 | ``` 160 | 161 | #### Cookies 162 | 163 | - `accessToken` the access token that you acquired before 164 | - `refreshToken` the refresh token that you acquired before 165 | -------------------------------------------------------------------------------- /src/config/index.js: -------------------------------------------------------------------------------- 1 | const dotenv = require('dotenv'); 2 | const Joi = require('joi'); 3 | const path = require('path'); 4 | 5 | const { yargs } = require('../cli'); 6 | 7 | const { argv } = yargs; 8 | 9 | dotenv.config({ path: argv.envpath || path.join(__dirname, '../../.env') }); 10 | 11 | const envVarsSchema = Joi.object() 12 | .keys({ 13 | CORE_PORT: Joi.number().positive().default(8000), 14 | 15 | NODE_ENV: Joi.string() 16 | .valid('production', 'development', 'test') 17 | .default('production'), 18 | 19 | DB: Joi.string().required(), 20 | VERBOSE: Joi.string().valid('console', null).default(null), 21 | 22 | CORS_ORIGIN_WHITELIST: Joi.string().default('*'), 23 | AUTH: Joi.boolean(), 24 | 25 | RATE_LIMIT_ENABLED: Joi.boolean().default(false), 26 | RATE_LIMIT_WINDOW_MS: Joi.number().positive().default(1000), 27 | RATE_LIMIT_MAX_REQUESTS: Joi.number().positive().default(10), 28 | 29 | EXTENSIONS: Joi.string().default(null), 30 | 31 | START_WITH_STUDIO: Joi.boolean().default(false), 32 | 33 | INITIAL_USER_USERNAME: Joi.string(), 34 | INITIAL_USER_PASSWORD: Joi.string(), 35 | 36 | TOKEN_SECRET: Joi.string(), 37 | ACCESS_TOKEN_EXPIRATION_TIME: Joi.string(), 38 | REFRESH_TOKEN_EXPIRATION_TIME: Joi.string(), 39 | }) 40 | .unknown(); 41 | 42 | const env = { 43 | ...process.env, 44 | }; 45 | 46 | if (argv.port) { 47 | env.CORE_PORT = argv.port; 48 | } 49 | 50 | if (argv.verbose) { 51 | env.VERBOSE = argv.verbose; 52 | } 53 | 54 | if (argv.database) { 55 | env.DB = argv.database; 56 | } 57 | 58 | if (argv.cors) { 59 | env.CORS_ORIGIN_WHITELIST = argv.cors; 60 | } 61 | 62 | if (argv.auth) { 63 | env.AUTH = argv.auth; 64 | } 65 | 66 | if (argv['rate-limit-enabled']) { 67 | env.RATE_LIMIT_ENABLED = argv['rate-limit-enabled']; 68 | } 69 | 70 | if (argv.tokensecret) { 71 | env.TOKEN_SECRET = argv.tokensecret; 72 | } 73 | 74 | if (argv.accesstokenexpirationtime) { 75 | env.ACCESS_TOKEN_EXPIRATION_TIME = argv.accesstokenexpirationtime; 76 | } 77 | 78 | if (argv.refreshtokenexpirationtime) { 79 | env.REFRESH_TOKEN_EXPIRATION_TIME = argv.refreshtokenexpirationtime; 80 | } 81 | 82 | if (argv.initialuserusername) { 83 | env.INITIAL_USER_USERNAME = argv.initialuserusername; 84 | } 85 | 86 | if (argv.initialuserpassword) { 87 | env.INITIAL_USER_PASSWORD = argv.initialuserpassword; 88 | } 89 | 90 | const { value: envVars, error } = envVarsSchema 91 | .prefs({ errors: { label: 'key' } }) 92 | .validate(env); 93 | 94 | if (error) { 95 | throw new Error(`Config validation error: ${error.message}`); 96 | } 97 | 98 | module.exports = { 99 | env: envVars.NODE_ENV, 100 | 101 | isProduction: envVars.NODE_ENV === 'production', 102 | isDevelopment: envVars.NODE_ENV === 'development', 103 | isTest: envVars.NODE_ENV === 'test', 104 | 105 | port: argv.port || envVars.CORE_PORT, 106 | verbose: argv['verbose'] || envVars.VERBOSE, 107 | 108 | db: { 109 | filename: argv.database || envVars.DB || ':memory:', 110 | }, 111 | cors: { 112 | origin: argv.cors?.split(',') || 113 | envVars.CORS_ORIGIN_WHITELIST?.split(',') || ['*'], 114 | }, 115 | 116 | auth: argv.auth || envVars.AUTH || false, 117 | tokenSecret: argv.tokensecret || envVars.TOKEN_SECRET || null, 118 | accessTokenExpirationTime: 119 | argv.accesstokenexpirationtime || 120 | envVars.ACCESS_TOKEN_EXPIRATION_TIME || 121 | '5H', 122 | refreshTokenExpirationTime: 123 | argv.refreshtokenexpirationtime || 124 | envVars.REFRESH_TOKEN_EXPIRATION_TIME || 125 | '3D', 126 | 127 | initialUserUsername: 128 | argv.initialuserusername || envVars.INITIAL_USER_USERNAME, 129 | initialUserPassword: 130 | argv.initialuserpassword || envVars.INITIAL_USER_PASSWORD, 131 | 132 | rateLimit: { 133 | enabled: argv['rate-limit-enabled'] || envVars.RATE_LIMIT_ENABLED, 134 | windowMs: envVars.RATE_LIMIT_WINDOW, 135 | max: envVars.RATE_LIMIT_MAX, 136 | }, 137 | 138 | extensions: { 139 | path: argv.extensions || envVars.EXTENSIONS, 140 | }, 141 | 142 | startWithStudio: argv.studio || envVars.START_WITH_STUDIO, 143 | }; 144 | -------------------------------------------------------------------------------- /src/services/tableService.js: -------------------------------------------------------------------------------- 1 | module.exports = (db) => { 2 | return { 3 | createTable(tableName, schema, options = {}) { 4 | const { 5 | autoAddCreatedAt = true, 6 | autoAddUpdatedAt = true, 7 | multipleUniqueConstraints, 8 | } = options; 9 | 10 | let indices = []; 11 | 12 | let schemaString = schema 13 | .map(({ name, type, notNull, unique, primaryKey, foreignKey }) => { 14 | let column = `'${name}' '${type}'`; 15 | 16 | if (notNull) { 17 | column += ' NOT NULL'; 18 | } 19 | if (unique) { 20 | column += ' UNIQUE'; 21 | } 22 | if (primaryKey) { 23 | column += ' PRIMARY KEY'; 24 | } 25 | if (foreignKey) { 26 | column += ` REFERENCES ${foreignKey.table}(${foreignKey.column})`; 27 | } 28 | if (foreignKey && foreignKey.onDelete) { 29 | column += ` ON DELETE ${foreignKey.onDelete}`; 30 | } 31 | if (foreignKey && foreignKey.onUpdate) { 32 | column += ` ON UPDATE ${foreignKey.onUpdate}`; 33 | } 34 | 35 | return column; 36 | }) 37 | .join(', '); 38 | 39 | // add id if primary key is not defined 40 | if (!schema.find((field) => field.primaryKey)) { 41 | schemaString = ` 42 | id INTEGER PRIMARY KEY AUTOINCREMENT, 43 | ${schemaString} 44 | `; 45 | } 46 | 47 | // add created at and updated at 48 | if (autoAddCreatedAt) { 49 | schemaString = `${schemaString}, createdAt DATETIME DEFAULT CURRENT_TIMESTAMP`; 50 | } 51 | 52 | if (autoAddUpdatedAt) { 53 | schemaString = `${schemaString}, updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP`; 54 | } 55 | 56 | if (multipleUniqueConstraints) { 57 | schemaString = `${schemaString}, CONSTRAINT ${ 58 | multipleUniqueConstraints.name 59 | } UNIQUE (${multipleUniqueConstraints.fields 60 | .map((field) => field) 61 | .join(' ,')})`; 62 | } 63 | 64 | let indicesString = indices 65 | .map((field) => { 66 | return ` 67 | CREATE INDEX ${tableName}_${field}_index 68 | ON ${tableName} (${field}) 69 | `; 70 | }) 71 | .join(';'); 72 | 73 | const query = `CREATE TABLE ${tableName} (${schemaString})`; 74 | 75 | try { 76 | db.prepare(query).run(); 77 | 78 | if (indicesString) { 79 | db.prepare(indicesString).run(); 80 | } 81 | 82 | db.prepare(`PRAGMA table_info(${tableName})`).all(); 83 | } catch (error) { 84 | console.log(error); 85 | } 86 | }, 87 | 88 | listTables(options = {}) { 89 | const { search, ordering, exclude } = options; 90 | 91 | let query = `SELECT name FROM sqlite_master WHERE type IN ('table', 'view')`; 92 | 93 | // if search is provided, search the tables 94 | // e.g. search=users 95 | if (search) { 96 | query += ` AND name LIKE $searchQuery`; 97 | } 98 | 99 | // if exclude is passed don't return the some tables 100 | // e.g. exclude=['_users', '_roles'] 101 | if (exclude) { 102 | const excludeTables = exclude.map((field) => `'${field}'`).join(' ,'); 103 | query += `AND name NOT IN (${excludeTables});`; 104 | } 105 | 106 | // if ordering is provided, order the tables 107 | // e.g. ordering=name (ascending) or ?_ordering=-name (descending) 108 | if (ordering) { 109 | query += ` ORDER BY $ordering`; 110 | } 111 | 112 | try { 113 | const tables = db.prepare(query).all({ 114 | searchQuery: `%${search}%`, 115 | ordering: `${ordering?.replace('-', '')} ${ 116 | ordering?.startsWith('-') ? 'DESC' : 'ASC' 117 | }`, 118 | }); 119 | return tables; 120 | } catch (error) { 121 | console.log(error); 122 | } 123 | }, 124 | 125 | checkTableExists(tableName) { 126 | const query = `SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`; 127 | const result = db.prepare(query).get(); 128 | return result; 129 | }, 130 | }; 131 | }; 132 | -------------------------------------------------------------------------------- /src/services/rowService.js: -------------------------------------------------------------------------------- 1 | const { apiConstants } = require('../constants'); 2 | 3 | module.exports = (db) => { 4 | return { 5 | get(data) { 6 | const query = `SELECT ${data.schemaString || '*'} FROM ${ 7 | data.tableName 8 | } ${data.extendString || ''} ${data.whereString || ''} ${ 9 | data.orderString || '' 10 | } LIMIT ? OFFSET ?`; 11 | 12 | const statement = db.prepare(query); 13 | const result = statement.all( 14 | ...data.whereStringValues, 15 | data.limit || apiConstants.DEFAULT_PAGE_LIMIT, 16 | data.page || apiConstants.DEFAULT_PAGE_INDEX, 17 | ); 18 | 19 | return result; 20 | }, 21 | 22 | getById(data) { 23 | const pks = data.pks.split(','); 24 | const placeholders = pks.map(() => '?').join(','); 25 | const query = `SELECT ${data.schemaString} FROM ${data.tableName} ${data.extendString} WHERE ${data.tableName}.${data.lookupField} in (${placeholders})`; 26 | const statement = db.prepare(query); 27 | const result = statement.all(...pks); 28 | return result; 29 | }, 30 | 31 | getCount(data) { 32 | const query = `SELECT COUNT(*) as total FROM ${data.tableName} ${data.whereString}`; 33 | const statement = db.prepare(query); 34 | const result = statement.get(...data.whereStringValues).total; 35 | return result; 36 | }, 37 | 38 | save(data) { 39 | // wrap text values in quotes 40 | const fieldsString = Object.keys(data.fields) 41 | .map((field) => `'${field}'`) 42 | .join(', '); 43 | 44 | // wrap text values in quotes 45 | const valuesString = Object.values(data.fields).map((value) => value); 46 | const placeholders = Object.values(data.fields) 47 | .map(() => '?') 48 | .join(','); 49 | 50 | let values = `(${fieldsString}) VALUES (${placeholders})`; 51 | if (valuesString === '') { 52 | values = 'DEFAULT VALUES'; 53 | } 54 | 55 | const query = `INSERT INTO ${data.tableName} ${values}`; 56 | const statement = db.prepare(query); 57 | const result = statement.run(...valuesString); 58 | return result; 59 | }, 60 | 61 | bulkWrite(data) { 62 | const { tableName, fields } = data; 63 | const fieldNames = Object.keys(fields[0]); 64 | const valueSets = fields.map((row) => Object.values(row)); 65 | 66 | const placeholders = fieldNames.map(() => '?'); 67 | const valuesString = valueSets 68 | .map(() => `(${placeholders.join(',')})`) 69 | .join(','); 70 | 71 | const query = `INSERT INTO ${tableName} (${fieldNames 72 | .map((field) => `'${field}'`) 73 | .join(', ')}) VALUES ${valuesString}`; 74 | 75 | const statement = db.prepare(query); 76 | const result = statement.run(...valueSets.flat()); 77 | return result; 78 | }, 79 | 80 | update(data) { 81 | const pks = data.pks.split(','); 82 | const placeholders = pks.map(() => '?').join(','); 83 | const query = `UPDATE ${data.tableName} SET ${data.fieldsString} WHERE ${data.lookupField} in (${placeholders})`; 84 | const statement = db.prepare(query); 85 | const result = statement.run(...pks); 86 | return result; 87 | }, 88 | 89 | delete(data) { 90 | const pks = data.pks.split(','); 91 | const placeholders = pks.map(() => '?').join(','); 92 | const query = `DELETE FROM ${data.tableName} WHERE ${data.lookupField} in (${placeholders})`; 93 | const statement = db.prepare(query); 94 | const result = statement.run(...pks); 95 | return result; 96 | }, 97 | 98 | getForeignKeyInfo(tableName, field) { 99 | const foreignKey = db 100 | .prepare(`PRAGMA foreign_key_list(${tableName})`) 101 | .all() 102 | .find((fk) => fk.from === field); 103 | 104 | if (!foreignKey) { 105 | throw new Error(`Foreign key not found for field '${field}'`); 106 | } 107 | 108 | const joinedTableName = foreignKey.table; 109 | const joinedTableFields = db 110 | .prepare(`PRAGMA table_info(${joinedTableName})`) 111 | .all(); 112 | 113 | return { foreignKey, joinedTableName, joinedTableFields }; 114 | }, 115 | }; 116 | }; 117 | -------------------------------------------------------------------------------- /docs/api/tables-examples.md: -------------------------------------------------------------------------------- 1 | ## Tables 2 | 3 | ### 1. List Tables 4 | 5 | To list all tables we simply call `/tables` endpoint with `GET` method. 6 | 7 | ```bash 8 | curl 'localhost:8000/api/tables' 9 | ``` 10 | 11 | Response 12 | 13 | ```json 14 | { 15 | "data": [ 16 | { "name": "Album" }, 17 | { "name": "Artist" } 18 | // ... 19 | ] 20 | } 21 | ``` 22 | 23 | #### Query Params 24 | 25 | - `_search` e.g. `?_search=art`, to search between tables. 26 | - `_ordering` e.g. `?_ordering=-name`, to order tables by name descending, or without `-` to sort ascending, e.g. `?_ordering=name` 27 | 28 | Example with query params 29 | 30 | ```bash 31 | curl 'localhost:8000/api/tables?_search=pl&_ordering=-name' 32 | ``` 33 | 34 | Response 35 | 36 | ```json 37 | { 38 | "data": [ 39 | { "name": "Playlist" }, 40 | { "name": "PlaylistTrack" }, 41 | { "name": "Employee" } 42 | // ... 43 | ] 44 | } 45 | ``` 46 | 47 | ### 2. Create Tables 48 | 49 | To create a new table use the following `POST` endpoint. 50 | 51 | ```bash 52 | curl --request POST \ 53 | --url http://localhost:8000/api/tables \ 54 | --header 'Content-Type: application/json' \ 55 | --data '{ 56 | "name": "pets", 57 | "autoAddCreatedAt": true, 58 | "autoAddUpdatedAt": false, 59 | "schema": [ 60 | { 61 | "name": "name", 62 | "type": "Text", 63 | "index": true 64 | }, 65 | { 66 | "name": "birth_date", 67 | "type": "Date", 68 | "notNull": true 69 | }, 70 | { 71 | "name": "owner_id", 72 | "type": "Integer", 73 | "foreignKey": { 74 | "table": "artists", 75 | "column": "ArtistId", 76 | "onDelete": "CASCADE", 77 | "onUpdate": "CASCADE" 78 | } 79 | } 80 | ] 81 | }' 82 | 83 | ``` 84 | 85 | Response 86 | 87 | ```json 88 | { 89 | "message": "Table created", 90 | "data": { 91 | "name": "pets", 92 | "schema": [ 93 | { 94 | "cid": 0, 95 | "name": "createdAt", 96 | "type": "DATETIME", 97 | "notnull": 0, 98 | "dflt_value": "CURRENT_TIMESTAMP", 99 | "pk": 0 100 | }, 101 | { 102 | "cid": 1, 103 | "name": "id", 104 | "type": "INTEGER", 105 | "notnull": 0, 106 | "dflt_value": null, 107 | "pk": 1 108 | }, 109 | { 110 | "cid": 2, 111 | "name": "name", 112 | "type": "TEXT", 113 | "notnull": 0, 114 | "dflt_value": null, 115 | "pk": 0 116 | }, 117 | { 118 | "cid": 3, 119 | "name": "birth_date", 120 | "type": "Date", 121 | "notnull": 1, 122 | "dflt_value": null, 123 | "pk": 0 124 | }, 125 | { 126 | "cid": 4, 127 | "name": "owner_id", 128 | "type": "INTEGER", 129 | "notnull": 0, 130 | "dflt_value": null, 131 | "pk": 0 132 | } 133 | ] 134 | } 135 | } 136 | ``` 137 | 138 | #### Body Params 139 | 140 | - `name` e.g. `name: pets`, to be used as the table name 141 | - `autoAddCreatedAt` e.g. `autoAddCreatedAt: false` to automatically add a created at field, default `true` 142 | - `autoAddUpdatedAt` e.g. `autoAddCreatedAt: false` to automatically add a updated at field, default `true` 143 | - `schema` e.g. 144 | 145 | ```json 146 | "schema": [ 147 | { 148 | "name": "name", // field name (required) 149 | "type": "TEXT", // field type (required) (one of `TEXT | NUMERIC | INTEGER | REAL | BLOB | BOOLEAN | DATE | DATETIME) 150 | "index": true, // should this field be indexed? 151 | "default": "John", // field default value 152 | "notNull": false, // should this field be non-nullable? 153 | "unique": false, // should this field be unique? 154 | "primaryKey": true // should this field be the primaryKey? if false Soul will add an auto-increment primary key field 155 | }, 156 | { 157 | "name": "user_id", 158 | "foreignKey": { 159 | "table": "users", // foreign key table 160 | "column": "id", // foreign key related field 161 | "onDelete": "CASCADE", // on delete constraint (on of CASCADE | SET NULL | SET DEFAULT | RESTRICT) 162 | "onUpdate": "CASCADE" // on update constraint (on of CASCADE | SET NULL | SET DEFAULT | RESTRICT) 163 | } 164 | }, 165 | // ... 166 | ] 167 | ``` 168 | 169 | ### 3. Get a Table's Schema 170 | 171 | To get a table's schema call `/tables/` endpoint with `GET` method. 172 | 173 | ```bash 174 | curl 'localhost:8000/api/tables/genres' 175 | ``` 176 | 177 | Response 178 | 179 | ```json 180 | { 181 | "data": [ 182 | { 183 | "cid": 0, 184 | "name": "GenreId", 185 | "type": "INTEGER", 186 | "notnull": 1, 187 | "dflt_value": null, 188 | "pk": 1 189 | }, 190 | { 191 | "cid": 1, 192 | "name": "Name", 193 | "type": "NVARCHAR(120)", 194 | "notnull": 0, 195 | "dflt_value": null, 196 | "pk": 0 197 | } 198 | ] 199 | } 200 | ``` 201 | 202 | ### 4. Delete / Drop a Table 203 | 204 | > CAUTION: Be careful when using this endpoint, it will delete the table and all its data. 205 | 206 | To delete a table call `/tables/` with a `DELETE` endpoint. 207 | 208 | ```bash 209 | curl --request DELETE \ 210 | --url http://localhost:8000/api/tables/pets 211 | ``` 212 | 213 | Response 214 | 215 | ```json 216 | { "message": "Table deleted" } 217 | ``` 218 | -------------------------------------------------------------------------------- /src/swagger/index.js: -------------------------------------------------------------------------------- 1 | const swaggerAutogen = require('swagger-autogen')(); 2 | 3 | const config = require('../config/index'); 4 | const version = require('../../package.json').version; 5 | 6 | const outputFile = './swagger.json'; 7 | const endpointsFiles = ['../index.js']; 8 | 9 | const doc = { 10 | info: { 11 | version: version, 12 | title: 'Soul API', 13 | description: 14 | 'API Documentation for Soul, a SQLite REST and realtime server. ', 15 | }, 16 | host: `localhost:${config.port}`, 17 | basePath: '/', 18 | schemes: ['http', 'https'], 19 | consumes: ['application/json'], 20 | produces: ['application/json'], 21 | tags: [ 22 | { 23 | name: 'Root', 24 | description: 'Root endpoints', 25 | }, 26 | { 27 | name: 'Tables', 28 | description: 'Tables endpoints', 29 | }, 30 | { 31 | name: 'Rows', 32 | description: 'Rows endpoints', 33 | }, 34 | { 35 | name: 'Auth', 36 | description: 'Auth endpoints', 37 | }, 38 | ], 39 | securityDefinitions: {}, 40 | definitions: { 41 | Table: { 42 | name: 'users', 43 | }, 44 | Row: {}, 45 | Query: { 46 | query: 'SELECT * FROM users', 47 | }, 48 | Statement: { 49 | statement: 50 | 'INSERT INTO users (id, firstName, lastName) VALUES (:id, :firstName, :lastName)', 51 | values: { id: 1, firstName: 'John', lastName: 'Doe' }, 52 | }, 53 | Transaction: { 54 | transaction: [ 55 | { $ref: '#/definitions/Query' }, 56 | { $ref: '#/definitions/Statement' }, 57 | ], 58 | }, 59 | ForeignKey: { 60 | table: 'users', 61 | column: 'id', 62 | onDelete: 'CASCADE', 63 | onUpdate: 'CASCADE', 64 | }, 65 | Field: { 66 | name: 'user_id', 67 | type: 'INTEGER', 68 | default: 1, 69 | notNull: true, 70 | unique: false, 71 | primaryKey: false, 72 | foreignKey: { $ref: '#/definitions/ForeignKey' }, 73 | index: false, 74 | }, 75 | Schema: [{ $ref: '#/definitions/Field' }], 76 | 77 | CreateTableRequestBody: { 78 | name: 'users', 79 | schema: { $ref: '#/definitions/Schema' }, 80 | autoAddCreatedAt: true, 81 | autoAddUpdatedAt: true, 82 | }, 83 | CreateTableSuccessResponse: { 84 | message: 'Table created', 85 | data: { 86 | name: 'users', 87 | fields: [{ $ref: '#/definitions/Field' }], 88 | }, 89 | }, 90 | CreateTableErrorResponse: { 91 | message: 'Table not created', 92 | error: 'already_exists', 93 | data: {}, 94 | }, 95 | 96 | InsertRowRequestBody: { 97 | $ref: '#/definitions/Row', 98 | }, 99 | InsertRowSuccessResponse: { 100 | message: 'Row inserted', 101 | data: { 102 | id: 1, 103 | createdAt: '2022-10-10 10:55:29', 104 | updatedAt: '2022-10-10 10:55:29', 105 | firstName: 'John', 106 | }, 107 | }, 108 | InsertRowErrorResponse: { 109 | message: 'Row not inserted', 110 | error: 'not_found', 111 | }, 112 | 113 | UpdateRowRequestBody: { 114 | fields: [{ $ref: '#/definitions/Field' }], 115 | }, 116 | 117 | BulkUpdateRowsRequestBody: { 118 | pks: [1, 2, 3], 119 | fields: [{ $ref: '#/definitions/Field' }], 120 | }, 121 | 122 | BulkDeleteRowsRequestBody: { 123 | pks: [1, 2, 3], 124 | }, 125 | 126 | TransactionRequestBody: { 127 | $ref: '#/definitions/Transaction', 128 | }, 129 | ObtainAccessTokenRequestBody: { 130 | fields: { 131 | username: '@john', 132 | password: 'Ak22#cPM33@v*#', 133 | }, 134 | }, 135 | 136 | ObtainAccessTokenSuccessResponse: { 137 | message: 'Success', 138 | data: { 139 | userId: 1, 140 | }, 141 | }, 142 | 143 | InvalidCredentialErrorResponse: { 144 | message: 'Invalid username or password', 145 | }, 146 | 147 | UserRegisterationRequestBody: { 148 | fields: { 149 | username: '@john', 150 | password: 'Ak22#cPM33@v*#', 151 | }, 152 | }, 153 | 154 | WeakPasswordErrorResponse: { 155 | message: 156 | 'This password is weak, it should be at least 8 characters long and contain a combination of lowercase letters, uppercase letters, numbers, and special characters', 157 | }, 158 | 159 | UsernameTakenErrorResponse: { 160 | message: 'This username is taken', 161 | }, 162 | 163 | DefaultRoleNotCreatedErrorResponse: { 164 | message: 'Please restart soul so a default role can be created', 165 | }, 166 | 167 | UserNotFoundErrorResponse: { 168 | message: 'User not found', 169 | }, 170 | 171 | InvalidRefreshTokenErrorResponse: { 172 | message: 'Invalid refresh token', 173 | }, 174 | 175 | ChangePasswordRequestBody: { 176 | fields: { 177 | currentPassword: 'Ak22#cPM33@v*#', 178 | newPassword: 'hKB33o@3245CD$', 179 | }, 180 | }, 181 | 182 | ChangePasswordSuccessResponse: { 183 | message: 'Password updated successfully', 184 | data: { id: 1, username: '@john' }, 185 | }, 186 | 187 | RefreshAccessTokenSuccessResponse: { 188 | message: 'Success', 189 | data: { userId: 1 }, 190 | }, 191 | 192 | InvalidPasswordErrorResponse: { message: 'Invalid password' }, 193 | 194 | RemoveTokensResponse: { 195 | message: 'Logout successful', 196 | }, 197 | }, 198 | }; 199 | 200 | swaggerAutogen(outputFile, endpointsFiles, doc); 201 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | thevahidal@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/api/rows-examples.md: -------------------------------------------------------------------------------- 1 | ## Rows 2 | 3 | ### 1. List Rows of a Table 4 | 5 | To list all (or some of) rows we simply call `/tables//rows/` endpoint with `GET` method. 6 | 7 | ```bash 8 | curl 'localhost:8000/api/tables/Album/rows/' 9 | ``` 10 | 11 | Response 12 | 13 | ```json 14 | { 15 | "data": [ 16 | { 17 | "AlbumId": 1, 18 | "Title": "For Those About To Rock We Salute You", 19 | "ArtistId": 1 20 | }, 21 | { "AlbumId": 2, "Title": "Balls to the Wall", "ArtistId": 2 } 22 | // ... 23 | ], 24 | "total": 347, 25 | "next": "/tables/Album?page=2", 26 | "previous": null 27 | } 28 | ``` 29 | 30 | #### Query Params 31 | 32 | - `_page` e.g. `?_page=2`, to get the second page of results. 33 | - `_limit` e.g. `?_limit=20`, to get 20 results per page. 34 | - `_search` e.g. `?_search=rock`, to search across all fields. 35 | - `_ordering` e.g. `?_ordering=-Title`, to order rows by title descending, or without `-` to sort ascending, e.g. `?_ordering=Title` 36 | - `_schema` e.g. `?_schema=Title,ArtistId`, to get only the Title and ArtistId columns. 37 | - `_extend` e.g. `?_extend=ArtistId`, to get the Artist object related to the Album. 38 | - `_filters` e.g. `?_filters=ArtistId:[1,2,3],Title:Rock`, to get only the rows where the ArtistId can be 1,2 or 3 and the Title is Rock. 39 | NOTE: If you want to use comparison operators in the filter, you can use these operators after the field: `__eq, __neq, __lt, __gt, __lte, __gte, __null, __notnull` . For example, you can use `/invoices/rows?_filters=InvoiceId__neq:1,Total__gte:5,BillingPostalCode__notnull` 40 | 41 | Example with query params 42 | 43 | ```bash 44 | curl 'localhost:8000/api/tables/Album/rows?_page=1&_limit=20&_search=rock&_ordering=-Title&_schema=Title,ArtistId&_extend=ArtistId&_filters=ArtistId:90' 45 | ``` 46 | 47 | Response 48 | 49 | ```json 50 | { 51 | "data": [ 52 | { 53 | "Title": "Rock In Rio [CD2]", 54 | "ArtistId": 90, 55 | "ArtistId_data": { "ArtistId": 90, "Name": "Iron Maiden" } 56 | }, 57 | { 58 | "Title": "Rock In Rio [CD1]", 59 | "ArtistId": 90, 60 | "ArtistId_data": { "ArtistId": 90, "Name": "Iron Maiden" } 61 | } 62 | ], 63 | "total": 2, 64 | "next": null, 65 | "previous": null 66 | } 67 | ``` 68 | 69 | ### 2. Insert a New Row 70 | 71 | To insert a new row to a `table` call `/tables//rows/` endpoint with `POST` method. 72 | 73 | ```bash 74 | curl --request POST \ 75 | --url http://localhost:8000/api/tables/Employee/rows \ 76 | --header 'Content-Type: application/json' \ 77 | --data '{ 78 | "fields": { 79 | "FirstName": "Damien", 80 | "LastName": "Rice" 81 | } 82 | }' 83 | ``` 84 | 85 | Response 86 | 87 | ```json 88 | { 89 | "message": "Row inserted", 90 | "data": { 91 | "changes": 1, 92 | "lastInsertRowid": 9 93 | } 94 | } 95 | ``` 96 | 97 | #### Body Params 98 | 99 | - `fields` e.g. 100 | 101 | ```json 102 | "fields": { 103 | // fields values for the new row 104 | } 105 | ``` 106 | 107 | ### 3. Get Row(s) by ID 108 | 109 | To get a row call `/tables//rows//` endpoint with `GET` method. 110 | 111 | ```bash 112 | curl http://localhost:8000/api/tables/Album/rows/1/ 113 | ``` 114 | 115 | Response 116 | 117 | ```json 118 | { 119 | "data": { 120 | "AlbumId": 1, 121 | "Title": "For Those About To Rock We Salute You", 122 | "ArtistId": 1 123 | } 124 | } 125 | ``` 126 | 127 | #### Path Params 128 | 129 | - `lookup-values` e.g. `1`, to get the row with the AlbumId 1. or comma-separated values e.g. `1,2`, to get the rows with the AlbumId 1 and 2. (Bulk Retrieve) 130 | 131 | #### Query Params 132 | 133 | - `_lookup_field` e.g. `?_lookup_field=ArtistId`, to get the row by the ArtistId field. If not provided, the default lookup field is the primary key of the table. 134 | - `_schema` e.g. `?_schema=Title,ArtistId`, to get only the Title and ArtistId columns. 135 | - `_extend` e.g. `?_extend=ArtistId`, to get the Artist object related to the Album. 136 | 137 | Example with query params 138 | 139 | ```bash 140 | curl 'http://localhost:8000/api/tables/Album/rows/Facelift?_lookup_field=Title&_extend=ArtistId&_schema=Title' 141 | ``` 142 | 143 | Response 144 | 145 | ```json 146 | { 147 | "data": { 148 | "Title": "Facelift", 149 | "ArtistId_data": { 150 | "ArtistId": 5, 151 | "Name": "Alice In Chains" 152 | } 153 | } 154 | } 155 | ``` 156 | 157 | ### 4. Update Row(s) by ID 158 | 159 | To update a row call `/tables//rows//` endpoint with `PUT` method. 160 | 161 | ```bash 162 | curl --request PUT \ 163 | --url http://localhost:8000/api/tables/Album/rows/7 \ 164 | --header 'Content-Type: application/json' \ 165 | --data '{ 166 | "fields": { 167 | "Title": "FaceElevate" 168 | } 169 | }' 170 | ``` 171 | 172 | Response 173 | 174 | ```json 175 | { 176 | "message": "Row updated" 177 | } 178 | ``` 179 | 180 | #### Path Params 181 | 182 | - `lookup-values` e.g. `1`, to update the row with the AlbumId 1. or comma-separated values e.g. `1,2`, to update the rows with the AlbumId 1 and 2. (Bulk Update) 183 | 184 | #### Query Params 185 | 186 | - `_lookup_field` e.g. `?_lookup_field=ArtistId`, to update the row by the ArtistId field. If not provided, the default lookup field is the primary key of the table. 187 | 188 | #### Body Params 189 | 190 | - `fields` e.g. 191 | 192 | ```json 193 | "fields": { 194 | // fields values to update 195 | } 196 | ``` 197 | 198 | ### 5. Delete Row(s) by ID 199 | 200 | To delete a row call `/tables//rows//` endpoint with `DELETE` method. 201 | 202 | ```bash 203 | curl --request DELETE \ 204 | --url http://localhost:8000/api/tables/PlaylistTrack/rows/1 205 | ``` 206 | 207 | Response 208 | 209 | ```json 210 | { 211 | "message": "Row deleted", 212 | "data": { 213 | "changes": 3290, 214 | "lastInsertRowid": 0 215 | } 216 | } 217 | ``` 218 | 219 | #### Path Params 220 | 221 | - `lookup-values` e.g. `1`, to delete the row with the AlbumId 1. or comma-separated values e.g. `1,2`, to delete the rows with the AlbumId 1 and 2. (Bulk Delete) 222 | 223 | #### Query Params 224 | 225 | - `_lookup_field` e.g. `?_lookup_field=ArtistId`, to delete the row by the ArtistId field. If not provided, the default lookup field is the primary key of the table. 226 | -------------------------------------------------------------------------------- /src/controllers/tables.js: -------------------------------------------------------------------------------- 1 | const db = require('../db/index'); 2 | 3 | const createTable = async (req, res) => { 4 | /* 5 | #swagger.tags = ['Tables'] 6 | #swagger.summary = 'Create Table' 7 | #swagger.description = 'Endpoint to create a table' 8 | #swagger.parameters['body'] = { 9 | in: 'body', 10 | required: true, 11 | type: 'object', 12 | schema: { $ref: "#/definitions/CreateTableRequestBody" } 13 | } 14 | */ 15 | const { 16 | name: tableName, 17 | schema, 18 | autoAddCreatedAt = true, 19 | autoAddUpdatedAt = true, 20 | } = req.body; 21 | 22 | let indices = []; 23 | let schemaString = schema 24 | // support name, type, default, not null, unique, primary key, foreign key, index 25 | // e.g. { name: 'id', type: 'INTEGER', primaryKey: true } 26 | 27 | .map( 28 | ({ 29 | name, 30 | type, 31 | default: defaultValue, 32 | notNull, 33 | unique, 34 | primaryKey, 35 | foreignKey, 36 | index, 37 | }) => { 38 | let column = `${name} ${type}`; 39 | if (defaultValue) { 40 | column += ` DEFAULT ${defaultValue}`; 41 | } 42 | if (notNull) { 43 | column += ' NOT NULL'; 44 | } 45 | if (unique) { 46 | column += ' UNIQUE'; 47 | } 48 | if (primaryKey) { 49 | column += ' PRIMARY KEY'; 50 | } 51 | if (foreignKey) { 52 | column += ` REFERENCES ${foreignKey.table}(${foreignKey.column})`; 53 | } 54 | if (foreignKey && foreignKey.onDelete) { 55 | column += ` ON DELETE ${foreignKey.onDelete}`; 56 | } 57 | if (foreignKey && foreignKey.onUpdate) { 58 | column += ` ON UPDATE ${foreignKey.onUpdate}`; 59 | } 60 | if (index) { 61 | indices.push(name); 62 | } 63 | 64 | return column; 65 | } 66 | ) 67 | .join(', '); 68 | 69 | // add id if primary key is not defined 70 | if (!schema.find((field) => field.primaryKey)) { 71 | schemaString = ` 72 | id INTEGER PRIMARY KEY AUTOINCREMENT, 73 | ${schemaString} 74 | `; 75 | } 76 | 77 | // add created at and updated at 78 | if (autoAddCreatedAt) { 79 | schemaString = ` 80 | createdAt DATETIME DEFAULT CURRENT_TIMESTAMP, 81 | ${schemaString} 82 | `; 83 | } 84 | 85 | if (autoAddUpdatedAt) { 86 | schemaString = ` 87 | updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP, 88 | ${schemaString} 89 | `; 90 | } 91 | 92 | let indicesString = indices 93 | .map((field) => { 94 | return ` 95 | CREATE INDEX ${tableName}_${field}_index 96 | ON ${tableName} (${field}) 97 | `; 98 | }) 99 | .join(';'); 100 | 101 | const query = `CREATE TABLE ${tableName} (${schemaString})`; 102 | 103 | try { 104 | db.prepare(query).run(); 105 | 106 | if (indicesString) { 107 | db.prepare(indicesString).run(); 108 | } 109 | 110 | const generatedSchema = db.prepare(`PRAGMA table_info(${tableName})`).all(); 111 | 112 | /* 113 | #swagger.responses[201] = { 114 | description: 'Table created', 115 | schema: { 116 | $ref: "#/definitions/CreateTableSuccessResponse" 117 | } 118 | } 119 | */ 120 | res.status(201).json({ 121 | message: 'Table created', 122 | data: { 123 | name: tableName, 124 | schema: generatedSchema, 125 | }, 126 | }); 127 | } catch (error) { 128 | /* 129 | #swagger.responses[400] = { 130 | description: 'Bad request', 131 | schema: { 132 | $ref: "#/definitions/CreateTableErrorResponse" 133 | } 134 | } 135 | */ 136 | res.status(400).json({ 137 | message: error.message, 138 | error: error, 139 | }); 140 | } 141 | }; 142 | 143 | // Return all tables 144 | const listTables = async (req, res) => { 145 | /* 146 | #swagger.tags = ['Tables'] 147 | #swagger.summary = 'List Tables' 148 | #swagger.description = 'Endpoint to list all tables' 149 | 150 | #swagger.parameters['_search'] = { 151 | in: 'query', 152 | required: false, 153 | type: 'string', 154 | description: 'Search term' 155 | } 156 | #swagger.parameters['_ordering'] = { 157 | in: 'query', 158 | required: false, 159 | type: 'string', 160 | description: 'Ordering term' 161 | } 162 | */ 163 | const { _search, _ordering } = req.query; 164 | 165 | let query = `SELECT name FROM sqlite_master WHERE type IN ('table', 'view')`; 166 | 167 | // if search is provided, search the tables 168 | // e.g. ?_search=users 169 | if (_search) { 170 | query += ` AND name LIKE $searchQuery`; 171 | } 172 | 173 | // if ordering is provided, order the tables 174 | // e.g. ?_ordering=name (ascending) or ?_ordering=-name (descending) 175 | if (_ordering) { 176 | query += ` ORDER BY $ordering`; 177 | } 178 | 179 | try { 180 | const tables = db.prepare(query).all({ 181 | searchQuery: `%${_search}%`, 182 | ordering: `${_ordering?.replace('-', '')} ${ 183 | _ordering?.startsWith('-') ? 'DESC' : 'ASC' 184 | }`, 185 | }); 186 | 187 | res.json({ 188 | data: tables, 189 | }); 190 | } catch (error) { 191 | res.status(400).json({ 192 | message: error.message, 193 | error: error, 194 | }); 195 | } 196 | }; 197 | 198 | // TODO: Return the schema of a table by name 199 | const getTableSchema = async (req, res) => { 200 | /* 201 | #swagger.tags = ['Tables'] 202 | #swagger.summary = 'Get Table Schema' 203 | #swagger.description = 'Endpoint to get the schema of a table' 204 | #swagger.parameters['name'] = { 205 | in: 'path', 206 | required: true, 207 | type: 'string', 208 | description: 'Name of the table' 209 | } 210 | 211 | */ 212 | const { name: tableName } = req.params; 213 | const query = `PRAGMA table_info(${tableName})`; 214 | try { 215 | const schema = db.prepare(query).all(); 216 | 217 | res.json({ 218 | data: schema, 219 | }); 220 | } catch (error) { 221 | res.status(400).json({ 222 | message: error.message, 223 | error: error, 224 | }); 225 | } 226 | }; 227 | 228 | // Delete a table by name 229 | const deleteTable = async (req, res) => { 230 | /* 231 | #swagger.tags = ['Tables'] 232 | #swagger.summary = 'Delete Table' 233 | #swagger.description = 'Endpoint to delete a table' 234 | #swagger.parameters['name'] = { 235 | in: 'path', 236 | required: true, 237 | type: 'string', 238 | description: 'Name of the table' 239 | } 240 | 241 | */ 242 | const { name: tableName } = req.params; 243 | const query = `DROP TABLE ${tableName}`; 244 | try { 245 | const data = db.prepare(query).run(); 246 | 247 | res.json({ 248 | message: 'Table deleted', 249 | data, 250 | }); 251 | } catch (error) { 252 | res.status(400).json({ 253 | message: error.message, 254 | error: error, 255 | }); 256 | } 257 | }; 258 | 259 | module.exports = { 260 | listTables, 261 | createTable, 262 | getTableSchema, 263 | deleteTable, 264 | }; 265 | -------------------------------------------------------------------------------- /docs/auth.md: -------------------------------------------------------------------------------- 1 | ## User Authentication in Soul 2 | 3 | Soul incorporates a robust user authentication system that handles user accounts, groups, permissions, and cookie-based user sessions. This section provides an overview of how the default implementation works. 4 | 5 | Authentication is switched off by default in Soul, but is enabled when either of the `-a` or `--auth` flags are provided at the command line. 6 | 7 | ### Overview 8 | 9 | The Soul authentication system handles both authentication and authorization. Briefly, authentication verifies a user is who they claim to be, and authorization determines what an authenticated user is allowed to do. Here, the term "authentication" is used to refer to both tasks. 10 | 11 | The auth system consists of: 12 | 13 | - Users 14 | - Roles and Permissions: A generic way of applying labels and permissions to more than one user. 15 | - A password hashing system 16 | - APIs for logging in users or restricting content 17 | - JWT based access tokens 18 | 19 | The authentication system in Soul aims to be very generic and doesn't provide some features commonly found in web authentication systems, such as: 20 | 21 | - Password strength checking 22 | - Throttling of login attempts 23 | - Authentication against third-parties (OAuth, for example) 24 | - Object-level permissions 25 | 26 | ![auth-db-diagram](./auth-db-diagram.png) 27 | 28 |
Figure 1: Soul Authentication Database Diagram
29 | 30 | #### Users 31 | 32 | The `_users` table serves as the central component of the authentication system. Each row in the `_users` table represents an individual who interacts with your Soul instance. This table is crucial for enabling various functionalities, such as access restriction, and user registration. 33 | 34 | **_Superusers_**, distinguished by the `is_superuser` attribute set to true, are a special type of user in Soul. They can bypass any permissions and restrictions, effectively granting them "God-mode" within the system, including being able to access the `/api/tables` and `/api` end-points. Authorization limitations do not apply to superusers. 35 | 36 | The attributes of the `_users` table are: 37 | 38 | - id int 39 | - username varchar 40 | - \_hashed_password varchar 41 | - \_salt varchar 42 | - is_superuser boolean 43 | - created_at datetime 44 | - updated_at datetime 45 | 46 | Note that when Soul boots up, it looks for a table called `_users` (Otherwise Soul creates `_users` table) which holds the Users mentioned above. 47 | 48 | ##### Modifying Superusers 49 | 50 | Due to the sensitive nature of the superuser status, it is not possible to change the `is_superuser` attribute of a user through the API. 51 | 52 | Instead, you can update superusers using the command line. 53 | 54 | ``` 55 | $ node src/server.js updateuser --id=1 --password=newstrongstring // To update password of a superuser with id 1 56 | $ node src/server.js updateuser --id=1 --is_superuser=true // To promote a user with id 1 to superuser 57 | ``` 58 | 59 | #### Roles 60 | 61 | Roles are a generic way of categorizing users so you can assign permissions to those users. A user can belong to any number of roles. 62 | 63 | If we have an `editor` role, and a `_roles_permissions` entry for the `posts` table that allows `update`, then any user with that role will be able to update `posts`. 64 | 65 | The attributes of the `_roles` table are: 66 | 67 | - id int 68 | - name varchar 69 | - created_at datetime 70 | - updated_at datetime 71 | 72 | ##### Roles Permissions 73 | 74 | Using a table called `_roles_permissions` we can assign permissions to roles. 75 | 76 | The attributes of this table are: 77 | 78 | - id int 79 | - role_id int 80 | - table_name varchar 81 | - create boolean 82 | - read boolean 83 | - update boolean 84 | - delete boolean 85 | 86 | There is unique constraint on the combination of `role_id` and `table_name` attributes, 87 | to prevent duplication of permissions for the same role and table. 88 | 89 | ###### Default Role 90 | 91 | Once a new table is created, Soul will automatically create a new `_roles_permissions` row for the `default` role and the new table, with the following permissions: 92 | 93 | - create: false 94 | - read: true 95 | - update: false 96 | - delete: false 97 | 98 | Which basically means that any user can read the table data, but can't create, update or delete data. 99 | 100 | Soul uses the `default` role to assign permissions to new users. 101 | 102 | The same happens when Soul boots up, but for all existing tables, making sure that all tables have the `default` role assigned to them. 103 | 104 | ##### Users Roles 105 | 106 | To assign roles to users we have a join table called `_users_roles` with the following attributes: 107 | 108 | - id int 109 | - user_id int 110 | - role_id int 111 | 112 | #### Authentication 113 | 114 | Soul uses cookies and middleware to hook the authentication system into request objects. 115 | 116 | These provide a `req.user` attribute on every request, which represents the current user. If the current user has not logged in, it is set to null. 117 | 118 | #### Obtain Access Token 119 | 120 | To be able to use private APIs, users need to obtain an access token, which is a JWT token consisting of this payload: 121 | 122 | - username 123 | - is_superuser 124 | - roles 125 | 126 | For security reasons, Access tokens have a very short lifetime, and once expired, they can be refreshed using another API called Refresh Access token. Refresh tokens have a much longer lifetime, and both access and refresh tokens are provided to the user upon logging in. 127 | 128 | #### Register New Users 129 | 130 | To register new users, you need to create a new user using the `/api/tables/_users/rows/` endpoint, and then assign roles to that user using the `/api/tables/_users_roles/rows/` endpoint. 131 | Note that you need to be logged in using a user with a role that has creating users permission. 132 | 133 | Additionally, it's important to note that the `/api/tables/_users/rows/` endpoint functions slightly differently compared to other `/api/tables//rows/` endpoints. When creating or updating user data through this endpoint, we need to provide the raw passwords, which are then automatically hashed before being stored in the `_hashed_password` field. This extra step enhances the security of the stored passwords. 134 | 135 | When creating a user, the required fields are `username` and `password`. However, you also have the flexibility to include additional optional fields. To do this, you will need to modify the schema of the `_users` table in your database using a suitable database editor GUI tool. Simply add the desired field(s) to the database schema for the `_users` table. Once the schema is updated, you can pass the optional field(s) from your client application during user creation. 136 | 137 | Furthermore, when retrieving user data, the endpoint automatically filters out sensitive information such as the `_hashed_password` and `_salt` fields. This precautionary measure is in place to address security concerns and ensure that only necessary and non-sensitive information is included in the returned results. 138 | 139 | #### Revoked Refresh Tokens 140 | 141 | When you send a request to log in to Soul using the `/token/obtain` endpoint, you will receive `Access` and `Refresh` tokens. These tokens can be stored in a secure client environment. You can then send a request to the `/token/refresh` endpoint to refresh your access token when it expires. 142 | 143 | When you log out of Soul, the `Access` and `Refresh` tokens are removed. However, there might be instances where you log out before the refresh token expires, allowing these tokens to be reused by other users. To handle this issue, Soul saves removed refresh tokens temporarily in a table named `revoked_access_tokens`. Each time you attempt to refresh your access token, Soul checks if the `Refresh` token you are using is valid. If you try to reuse a revoked refresh token, Soul will throw an error. 144 | -------------------------------------------------------------------------------- /src/controllers/auth/token.js: -------------------------------------------------------------------------------- 1 | const { authService } = require('../../services'); 2 | const { responseMessages, authConstants } = require('../../constants'); 3 | const config = require('../../config'); 4 | const { 5 | comparePasswords, 6 | generateToken, 7 | decodeToken, 8 | toBoolean, 9 | } = require('../../utils'); 10 | 11 | const { successMessage, errorMessage } = responseMessages; 12 | 13 | const obtainAccessToken = async (req, res) => { 14 | /* 15 | #swagger.tags = ['Auth'] 16 | #swagger.summary = 'Obtain Access Token' 17 | #swagger.description = 'Endpoint to generate access and refresh tokens' 18 | 19 | #swagger.parameters['body'] = { 20 | in: 'body', 21 | required: true, 22 | type: 'object', 23 | schema: { $ref: '#/definitions/ObtainAccessTokenRequestBody' } 24 | } 25 | */ 26 | 27 | // extract payload 28 | const { username, password } = req.body.fields; 29 | 30 | try { 31 | // check if the username exists in the Db 32 | const users = authService.getUsersByUsername({ username }); 33 | 34 | if (users.length <= 0) { 35 | return res 36 | .status(401) 37 | .send({ message: errorMessage.INVALID_USERNAME_PASSWORD_ERROR }); 38 | } 39 | 40 | // check if the password is valid 41 | const user = users[0]; 42 | const isMatch = await comparePasswords(password, user.hashed_password); 43 | 44 | if (!isMatch) { 45 | return res 46 | .status(401) 47 | .send({ message: errorMessage.INVALID_USERNAME_PASSWORD_ERROR }); 48 | /* 49 | #swagger.responses[401] = { 50 | description: 'Invalid username or password error', 51 | schema: { 52 | $ref: '#/definitions/InvalidCredentialErrorResponse' 53 | } 54 | } 55 | */ 56 | } 57 | 58 | let roleIds; 59 | 60 | // if the user is not a superuser get the role and its permission from the DB 61 | if (!toBoolean(user.is_superuser)) { 62 | try { 63 | const roleData = getUsersRoleAndPermission({ 64 | userId: user.id, 65 | res, 66 | }); 67 | 68 | roleIds = roleData.roleIds; 69 | } catch (err) { 70 | return res 71 | .status(401) 72 | .send({ message: errorMessage.ROLE_NOT_FOUND_ERROR }); 73 | } 74 | } 75 | 76 | const payload = { 77 | username: user.username, 78 | userId: user.id, 79 | isSuperuser: user.is_superuser, 80 | roleIds, 81 | }; 82 | 83 | // generate an access token 84 | const accessToken = await generateToken( 85 | { subject: authConstants.ACCESS_TOKEN_SUBJECT, ...payload }, 86 | config.tokenSecret, 87 | config.accessTokenExpirationTime, 88 | ); 89 | 90 | // generate a refresh token 91 | const refreshToken = await generateToken( 92 | { subject: authConstants.REFRESH_TOKEN_SUBJECT, ...payload }, 93 | config.tokenSecret, 94 | config.refreshTokenExpirationTime, 95 | ); 96 | 97 | // set the token in the cookie 98 | let cookieOptions = { httpOnly: true, secure: false, Path: '/' }; 99 | res.cookie(authConstants.ACCESS_TOKEN_SUBJECT, accessToken, cookieOptions); 100 | res.cookie( 101 | authConstants.REFRESH_TOKEN_SUBJECT, 102 | refreshToken, 103 | cookieOptions, 104 | ); 105 | 106 | res 107 | .status(201) 108 | .send({ message: successMessage.SUCCESS, data: { userId: user.id } }); 109 | 110 | /* 111 | #swagger.responses[201] = { 112 | description: 'Access token and Refresh token generated', 113 | schema: { 114 | $ref: '#/definitions/ObtainAccessTokenSuccessResponse' 115 | } 116 | } 117 | */ 118 | } catch (error) { 119 | console.log(error); 120 | return res.status(500).json({ 121 | message: errorMessage.SERVER_ERROR, 122 | }); 123 | } 124 | }; 125 | 126 | const refreshAccessToken = async (req, res) => { 127 | /* 128 | #swagger.tags = ['Auth'] 129 | #swagger.summary = 'Refresh Access Token' 130 | #swagger.description = 'Endpoint to refresh access and refresh tokens' 131 | */ 132 | const refreshTokenFromCookies = req.cookies.refreshToken; 133 | 134 | try { 135 | // check if the refresh token is revoked 136 | if (isRefreshTokenRevoked({ refreshToken: refreshTokenFromCookies })) { 137 | return res 138 | .status(403) 139 | .send({ message: errorMessage.INVALID_REFRESH_TOKEN_ERROR }); 140 | } 141 | 142 | // extract the payload from the token and verify it 143 | const payload = await decodeToken( 144 | refreshTokenFromCookies, 145 | config.tokenSecret, 146 | ); 147 | 148 | // find the user 149 | const users = authService.getUsersById({ userId: payload.userId }); 150 | 151 | if (users.length <= 0) { 152 | return res 153 | .status(401) 154 | .send({ message: errorMessage.USER_NOT_FOUND_ERROR }); 155 | 156 | /* 157 | #swagger.responses[401] = { 158 | description: 'User not found error', 159 | schema: { 160 | $ref: '#/definitions/UserNotFoundErrorResponse' 161 | } 162 | } 163 | */ 164 | } 165 | 166 | let roleIds; 167 | const user = users[0]; 168 | 169 | // if the user is not a superuser get the role and its permission from the DB 170 | if (!toBoolean(user.is_superuser)) { 171 | try { 172 | const roleData = getUsersRoleAndPermission({ 173 | userId: user.id, 174 | }); 175 | 176 | roleIds = roleData.roleIds; 177 | } catch (err) { 178 | return res 179 | .status(401) 180 | .send({ message: errorMessage.ROLE_NOT_FOUND_ERROR }); 181 | } 182 | } 183 | 184 | const newPayload = { 185 | username: user.username, 186 | userId: user.id, 187 | isSuperuser: user.is_superuser, 188 | roleIds, 189 | }; 190 | 191 | // generate an access token 192 | const accessToken = await generateToken( 193 | { subject: authConstants.ACCESS_TOKEN_SUBJECT, ...newPayload }, 194 | config.tokenSecret, 195 | config.accessTokenExpirationTime, 196 | ); 197 | 198 | // generate a refresh token 199 | const refreshToken = await generateToken( 200 | { subject: authConstants.REFRESH_TOKEN_SUBJECT, ...newPayload }, 201 | config.tokenSecret, 202 | config.refreshTokenExpirationTime, 203 | ); 204 | 205 | // set the token in the cookie 206 | let cookieOptions = { httpOnly: true, secure: false, Path: '/' }; 207 | res.cookie(authConstants.ACCESS_TOKEN_SUBJECT, accessToken, cookieOptions); 208 | res.cookie( 209 | authConstants.REFRESH_TOKEN_SUBJECT, 210 | refreshToken, 211 | cookieOptions, 212 | ); 213 | 214 | res 215 | .status(200) 216 | .send({ message: successMessage.SUCCESS, data: { userId: user.id } }); 217 | 218 | /* 219 | #swagger.responses[200] = { 220 | description: 'Access token refreshed', 221 | schema: { 222 | $ref: '#/definitions/RefreshAccessTokenSuccessResponse' 223 | } 224 | } 225 | */ 226 | } catch (error) { 227 | res.status(403).send({ message: errorMessage.INVALID_REFRESH_TOKEN_ERROR }); 228 | /* 229 | #swagger.responses[401] = { 230 | description: 'Invalid refresh token error', 231 | schema: { 232 | $ref: '#/definitions/InvalidRefreshTokenErrorResponse' 233 | } 234 | } 235 | */ 236 | } 237 | }; 238 | 239 | const removeTokens = async (req, res) => { 240 | /* 241 | #swagger.tags = ['Auth'] 242 | #swagger.summary = 'Remove Tokens' 243 | #swagger.description = 'Endpoint to remove access and refresh tokens' 244 | */ 245 | 246 | const refreshToken = req.cookies.refreshToken; 247 | 248 | try { 249 | // decode the token 250 | const payload = await decodeToken(refreshToken, config.tokenSecret); 251 | 252 | // store the refresh token in the _revoked_refresh_tokens table 253 | authService.saveRevokedRefreshToken({ 254 | refreshToken, 255 | expiresAt: payload.exp, 256 | }); 257 | 258 | // remove the token from the cookie 259 | res.clearCookie(authConstants.ACCESS_TOKEN_SUBJECT); 260 | res.clearCookie(authConstants.REFRESH_TOKEN_SUBJECT); 261 | 262 | res 263 | .status(200) 264 | .send({ message: responseMessages.successMessage.LOGOUT_MESSAGE }); 265 | 266 | /* 267 | #swagger.responses[200] = { 268 | description: 'Tokens Removed', 269 | schema: { 270 | $ref: '#/definitions/RemoveTokensResponse' 271 | } 272 | } 273 | */ 274 | } catch (error) { 275 | res.status(500).send({ message: errorMessage.SERVER_ERROR }); 276 | } 277 | }; 278 | 279 | const removeRevokedRefreshTokens = () => { 280 | authService.deleteRevokedRefreshTokens({ 281 | lookupField: `WHERE expires_at < CURRENT_TIMESTAMP`, 282 | }); 283 | }; 284 | 285 | const getUsersRoleAndPermission = ({ userId }) => { 286 | const userRoles = authService.getUserRoleByUserId({ userId }); 287 | 288 | if (userRoles.length <= 0) { 289 | throw new Error(errorMessage.ROLE_NOT_FOUND_ERROR); 290 | } 291 | 292 | const roleIds = userRoles.map((role) => role.role_id); 293 | 294 | // get the permission of the role 295 | const permissions = authService.getPermissionByRoleIds({ roleIds }); 296 | 297 | return { userRoles, roleIds, permissions }; 298 | }; 299 | 300 | const isRefreshTokenRevoked = ({ refreshToken }) => { 301 | const tokens = authService.getRevokedRefreshToken({ refreshToken }); 302 | return tokens.length > 0; 303 | }; 304 | 305 | module.exports = { 306 | obtainAccessToken, 307 | refreshAccessToken, 308 | removeTokens, 309 | removeRevokedRefreshTokens, 310 | }; 311 | -------------------------------------------------------------------------------- /src/controllers/auth/user.js: -------------------------------------------------------------------------------- 1 | const { rowService, authService } = require('../../services'); 2 | const { 3 | apiConstants, 4 | dbConstants, 5 | responseMessages, 6 | authConstants, 7 | } = require('../../constants'); 8 | const config = require('../../config'); 9 | const { 10 | hashPassword, 11 | checkPasswordStrength, 12 | comparePasswords, 13 | } = require('../../utils'); 14 | 15 | const { USERS_TABLE, USERS_ROLES_TABLE, tableFields } = dbConstants; 16 | 17 | const { SALT_ROUNDS } = authConstants; 18 | 19 | const { successMessage, errorMessage, infoMessage } = responseMessages; 20 | 21 | const updateSuperuser = async (fields) => { 22 | const { id, password, is_superuser } = fields; 23 | let newHashedPassword, newSalt; 24 | let fieldsString = ''; 25 | 26 | try { 27 | // find the user by using the id field 28 | const users = authService.getUsersById({ userId: id }); 29 | 30 | // abort if the id is invalid 31 | if (users.length === 0) { 32 | console.log(errorMessage.USER_NOT_FOUND_ERROR); 33 | process.exit(1); 34 | } 35 | 36 | // check if the is_superuser field is passed 37 | if (is_superuser !== undefined) { 38 | fieldsString = `${tableFields.IS_SUPERUSER} = '${is_superuser}'`; 39 | } 40 | 41 | // if the password is sent from the CLI, update it 42 | if (password) { 43 | // check if the password is weak 44 | if ( 45 | [apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes( 46 | checkPasswordStrength(password), 47 | ) 48 | ) { 49 | console.log(errorMessage.WEAK_PASSWORD_ERROR); 50 | process.exit(1); 51 | } 52 | 53 | //hash the password 54 | const { hashedPassword, salt } = await hashPassword( 55 | password, 56 | SALT_ROUNDS, 57 | ); 58 | newHashedPassword = hashedPassword; 59 | newSalt = salt; 60 | 61 | fieldsString = `${fieldsString ? fieldsString + ', ' : ''} ${ 62 | tableFields.HASHED_PASSWORD 63 | } = '${newHashedPassword}', ${tableFields.SALT} = '${newSalt}'`; 64 | } 65 | 66 | // update the user 67 | rowService.update({ 68 | tableName: USERS_TABLE, 69 | lookupField: tableFields.ID, 70 | fieldsString, 71 | pks: `${id}`, 72 | }); 73 | 74 | console.log(successMessage.USER_UPDATE_SUCCESS); 75 | process.exit(1); 76 | } catch (error) { 77 | console.log(error); 78 | } 79 | }; 80 | 81 | const registerUser = async (req, res) => { 82 | /* 83 | #swagger.tags = ['Auth'] 84 | #swagger.summary = 'Register User' 85 | #swagger.description = 'Endpoint to signup' 86 | 87 | #swagger.parameters['username'] = { 88 | in: 'body', 89 | required: true, 90 | type: 'object', 91 | schema: { $ref: '#/definitions/UserRegistrationRequestBody' } 92 | } 93 | */ 94 | 95 | const { username, password, ...optionalFields } = req.body.fields; 96 | 97 | try { 98 | if (!username) { 99 | return res 100 | .status(400) 101 | .send({ message: errorMessage.USERNAME_REQUIRED_ERROR }); 102 | } 103 | 104 | if (!password) { 105 | return res 106 | .status(400) 107 | .send({ message: errorMessage.PASSWORD_REQUIRED_ERROR }); 108 | } 109 | 110 | // check if the username is taken 111 | const users = authService.getUsersByUsername({ username }); 112 | 113 | if (users.length > 0) { 114 | return res 115 | .status(409) 116 | .send({ message: errorMessage.USERNAME_TAKEN_ERROR }); 117 | 118 | /* 119 | #swagger.responses[409] = { 120 | description: 'Username taken error', 121 | schema: { 122 | $ref: '#/definitions/UsernameTakenErrorResponse' 123 | } 124 | } 125 | */ 126 | } 127 | 128 | // check if the password is weak 129 | if ( 130 | [apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes( 131 | checkPasswordStrength(password), 132 | ) 133 | ) { 134 | return res.status(400).send({ 135 | message: errorMessage.WEAK_PASSWORD_ERROR, 136 | }); 137 | 138 | /* 139 | #swagger.responses[400] = { 140 | description: 'Weak password error', 141 | schema: { 142 | $ref: '#/definitions/WeakPasswordErrorResponse' 143 | } 144 | } 145 | */ 146 | } 147 | 148 | // hash the password 149 | const { salt, hashedPassword } = await hashPassword(password, SALT_ROUNDS); 150 | 151 | // create the user 152 | const newUser = rowService.save({ 153 | tableName: USERS_TABLE, 154 | fields: { 155 | username, 156 | salt, 157 | hashed_password: hashedPassword, 158 | is_superuser: 'false', 159 | ...optionalFields, 160 | }, 161 | }); 162 | 163 | // find the default role from the DB 164 | const defaultRole = authService.getDefaultRole(); 165 | 166 | if (defaultRole.length <= 0) { 167 | return res.status(500).send({ 168 | message: errorMessage.DEFAULT_ROLE_NOT_CREATED_ERROR, 169 | }); 170 | /* 171 | #swagger.responses[500] = { 172 | description: 'Server error', 173 | schema: { 174 | $ref: '#/definitions/DefaultRoleNotCreatedErrorResponse' 175 | } 176 | } 177 | */ 178 | } 179 | 180 | // create a role for the user 181 | rowService.save({ 182 | tableName: USERS_ROLES_TABLE, 183 | fields: { user_id: newUser.lastInsertRowid, role_id: defaultRole[0].id }, 184 | }); 185 | 186 | res.status(201).send({ message: successMessage.ROW_INSERTED }); 187 | 188 | /* 189 | #swagger.responses[201] = { 190 | description: 'Row inserted', 191 | schema: { 192 | $ref: '#/definitions/InsertRowSuccessResponse' 193 | } 194 | } 195 | */ 196 | } catch (error) { 197 | console.log(error); 198 | res.status(500).send({ message: errorMessage.SERVER_ERROR }); 199 | } 200 | }; 201 | 202 | const changePassword = async (req, res) => { 203 | /* 204 | #swagger.tags = ['Auth'] 205 | #swagger.summary = 'Change Password' 206 | #swagger.description = 'Endpoint to change a password' 207 | 208 | #swagger.parameters['body'] = { 209 | in: 'body', 210 | required: true, 211 | type: 'object', 212 | schema: { 213 | $ref: '#/definitions/ChangePasswordRequestBody' 214 | } 215 | } 216 | */ 217 | 218 | const userInfo = req.user; 219 | const { currentPassword, newPassword } = req.body.fields; 220 | 221 | try { 222 | // get the user from the Db 223 | const users = authService.getUsersById({ userId: userInfo.userId }); 224 | 225 | if (users.length <= 0) { 226 | return res 227 | .status(401) 228 | .send({ message: errorMessage.USER_NOT_FOUND_ERROR }); 229 | } 230 | 231 | const user = users[0]; 232 | 233 | // check if the users current password is valid 234 | const isMatch = await comparePasswords( 235 | currentPassword, 236 | user.hashed_password, 237 | ); 238 | 239 | if (!isMatch) { 240 | return res 241 | .status(401) 242 | .send({ message: errorMessage.INVALID_CURRENT_PASSWORD_ERROR }); 243 | /* 244 | #swagger.responses[401] = { 245 | description: 'User not found error', 246 | schema: { 247 | $ref: '#/definitions/InvalidPasswordErrorResponse' 248 | } 249 | } 250 | */ 251 | } 252 | 253 | // check if the new password is strong 254 | if ( 255 | [apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes( 256 | checkPasswordStrength(newPassword), 257 | ) 258 | ) { 259 | return res.status(400).send({ 260 | message: errorMessage.WEAK_PASSWORD_ERROR, 261 | }); 262 | 263 | /* 264 | #swagger.responses[400] = { 265 | description: 'Weak password error', 266 | schema: { 267 | $ref: '#/definitions/WeakPasswordErrorResponse' 268 | } 269 | } 270 | */ 271 | } 272 | 273 | // hash the password 274 | const { salt, hashedPassword } = await hashPassword( 275 | newPassword, 276 | SALT_ROUNDS, 277 | ); 278 | 279 | user.salt = salt; 280 | user.hashed_password = hashedPassword; 281 | 282 | // update the user 283 | rowService.update({ 284 | tableName: USERS_TABLE, 285 | lookupField: tableFields.ID, 286 | fieldsString: `${tableFields.HASHED_PASSWORD} = '${hashedPassword}', ${tableFields.SALT} = '${salt}'`, 287 | pks: `${user.id}`, 288 | }); 289 | 290 | res.status(200).send({ 291 | message: successMessage.PASSWORD_UPDATE_SUCCESS, 292 | data: { id: user.id, username: user.username }, 293 | }); 294 | 295 | /* 296 | #swagger.responses[200] = { 297 | description: 'Weak password error', 298 | schema: { 299 | $ref: '#/definitions/ChangePasswordSuccessResponse' 300 | } 301 | } 302 | */ 303 | } catch (error) { 304 | res.status(500).send({ message: errorMessage.SERVER_ERROR }); 305 | } 306 | }; 307 | 308 | const createInitialUser = async () => { 309 | // extract some fields from the environment variables or from the CLI 310 | const { initialUserUsername: username, initialUserPassword: password } = 311 | config; 312 | 313 | try { 314 | // check if there are users in the DB 315 | const users = authService.getAllUsers(); 316 | 317 | if (users.length <= 0) { 318 | // check if initial users username is passed from the env or CLI 319 | if (!username) { 320 | console.error(errorMessage.INITIAL_USER_USERNAME_NOT_PASSED_ERROR); 321 | process.exit(1); 322 | } 323 | 324 | // check if initial users password is passed from the env or CLI 325 | if (!password) { 326 | console.error(errorMessage.INITIAL_USER_PASSWORD_NOT_PASSED_ERROR); 327 | process.exit(1); 328 | } 329 | 330 | // check if the usernmae is taken 331 | const users = authService.getUsersByUsername({ username }); 332 | 333 | if (users.length > 0) { 334 | console.error(errorMessage.USERNAME_TAKEN_ERROR); 335 | process.exit(1); 336 | } 337 | 338 | // check if the password is strong 339 | if ( 340 | [apiConstants.PASSWORD.TOO_WEAK, apiConstants.PASSWORD.WEAK].includes( 341 | checkPasswordStrength(password), 342 | ) 343 | ) { 344 | console.error(errorMessage.WEAK_PASSWORD_ERROR); 345 | process.exit(1); 346 | } 347 | 348 | // hash the password 349 | const { hashedPassword, salt } = await hashPassword( 350 | password, 351 | SALT_ROUNDS, 352 | ); 353 | 354 | // create the initial user 355 | const { lastInsertRowid: userId } = rowService.save({ 356 | tableName: USERS_TABLE, 357 | fields: { 358 | username, 359 | hashed_password: hashedPassword, 360 | salt, 361 | is_superuser: 'false', 362 | }, 363 | }); 364 | 365 | // get the default role from the DB 366 | const roles = authService.getDefaultRole(); 367 | 368 | if (roles.length <= 0) { 369 | console.log(errorMessage.DEFAULT_ROLE_NOT_CREATED_ERROR); 370 | process.exit(1); 371 | } 372 | 373 | const defaultRoleId = roles[0].id; 374 | 375 | // create a _users_role for the initial user 376 | rowService.save({ 377 | tableName: USERS_ROLES_TABLE, 378 | fields: { user_id: userId, role_id: defaultRoleId }, 379 | }); 380 | 381 | console.log(successMessage.INITIAL_USER_CREATED_SUCCESS); 382 | } else { 383 | console.log(infoMessage.INITIAL_USER_ALREADY_CREATED); 384 | } 385 | } catch (error) { 386 | console.log(error); 387 | } 388 | }; 389 | 390 | module.exports = { 391 | updateSuperuser, 392 | registerUser, 393 | changePassword, 394 | createInitialUser, 395 | }; 396 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | A SQLite REST and Realtime server 5 |

6 |

7 | 8 | [![justforfunnoreally.dev badge](https://img.shields.io/badge/justforfunnoreally-dev-9ff)](https://justforfunnoreally.dev) 9 | [![All Contributors](https://img.shields.io/github/all-contributors/thevahidal/soul?color=ee8449&style=flat-square)](#contributors) 10 | 11 | ## Usage 12 | 13 | ### Docker 14 | 15 | > This is the preferred method as the dependency [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) is making of lot of assumptions about the system it will be installed and running on which in most cases will lead to errors when installing soul. 16 | 17 | Using the following Dockerfile: 18 | 19 |
20 | Dockerfile 21 | 22 | ```nginx 23 | # node:19-alpine amd64 24 | FROM node@sha256:d0ba7111bc031323ce2706f8e424afc868db289ba40ff55b05561cf59c123be1 AS node 25 | 26 | WORKDIR /app 27 | 28 | ENV NODE_ENV="production" 29 | 30 | COPY package-lock.json package.json ./ 31 | 32 | RUN apk update && apk add python3=3.11.10-r1 build-base=0.5-r3 && npm ci 33 | 34 | COPY . . 35 | 36 | CMD [ "npm", "start" ] 37 | ``` 38 |
39 | 40 | You can proceed [to building the application](https://docs.docker.com/get-started/workshop/02_our_app/#build-the-apps-image). 41 | 42 | ### npm 43 | 44 | ```bash 45 | npm install -g soul-cli 46 | ``` 47 | 48 | ### 1. Running Soul 49 | 50 | Soul is command line tool, after installing it, 51 | Run `soul -d sqlite.db -p 8000` and it'll start a REST API on [http://localhost:8000](http://localhost:8000) and a Websocket server on [ws://localhost:8000](ws://localhost:8000). 52 | 53 | ```bash 54 | Usage: soul [options] 55 | 56 | 57 | Options: 58 | --version Show version number [boolean] 59 | -d, --database SQLite database file or :memory: [string] [required] 60 | -p, --port Port to listen on [number] 61 | -r, --rate-limit-enabled Enable rate limiting [boolean] 62 | -c, --cors CORS whitelist origins [string] 63 | --env, --envpath Path to load .env file [string] 64 | -a, --auth Enable authentication and authorization [boolean] 65 | 66 | --iuu, --initialuserusername Initial user username [string] 67 | --iup, --initialuserpassword Initial user password [string] 68 | 69 | --ts, --tokensecret Token Secret [string] 70 | --atet, --accesstokenexpirationtime Access Token Expiration Time (Default: 5H) [string] 71 | --rtet, --refreshtokenexpirationtime Refresh Token Expiration Time (Default: 1D) [string] 72 | -S, --studio Start Soul Studio in parallel 73 | --help Show help 74 | 75 | ``` 76 | 77 | Then to test Soul is working run the following command 78 | 79 | ```bash 80 | curl http://localhost:8000/api/tables 81 | ``` 82 | 83 | It should return a list of the tables inside `sqlite.db` database. 84 | 85 | ### 2. Running Soul in Auth mode 86 | 87 | To run Soul in auth mode, allowing login and signup features with authorization capabilities in your database tables, follow these steps: 88 | 89 | Run the Soul command with the necessary parameters: 90 | 91 | ``` 92 | 93 | soul -d foobar.db -a --ts= --atet=4H --rtet=3D --iuu=john --iup= 94 | 95 | ``` 96 | 97 | > Note: When configuring your JWT Secret, it is recommended to use a long string value for enhanced security. It is advisable to use a secret that is at least 10 characters in length. 98 | 99 | In this example: 100 | 101 | - The `-a` flag instructs Soul to run in auth mode. 102 | - The `--ts` flag allows you to pass a JWT secret value for the `access and refresh tokens` generation and verification. Replace with your desired secret value. 103 | - The `--atet` flag sets the JWT expiration time for the access token. In this case, it is set to four hours (4H), meaning the token will expire after 4 hours. 104 | - The `--rtet` flag sets the JWT expiration time for the refresh token. In this case, it is set to three days (3D), meaning the token will expire after 3 days. 105 | - The `--iuu` flag is used to pass a username for the initial user 106 | - The `--iup` flag is used to pass a password for the initial user 107 | 108 | Here are some example values for the `atet` and `rtet` flags 109 | 110 | - 60M: Represents a duration of 60 minutes. 111 | - 5H: Represents a duration of 5 hours. 112 | - 1D: Represents a duration of 1 day. 113 | 114 | NOTE: It is crucial to securely store a copy of the `--ts`(`Token Secret`) value used in Soul. Once you pass this values, make sure to keep a backup because you will need it every time you restart Soul. Losing this secret values can result in a situation where all of your users are blocked from accessing Soul. 115 | 116 | ### 3. Updating Super Users 117 | 118 | To modify a superuser information in a database, you can utilize the `updatesuperuser` command. This command allows you to change a superuser's `password` or upgrade/downgrade a normal user to a `superuser`. Below is an example of how to use it: 119 | 120 | ``` 121 | soul -d foobar.db updatesuperuser --id=1 password= // Update the password for the superuser with ID 1 122 | 123 | soul -d foobar.db updatesuperuser --id=1 --is_superuser=true // Upgrade the user with ID 1 to a superuser 124 | 125 | soul -d foobar.db updatesuperuser --id=1 --is_superuser=false // Revoke the superuser role from the superuser with ID 1 126 | ``` 127 | 128 | ### Passing Custom Path for .env File 129 | 130 | There might be cases where you want to pass a custom path for your `.env` file. For this, you can use the `--env` flag when running the `soul` command, providing the absolute file path of your `.env` file. 131 | 132 | ```shell 133 | soul -d foobar.db --env=/absolute/path/of/your/.env/file 134 | ``` 135 | 136 | NOTE: 137 | 138 | - You should pass an absolute file path of the .env file. 139 | - Relative paths are not allowed. 140 | - Don't forget to include the .env file in the specified path. 141 | 142 | ## Documentation 143 | 144 | API documentation is available while the project is running at [http://localhost:8000/api/docs](http://localhost:8000/api/docs) 145 | 146 | There's also a list of all endpoints examples at [docs/api-examples.md](docs/api-examples.md) 147 | 148 | For websocket examples, check [docs/ws-examples.md](docs/ws-examples.md) 149 | 150 | For detailed information on how authentication works in Soul, please refer to the [docs/auth.md](docs/auth.md) 151 | 152 | ## Extending Soul 153 | 154 | Soul is able to be extended (e.g. Adding custom APIs) via extensions, you can find a list of extensions at [docs/extensions-examples.md](docs/extensions-examples.md) 155 | 156 | ## Soul-mates 157 | 158 | A collection of projects that revolve around the Soul ecosystem. 159 | 160 | - [Soul Studio](https://github.com/thevahidal/soul-studio) provides a GUI to work with your database. 161 | 162 | Right now Soul Studio is in early stages of development and not useful to work with. 163 | 164 |

165 | 166 |

167 | 168 | - [RCO-Soul](https://github.com/DeepBlueCLtd/RCO-Soul) The purpose of this project is to demonstrate how to run a React admin client using Soul as a REST API service. 169 | 170 | - [dber](https://github.com/findyourmagic/dber) Database design tool based on entity relation diagram 171 | 172 | ## Development 173 | 174 | ```bash 175 | git clone https://github.com/thevahidal/soul # Clone project 176 | 177 | cp .env.sample .env # Duplicate sample environment variables 178 | vim .env # Update the environment variables 179 | 180 | npm install # Install dependencies 181 | npm run dev # Start the dev server 182 | ``` 183 | 184 | ## Testing 185 | 186 | 1. Set the `AUTH` variable to true in your `.env` file. 187 | 2. Provide a username for the `INITIAL_USER_USERNAME` environment variable. The username should be a valid, meaningful username. 188 | 3. Provide a strong password for the `INITIAL_USER_PASSWORD` environment variable. The password should be at least 8 characters long and contain a combination of lowercase letters, uppercase letters, numbers, and special characters, for example: "Str0ng$Pw!". 189 | 4. Provider a secret for the `TOKEN_SECRET` environment variable. 190 | 5. Use the following command to run the tests: 191 | 192 | ``` 193 | npm run test 194 | ``` 195 | 196 | Make sure to replace the placeholders with the appropriate values for your environment. 197 | 198 | ## Hosting Static Content Using Soul 199 | 200 | You can host static content using Soul by utilizing its extensions feature. This allows you to expose your static application through Soul, enabling users to access your content without hosting multiple applications. please check [this](./docs/self-hosting.md) document 201 | 202 | ## Community 203 | 204 | [Join](https://bit.ly/soul-discord) the discussion in our Discord server and help making Soul together. 205 | 206 | ## License 207 | 208 | [MIT](https://choosealicense.com/licenses/mit/) 209 | 210 | ## Contributing 211 | 212 | Contributions are always welcome! 213 | 214 | See `CONTRIBUTING.md` for ways to get started and please adhere to `CODE OF CONDUCT`. 215 | 216 | ## Contributors ✨ 217 | 218 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 |
Vahid Al
Vahid Al

💻 👀
Abenezer Melkamu
Abenezer Melkamu

💻
Ian Mayo
Ian Mayo

💻 👀
Hanz
Hanz

💻
Koen De Groote
Koen De Groote

💻
Muhammad Taha Khan
Muhammad Taha Khan

💻
Romain Lebesle
Romain Lebesle

💻
236 | 237 | 238 | 239 | 240 | 241 | 242 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. 243 | -------------------------------------------------------------------------------- /src/controllers/rows.test.js: -------------------------------------------------------------------------------- 1 | const { not } = require('joi'); 2 | const supertest = require('supertest'); 3 | 4 | const app = require('../index'); 5 | const config = require('../config'); 6 | const { generateToken } = require('../utils'); 7 | 8 | const requestWithSupertest = supertest(app); 9 | 10 | function queryString(params) { 11 | const queryString = Object.keys(params) 12 | .map((key) => `${key}=${params[key]}`) 13 | .join('&'); 14 | 15 | return queryString; 16 | } 17 | 18 | describe('Rows Endpoints', () => { 19 | it('GET /tables/:name/rows should return a list of all rows', async () => { 20 | const accessToken = await generateToken( 21 | { username: 'John', isSuperuser: true }, 22 | config.tokenSecret, 23 | '1H', 24 | ); 25 | 26 | const res = await requestWithSupertest 27 | .get('/api/tables/users/rows') 28 | .set('Cookie', [`accessToken=${accessToken}`]); 29 | 30 | expect(res.status).toEqual(200); 31 | expect(res.type).toEqual(expect.stringContaining('json')); 32 | expect(res.body).toHaveProperty('data'); 33 | expect(res.body.data).toEqual(expect.any(Array)); 34 | expect(res.body.data[0]).toHaveProperty('id'); 35 | expect(res.body.data[0]).toHaveProperty('firstName'); 36 | expect(res.body.data[0]).toHaveProperty('lastName'); 37 | }); 38 | 39 | it('GET /tables/:name/rows?_limit=8&_schema=firstName,lastName&_ordering:-firstName&_page=2: should query the rows by the provided query params', async () => { 40 | const accessToken = await generateToken( 41 | { username: 'John', isSuperuser: true }, 42 | config.tokenSecret, 43 | '1H', 44 | ); 45 | 46 | const params = { 47 | _search: 'a', 48 | _ordering: '-firstName', 49 | _schema: 'firstName,lastName', 50 | _limit: 8, 51 | _page: 2, 52 | }; 53 | const query = queryString(params); 54 | const res = await requestWithSupertest 55 | .get(`/api/tables/users/rows?${query}`) 56 | .set('Cookie', [`accessToken=${accessToken}`]); 57 | 58 | expect(res.status).toEqual(200); 59 | expect(res.type).toEqual(expect.stringContaining('json')); 60 | expect(res.body).toHaveProperty('data'); 61 | expect(res.body.data).toEqual(expect.any(Array)); 62 | expect(res.body.data[0]).toHaveProperty('firstName'); 63 | expect(res.body.data[0]).toHaveProperty('lastName'); 64 | 65 | expect(res.body.next).toEqual( 66 | `/tables/users/rows?${queryString({ 67 | ...params, 68 | _page: params._page + 1, 69 | }).toString()}`, 70 | ); 71 | 72 | expect(res.body.previous).toEqual( 73 | `/tables/users/rows?${queryString({ 74 | ...params, 75 | _page: params._page - 1, 76 | }).toString()}`, 77 | ); 78 | }); 79 | 80 | it('GET /tables/:name/rows?_ordering:userId.firstName&_extend=userId: should return the rows ordered by the related table field', async () => { 81 | const accessToken = await generateToken( 82 | { username: 'John', isSuperuser: true }, 83 | config.tokenSecret, 84 | '1H', 85 | ); 86 | 87 | const params = { 88 | _ordering: 'userId.firstName', 89 | _extend: 'userId', 90 | }; 91 | const query = queryString(params); 92 | const res = await requestWithSupertest 93 | .get(`/api/tables/posts/rows?${query}`) 94 | .set('Cookie', [`accessToken=${accessToken}`]); 95 | 96 | expect(res.status).toEqual(200); 97 | expect(res.type).toEqual(expect.stringContaining('json')); 98 | expect(res.body).toHaveProperty('data'); 99 | expect(res.body.data).toEqual(expect.any(Array)); 100 | 101 | const firstUserFirstName = res.body.data[1].userId_data.firstName; 102 | const secondUserFirstName = res.body.data[2].userId_data.firstName; 103 | const thirdUserFirstName = res.body.data[3].userId_data.firstName; 104 | 105 | expect(firstUserFirstName <= secondUserFirstName).toBe(true); 106 | expect(secondUserFirstName <= thirdUserFirstName).toBe(true); 107 | }); 108 | 109 | it('GET /tables/:name/rows: should return a null field', async () => { 110 | const accessToken = await generateToken( 111 | { username: 'John', isSuperuser: true }, 112 | config.tokenSecret, 113 | '1H', 114 | ); 115 | 116 | const res = await requestWithSupertest 117 | .get('/api/tables/users/rows?_filters=firstName__null,lastName__notnull') 118 | .set('Cookie', [`accessToken=${accessToken}`]); 119 | 120 | expect(res.status).toEqual(200); 121 | expect(res.body.data[0].firstName).toBeNull(); 122 | expect(res.body.data[0].lastName).not.toBeNull(); 123 | }); 124 | 125 | it('GET /tables/:name/rows: should successfully retrieve users created after 2010-01-01 00:00:00.', async () => { 126 | const accessToken = await generateToken( 127 | { username: 'John', isSuperuser: true }, 128 | config.tokenSecret, 129 | '1H', 130 | ); 131 | 132 | const date = '2010-01-01 00:00:00'; 133 | const res = await requestWithSupertest 134 | .get(`/api/tables/users/rows?_filters=createdAt__gte:${date}`) 135 | .set('Cookie', [`accessToken=${accessToken}`]); 136 | 137 | res.body.data.map((user) => { 138 | const createdAt = new Date(user.createdAt); 139 | const referenceDate = new Date(date); 140 | expect(createdAt.getTime()).toBeGreaterThan(referenceDate.getTime()); 141 | }); 142 | 143 | expect(res.status).toEqual(200); 144 | expect(res.body.data[0]).toHaveProperty('id'); 145 | expect(res.body.data[0]).toHaveProperty('firstName'); 146 | expect(res.body.data[0]).toHaveProperty('lastName'); 147 | expect(res.body.data[0]).toHaveProperty('createdAt'); 148 | }); 149 | 150 | it('GET /tables/:name/rows: should successfully retrieve users created before 2008-01-20 00:00:00.', async () => { 151 | const accessToken = await generateToken( 152 | { username: 'John', isSuperuser: true }, 153 | config.tokenSecret, 154 | '1H', 155 | ); 156 | 157 | const date = '2008-01-20 00:00:00'; 158 | const res = await requestWithSupertest 159 | .get(`/api/tables/users/rows?_filters=createdAt__lte:${date}`) 160 | .set('Cookie', [`accessToken=${accessToken}`]); 161 | 162 | res.body.data.map((user) => { 163 | const createdAt = new Date(user.createdAt); 164 | const referenceDate = new Date(date); 165 | expect(createdAt.getTime()).toBeLessThan(referenceDate.getTime()); 166 | }); 167 | 168 | expect(res.status).toEqual(200); 169 | expect(res.body.data[0]).toHaveProperty('id'); 170 | expect(res.body.data[0]).toHaveProperty('firstName'); 171 | expect(res.body.data[0]).toHaveProperty('lastName'); 172 | expect(res.body.data[0]).toHaveProperty('createdAt'); 173 | }); 174 | 175 | it('GET /tables/:name/rows: should successfully retrieve users created at 2013-01-08 00:00:00', async () => { 176 | const accessToken = await generateToken( 177 | { username: 'John', isSuperuser: true }, 178 | config.tokenSecret, 179 | '1H', 180 | ); 181 | 182 | const date = '2013-01-08 00:00:00'; 183 | const res = await requestWithSupertest 184 | .get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`) 185 | .set('Cookie', [`accessToken=${accessToken}`]); 186 | 187 | res.body.data.map((user) => { 188 | const createdAt = new Date(user.createdAt); 189 | const referenceDate = new Date(date); 190 | expect(createdAt.getTime()).toEqual(referenceDate.getTime()); 191 | }); 192 | 193 | expect(res.status).toEqual(200); 194 | expect(res.body.data[0]).toHaveProperty('id'); 195 | expect(res.body.data[0]).toHaveProperty('firstName'); 196 | expect(res.body.data[0]).toHaveProperty('lastName'); 197 | expect(res.body.data[0]).toHaveProperty('createdAt'); 198 | }); 199 | 200 | it('GET /tables/:name/rows: should successfully retrieve users created at 2007-01-08 00:00:00', async () => { 201 | const accessToken = await generateToken( 202 | { username: 'John', isSuperuser: true }, 203 | config.tokenSecret, 204 | '1H', 205 | ); 206 | 207 | const date = '2007-01-08 00:00:00'; 208 | const res = await requestWithSupertest 209 | .get(`/api/tables/users/rows?_filters=createdAt__eq:${date}`) 210 | .set('Cookie', [`accessToken=${accessToken}`]); 211 | 212 | //There are no users that are created at 2007-01-08 00:00:00 so the API should return empty data 213 | expect(res.body.data).toHaveLength(0); 214 | expect(res.status).toEqual(200); 215 | }); 216 | 217 | it('GET /tables/:name/rows: should successfully retrieve users that are not created at 2021-01-08 00:00:00', async () => { 218 | const accessToken = await generateToken( 219 | { username: 'John', isSuperuser: true }, 220 | config.tokenSecret, 221 | '1H', 222 | ); 223 | 224 | const date = '2021-01-08 00:00:00'; 225 | const res = await requestWithSupertest 226 | .get(`/api/tables/users/rows?_filters=createdAt__neq:${date}`) 227 | .set('Cookie', [`accessToken=${accessToken}`]); 228 | 229 | res.body.data.map((user) => { 230 | const createdAt = new Date(user.createdAt); 231 | const referenceDate = new Date(date); 232 | expect(createdAt.getTime()).not.toEqual(referenceDate.getTime()); 233 | }); 234 | 235 | expect(res.status).toEqual(200); 236 | expect(res.body.data[0]).toHaveProperty('id'); 237 | expect(res.body.data[0]).toHaveProperty('firstName'); 238 | expect(res.body.data[0]).toHaveProperty('lastName'); 239 | expect(res.body.data[0]).toHaveProperty('createdAt'); 240 | }); 241 | 242 | it('POST /tables/:name/rows should insert a new row and return the lastInsertRowid', async () => { 243 | const accessToken = await generateToken( 244 | { username: 'John', isSuperuser: true }, 245 | config.tokenSecret, 246 | '1H', 247 | ); 248 | 249 | const res = await requestWithSupertest 250 | .post('/api/tables/users/rows') 251 | .set('Cookie', [`accessToken=${accessToken}`]) 252 | .send({ fields: { firstName: 'Jane', lastName: 'Doe' } }); 253 | 254 | expect(res.status).toEqual(201); 255 | expect(res.type).toEqual(expect.stringContaining('json')); 256 | expect(res.body).toHaveProperty('data'); 257 | }); 258 | 259 | it('GET /tables/:name/rows/:pks should return a row by its primary key', async () => { 260 | const accessToken = await generateToken( 261 | { username: 'John', isSuperuser: true }, 262 | config.tokenSecret, 263 | '1H', 264 | ); 265 | 266 | const res = await requestWithSupertest 267 | .get('/api/tables/users/rows/1') 268 | .set('Cookie', [`accessToken=${accessToken}`]); 269 | 270 | expect(res.status).toEqual(200); 271 | expect(res.type).toEqual(expect.stringContaining('json')); 272 | expect(res.body).toHaveProperty('data'); 273 | expect(res.body.data[0]).toHaveProperty('id'); 274 | expect(res.body.data[0]).toHaveProperty('firstName'); 275 | expect(res.body.data[0]).toHaveProperty('lastName'); 276 | }); 277 | 278 | it('PUT /tables/:name/rows/:pks should update a row by its primary key and return the number of changes', async () => { 279 | const accessToken = await generateToken( 280 | { username: 'John', isSuperuser: true }, 281 | config.tokenSecret, 282 | '1H', 283 | ); 284 | const res = await requestWithSupertest 285 | .put('/api/tables/users/rows/1') 286 | .set('Cookie', [`accessToken=${accessToken}`]) 287 | .send({ fields: { firstName: 'Jane', lastName: 'Doe' } }); 288 | expect(res.status).toEqual(200); 289 | expect(res.type).toEqual(expect.stringContaining('json')); 290 | }); 291 | 292 | it('DELETE /tables/:name/rows/:pks should delete a row by its primary key and return the number of changes', async () => { 293 | const accessToken = await generateToken( 294 | { username: 'John', isSuperuser: true }, 295 | config.tokenSecret, 296 | '1H', 297 | ); 298 | 299 | const res = await requestWithSupertest 300 | .delete('/api/tables/posts/rows/1') 301 | .set('Cookie', [`accessToken=${accessToken}`]); 302 | 303 | expect(res.status).toEqual(200); 304 | expect(res.type).toEqual(expect.stringContaining('json')); 305 | }); 306 | 307 | it('POST /tables/:name/rows should insert a new row if any of the value of the object being inserted is null', async () => { 308 | const accessToken = await generateToken( 309 | { username: 'John', isSuperuser: true }, 310 | config.tokenSecret, 311 | '1H', 312 | ); 313 | const res = await requestWithSupertest 314 | .post('/api/tables/users/rows') 315 | .send({ 316 | fields: { 317 | firstName: null, 318 | lastName: 'Doe', 319 | email: null, 320 | username: 'Jane', 321 | }, 322 | }) 323 | .set('Cookie', [`accessToken=${accessToken}`]); 324 | expect(res.status).toEqual(201); 325 | expect(res.type).toEqual(expect.stringContaining('json')); 326 | expect(res.body).toHaveProperty('data'); 327 | }); 328 | 329 | it('GET /tables/:name/rows should return values if any of the IDs from the array match the user ID.', async () => { 330 | const accessToken = await generateToken( 331 | { username: 'John', isSuperuser: true }, 332 | config.tokenSecret, 333 | '1H', 334 | ); 335 | 336 | const res = await requestWithSupertest 337 | .get('/api/tables/users/rows?_filters=id:[2,3]') 338 | .set('Cookie', [`accessToken=${accessToken}`]); 339 | expect(res.status).toEqual(200); 340 | expect(res.body).toHaveProperty('data'); 341 | expect(res.body.data).toEqual(expect.any(Array)); 342 | expect(res.body.data.length).toEqual(2); 343 | }); 344 | 345 | it('GET /tables/:name/rows should return values if the provided ID matches the user ID.', async () => { 346 | const accessToken = await generateToken( 347 | { username: 'John', isSuperuser: true }, 348 | config.tokenSecret, 349 | '1H', 350 | ); 351 | 352 | const res = await requestWithSupertest 353 | .get( 354 | '/api/tables/users/rows?_filters=id:2,firstName:Michael,lastName:Lee', 355 | ) 356 | .set('Cookie', [`accessToken=${accessToken}`]); 357 | expect(res.status).toEqual(200); 358 | expect(res.body).toHaveProperty('data'); 359 | expect(res.body.data).toEqual(expect.any(Array)); 360 | expect(res.body.data.length).toEqual(1); 361 | }); 362 | }); 363 | -------------------------------------------------------------------------------- /src/controllers/auth.test.js: -------------------------------------------------------------------------------- 1 | const supertest = require('supertest'); 2 | 3 | const app = require('../index'); 4 | const config = require('../config'); 5 | const { generateToken } = require('../utils'); 6 | const { testData } = require('../tests/testData'); 7 | 8 | const requestWithSupertest = supertest(app); 9 | 10 | describe('Auth Endpoints', () => { 11 | describe('User Endpoints', () => { 12 | it('POST /tables/_users/rows should register a user', async () => { 13 | const accessToken = await generateToken( 14 | { username: 'John', userId: 1, isSuperuser: true }, 15 | config.tokenSecret, 16 | '1H', 17 | ); 18 | 19 | const res = await requestWithSupertest 20 | .post('/api/tables/_users/rows') 21 | .set('Cookie', [`accessToken=${accessToken}`]) 22 | .send({ 23 | fields: { 24 | username: testData.users.user1.username, 25 | password: testData.strongPassword, 26 | }, 27 | }); 28 | 29 | expect(res.status).toEqual(201); 30 | expect(res.type).toEqual(expect.stringContaining('json')); 31 | 32 | expect(res.body).toHaveProperty('message'); 33 | expect(res.body.message).toBe('Row Inserted'); 34 | 35 | expect(res.body).not.toHaveProperty('password'); 36 | expect(res.body).not.toHaveProperty('hashed_password'); 37 | expect(res.body).not.toHaveProperty('salt'); 38 | }); 39 | 40 | it('POST /tables/_users/rows should throw 400 error if username is not passed', async () => { 41 | const accessToken = await generateToken( 42 | { username: 'John', isSuperuser: true }, 43 | config.tokenSecret, 44 | '1H', 45 | ); 46 | 47 | const res = await requestWithSupertest 48 | .post('/api/tables/_users/rows') 49 | .set('Cookie', [`accessToken=${accessToken}`]) 50 | .send({ 51 | fields: { password: testData.strongPassword }, 52 | }); 53 | 54 | expect(res.status).toEqual(400); 55 | expect(res.body.message).toBe('username is required'); 56 | 57 | expect(res.body).not.toHaveProperty('password'); 58 | expect(res.body).not.toHaveProperty('hashed_password'); 59 | expect(res.body).not.toHaveProperty('salt'); 60 | }); 61 | 62 | it('POST /tables/_users/rows should throw 400 error if the password is not strong', async () => { 63 | const accessToken = await generateToken( 64 | { username: 'John', isSuperuser: true }, 65 | config.tokenSecret, 66 | '1H', 67 | ); 68 | 69 | const res = await requestWithSupertest 70 | .post('/api/tables/_users/rows') 71 | .set('Cookie', [`accessToken=${accessToken}`]) 72 | .send({ 73 | fields: { 74 | username: testData.users.user2.username, 75 | password: testData.weakPassword, 76 | }, 77 | }); 78 | 79 | expect(res.status).toEqual(400); 80 | expect(res.body.message).toBe( 81 | 'This password is weak, it should be at least 8 characters long and contain a combination of lowercase letters, uppercase letters, numbers, and special characters', 82 | ); 83 | 84 | expect(res.body).not.toHaveProperty('password'); 85 | expect(res.body).not.toHaveProperty('hashed_password'); 86 | expect(res.body).not.toHaveProperty('salt'); 87 | }); 88 | 89 | it('POST /tables/_users/rows should throw 409 error if the username is taken', async () => { 90 | const accessToken = await generateToken( 91 | { username: 'John', isSuperuser: true }, 92 | config.tokenSecret, 93 | '1H', 94 | ); 95 | 96 | const res = await requestWithSupertest 97 | .post('/api/tables/_users/rows') 98 | .set('Cookie', [`accessToken=${accessToken}`]) 99 | .send({ 100 | fields: { 101 | username: testData.users.user1.username, 102 | password: testData.strongPassword, 103 | }, 104 | }); 105 | 106 | expect(res.status).toEqual(409); 107 | expect(res.body.message).toBe('This username is taken'); 108 | 109 | expect(res.body).not.toHaveProperty('password'); 110 | expect(res.body).not.toHaveProperty('hashed_password'); 111 | expect(res.body).not.toHaveProperty('salt'); 112 | }); 113 | 114 | it('GET /tables/_users/rows should return list of users', async () => { 115 | const accessToken = await generateToken( 116 | { username: 'John', isSuperuser: true }, 117 | config.tokenSecret, 118 | '1H', 119 | ); 120 | 121 | const res = await requestWithSupertest 122 | .get('/api/tables/_users/rows') 123 | .set('Cookie', [`accessToken=${accessToken}`]); 124 | 125 | expect(res.status).toEqual(200); 126 | expect(res.body.data[0]).toHaveProperty('id'); 127 | expect(res.body.data[0]).toHaveProperty('username'); 128 | expect(res.body.data[0]).toHaveProperty('is_superuser'); 129 | expect(res.body.data[0]).toHaveProperty('createdAt'); 130 | 131 | expect(res.body.data[0]).not.toHaveProperty('password'); 132 | expect(res.body.data[0]).not.toHaveProperty('hashed_password'); 133 | expect(res.body.data[0]).not.toHaveProperty('salt'); 134 | }); 135 | 136 | it('GET /tables/_users/rows/:id should retrive a single user', async () => { 137 | const accessToken = await generateToken( 138 | { username: 'John', isSuperuser: true }, 139 | config.tokenSecret, 140 | '1H', 141 | ); 142 | 143 | const res = await requestWithSupertest 144 | .get('/api/tables/_users/rows/1') 145 | .set('Cookie', [`accessToken=${accessToken}`]); 146 | 147 | expect(res.status).toEqual(200); 148 | expect(res.body.data[0]).toHaveProperty('id'); 149 | expect(res.body.data[0]).toHaveProperty('username'); 150 | expect(res.body.data[0]).toHaveProperty('is_superuser'); 151 | expect(res.body.data[0]).toHaveProperty('createdAt'); 152 | 153 | expect(res.body.data[0]).not.toHaveProperty('password'); 154 | expect(res.body.data[0]).not.toHaveProperty('hashed_password'); 155 | expect(res.body.data[0]).not.toHaveProperty('salt'); 156 | }); 157 | 158 | it('PUT /tables/_users/rows/:id should update a user', async () => { 159 | const accessToken = await generateToken( 160 | { username: 'John', isSuperuser: true }, 161 | config.tokenSecret, 162 | '1H', 163 | ); 164 | 165 | const res = await requestWithSupertest 166 | .put('/api/tables/_users/rows/1') 167 | .set('Cookie', [`accessToken=${accessToken}`]) 168 | .send({ 169 | fields: { 170 | username: testData.users.user3.username, 171 | }, 172 | }); 173 | 174 | expect(res.status).toEqual(200); 175 | expect(res.type).toEqual(expect.stringContaining('json')); 176 | 177 | expect(res.body).toHaveProperty('message'); 178 | expect(res.body.message).toBe('Row updated'); 179 | 180 | expect(res.body).not.toHaveProperty('password'); 181 | expect(res.body).not.toHaveProperty('hashed_password'); 182 | expect(res.body).not.toHaveProperty('salt'); 183 | }); 184 | 185 | it('DELETE /tables/_users/rows/:id should remove a user', async () => { 186 | const accessToken = await generateToken( 187 | { username: 'John', isSuperuser: true }, 188 | config.tokenSecret, 189 | '1H', 190 | ); 191 | 192 | const res = await requestWithSupertest 193 | .delete('/api/tables/_users/rows/2') 194 | .set('Cookie', [`accessToken=${accessToken}`]); 195 | 196 | expect(res.status).toEqual(400); 197 | expect(res.body.message).toBe('FOREIGN KEY constraint failed'); 198 | 199 | expect(res.body).not.toHaveProperty('password'); 200 | expect(res.body).not.toHaveProperty('hashed_password'); 201 | expect(res.body).not.toHaveProperty('salt'); 202 | }); 203 | }); 204 | 205 | describe('Obtain Access Token Endpoint', () => { 206 | it('POST /auth/token/obtain should return an access token and refresh token values and a success message', async () => { 207 | const res = await requestWithSupertest 208 | .post('/api/auth/token/obtain') 209 | .send({ 210 | fields: { 211 | username: testData.users.user1.username, 212 | password: testData.strongPassword, 213 | }, 214 | }); 215 | 216 | expect(res.status).toEqual(201); 217 | expect(res.type).toEqual(expect.stringContaining('json')); 218 | expect(res.body).toHaveProperty('message'); 219 | expect(res.body.message).toBe('Success'); 220 | 221 | expect(res.headers['set-cookie']).toBeDefined(); 222 | expect(res.headers['set-cookie']).toEqual( 223 | expect.arrayContaining([ 224 | expect.stringContaining('refreshToken='), 225 | expect.stringContaining('accessToken='), 226 | ]), 227 | ); 228 | 229 | expect(res.body).not.toHaveProperty('password'); 230 | expect(res.body).not.toHaveProperty('hashed_password'); 231 | expect(res.body).not.toHaveProperty('salt'); 232 | }); 233 | 234 | it('POST /auth/token/obtain should throw a 401 error if the username does not exist in the DB', async () => { 235 | const res = await requestWithSupertest 236 | .post('/api/auth/token/obtain') 237 | .send({ 238 | fields: { 239 | username: testData.invalidUsername, 240 | password: testData.strongPassword, 241 | }, 242 | }); 243 | 244 | expect(res.status).toEqual(401); 245 | expect(res.type).toEqual(expect.stringContaining('json')); 246 | expect(res.body).toHaveProperty('message'); 247 | expect(res.body.message).toBe('Invalid username or password'); 248 | 249 | expect(res.body).not.toHaveProperty('password'); 250 | expect(res.body).not.toHaveProperty('hashed_password'); 251 | expect(res.body).not.toHaveProperty('salt'); 252 | }); 253 | 254 | it('POST /auth/token/obtain should throw a 401 error if the password is invalid', async () => { 255 | const res = await requestWithSupertest 256 | .post('/api/auth/token/obtain') 257 | .send({ 258 | fields: { 259 | username: testData.users.user1.username, 260 | password: testData.invalidPassword, 261 | }, 262 | }); 263 | 264 | expect(res.status).toEqual(401); 265 | expect(res.type).toEqual(expect.stringContaining('json')); 266 | expect(res.body).toHaveProperty('message'); 267 | expect(res.body.message).toBe('Invalid username or password'); 268 | 269 | expect(res.body).not.toHaveProperty('password'); 270 | expect(res.body).not.toHaveProperty('hashed_password'); 271 | expect(res.body).not.toHaveProperty('salt'); 272 | }); 273 | }); 274 | 275 | describe('Refresh Access Token Endpoint', () => { 276 | it('GET /auth/token/refresh should refresh the access and refresh tokens', async () => { 277 | const refreshToken = await generateToken( 278 | { username: 'John', userId: 1, isSuperuser: true }, 279 | config.tokenSecret, 280 | '1H', 281 | ); 282 | 283 | const res = await requestWithSupertest 284 | .get('/api/auth/token/refresh') 285 | .set('Cookie', [`refreshToken=${refreshToken}`]); 286 | 287 | expect(res.status).toEqual(200); 288 | expect(res.type).toEqual(expect.stringContaining('json')); 289 | expect(res.body).toHaveProperty('message'); 290 | expect(res.body.message).toBe('Success'); 291 | 292 | expect(res.headers['set-cookie']).toBeDefined(); 293 | expect(res.headers['set-cookie']).toEqual( 294 | expect.arrayContaining([ 295 | expect.stringContaining('refreshToken='), 296 | expect.stringContaining('accessToken='), 297 | ]), 298 | ); 299 | }); 300 | }); 301 | 302 | describe('Change Password Endpoint', () => { 303 | it('PUT /auth/change-password/ should change a password', async () => { 304 | const accessToken = await generateToken( 305 | { username: 'John', userId: 2, isSuperuser: true }, 306 | config.tokenSecret, 307 | '1H', 308 | ); 309 | 310 | const res = await requestWithSupertest 311 | .put('/api/auth/change-password') 312 | .set('Cookie', [`accessToken=${accessToken}`]) 313 | .send({ 314 | fields: { 315 | currentPassword: testData.strongPassword, 316 | newPassword: testData.strongPassword2, 317 | }, 318 | }); 319 | 320 | expect(res.status).toEqual(200); 321 | expect(res.type).toEqual(expect.stringContaining('json')); 322 | expect(res.body).toHaveProperty('message'); 323 | expect(res.body.message).toBe('Password updated successfully'); 324 | 325 | expect(res.body).not.toHaveProperty('password'); 326 | expect(res.body).not.toHaveProperty('hashed_password'); 327 | expect(res.body).not.toHaveProperty('salt'); 328 | 329 | // check if the password is really updated 330 | const res2 = await requestWithSupertest 331 | .post('/api/auth/token/obtain') 332 | .send({ 333 | fields: { 334 | username: testData.users.user1.username, 335 | password: testData.strongPassword2, 336 | }, 337 | }); 338 | 339 | expect(res2.status).toEqual(201); 340 | expect(res2.type).toEqual(expect.stringContaining('json')); 341 | expect(res2.body).toHaveProperty('message'); 342 | expect(res2.body.message).toBe('Success'); 343 | }); 344 | 345 | it('PUT /auth/change-password/ should throw 401 error if the current password is not valid', async () => { 346 | const accessToken = await generateToken( 347 | { username: 'John', userId: 2, isSuperuser: true }, 348 | config.tokenSecret, 349 | '1H', 350 | ); 351 | 352 | const res = await requestWithSupertest 353 | .put('/api/auth/change-password') 354 | .set('Cookie', [`accessToken=${accessToken}`]) 355 | .send({ 356 | fields: { 357 | currentPassword: testData.invalidPassword, 358 | newPassword: testData.strongPassword2, 359 | }, 360 | }); 361 | 362 | expect(res.status).toEqual(401); 363 | expect(res.type).toEqual(expect.stringContaining('json')); 364 | expect(res.body).toHaveProperty('message'); 365 | expect(res.body.message).toBe('Invalid current password'); 366 | 367 | expect(res.body).not.toHaveProperty('password'); 368 | expect(res.body).not.toHaveProperty('hashed_password'); 369 | expect(res.body).not.toHaveProperty('salt'); 370 | }); 371 | }); 372 | }); 373 | --------------------------------------------------------------------------------