├── 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 | 
9 |
10 | **Accessing a React Admin Application via Soul**
11 | 
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 | 
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 | [](https://justforfunnoreally.dev)
9 | [](#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 |
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 |
--------------------------------------------------------------------------------