├── .babelrc ├── .github └── workflows │ └── build.yaml ├── .gitignore ├── FUNDING.yml ├── LICENSE ├── README.md ├── assets ├── actiologo.png └── actioscreen.png ├── docs ├── README.md ├── auth.ts ├── basic.ts ├── decorators.md ├── docker-compose.yaml ├── dotenv ├── env.ts ├── example.test.ts ├── package-lock.json ├── package.json ├── seeding.md ├── system.ts ├── tsconfig.json └── unexposed.ts ├── examples └── README.md ├── jest.config.ts ├── jest.setup.ts ├── package-lock.json ├── package.json ├── src ├── api.test.ts ├── db.ts ├── env.ts ├── globalNamespace.test.ts ├── index.ts ├── injector.test.ts ├── injector.ts ├── microservices.test.ts ├── reflect.api.test.ts ├── reflect.api.ts ├── reflect.field.test.ts ├── reflect.field.ts ├── reflect.test.ts ├── reflect.ts ├── registrator.test.ts ├── registrator.ts ├── service │ ├── admin │ │ ├── index.ts │ │ ├── models.ts │ │ └── teardown.ts │ ├── authentication │ │ ├── README.md │ │ ├── authentication.test.ts │ │ ├── department │ │ │ └── departmentList.ts │ │ ├── index.ts │ │ ├── models.ts │ │ ├── oauth │ │ │ ├── facebookLogin.ts │ │ │ ├── oauthInfo.ts │ │ │ └── registerOrLoginWithProvenIdentity.ts │ │ ├── platform │ │ │ └── platformList.ts │ │ ├── role │ │ │ └── roleList.ts │ │ ├── token │ │ │ └── tokenRead.ts │ │ ├── user │ │ │ ├── passwordChange.ts │ │ │ ├── passwordChangeWithOld.ts │ │ │ ├── passwordSendReset.ts │ │ │ ├── tokenAdminGet.ts │ │ │ ├── userCreateOrganization.ts │ │ │ ├── userList.ts │ │ │ ├── userLogin.ts │ │ │ ├── userRegister.ts │ │ │ ├── userSave.ts │ │ │ ├── userSlugCheck.ts │ │ │ ├── userUnGhost.ts │ │ │ ├── verificationCodeSend.ts │ │ │ └── verificationCodeVerify.ts │ │ └── userSave.test.ts │ ├── config │ │ ├── README.md │ │ ├── config.test.ts │ │ ├── configRead.ts │ │ ├── configSave.ts │ │ ├── configSaveS2S.ts │ │ ├── index.ts │ │ ├── models.ts │ │ ├── secretRead.ts │ │ ├── secretSave.ts │ │ └── secretSaveS2S.ts │ ├── file │ │ ├── README.md │ │ ├── file.test.ts │ │ ├── fileUpload.ts │ │ ├── httpFileServe.ts │ │ ├── index.ts │ │ ├── models.ts │ │ └── testfile.png │ ├── keyvalue │ │ ├── README.md │ │ ├── get.ts │ │ ├── index.ts │ │ ├── kv.test.ts │ │ ├── list.ts │ │ ├── models.ts │ │ └── set.ts │ ├── payment │ │ ├── README.md │ │ ├── balance.ts │ │ ├── charges.ts │ │ ├── index.ts │ │ ├── models.ts │ │ ├── payWithBalance.ts │ │ ├── payment.test.ts │ │ ├── systemBalance.ts │ │ └── topup.ts │ └── system │ │ ├── README.md │ │ ├── apiRead.ts │ │ ├── index.ts │ │ ├── models.ts │ │ ├── nodes.test.ts │ │ └── nodesRead.ts ├── typeorm.ts └── util.ts ├── tsconfig.dev.json ├── tsconfig.json └── tslint.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-typescript" 5 | ], 6 | "plugins": [ 7 | ["@babel/plugin-proposal-decorators", { "legacy": true }], 8 | ["@babel/plugin-proposal-class-properties", { "loose": true }], 9 | ["@babel/plugin-proposal-private-methods", { "loose": true }], 10 | ["@babel/plugin-proposal-private-property-in-object", { "loose": true }] 11 | 12 | ] 13 | } -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: [push] 3 | 4 | jobs: 5 | backend-tests-run: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v3 10 | 11 | - name: Cache node modules 12 | id: cache 13 | uses: actions/cache@v3 14 | with: 15 | path: ./node_modules 16 | key: modules-${{ hashFiles('backend/package-lock.json') }} 17 | 18 | - name: Set up node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: "16" 22 | 23 | - name: Launch docker containers 24 | run: | 25 | cp docs/docker-compose.yaml . 26 | docker-compose up --detach 27 | npm install 28 | 29 | - name: Npm install 30 | if: steps.cache.outputs.cache-hit != 'true' 31 | run: | 32 | npm install 33 | 34 | - name: Set up Run backend tests 35 | run: | 36 | printf "CONNECTION_NAME=$CONNECTION_NAME\n" > .env 37 | printf "SQL_USER=$SQL_USER\n" >> .env 38 | printf "SQL_PASSWORD=$SQL_PASSWORD\n" >> .env 39 | printf "SQL_NAME=$SQL_NAME\n" >> .env 40 | cat .env 41 | cp src/service/file/testfile.png /tmp/ 42 | npm test 43 | env: 44 | CONNECTION_NAME: ${{ secrets.CONNECTION_NAME }} 45 | SQL_USER: ${{ secrets.SQL_USER }} 46 | SQL_PASSWORD: ${{ secrets.SQL_PASSWORD }} 47 | SQL_NAME: ${{ secrets.SQL_NAME }} 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | firebase-debug.log* 8 | firebase-debug.*.log* 9 | saved-data/ 10 | 11 | # Firebase cache 12 | .firebase/ 13 | 14 | # Firebase config 15 | 16 | # Uncomment this if you'd like others to create their own Firebase project. 17 | # For a team working on the same Firebase project(s), it is recommended to leave 18 | # it commented so all members can deploy to the same project(s) in .firebaserc. 19 | # .firebaserc 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (http://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variables file 67 | .env 68 | 69 | lib 70 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [crufter] 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

Actio

5 |

The Node.js framework for monoliths and microservices.

6 |

7 | License: AGPL v3 8 |

9 | 10 | Actio is a modern, batteries included Node.js (Typescript) framework for your backend applications. 11 | It enables you to effortlessly switch between monolithic and microservices architectures. 12 | 13 | Start out with a monolith and turn it into microservices without changing your code once you need to scale! 14 | 15 | [Get started](./docs/README.md). 16 | 17 | ```sh 18 | npm i -S @crufters/actio 19 | ``` 20 | 21 | ## Simple 22 | 23 | Actio values simplicity and elegance, because enjoying coding makes you more productive. 24 | 25 | ```typescript 26 | import { Service, Servicelike, startServer } from "@crufters/actio"; 27 | 28 | interface MyEndpointRequest { 29 | name?: string; 30 | } 31 | 32 | @Service() 33 | class MyService implements Servicelike { 34 | constructor() {} 35 | 36 | // this method will be exposed as an HTTP endpoint 37 | async myEndpoint(req: MyEndpointRequest) { 38 | return { hi: req.name }; 39 | } 40 | 41 | async _onInit() { 42 | console.log("MyService: _onInit runs whenever the server boots up."); 43 | } 44 | } 45 | 46 | startServer([MyService]); 47 | ``` 48 | 49 | ## Dependencies made easy 50 | 51 | Your services can easily call each other just by accepting a constructor parameter: 52 | 53 | ```ts 54 | @Service() 55 | class MyService implements Servicelike { 56 | constructor(otherService: MyOtherService) {} 57 | } 58 | ``` 59 | 60 | ## Monolith or microservices? Actio blurs the line 61 | 62 | Service calls are just function calls. Function calls become network calls simply by configuring Actio with envars: 63 | 64 | ``` 65 | Without configuration, service calls are just normal function calls: 66 | -------------------------------- 67 | | LoginService <-| <-| | 68 | | PaymentService ----| | | 69 | | OrderService ---------| | 70 | -------------------------------| 71 | instance address 72 | 0.0.0.0 73 | no Actio config 74 | 75 | 76 | With some lightweight configuration a true services based 77 | architecture can be achieved, without code changes: 78 | 79 | ------------------- ----------------- 80 | | PaymentService |-------------------> | LoginService | 81 | | OrderService |-------------------> | | 82 | ------------------- ----------------- 83 | instance address instance address 84 | 0.0.0.0 0.0.0.1 85 | envar LOGIN_SERVICE=0.0.0.1 86 | 87 | Calls to the login service become network calls automatically. 88 | ``` 89 | 90 | ## Batteries included 91 | 92 | Actio is batteries included: it comes with services that help you bootstrap your system (but tries to not force you to use these) faster: 93 | 94 | - [x] [Authentication service](./src/service/authentication/README.md) for login, register, oauth (facebook etc.). 95 | - [x] [KeyValue service](./src/service/keyvalue/README.md) for saving unstructured data without creating yet another anemic endpoint/service. 96 | - [x] [File service](./src/service/file/README.md) for file upload. Upload to a local disk or to Google Storage etc. in production. 97 | - [x] [Config service](./src/service/config/README.md) for handling public configuration and secret values. 98 | - [x] [System service](./src/service/system/README.md) for inspecting the Actio runtime and enabling building tools upon Actio (such as API explorers etc.). 99 | - [x] Payment service: a double entry ledger system with Stripe and other payment provider support. 100 | - [ ] ...and many others that the community might find useful. 101 | 102 | ## Built with infrastructure in mind 103 | 104 | Real world apps need persistence and many other infrastructure elements. 105 | Actio manages your infra dependencies just like your service dependencies. 106 | 107 | - [x] Postgres 108 | - [ ] Redis 109 | - [ ] Many more coming 110 | 111 | ## Testing without the hassle 112 | 113 | Run integration tests easily including all of your services and infrastructure dependencies. No need for mocking. 114 | 115 | ## Namespaced for server savings 116 | 117 | Actio enables you to run multiple projects from the same single server by namespaces. Save on server and maintenance cost. 118 | 119 | ## Firm service boundaries 120 | 121 | Actio isolates your services - no more sidestepping of service boundaries, be it intentional or accidental. 122 | Each service is a black box for other services, which enable you to reimplement services without breaking depending services. 123 | 124 | The dependency injector makes sure your service tables live in different databases entirely - joins won't work across services. 125 | This and similar constraints enable you a seamless and refactor free transition to microservices - but also ensures your monolith doesn't became overly tightly coupled. 126 | 127 | ## Examples and tutorials 128 | 129 | For examples and tutorials see the [Getting started guide](./docs/README.md). 130 | 131 | ## Credits 132 | 133 | Inspired by other microservices systems such as [Micro](https://github.com/micro/micro) and the author's previous work with Asim Aslam. 134 | Author: [János Dobronszki](https://github.com/crufter). 135 | Contributors: [Dávid Dobronszki](https://github.com/Dobika), [Asim Aslam](https://github.com/asim), [Viktor Veress](https://github.com/vvik91). 136 | -------------------------------------------------------------------------------- /assets/actiologo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/assets/actiologo.png -------------------------------------------------------------------------------- /assets/actioscreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/assets/actioscreen.png -------------------------------------------------------------------------------- /docs/auth.ts: -------------------------------------------------------------------------------- 1 | import "./env.js"; 2 | 3 | import { Service, startServer, AuthenticationService } from "@crufters/actio"; 4 | 5 | interface MyEndpointRequest { 6 | token: string; 7 | } 8 | 9 | @Service() 10 | class MyService { 11 | auth: AuthenticationService; 12 | 13 | constructor(auth: AuthenticationService) { 14 | this.auth = auth; 15 | } 16 | 17 | async myEndpoint(req: MyEndpointRequest) { 18 | let trsp = await this.auth.tokenRead({ token: req.token }); 19 | return { hi: trsp.token.user?.fullName }; 20 | } 21 | } 22 | 23 | startServer([MyService]); 24 | -------------------------------------------------------------------------------- /docs/basic.ts: -------------------------------------------------------------------------------- 1 | import { Service, Servicelike, startServer } from "@crufters/actio"; 2 | 3 | interface MyEndpointRequest { 4 | name?: string; 5 | } 6 | 7 | @Service() 8 | class MyService implements Servicelike { 9 | constructor() {} 10 | 11 | // this endpoint will be exposed as a http endpoint 12 | async myEndpoint(req: MyEndpointRequest) { 13 | return { hi: req.name }; 14 | } 15 | 16 | async _onInit() { 17 | console.log( 18 | "MyService: This callback runs when the server boots up. Perfect place to run do things like seeding the database." 19 | ); 20 | } 21 | } 22 | 23 | startServer([MyService]); 24 | -------------------------------------------------------------------------------- /docs/decorators.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](./README.md) 2 | # Decorators 3 | 4 | - [Decorators](#decorators) 5 | - [Unexposed](#unexposed) 6 | - [Raw](#raw) 7 | - [Field](#field) 8 | - [Param](#param) 9 | - [Endpoint](#endpoint) 10 | 11 | Quite a few decorators (`@Field`, `@Param` and `@Endpoint`) exists to help with reflection. For more information about that please see the [SystemService](../src/service/system/README.md). 12 | 13 | ## Unexposed 14 | 15 | The `@Unexposed` decorator makes your endpoint not accessible through HTTP. Your endpoint is still callable from other services as normal class methods. 16 | 17 | Prime example is `secretRead` in the [config service](../src/service/config/README.md): 18 | 19 | ```ts 20 | @Unexposed() 21 | secretRead(req: SecretReadRequest) { 22 | // ... 23 | } 24 | ``` 25 | 26 | ## Raw 27 | 28 | The `@Raw` decorator tells Actio that it should pass in raw HTTP request and response objects to your endpoints instead of just JSON request types: 29 | 30 | ```ts 31 | @Raw() 32 | httpFileUpload(req: express.Request, rsp: express.Response) { 33 | // ... 34 | } 35 | ``` 36 | 37 | ## Field 38 | 39 | The `@Field` decorator is meant to be used on class properties so the Actio runtime reflection knows about the class structure. 40 | 41 | ```ts 42 | export class PasswordChangeRequest { 43 | @Field() 44 | code?: string; 45 | @Field() 46 | newPassword?: string; 47 | } 48 | ``` 49 | 50 | ## Param 51 | 52 | The `@Param` decorator is used to specify the types contained in higher order types for endpoint parameters. 53 | 54 | Take this example: 55 | 56 | ```ts 57 | myEndpoint(@Param({ type: number }) req: number[]) {} 58 | ``` 59 | 60 | Since `Array` (`[]`) is a higher order type, Typescript reflection can't see the contained `number` type. `@Param` solves that. 61 | 62 | ## Endpoint 63 | 64 | The `@Endpoint` decorator is primarily used to specify the return type of an endpoint. 65 | 66 | Take this example: 67 | 68 | ```ts 69 | @Endpoint({ 70 | returns: number 71 | }) 72 | myEndpoint(): Promise 73 | ``` 74 | 75 | Since `Promise` is a higher order type, TypeScript reflection can't see the contained `number` type. Specifying the contained type with the `returns` parameter helps with that. 76 | -------------------------------------------------------------------------------- /docs/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | volumes: 4 | postgis-data: 5 | pgadmin: 6 | 7 | services: 8 | db: 9 | image: kartoza/postgis:14-3.2 10 | volumes: 11 | - postgis-data:/var/lib/postgresql 12 | environment: 13 | - POSTGRES_USER=postgres 14 | - POSTGRES_PASS=postgres 15 | # Add extensions you need to be enabled by default in the DB. Default are the five specified below 16 | - POSTGRES_MULTIPLE_EXTENSIONS=postgis,hstore,postgis_topology,postgis_raster,pgrouting 17 | ports: 18 | - "5432:5432" 19 | restart: on-failure 20 | healthcheck: 21 | test: "exit 0" 22 | 23 | # https://github.com/khezen/compose-postgres 24 | pgadmin: 25 | container_name: pgadmin4_container 26 | image: dpage/pgadmin4 27 | restart: always 28 | environment: 29 | PGADMIN_DEFAULT_EMAIL: admin@admin.com 30 | PGADMIN_DEFAULT_PASSWORD: postgres 31 | PGADMIN_CONFIG_SERVER_MODE: 'False' 32 | volumes: 33 | - pgadmin:/var/lib/pgadmin 34 | ports: 35 | - "5051:80" -------------------------------------------------------------------------------- /docs/dotenv: -------------------------------------------------------------------------------- 1 | CONNECTION_NAME=127.0.0.1 2 | SQL_USER=postgres 3 | SQL_PASSWORD=postgres 4 | SQL_NAME=postgres 5 | -------------------------------------------------------------------------------- /docs/env.ts: -------------------------------------------------------------------------------- 1 | // this dotenv import needs to be in a separate file due to 2 | // https://stackoverflow.com/questions/42817339/es6-import-happening-before-env-import 3 | import dotenv from "dotenv"; 4 | dotenv.config({ path: "./dotenv" }); 5 | -------------------------------------------------------------------------------- /docs/example.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { nanoid } from "nanoid"; 3 | import { Service, Injector } from "@crufters/actio"; 4 | import { DataSource, Entity, PrimaryColumn, Column } from "typeorm"; 5 | 6 | @Entity() 7 | export class KV { 8 | @PrimaryColumn() 9 | id?: string; 10 | 11 | @Column() 12 | value?: string; 13 | } 14 | 15 | interface SetRequest { 16 | key?: string; 17 | value?: string; 18 | } 19 | 20 | interface GetRequest { 21 | key?: string; 22 | } 23 | 24 | interface GetResponse { 25 | value?: string; 26 | } 27 | 28 | @Service() 29 | class MyService { 30 | meta = { 31 | typeorm: { 32 | entities: [KV], 33 | }, 34 | }; 35 | constructor(private db: DataSource) {} 36 | 37 | // this method will be exposed as an HTTP endpoint 38 | async set(req: SetRequest) { 39 | await this.db 40 | .createEntityManager() 41 | .save(KV, { id: req.key, value: req.value }); 42 | } 43 | 44 | async get(req: GetRequest): Promise { 45 | let v = await this.db 46 | .createQueryBuilder(KV, "kv") 47 | .where("kv.id = :id", { id: req.key }) 48 | .getOne(); 49 | return { value: v?.value }; 50 | } 51 | } 52 | 53 | describe("my test", () => { 54 | var myService: MyService; 55 | 56 | test("setup", async () => { 57 | let namespace = "t_" + nanoid().slice(0, 7); 58 | let i = new Injector([MyService]); 59 | myService = await i.getInstance("MyService", namespace); 60 | }); 61 | 62 | test("set get test", async () => { 63 | await myService.set({ key: "testkey", value: "testvalue" }); 64 | let rsp = await myService.get({ key: "testkey" }); 65 | expect(rsp.value).toBe("testvalue"); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "myproject", 3 | "version": "1.0.0", 4 | "description": "", 5 | "type": "module", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "echo \"Error: no test specified\" && exit 1" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "@types/express": "^4.17.17", 15 | "dotenv": "^16.0.3", 16 | "express": "^4.18.2" 17 | }, 18 | "devDependencies": { 19 | "@types/googlemaps": "^3.43.3", 20 | "typescript": "^5.0.2" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docs/seeding.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](./README.md) 2 | 3 | # Seeding and constants 4 | 5 | - [Seeding and constants](#seeding-and-constants) 6 | - [Constants](#constants) 7 | - [Seeding](#seeding) 8 | 9 | ## Constants 10 | 11 | In some of the service `models.ts` files the astute reader might spot constants similar to this: 12 | 13 | ```ts 14 | export const platformEmail = new Platform(); 15 | platformEmail.id = "h4HZYn7FPpbdgVHBk1byc"; 16 | platformEmail.slug = "email"; 17 | platformEmail.name = "Email"; 18 | 19 | export const platformFacebook = new Platform(); 20 | platformFacebook.id = "wy_upG9BnzHvRg2WcSNgC"; 21 | platformFacebook.slug = "facebook"; 22 | platformFacebook.name = "Facebook"; 23 | 24 | export const platforms = [ 25 | platformEmail, 26 | platformFacebook 27 | ]; 28 | ``` 29 | 30 | This pattern is used for values that: 31 | - do not change dynamically 32 | - requires custom code support, so can't really be dynamic 33 | - need to be (intra-service) referenced in the databased for strong guarantees 34 | 35 | While hardcoding the id is admittedly not elegant, it enables both the backend and the frontend to easily reference the constants by their id. 36 | 37 | There is no need to call a platform list endpoint to simply display a dropdown. 38 | 39 | These values should be saved in an `_onInit` function. 40 | 41 | ## Seeding 42 | 43 | @todo -------------------------------------------------------------------------------- /docs/system.ts: -------------------------------------------------------------------------------- 1 | import "./env.js"; 2 | 3 | import { 4 | SystemService, 5 | AuthenticationService, 6 | startServer, 7 | } from "@crufters/actio"; 8 | 9 | startServer([SystemService, AuthenticationService]); 10 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "outDir": "build", 6 | "rootDir": "./", 7 | "strict": true, 8 | "noImplicitAny": true, 9 | "moduleResolution": "node", 10 | "esModuleInterop": true, 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/unexposed.ts: -------------------------------------------------------------------------------- 1 | import { Service, startServer, Unexposed } from "@crufters/actio"; 2 | 3 | interface MyEndpointRequest { 4 | name?: string; 5 | } 6 | 7 | @Service() 8 | class MyService { 9 | constructor() {} 10 | 11 | // this method will be exposed as an HTTP endpoint 12 | async myEndpoint(req: MyEndpointRequest) { 13 | return { hi: req.name }; 14 | } 15 | 16 | // this method WILL NOT be exposed as an HTTP endpoint 17 | @Unexposed() 18 | async notMyEndpoint(req: MyEndpointRequest) { 19 | return { hi: req.name }; 20 | } 21 | } 22 | 23 | startServer([MyService]); 24 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | This was moved to the [docs folder](../docs/README.md) 2 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | bail: true, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/tmp/jest_rs", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | // preset: undefined, 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | setupFiles: ["/jest.setup.ts"], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: ["/jest.setup.ts"], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | // testEnvironment: "jest-environment-node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | testMatch: ["**/lib/**/*.test.js"], 157 | 158 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 159 | // testPathIgnorePatterns: [ 160 | // "/node_modules/" 161 | // ], 162 | 163 | // The regexp pattern or array of patterns that Jest uses to detect test files 164 | // testRegex: [], 165 | 166 | // This option allows the use of a custom results processor 167 | // testResultsProcessor: undefined, 168 | 169 | // This option allows use of a custom test runner 170 | // testRunner: "jest-circus/runner", 171 | 172 | // A map from regular expressions to paths to transformers 173 | // transform: undefined, 174 | 175 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 176 | // transformIgnorePatterns: [ 177 | // "/node_modules/", 178 | // "\\.pnp\\.[^\\/]+$" 179 | // ], 180 | 181 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 182 | // unmockedModulePathPatterns: undefined, 183 | 184 | // Indicates whether each individual test should be reported during the run 185 | // verbose: undefined, 186 | 187 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 188 | // watchPathIgnorePatterns: [], 189 | 190 | // Whether to use watchman for file crawling 191 | // watchman: true, 192 | }; 193 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import dotenv from "dotenv"; 4 | 5 | dotenv.config({ path: "./.env" }); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@crufters/actio", 3 | "version": "0.3.6", 4 | "type": "module", 5 | "description": "Actio is a lightweight, batteries included Node.js framework for your backend applications.", 6 | "main": "lib/index.js", 7 | "types": "lib/index.d.ts", 8 | "license": "AGPLv3", 9 | "files": [ 10 | "lib/**/*" 11 | ], 12 | "scripts": { 13 | "lint": "./node_modules/.bin/tslint -p tsconfig.json", 14 | "build": "./node_modules/.bin/tsc", 15 | "serve": "npm run build && node ./lib/index.js", 16 | "pretty": "prettier --write \"./**/*.{ts,json,html,css,sass,scss}\"", 17 | "deploy": "", 18 | "logs": "", 19 | "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", 20 | "test": "$(rm -r ./lib/* || true) && ./node_modules/.bin/tsc && jest" 21 | }, 22 | "dependencies": { 23 | "@google-cloud/storage": "^6.10.1", 24 | "@sendgrid/mail": "^7.7.0", 25 | "@types/bcrypt": "^5.0.0", 26 | "@types/busboy": "^1.5.0", 27 | "@types/geojson": "^7946.0.10", 28 | "@types/slug": "^5.0.3", 29 | "@types/stripe-v3": "^3.1.28", 30 | "axios": "^1.4.0", 31 | "bcrypt": "^5.1.0", 32 | "busboy": "^1.6.0", 33 | "chalk": "^4.1.2", 34 | "dotenv": "^16.0.3", 35 | "express": "^4.18.2", 36 | "lodash": "^4.17.21", 37 | "nanoid": "^3.0.0", 38 | "reflect-metadata": "^0.1.13", 39 | "sinon": "^15.1.0", 40 | "slug": "^8.2.2", 41 | "stripe": "^12.6.0", 42 | "superagent": "^8.0.9", 43 | "ts-node": "^10.9.1", 44 | "typeorm": "^0.3.16" 45 | }, 46 | "devDependencies": { 47 | "@babel/plugin-proposal-class-properties": "^7.18.6", 48 | "@babel/plugin-proposal-decorators": "^7.21.0", 49 | "@babel/plugin-proposal-private-methods": "^7.18.6", 50 | "@babel/plugin-proposal-private-property-in-object": "^7.21.0", 51 | "@babel/preset-env": "^7.21.5", 52 | "@babel/preset-typescript": "^7.21.5", 53 | "@types/express": "^4.17.17", 54 | "@types/lodash": "^4.14.194", 55 | "@types/node": "^20.2.3", 56 | "@types/node-cron": "^3.0.7", 57 | "@types/superagent": "^4.1.17", 58 | "@types/supertest": "^2.0.12", 59 | "form-data": "^4.0.0", 60 | "javascript-obfuscator": "^4.0.2", 61 | "jest": "^29.5.0", 62 | "prettier": "^2.8.8", 63 | "supertest": "^6.3.3", 64 | "ts-jest": "^29.1.0", 65 | "tslint": "^5.20.1", 66 | "typescript": "^5.0.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/api.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | 3 | import { createApp } from "./registrator.js"; 4 | import { Service } from "./reflect.js"; 5 | import { default as request } from "supertest"; 6 | import _ from "lodash"; 7 | 8 | @Service() 9 | class InvalidJSON { 10 | constructor() {} 11 | 12 | async hey() { 13 | return 1; 14 | } 15 | } 16 | 17 | test("invalid json", async () => { 18 | let appA = createApp([InvalidJSON]); 19 | 20 | let response = await request(appA).post("/InvalidJSON/hey").send(null); 21 | expect(response.status).toBe(200); 22 | expect(response.body).toEqual(1); 23 | }); 24 | -------------------------------------------------------------------------------- /src/db.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DataSource, 3 | DataSourceOptions, 4 | MixedList, 5 | EntitySchema, 6 | } from "typeorm"; 7 | 8 | import env from "./env.js"; 9 | 10 | let defaultConnectionOptions: DataSourceOptions = { 11 | name: "deflt", 12 | type: "postgres", 13 | host: env.postgres.connectionName, 14 | username: env.postgres.dbUser, 15 | password: env.postgres.dbPassword, 16 | database: env.postgres.dbName, 17 | // logging: true, 18 | synchronize: true, 19 | }; 20 | 21 | interface ConnectorCallbacks { 22 | /** Invoked right after the database is created ie. 23 | * after the CREATE DATABASE command. 24 | */ 25 | typeormDbCreated?: (conn: DataSource) => void; 26 | /** Invoked right after connecting to the database. 27 | */ 28 | typeormConnectionInitialized?: (conn: DataSource) => void; 29 | } 30 | 31 | /** Connector manages database connections. 32 | */ 33 | export class Connector { 34 | log = false; 35 | private connections: Map = new Map(); 36 | private callbacks: ConnectorCallbacks = {}; 37 | 38 | constructor(callbacks: ConnectorCallbacks) { 39 | this.callbacks = callbacks; 40 | } 41 | 42 | /** Connect to a postgres database and return 43 | * a typeorm connection. 44 | * It creates databases if they don't exist. 45 | */ 46 | public connect = async ( 47 | namespace: string, 48 | entities: MixedList> 49 | ): Promise => { 50 | let finalNamespace = namespace 51 | .replace(".", "_") 52 | .replace("-", "_") 53 | .toLowerCase(); 54 | if (this.connections.has(finalNamespace)) { 55 | return this.connections.get(finalNamespace); 56 | } 57 | 58 | let copts: DataSourceOptions = { 59 | ...defaultConnectionOptions, 60 | name: finalNamespace as any, 61 | database: finalNamespace as any, 62 | entities: entities, 63 | }; 64 | 65 | let connection: DataSource; 66 | 67 | try { 68 | connection = await this.connectTo(copts); 69 | } catch (err) { 70 | // is it a database not exist error? 71 | if (err.toString().includes("does not exist")) { 72 | // connect to the default database to create the other databases 73 | let c = await this.connectTo(defaultConnectionOptions); 74 | try { 75 | this.log && console.log("creating database", finalNamespace); 76 | await c.query("CREATE DATABASE " + finalNamespace); 77 | this.log && console.log("created database", finalNamespace); 78 | //await sleep(100); 79 | } catch (e) { 80 | this.log && console.log(e); 81 | } 82 | connection = await this.connectTo(copts); 83 | if (this.callbacks?.typeormDbCreated != undefined) { 84 | this.callbacks.typeormDbCreated(connection); 85 | } 86 | } else { 87 | throw err; 88 | } 89 | } 90 | return connection; 91 | }; 92 | 93 | // connect to the default database 94 | private connectTo = async ( 95 | connOpts: DataSourceOptions 96 | ): Promise => { 97 | let dbName = connOpts.database as string; 98 | let connection = new DataSource(connOpts); 99 | 100 | if (!connection.isInitialized) { 101 | connection = await connection.initialize(); 102 | if (this.callbacks?.typeormConnectionInitialized != undefined) { 103 | this.callbacks?.typeormConnectionInitialized(connection); 104 | } 105 | this.connections.set(dbName, connection); 106 | } 107 | 108 | return connection; 109 | }; 110 | } 111 | 112 | //function sleep(ms) { 113 | // return new Promise((resolve) => { 114 | // setTimeout(resolve, ms); 115 | // }); 116 | //} 117 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | export const env = { 2 | isProd: process.env.IS_PRODUCTION, 3 | selfAddress: process.env.ACTIO_SELF_ADDRESS, 4 | postgres: { 5 | connectionName: process.env.CONNECTION_NAME, 6 | dbUser: process.env.SQL_USER, 7 | dbPassword: process.env.SQL_PASSWORD, 8 | dbName: process.env.SQL_NAME, 9 | }, 10 | }; 11 | 12 | export default env; 13 | -------------------------------------------------------------------------------- /src/globalNamespace.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test, describe } from "@jest/globals"; 2 | 3 | //import { Registrator } from "./registrator.js"; 4 | //import express from "express"; 5 | import { Service } from "./reflect.js"; 6 | //import { default as request } from "supertest"; 7 | import { Entity, PrimaryColumn, Column } from "typeorm"; 8 | import { DataSource } from "typeorm"; 9 | import { nanoid } from "nanoid"; 10 | import { Injector } from "./injector.js"; 11 | 12 | @Entity() 13 | export class E { 14 | @PrimaryColumn() 15 | id?: string; 16 | 17 | @Column({ nullable: true }) 18 | something?: string; 19 | } 20 | 21 | @Service() 22 | class NsA { 23 | meta = { 24 | name: "nsa", 25 | typeorm: { 26 | entities: [E], 27 | }, 28 | }; 29 | 30 | constructor(private db: DataSource) {} 31 | 32 | async a(): Promise { 33 | let e = await this.db.createQueryBuilder(E, "e").getMany(); 34 | if (e.length > 1) { 35 | throw new Error("too many entities"); 36 | } 37 | return { hi: e[0].something }; 38 | } 39 | 40 | async _onInit() { 41 | console.log("NsA init - should only happen once"); 42 | this.db.transaction(async (manager) => { 43 | await manager.save(E, { id: "1", something: nanoid() }); 44 | }); 45 | } 46 | } 47 | 48 | @Service() 49 | class NsB { 50 | constructor(private a: NsA) {} 51 | 52 | async b() { 53 | return await this.a.a(); 54 | } 55 | } 56 | 57 | @Service() 58 | class NsC { 59 | constructor(private a: NsA) {} 60 | 61 | async c() { 62 | return await this.a.a(); 63 | } 64 | } 65 | 66 | describe("test global namespaces", () => { 67 | let b: NsB; 68 | let c: NsC; 69 | 70 | test("ns", async () => { 71 | let bNamespace = "t_" + nanoid().slice(0, 7); 72 | let cNamespace = "t_" + nanoid().slice(0, 7); 73 | let i = await new Injector([NsB, NsC]); 74 | i.log = true 75 | i.fixedNamespaces = new Map().set("NsA", "global"); 76 | b = await i.getInstance("NsB", bNamespace); 77 | c = await i.getInstance("NsC", cNamespace); 78 | 79 | let rspB = await b.b(); 80 | let rspC = await c.c(); 81 | expect(await rspB).toEqual(await rspC); 82 | }); 83 | }); 84 | 85 | // @todo test multinode setup -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Service, Raw, Unexposed } from "./reflect.js"; 2 | import { Field, FieldOptions } from "./reflect.field.js"; 3 | import { Endpoint, EndpointOptions } from "./reflect.js"; 4 | import { Injector } from "./injector.js"; 5 | import { Registrator, startServer } from "./registrator.js"; 6 | import { error, Error, copy, Servicelike, ServiceMeta } from "./util.js"; 7 | import { AuthenticationService } from "./service/authentication/index.js"; 8 | import { AdminService } from "./service/admin/index.js"; 9 | import { PaymentService } from "./service/payment/index.js"; 10 | import { ConfigService } from "./service/config/index.js"; 11 | import { FileService } from "./service/file/index.js"; 12 | import { SystemService } from "./service/system/index.js"; 13 | import { KeyValueService } from "./service/keyvalue/index.js"; 14 | 15 | export { 16 | Service, 17 | Raw, 18 | Unexposed, 19 | ServiceMeta, 20 | Servicelike, 21 | Injector, 22 | Registrator, 23 | error, 24 | Error, 25 | copy, 26 | AuthenticationService, 27 | AdminService, 28 | PaymentService, 29 | ConfigService, 30 | FileService, 31 | SystemService, 32 | Field, 33 | FieldOptions, 34 | Endpoint, 35 | EndpointOptions, 36 | startServer, 37 | KeyValueService, 38 | }; 39 | -------------------------------------------------------------------------------- /src/injector.test.ts: -------------------------------------------------------------------------------- 1 | import { Injector } from "./injector.js"; 2 | import { Service } from "./reflect.js"; 3 | import { expect, test } from "@jest/globals"; 4 | import { nanoid } from "nanoid"; 5 | import { random } from "lodash"; 6 | import { startServer } from "./registrator.js"; 7 | import { default as request } from "supertest"; 8 | 9 | /** @todo test for circular dependencies */ 10 | 11 | @Service() 12 | class B { 13 | constructor() { 14 | this.val = 1; 15 | this.id = nanoid(); 16 | } 17 | val: Number; 18 | id: string; 19 | 20 | onInited: boolean; 21 | _onInit(): any { 22 | this.onInited = true; 23 | } 24 | } 25 | 26 | @Service() 27 | class A { 28 | b: B; 29 | id: string; 30 | constructor(dep: B) { 31 | this.b = dep; 32 | this.id = nanoid(); 33 | } 34 | 35 | onInited: boolean; 36 | _onInit(): any { 37 | this.onInited = true; 38 | } 39 | } 40 | 41 | test("Injects deps", async () => { 42 | let i = new Injector([A, B]); 43 | let aInstance: A = await i.getInstance("A", "inject-deps"); 44 | expect(aInstance.b?.val).toBe(1); 45 | }); 46 | 47 | test("Injects deps multiple times", async () => { 48 | let i = new Injector([A, B]); 49 | let aInstance: A = await i.getInstance("A", "inject-deps-multiple-times"); 50 | expect(aInstance.b?.val).toBe(1); 51 | 52 | aInstance = await i.getInstance("A", "inject-deps-multiple-times"); 53 | expect(aInstance.b?.val).toBe(1); 54 | }); 55 | 56 | test("Reuses deps", async () => { 57 | let i = new Injector([A, B]); 58 | let aInstance: A = await i.getInstance("A", "reuse-deps"); 59 | let aInstance2: A = await i.getInstance("A", "reuse-deps"); 60 | expect(aInstance.b?.id == aInstance2.b.id).toBe(true); 61 | }); 62 | 63 | test("Class list is available immediately", async () => { 64 | let i = new Injector([A, B]); 65 | let list = i.availableClassNames(); 66 | expect(list).toStrictEqual(["A", "B"]); 67 | }); 68 | 69 | test("Class list is available immediately - implicit list", async () => { 70 | let i = new Injector([A]); 71 | let list = i.availableClassNames(); 72 | expect(list).toStrictEqual(["A", "B"]); 73 | }); 74 | 75 | @Service() 76 | class C { 77 | constructor() {} 78 | } 79 | 80 | test("Depless class works", async () => { 81 | let i = new Injector([C]); 82 | let cInstance: A = await i.getInstance("C", "depless-class-works"); 83 | expect(cInstance != undefined).toBe(true); 84 | }); 85 | 86 | test("Oninit gets called for top level type", async () => { 87 | let i = new Injector([A, B]); 88 | let aInstance: A = await i.getInstance( 89 | "A", 90 | "oninit-gets-called-for-top-level-type" 91 | ); 92 | expect(aInstance.onInited).toBe(true); 93 | }); 94 | 95 | test("Oninit gets called for second level type", async () => { 96 | let i = new Injector([A, B]); 97 | let bInstance: A = await i.getInstance( 98 | "B", 99 | "oninit-gets-called-for-second-level-type" 100 | ); 101 | expect(bInstance.onInited).toBe(true); 102 | }); 103 | 104 | test("Oninit gets called for second level type - implicit list", async () => { 105 | let i = new Injector([A]); 106 | let bInstance: A = await i.getInstance( 107 | "B", 108 | "oninit-gets-called-for-second-level-type-implicit-list" 109 | ); 110 | expect(bInstance.onInited).toBe(true); 111 | }); 112 | 113 | test("deps are loaded without explicitly passing them in (implicit list)", async () => { 114 | let i = new Injector([A]); 115 | let bInstance: A = await i.getInstance( 116 | "B", 117 | "deps-are-loaded-without-explicitly-passing-them-in-implicit-list" 118 | ); 119 | expect(bInstance.onInited).toBe(true); 120 | }); 121 | 122 | let counter = 0; 123 | 124 | @Service() 125 | class D { 126 | constructor() {} 127 | 128 | onInited: boolean; 129 | _onInit(): Promise { 130 | return new Promise((resolve) => { 131 | setTimeout(() => { 132 | counter++; 133 | resolve(); 134 | }, random(500, 1800)); 135 | }); 136 | } 137 | } 138 | 139 | @Service() 140 | class E { 141 | d: D; 142 | constructor(dep: D) { 143 | this.d = dep; 144 | } 145 | 146 | onInited: boolean; 147 | _onInit(): Promise { 148 | return new Promise((resolve) => { 149 | setTimeout(() => { 150 | resolve(); 151 | }, random(500, 1800)); 152 | }); 153 | } 154 | } 155 | 156 | test("Init only happens once", async () => { 157 | let i = new Injector([E]); 158 | i.log = false; 159 | 160 | // we make two request to the same type, but the init should only happen once 161 | // we make these requests in parallel 162 | let dInstance: Promise = i.getInstance("D", "init-once"); 163 | let d1Instance: Promise = i.getInstance("D", "init-once"); 164 | 165 | dInstance.then((e) => { 166 | expect(counter).toBe(1); 167 | }); 168 | 169 | d1Instance.then((e) => { 170 | expect(counter).toBe(1); 171 | }); 172 | 173 | await i.getInstance("D", "init-once"); 174 | expect(counter).toBe(1); 175 | }); 176 | 177 | test("Service instances are namespaced", async () => { 178 | let i = new Injector([A]); 179 | // we make two request to the same type, but the init should only happen once 180 | // we make these requests in parallel 181 | let bInstance: B = await i.getInstance("B", "ns1"); 182 | let bInstance1: B = await i.getInstance("B", "ns2"); 183 | expect(bInstance.id != bInstance1.id).toBe(true); 184 | 185 | let aInstance: A = await i.getInstance("A", "ns1"); 186 | let aInstance1: A = await i.getInstance("A", "ns2"); 187 | expect(aInstance.id != aInstance1.id).toBe(true); 188 | expect(aInstance.b.id != aInstance1.b.id).toBe(true); 189 | expect(aInstance.b.id == bInstance.id).toBe(true); 190 | expect(aInstance1.b.id == bInstance1.id).toBe(true); 191 | }); 192 | 193 | test("Not found", async () => { 194 | let i = new Injector([A]); 195 | try { 196 | await i.getInstance("D", "not-found"); 197 | } catch (e) { 198 | console.log(e); 199 | expect(JSON.stringify(e).includes("injector cannot find D")).toBe(true); 200 | } 201 | }); 202 | 203 | @Service() 204 | class F { 205 | cname = "F"; 206 | constructor() {} 207 | } 208 | 209 | @Service() 210 | class G { 211 | cname = "G"; 212 | constructor() {} 213 | } 214 | 215 | @Service() 216 | class H { 217 | f: F; 218 | g: G; 219 | 220 | fProducer: (serviceName: string) => Promise; 221 | gProducer: (serviceName: string) => Promise; 222 | constructor( 223 | f: (serviceName: string) => Promise, 224 | g: (serviceName: string) => Promise 225 | ) { 226 | this.fProducer = f; 227 | this.gProducer = g; 228 | } 229 | 230 | async _onInit() { 231 | this.f = await this.fProducer("F"); 232 | this.g = await this.gProducer("G"); 233 | } 234 | } 235 | 236 | test("function based injection", async () => { 237 | // not how we pass in the producers 238 | let i = new Injector([F, G, H]); 239 | let h = await i.getInstance("H", "function-based-injection"); 240 | expect(h.f?.cname).toBe("F"); 241 | expect(h.g?.cname).toBe("G"); 242 | // G F H + Function Function 243 | expect(i.availableClassNames().length).toBe(4); 244 | }); 245 | 246 | @Service() 247 | class InjectorServer { 248 | inj: Injector; 249 | hasServ; 250 | constructor(injector: Injector) { 251 | this.inj = injector; 252 | } 253 | 254 | async _onInit() { 255 | this.hasServ = this.inj.server != undefined; 256 | } 257 | 258 | hasServer() { 259 | return { has: this.hasServ }; 260 | } 261 | } 262 | 263 | test("injector has server", async () => { 264 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 265 | 266 | let serv = startServer([InjectorServer], randomPortNumber); 267 | 268 | let response = await request(serv.app) 269 | .post("/InjectorServer/hasServer") 270 | .send({}) 271 | .retry(0); 272 | expect(response.status).toBe(200); 273 | expect(response.body).toEqual({ has: true }); 274 | 275 | serv.server.close(); 276 | }); 277 | -------------------------------------------------------------------------------- /src/microservices.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | 3 | import { createApp } from "./registrator.js"; 4 | import { Service } from "./reflect.js"; 5 | import { default as request } from "supertest"; 6 | import { error } from "./util.js"; 7 | import _ from "lodash"; 8 | 9 | @Service() 10 | class MultiParam { 11 | constructor() {} 12 | 13 | async multiParam(a: number, b: string) { 14 | if (a == 1 && b == "2") { 15 | return "ok"; 16 | } 17 | return "not ok"; 18 | } 19 | } 20 | 21 | @Service() 22 | class MultiParamProxy { 23 | multiParamService: MultiParam; 24 | constructor(multiParamService: MultiParam) { 25 | this.multiParamService = multiParamService; 26 | } 27 | 28 | async multiParamProxy(a: number, b: string) { 29 | let rsp = await this.multiParamService.multiParam(a, b); 30 | return rsp; 31 | } 32 | } 33 | 34 | test("multiparam api call", async () => { 35 | let appA = createApp([MultiParam]); 36 | 37 | let response = await request(appA) 38 | .post("/MultiParam/multiParam") 39 | .send([1, "2"]); 40 | 41 | expect(response.status).toBe(200); 42 | expect(response.body).toEqual("ok"); 43 | 44 | response = await request(appA).post("/MultiParam/multiParam").send([1, "3"]); 45 | expect(response.status).toBe(200); 46 | expect(response.body).toEqual("not ok"); 47 | }); 48 | 49 | test("multiParam microservice call", async () => { 50 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 51 | let appA = createApp([MultiParam]); 52 | 53 | let server = appA.listen(randomPortNumber); 54 | 55 | let appB = createApp([MultiParamProxy], { 56 | addresses: new Map().set( 57 | "MultiParamCall", 58 | "http://localhost:" + randomPortNumber 59 | ), 60 | }); 61 | 62 | let response = await request(appB) 63 | .post("/MultiParamProxy/multiParamProxy") 64 | .send([1, "2"]) 65 | .retry(0); 66 | expect(response.status).toBe(200); 67 | expect(response.body).toEqual("ok"); 68 | 69 | response = await request(appB) 70 | .post("/multiParamProxy/multiParamProxy") 71 | .send([1, "3"]) 72 | .retry(0); 73 | expect(response.status).toBe(200); 74 | expect(response.body).toEqual("not ok"); 75 | 76 | server.close(); 77 | }); 78 | 79 | @Service() 80 | class Hi { 81 | constructor() {} 82 | 83 | async hi(dat) { 84 | return { hi: dat.hi }; 85 | } 86 | } 87 | 88 | @Service() 89 | class HiProxy { 90 | hiService: Hi; 91 | constructor(hiService: Hi) { 92 | this.hiService = hiService; 93 | } 94 | 95 | async hiProxy(dat) { 96 | let rsp = await this.hiService.hi(dat); 97 | return { hi: rsp.hi + " hi there" }; 98 | } 99 | } 100 | 101 | test("microservice call", async () => { 102 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 103 | let appA = createApp([Hi]); 104 | 105 | let server = appA.listen(randomPortNumber); 106 | 107 | let appB = createApp([HiProxy], { 108 | addresses: new Map().set("Hi", "http://localhost:" + randomPortNumber), 109 | }); 110 | 111 | let response = await request(appB) 112 | .post("/HiProxy/hiProxy") 113 | .send({ hi: "hello" }) 114 | .retry(0); 115 | expect(response.status).toBe(200); 116 | expect(response.body).toEqual({ hi: "hello hi there" }); 117 | 118 | server.close(); 119 | }); 120 | 121 | test("microservice proof", async () => { 122 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 123 | 124 | const appB = createApp([HiProxy], { 125 | addresses: new Map().set("Hi", "http://localhost:" + randomPortNumber), 126 | }); 127 | 128 | let response = await request(appB) 129 | .post("/Hi/hi") 130 | .send({ hi: "hello" }) 131 | .retry(0); 132 | expect(response.status).toBe(500); 133 | 134 | response = await request(appB) 135 | .post("/Notexisting/hi") 136 | .send({ hi: "hello" }) 137 | .retry(0); 138 | expect(response.status).toBe(404); 139 | }); 140 | 141 | @Service() 142 | class DirectErr { 143 | constructor() {} 144 | 145 | async a(dat) { 146 | return { hi: dat.hi }; 147 | } 148 | 149 | async directJsErr() { 150 | throw new Error("directJsErr"); 151 | } 152 | 153 | async directFrameworkErr() { 154 | throw error("directFrameworkErr", 502); 155 | } 156 | } 157 | 158 | @Service() 159 | class ProxyErr { 160 | directErrService: DirectErr; 161 | constructor(directErrService: DirectErr) { 162 | this.directErrService = directErrService; 163 | } 164 | 165 | async proxyJsErr() { 166 | let rsp = await this.directErrService.directJsErr(); 167 | return rsp; 168 | } 169 | 170 | async proxyFrameworkErr() { 171 | let rsp = await this.directErrService.directFrameworkErr(); 172 | return rsp; 173 | } 174 | 175 | async directJsErr() { 176 | throw new Error("proxy:directJsErr"); 177 | } 178 | 179 | async directFrameworkErr() { 180 | throw error("proxy:directFrameworkErr", 502); 181 | } 182 | } 183 | 184 | // @todo these tests are a bit hard to follow 185 | test("microservice error propagates", async () => { 186 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 187 | 188 | let appA = createApp([DirectErr]); 189 | 190 | let server = appA.listen(randomPortNumber); 191 | 192 | let appB = createApp([ProxyErr], { 193 | addresses: new Map().set( 194 | "DirectErr", 195 | "http://localhost:" + randomPortNumber 196 | ), 197 | }); 198 | 199 | let response = await request(appB) 200 | .post("/ProxyErr/directJsErr") 201 | .send({}) 202 | .retry(0); 203 | expect(response.status).toBe(500); 204 | expect( 205 | (response.body.error as string).startsWith( 206 | '{"stack":"Error: proxy:directJsErr' 207 | ) 208 | ).toBe(true); 209 | 210 | response = await request(appB) 211 | .post("/ProxyErr/proxyJsErr") 212 | .send({ hi: "hello" }) 213 | .retry(0); 214 | expect(response.status).toBe(500); 215 | expect( 216 | (response.body.error as string).startsWith('{"stack":"Error: directJsErr') 217 | ).toBe(true); 218 | 219 | response = await request(appB) 220 | .post("/ProxyErr/proxyFrameworkErr") 221 | .send({ hi: "hey" }) 222 | .retry(0); 223 | expect(response.status).toBe(502); 224 | expect(response.body.error).toBe("directFrameworkErr"); 225 | 226 | response = await request(appB) 227 | .post("/ProxyErr/directFrameworkErr") 228 | .send({ hi: "ho" }) 229 | .retry(0); 230 | expect(response.status).toBe(502); 231 | expect(response.body.error).toBe("proxy:directFrameworkErr"); 232 | 233 | server.close(); 234 | }); 235 | 236 | @Service() 237 | class Array { 238 | constructor() {} 239 | 240 | async sum(as: number[]): Promise { 241 | return _.sum(as); 242 | } 243 | } 244 | 245 | @Service() 246 | class ArrayProxy { 247 | arrayService: Array; 248 | constructor(arrayService: Array) { 249 | this.arrayService = arrayService; 250 | } 251 | 252 | async sumProxy(as: number[]) { 253 | let rsp = await this.arrayService.sum(as); 254 | return rsp; 255 | } 256 | } 257 | 258 | test("array api call", async () => { 259 | let appA = createApp([Array]); 260 | 261 | let response = await request(appA).post("/Array/sum").send([1]).retry(0); 262 | expect(response.status).toBe(200); 263 | expect(response.body).toEqual(1); 264 | 265 | response = await request(appA).post("/Array/sum").send([1, 2]).retry(0); 266 | expect(response.status).toBe(200); 267 | expect(response.body).toEqual(3); 268 | }); 269 | 270 | test("array proxy api call", async () => { 271 | let randomPortNumber = Math.floor(Math.random() * 10000) + 10000; 272 | let appA = createApp([Array]); 273 | 274 | let server = appA.listen(randomPortNumber); 275 | 276 | let appB = createApp([ArrayProxy], { 277 | addresses: new Map().set("Array", "http://localhost:" + randomPortNumber), 278 | }); 279 | 280 | let response = await request(appB) 281 | .post("/ArrayProxy/sumProxy") 282 | .send([1]) 283 | .retry(0); 284 | expect(response.status).toBe(200); 285 | expect(response.body).toEqual(1); 286 | 287 | response = await request(appB) 288 | .post("/ArrayProxy/sumProxy") 289 | .send([1, 2]) 290 | .retry(3); 291 | expect(response.status).toBe(200); 292 | expect(response.body).toEqual(3); 293 | 294 | server.close(); 295 | }); 296 | -------------------------------------------------------------------------------- /src/reflect.api.test.ts: -------------------------------------------------------------------------------- 1 | import { getAPIJSON } from "./reflect.api.js"; 2 | import { Field, Param } from "./reflect.field.js"; 3 | import { Service } from "./reflect.js"; 4 | import { Endpoint } from "./reflect.js"; 5 | import { expect, test } from "@jest/globals"; 6 | 7 | class M { 8 | @Field() 9 | ID: string; 10 | 11 | @Field() 12 | name?: string; 13 | 14 | @Field() 15 | age: number; 16 | } 17 | 18 | @Service() 19 | class N { 20 | constructor() {} 21 | 22 | @Endpoint({ 23 | returns: null, 24 | }) 25 | async doSomething(req: M): Promise {} 26 | 27 | @Endpoint({ 28 | returns: M, 29 | }) 30 | async doSomethingElse(@Param({ type: M }) req: M[]): Promise { 31 | return null; 32 | } 33 | } 34 | 35 | test("api def", async () => { 36 | console.log(N); 37 | let equalTo = { 38 | services: { 39 | N: { 40 | doSomething: { 41 | info: { 42 | methodName: "doSomething", 43 | options: { 44 | returns: null, 45 | }, 46 | paramNames: ["_x"], 47 | paramTypes: ["M"], 48 | }, 49 | paramOptions: [{}], 50 | }, 51 | doSomethingElse: { 52 | info: { 53 | methodName: "doSomethingElse", 54 | options: { 55 | returns: "M", 56 | }, 57 | paramNames: ["_x2"], 58 | paramTypes: ["Array"], 59 | }, 60 | paramOptions: [ 61 | { 62 | type: "M", 63 | }, 64 | ], 65 | }, 66 | }, 67 | }, 68 | types: { 69 | M: { 70 | ID: { 71 | data: { name: "ID", type: "String" }, 72 | }, 73 | name: { 74 | data: { name: "name", type: "String" }, 75 | }, 76 | age: { 77 | data: { name: "age", type: "Number" }, 78 | }, 79 | }, 80 | }, 81 | }; 82 | 83 | let api = getAPIJSON(); 84 | let json = { 85 | services: { 86 | N: api.services.N, 87 | }, 88 | types: { 89 | M: api.types.M, 90 | }, 91 | }; 92 | expect(JSON.parse(JSON.stringify(json))).toEqual(equalTo); 93 | 94 | // do it again to test for idempotency 95 | api = getAPIJSON(); 96 | json = { 97 | services: { 98 | N: api.services.N, 99 | }, 100 | types: { 101 | M: api.types.M, 102 | }, 103 | }; 104 | expect(JSON.parse(JSON.stringify(json))).toEqual(equalTo); 105 | }); 106 | -------------------------------------------------------------------------------- /src/reflect.api.ts: -------------------------------------------------------------------------------- 1 | import { classNameToEndpointInfo, EndpointInfo } from "./reflect.js"; 2 | import { FieldData, getParamOptions, ParamOptions } from "./reflect.field.js"; 3 | import { listFieldClasses, listFields } from "./reflect.field.js"; 4 | 5 | interface APIJSON { 6 | types: { 7 | [typeName: string]: { 8 | [fieldName: string]: { 9 | data: FieldData; 10 | }; 11 | }; 12 | }; 13 | services: { 14 | [serviceName: string]: { 15 | [methodName: string]: { 16 | info?: EndpointInfo; 17 | // length equals to `info.paramTypes` field 18 | // contains null values for no options 19 | paramOptions: ParamOptions[]; 20 | }; 21 | }; 22 | }; 23 | } 24 | 25 | // this method is named JSON because 26 | // types are returned as strings and not actual 27 | // types (eg. [Function Array] becomes "Array") 28 | // 29 | // this way the return values is ready to be JSON stringified 30 | export function getAPIJSON(): APIJSON { 31 | let api: APIJSON = { 32 | services: {}, 33 | types: {}, 34 | }; 35 | 36 | classNameToEndpointInfo.forEach((endpointInfos) => { 37 | endpointInfos.forEach((endpointInfo) => { 38 | const serviceName = endpointInfo.target.name; 39 | if (!api.services[serviceName]) { 40 | api.services[serviceName] = {}; 41 | } 42 | let newInfo = { 43 | ...endpointInfo, 44 | options: { 45 | ...endpointInfo.options, 46 | }, 47 | }; 48 | newInfo.paramTypes = newInfo.paramTypes.map((type) => { 49 | return type.name; 50 | }); 51 | if (newInfo.options.returns) { 52 | newInfo.options.returns = newInfo.options.returns?.name; 53 | } 54 | 55 | api.services[serviceName][endpointInfo.methodName] = { 56 | info: newInfo, 57 | paramOptions: endpointInfo.paramTypes.map((_, index) => { 58 | let ret = { 59 | ...getParamOptions( 60 | endpointInfo.target, 61 | endpointInfo.methodName, 62 | index 63 | ), 64 | }; 65 | if (ret.type) { 66 | ret.type = ret.type?.name; 67 | } 68 | return ret; 69 | }), 70 | }; 71 | }); 72 | }); 73 | 74 | listFieldClasses().forEach((clas) => { 75 | listFields(clas.name).forEach((field) => { 76 | let className = clas.name; 77 | if (!api.types[className]) { 78 | api.types[className] = {}; 79 | } 80 | let data = { 81 | ...field, 82 | }; 83 | data.type = data.type?.name; 84 | if (data.hint) { 85 | data.hint = data.hint?.name; 86 | } 87 | delete data.target; 88 | api.types[className][field.name] = { 89 | data: data, 90 | }; 91 | }); 92 | }); 93 | 94 | return api; 95 | } 96 | -------------------------------------------------------------------------------- /src/reflect.field.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | import { 3 | Field, 4 | getParamOptions, 5 | listClasses, 6 | listFields, 7 | Param, 8 | } from "./reflect.field.js"; 9 | import { getMethodsForService, Service } from "./reflect.js"; 10 | import { Endpoint } from "./reflect.js"; 11 | 12 | class J { 13 | @Field() 14 | a: number; 15 | 16 | @Field() 17 | b: string; 18 | } 19 | 20 | test("field", async () => { 21 | console.log(J); 22 | let fields = listFields("J"); 23 | expect(fields.length).toBe(2); 24 | expect(fields[0].target.constructor.name).toBe("J"); 25 | expect(fields[0].name).toBe("a"); 26 | expect(fields[0].type).toBe(Number); 27 | expect(fields[1].target.constructor.name).toBe("J"); 28 | expect(fields[1].name).toBe("b"); 29 | expect(fields[1].type).toBe(String); 30 | 31 | let classes = listClasses(); 32 | expect(classes.find((c) => c.name == "J")).toBeTruthy(); 33 | }); 34 | 35 | class K { 36 | @Field({ hint: J }) 37 | a: J[]; 38 | } 39 | 40 | test("array field", async () => { 41 | console.log(K); 42 | let fields = listFields("K"); 43 | expect(fields.length).toBe(1); 44 | expect(fields[0].target.constructor.name).toBe("K"); 45 | expect(fields[0].name).toBe("a"); 46 | expect(fields[0].type).toBe(Array); 47 | expect(fields[0].hint).toBe(J); 48 | 49 | let classes = listClasses(); 50 | expect(classes.find((c) => c.name == "K")).toBeTruthy(); 51 | }); 52 | 53 | @Service() 54 | class L { 55 | constructor() {} 56 | 57 | @Endpoint({ 58 | returns: J, 59 | }) 60 | async doSomething(req: K): Promise { 61 | return { a: 1, b: "2" }; 62 | } 63 | 64 | @Endpoint({ 65 | returns: K, 66 | }) 67 | async doSomething2(@Param({ type: J }) req: J[]): Promise { 68 | return { a: [{ a: 1, b: "2" }] }; 69 | } 70 | } 71 | 72 | test("walk tree", async () => { 73 | console.log(L); 74 | let methods = getMethodsForService("L"); 75 | expect(methods.length).toBe(2); 76 | expect(methods).toEqual([ 77 | { 78 | target: L, 79 | methodName: "doSomething", 80 | paramNames: ["_x"], // ? 81 | paramTypes: [K], 82 | returnType: Promise, 83 | options: { 84 | returns: J, 85 | }, 86 | }, 87 | { 88 | target: L, 89 | methodName: "doSomething2", 90 | paramNames: ["_x2"], // ? 91 | paramTypes: [Array], 92 | returnType: Promise, 93 | options: { 94 | returns: K, 95 | }, 96 | }, 97 | ]); 98 | expect(getParamOptions(L, methods[1].methodName, 0)).toEqual({ 99 | type: J, 100 | }); 101 | }); 102 | 103 | class LambdaField { 104 | @Field({ hint: () => Number }) 105 | a: number[]; 106 | } 107 | 108 | test("lambda field", async () => { 109 | console.log(LambdaField); 110 | let fields = listFields("LambdaField"); 111 | expect(fields.length).toBe(1); 112 | expect(fields[0].hint).toBe(Number); 113 | }); 114 | -------------------------------------------------------------------------------- /src/reflect.field.ts: -------------------------------------------------------------------------------- 1 | export interface FieldData { 2 | target?: any; 3 | name?: string; 4 | required?: boolean; 5 | /** 6 | * Type returned by typescript reflection. 7 | * Do not set this, it's for internal usage. 8 | */ 9 | type?: any; 10 | /** 11 | * Type hint. This is needed because array types can not be retrieved with typescript reflection, so we need some help from the caller: 12 | * https://stackoverflow.com/questions/35022658/how-do-i-get-array-item-type-in-typescript-using-the-reflection-api 13 | */ 14 | hint?: any; 15 | /** Description for a field, it will appear in the API documentation/explorer */ 16 | desc?: string; 17 | } 18 | 19 | let classMap = new Map(); 20 | let fieldMap = new Map(); 21 | 22 | export function listFieldClasses(): any[] { 23 | let ret = []; 24 | classMap.forEach((value) => { 25 | ret.push(value); 26 | }); 27 | return ret; 28 | } 29 | 30 | export function listFields(target: any | string): FieldData[] { 31 | let key = target; 32 | if (typeof target != "string") { 33 | key = target.constructor.name; 34 | } 35 | if (!fieldMap.has(key)) { 36 | return []; 37 | } 38 | return fieldMap.get(key).map((opts) => { 39 | let ret = { 40 | ...opts, 41 | }; 42 | // support lambda hints ie. @Field({hint: () => User}) 43 | if (ret.hint?.name == "hint") { 44 | ret.hint = ret.hint(); 45 | } 46 | return ret; 47 | }); 48 | } 49 | 50 | export function listClasses(): any[] { 51 | let ret = []; 52 | classMap.forEach((value) => { 53 | ret.push(value); 54 | }); 55 | return ret; 56 | } 57 | 58 | export type FieldOptions = Omit; 59 | 60 | /** 61 | * Field is for data class properties. Makes properties available to the Actio runtime for API docs and API client generation. 62 | * The parent class is also registered with the Actio runtime, but only the fields that are decorated with @Field are available. 63 | */ 64 | export const Field = (options?: FieldOptions): PropertyDecorator => { 65 | return function (target, propertyKey) { 66 | classMap.set(target.constructor.name, target.constructor); 67 | 68 | let opts: FieldData = { 69 | target, 70 | }; 71 | if (!options) { 72 | options = {}; 73 | } 74 | opts.hint = options.hint; 75 | 76 | let name = target.constructor.name; 77 | 78 | let t = Reflect.getMetadata("design:type", target, propertyKey); 79 | if (!t) { 80 | throw `actio: Type for key '${String( 81 | propertyKey 82 | )}' in '${name}'' can not be determined.`; 83 | } 84 | opts.type = t; 85 | 86 | let list = fieldMap.get(name); 87 | if (!list) { 88 | list = []; 89 | fieldMap.set(name, list); 90 | } 91 | opts.name = options.name || String(propertyKey); 92 | list.push(opts); 93 | }; 94 | }; 95 | 96 | export interface ParamOptions { 97 | /** 98 | * The type contained in higher order types (`Array`, `Promise` etc.) in the parameter list of an endpoint. 99 | * 100 | * Due to Typescript reflection limitations, contained types (ie. the `User` in `Promise` or `string` in `string[]`) can't be automatically inferred. 101 | * 102 | * Example: If your endpoint is 103 | * 104 | * `async function getComments(@Param({type: string}) userIDs: string[]): Promise` 105 | * 106 | * This option and accurate type information is only required to enable API documentation and API client generation. 107 | **/ 108 | type: any | any[]; 109 | } 110 | 111 | // key format: __ 112 | // key example: CommentService_getComments_0 113 | let paramOptions = new Map(); 114 | 115 | /** 116 | * The Param parameter decorator is used to specify the type of a parameter for an endpoint when the 117 | * endpoint accepts higher order types (eg. arrays). See the `type` option in `ParamOptions` for more information. 118 | */ 119 | export function Param(options: ParamOptions) { 120 | return function (target: any, methodName: string, parameterIndex: number) { 121 | const key = `${target.constructor.name}_${methodName}_${parameterIndex}`; 122 | paramOptions.set(key, options); 123 | }; 124 | } 125 | 126 | export function getParamOptions( 127 | target: any, 128 | methodName: string, 129 | parameterIndex: number 130 | ): ParamOptions { 131 | const key = `${target.name}_${methodName}_${parameterIndex}`; 132 | return paramOptions.get(key); 133 | } 134 | -------------------------------------------------------------------------------- /src/reflect.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | import { nanoid } from "nanoid"; 3 | import { 4 | Endpoint, 5 | getDependencyGraph, 6 | isUnexposed, 7 | Service, 8 | Unexposed, 9 | getMethodParamsInfo, 10 | } from "./reflect.js"; 11 | 12 | @Service() 13 | class G {} 14 | 15 | @Service() 16 | class F {} 17 | 18 | @Service() 19 | class E { 20 | constructor(dep: G) {} 21 | } 22 | 23 | @Service() 24 | class D { 25 | constructor(dep: E) {} 26 | } 27 | 28 | @Service() 29 | class C { 30 | constructor(dep: D, dep1: F) {} 31 | } 32 | 33 | @Service() 34 | class B { 35 | constructor() { 36 | this.val = 1; 37 | this.id = nanoid(); 38 | } 39 | val: Number; 40 | id: string; 41 | 42 | onInited: boolean; 43 | _onInit(): any { 44 | this.onInited = true; 45 | } 46 | } 47 | 48 | @Service() 49 | class A { 50 | b: B; 51 | constructor(dep: B, dep2: C) { 52 | this.b = dep; 53 | } 54 | 55 | onInited: boolean; 56 | _onInit(): any { 57 | this.onInited = true; 58 | } 59 | } 60 | 61 | test("Test dependency graph", async () => { 62 | let deps = getDependencyGraph(A); 63 | 64 | expect(deps.length).toBe(6); 65 | expect(deps[0].name).toBe("B"); 66 | expect(deps[1].name).toBe("C"); 67 | expect(deps[2].name).toBe("D"); 68 | expect(deps[3].name).toBe("F"); 69 | expect(deps[4].name).toBe("E"); 70 | expect(deps[5].name).toBe("G"); 71 | }); 72 | 73 | @Service() 74 | class H { 75 | constructor() {} 76 | 77 | a() { 78 | return 1; 79 | } 80 | 81 | @Unexposed() 82 | b() { 83 | return 2; 84 | } 85 | } 86 | 87 | test("unexposed", async () => { 88 | expect(isUnexposed(H, "a")).toBe(false); 89 | expect(isUnexposed(H, "b")).toBe(true); 90 | }); 91 | 92 | @Service() 93 | class I { 94 | constructor() {} 95 | 96 | @Endpoint() 97 | a(a: number, b: string, c: C) { 98 | return 1; 99 | } 100 | } 101 | 102 | test("method types", async () => { 103 | console.log(I); 104 | let inf = getMethodParamsInfo("I"); 105 | expect(inf.length).toBe(1); 106 | expect(inf[0].target.name).toBe("I"); 107 | expect(inf[0].methodName).toBe("a"); 108 | expect(inf[0].paramNames.length).toBe(3); 109 | expect(inf[0].paramNames).toEqual(["_a", "b", "c"]); 110 | expect(inf[0].paramTypes.length).toBe(3); 111 | expect(inf[0].paramTypes[0]).toBe(Number); 112 | expect(inf[0].paramTypes[1]).toBe(String); 113 | expect(inf[0].paramTypes[2]).toBe(C); 114 | expect(inf[0].paramTypes[2].name).toBe("C"); 115 | }); 116 | -------------------------------------------------------------------------------- /src/reflect.ts: -------------------------------------------------------------------------------- 1 | import "reflect-metadata"; 2 | 3 | import { ServiceMeta } from "./util.js"; 4 | import _ from "lodash"; 5 | 6 | // The decorators that are empty here only exist to trigger 7 | // Reflect.getMetadata("design:paramtypes", ...) working properly 8 | // To see the effect how having a decorator causes 9 | // paramtypes to work play around with this gist: 10 | // https://gist.github.com/crufter/5fac85071864c41775cc3079015aac71 11 | 12 | /** 13 | * Makes the methods of a class available as API endpoints. 14 | * See `startServer` function or the `Registrator` class for a more manual 15 | * way to start a server. 16 | * 17 | * The constructor of your service 18 | * can accept the following list of allowed dependencies: 19 | * - other services marked with this decorator 20 | * - import { DataSource } from "typeorm" and other types handled by handlers 21 | * 22 | * Services should not panic if they are not supplied with all the dependencies 23 | * at the time of construction. 24 | */ 25 | export const Service = (): ClassDecorator => { 26 | return (target) => { 27 | // this does nothing, ignore it 28 | classDecoratorSaveParameterTypes(target); 29 | }; 30 | }; 31 | 32 | export interface EndpointOptions { 33 | /** 34 | * The types contained in higher order types (`Array`, `Promise` etc.) in the parameter list of an endpoint. 35 | * 36 | * Due to Typescript reflection limitations, contained types (ie. the `User` in `Promise` or `string` in `string[]`/`Array`) can't be automatically inferred. 37 | * 38 | * Example: If your endpoint is 39 | * 40 | * `async function getUser(userID: string): Promise` 41 | * 42 | * then `returns` should be `User`. 43 | * 44 | * This option is only required to enable API documentation and API client generation. 45 | * See also the `containedTypes` option for a similar concept but for the endpoint parameter types. 46 | */ 47 | returns: any; 48 | } 49 | 50 | /** 51 | * The `Endpoint` decorator makes a method visible to API docs and API client generation. 52 | * Your endpoint will function fine without this decorator. 53 | */ 54 | export const Endpoint = (options?: EndpointOptions): MethodDecorator => { 55 | return (target, propertyKey, descriptor) => { 56 | methodDecoratorSaveParameterTypes(options, target, propertyKey as string); 57 | }; 58 | }; 59 | 60 | export const Type = (): ClassDecorator => { 61 | return (target) => { 62 | // this does nothing, ignore it 63 | classDecoratorSaveParameterTypes(target); 64 | }; 65 | }; 66 | 67 | export interface EndpointInfo { 68 | target: any; // class 69 | methodName: string; // method name 70 | paramNames: any[]; 71 | paramTypes: any[]; 72 | returnType: any; 73 | options?: EndpointOptions; 74 | } 75 | 76 | export let classNameToEndpointInfo = new Map(); 77 | 78 | export function getMethodsForService(className: string): EndpointInfo[] { 79 | let methods = getMethodParamsInfo(className); 80 | return methods; 81 | } 82 | 83 | function methodDecoratorSaveParameterTypes( 84 | options: EndpointOptions, 85 | target: any, 86 | key: string 87 | ) { 88 | const types = Reflect.getMetadata("design:paramtypes", target, key); 89 | const returnType = Reflect.getMetadata("design:returntype", target, key); 90 | const paramNames = methodDecoratorGetParamNames(target[key]); 91 | 92 | if (!classNameToEndpointInfo.has(target.constructor.name)) { 93 | classNameToEndpointInfo.set(target.constructor.name, []); 94 | } 95 | classNameToEndpointInfo.get(target.constructor.name).push({ 96 | target: target.constructor, 97 | methodName: key, 98 | paramNames, 99 | paramTypes: types, 100 | returnType, 101 | options, 102 | }); 103 | } 104 | 105 | export function getMethodParamsInfo(className: string) { 106 | return classNameToEndpointInfo.get(className); 107 | } 108 | 109 | function methodDecoratorGetParamNames(func: Function) { 110 | const funcStr = func 111 | .toString() 112 | .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm, ""); 113 | const result = funcStr 114 | .slice(funcStr.indexOf("(") + 1, funcStr.indexOf(")")) 115 | .match(/([^\s,]+)/g); 116 | return result === null ? [] : result; 117 | } 118 | 119 | let devNull = (a, b) => {}; 120 | 121 | // @todo types are not being extracted here unless method are decorated 122 | // with @Endpoint (or any decorator) 123 | function classDecoratorSaveParameterTypes(target: any) { 124 | const methods = Object.getOwnPropertyNames(target.prototype); 125 | for (const method of methods) { 126 | const descriptor = Object.getOwnPropertyDescriptor( 127 | target.prototype, 128 | method 129 | ); 130 | if (!descriptor || typeof descriptor.value !== "function") { 131 | continue; 132 | } 133 | const types = Reflect.getMetadata( 134 | "design:paramtypes", 135 | target.prototype, 136 | method 137 | ); 138 | const paramNames = classDecoratorGetParamNames(descriptor.value); 139 | devNull(types, paramNames); 140 | } 141 | } 142 | 143 | function classDecoratorGetParamNames(func: Function) { 144 | const funcStr = func 145 | .toString() 146 | .replace(/((\/\/.*$)|(\/\*[\s\S]*?\*\/))/gm, ""); 147 | const result = funcStr 148 | .slice(funcStr.indexOf("(") + 1, funcStr.indexOf(")")) 149 | .match(/([^\s,]+)/g); 150 | return result === null ? [] : result; 151 | } 152 | 153 | let unexposedMethods = new Set(); 154 | 155 | /** 156 | * Class methods decorated with the `@Unexposed()` decorator will not be 157 | * exposed as HTTP endpoints. 158 | */ 159 | export function Unexposed() { 160 | return function ( 161 | target: any, 162 | propertyKey: string, 163 | descriptor: PropertyDescriptor 164 | ) { 165 | unexposedMethods.add(`${target.constructor.name}.${propertyKey}`); 166 | }; 167 | } 168 | 169 | export function isUnexposed(_class: any, methodName: string): boolean { 170 | return unexposedMethods.has(`${_class.name}.${methodName}`); 171 | } 172 | 173 | let rawMethods = new Set(); 174 | 175 | /** 176 | * Methods annotated with the `@Raw()` decorator will be registered as 177 | * direct HTTP methods and have access to HTTP request and response types. 178 | * A prime example of this is a file upload endpoint. 179 | * 180 | * Methods that are not annotated with `@Raw()` will only have access to their JSON request data. 181 | * That is favorable to raw HTTP methods as it is cleaner, enables testing etc. 182 | */ 183 | export function Raw() { 184 | return function ( 185 | target: any, 186 | propertyKey: string, 187 | descriptor: PropertyDescriptor 188 | ) { 189 | rawMethods.add(`${target.constructor.name}.${propertyKey}`); 190 | }; 191 | } 192 | 193 | export function isRaw(_class: any, methodName: string): boolean { 194 | return rawMethods.has(`${_class.name}.${methodName}`); 195 | } 196 | 197 | /** Returns the argument types of a class type as a string slice 198 | * make sure the type you pass into this has been decorated with a decorator 199 | * otherwise Reflect can't inspec its types (see comments at the top of this package). 200 | */ 201 | export function inputParamTypeNames(t: any): string[] { 202 | return inputParamTypes(t).map((t) => t.name); 203 | } 204 | 205 | export function inputParamTypes(t: any): any[] { 206 | let types = Reflect.getMetadata("design:paramtypes", t); 207 | if (!types) { 208 | return []; 209 | } 210 | // @todo why this filter is needed I'm not sure 211 | // there is likely a hidden bug here. investigate. 212 | return types.filter((t) => t !== undefined); 213 | } 214 | 215 | export function getMeta(serviceClass: any): ServiceMeta { 216 | let instance = new serviceClass(); 217 | return instance.meta; 218 | } 219 | 220 | /** 221 | * Recursively returns all dependencies of a class (ie. also 222 | * dependencies of dependencies), flattened into a single array. 223 | * 224 | * @param serviceClass the class to get the dependencies of 225 | * @returns a list of service classes 226 | */ 227 | export function getDependencyGraph(_class: any): any[] { 228 | let deps = inputParamTypes(_class); 229 | deps.forEach((d) => deps.push(getDependencyGraph(d))); 230 | return _.uniqBy(_.flatten(deps), (d) => d.name); 231 | } 232 | -------------------------------------------------------------------------------- /src/registrator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | 3 | import { Registrator } from "./registrator.js"; 4 | import express from "express"; 5 | import { Service, Unexposed, Raw } from "./reflect.js"; 6 | import { default as request } from "supertest"; 7 | import { error } from "./util.js"; 8 | 9 | @Service() 10 | class A1 { 11 | meta = { 12 | name: "aone", 13 | }; 14 | constructor() {} 15 | 16 | async a1() { 17 | return { hi: "a1" }; 18 | } 19 | } 20 | 21 | @Service() 22 | class A2Service { 23 | constructor() {} 24 | 25 | async a2() { 26 | return { hi: "a2" }; 27 | } 28 | } 29 | 30 | test("test meta name", async () => { 31 | const app = express(); 32 | let reg = new Registrator(app); 33 | reg.register([A1, A2Service]); 34 | 35 | let response = await request(app).post("/aone/a1").send({}); 36 | expect(response.status).toBe(200); 37 | expect(response.body).toEqual({ hi: "a1" }); 38 | 39 | // test fallback 40 | 41 | response = await request(app).post("/A1/a1").send({}); 42 | expect(response.status).toBe(200); 43 | expect(response.body).toEqual({ hi: "a1" }); 44 | 45 | response = await request(app).post("/A2/a2").send({}); 46 | expect(response.status).toBe(200); 47 | expect(response.body).toEqual({ hi: "a2" }); 48 | }); 49 | 50 | @Service() 51 | class A { 52 | constructor() {} 53 | 54 | async a() { 55 | return { hi: 5 }; 56 | } 57 | 58 | @Unexposed() 59 | async b() { 60 | return { hi: 5 + 1 }; 61 | } 62 | } 63 | 64 | test("test unexposed", async () => { 65 | const app = express(); 66 | let reg = new Registrator(app); 67 | reg.register([A]); 68 | 69 | let response = await request(app).post("/A/a").send({}); 70 | expect(response.status).toBe(200); 71 | expect(response.body).toEqual({ hi: 5 }); 72 | 73 | response = await request(app).post("/A/b").send({}); 74 | expect(response.status).toBe(404); 75 | }); 76 | 77 | @Service() 78 | class B { 79 | constructor() {} 80 | 81 | async a() { 82 | return { hi: 5 }; 83 | } 84 | 85 | @Raw() 86 | async b(req: express.Request, res: express.Response) { 87 | res.send(JSON.stringify({ hi: req.headers["hi"] })).end(); 88 | } 89 | } 90 | 91 | test("test raw", async () => { 92 | const app = express(); 93 | let reg = new Registrator(app); 94 | reg.register([B]); 95 | 96 | let response = await await request(app) 97 | .post("/B/b") 98 | .set({ hi: "hello" }) 99 | .send(); 100 | expect(response.status).toBe(200); 101 | expect(response.body).toEqual({ hi: "hello" }); 102 | }); 103 | 104 | @Service() 105 | class C { 106 | constructor() {} 107 | 108 | async a() { 109 | throw error("oh noes"); 110 | } 111 | 112 | async b() { 113 | let a; 114 | // trigger an undefined error 115 | console.log(a.x); 116 | } 117 | } 118 | 119 | test("test errors", async () => { 120 | const app = express(); 121 | let reg = new Registrator(app); 122 | reg.register([C]); 123 | 124 | let response = await await request(app).post("/C/a").send({ hi: "hello" }); 125 | expect(response.status).toBe(500); 126 | expect(response.body).toEqual({ error: "oh noes" }); 127 | 128 | response = await await request(app).post("/C/b").send({ hi: "hello" }); 129 | expect(response.status).toBe(500); 130 | expect( 131 | JSON.stringify(response.body).includes( 132 | "TypeError: Cannot read properties of undefined (reading 'x')" 133 | ) 134 | ).toEqual(true); 135 | }); 136 | -------------------------------------------------------------------------------- /src/service/admin/index.ts: -------------------------------------------------------------------------------- 1 | import teardown from "./teardown.js"; 2 | 3 | import { TeardownRequest } from "./models.js"; 4 | 5 | import { DataSource } from "typeorm"; 6 | import { Service } from "../../reflect.js"; 7 | import { Servicelike } from "../../util.js"; 8 | 9 | @Service() 10 | export class AdminService implements Servicelike { 11 | private connection: DataSource; 12 | 13 | constructor(connection: DataSource) { 14 | this.connection = connection; 15 | } 16 | 17 | meta = { 18 | name: "admin", 19 | typeorm: { 20 | entities: [File], 21 | }, 22 | }; 23 | 24 | teardown(req: TeardownRequest) { 25 | return teardown(this.connection, req); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/service/admin/models.ts: -------------------------------------------------------------------------------- 1 | import { Entity, PrimaryColumn } from "typeorm"; 2 | 3 | @Entity() 4 | // A recurrence is a rule to make something recurring every X interval 5 | export class Recurrence { 6 | @PrimaryColumn() 7 | id?: string; 8 | 9 | // cron expression 10 | // eg. "45 23 * * 6" 23:45 means (11:45 PM) every Saturday 11 | cron?: string; 12 | } 13 | 14 | export interface TeardownRequest { 15 | token: string; 16 | passphrase: string; 17 | } 18 | 19 | export interface TeardownResponse {} 20 | -------------------------------------------------------------------------------- /src/service/admin/teardown.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { TeardownRequest, TeardownResponse } from "./models.js"; 3 | import { Token, roleAdmin } from "../authentication/models.js"; 4 | import { error } from "../../util.js"; 5 | import { default as env } from "../../env.js"; 6 | 7 | export default async ( 8 | connection: DataSource, 9 | req: TeardownRequest 10 | ): Promise => { 11 | if (env.isProd) { 12 | throw error("can not tear down production", 400); 13 | } 14 | let token: Token = await connection 15 | .createQueryBuilder(Token, "token") 16 | .where("token.token = :id", { id: req.token }) 17 | .leftJoinAndSelect("token.user", "user") 18 | .leftJoinAndSelect("user.roles", "role") 19 | .getOne(); 20 | if (!token) { 21 | throw error("token not found", 400); 22 | } 23 | 24 | let isAdmin = false; 25 | if (token.user.roles?.find((r) => r.id == roleAdmin.id)) { 26 | isAdmin = true; 27 | } 28 | if (!isAdmin) { 29 | throw error("not authorized", 400); 30 | } 31 | if (req.passphrase != "i-really-do-want-to-tear-it-all-down") { 32 | throw error("not authorized", 400); 33 | } 34 | if (connection.name == "deflt") { 35 | throw error("can not drop default", 400); 36 | } 37 | 38 | await connection.dropDatabase(); 39 | await connection.synchronize(); 40 | return {}; 41 | }; 42 | -------------------------------------------------------------------------------- /src/service/authentication/README.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](../../../docs/README.md) 2 | # Authentication service 3 | 4 | ```ts 5 | import { Service, AuthenticationService } from "@crufters/actio" 6 | 7 | @Service() 8 | class MyService { 9 | constructor(auth: AuthenticationService) {...} 10 | } 11 | ``` 12 | 13 | - [Authentication service](#authentication-service) 14 | - [Basics](#basics) 15 | - [Concepts](#concepts) 16 | - [Contacts](#contacts) 17 | - [Organizations and departments](#organizations-and-departments) 18 | - [Roles](#roles) 19 | - [Oauth](#oauth) 20 | - [Facebook oauth setup](#facebook-oauth-setup) 21 | - [Facebook oauth troubleshooting](#facebook-oauth-troubleshooting) 22 | - [Envars](#envars) 23 | 24 | ## Basics 25 | 26 | The basic usage of the auth service involves registration 27 | 28 | ```ts 29 | let resp = await auth.userRegister({ 30 | user: { 31 | contacts: [ 32 | { 33 | url: "user@test.com", 34 | }, 35 | ], 36 | }, 37 | password: "123", 38 | }); 39 | ``` 40 | 41 | and reading token information: 42 | 43 | ```ts 44 | let trsp = await auth.tokenRead({ token: req.token }); 45 | console.log(trsp.token.user?.fullName) 46 | ``` 47 | 48 | ## Concepts 49 | 50 | ### Contacts 51 | 52 | Looking at the models of the this service you might spot that the `User` class has no email field. The reason for this is that the Auth service is designed so different way of authenticating, such proving access to phones, or a Facebook account etc. is equivalent to an email registration. 53 | 54 | This means that you can build a system where people register with phones and nothing else for example. 55 | 56 | ## Organizations and departments 57 | 58 | In most systems there is a need for `Organization` accounts. While most users will have no `Organization`s, your business clients for example will probably want multiple people accessing your software. 59 | 60 | Looking at the `User` class it can be seen that users belong to `Department`s. `Department`s are simply a way to further divide `Organization`s into section. This is mostly useful for larger systems so they get created automatically when a user creates an organization: 61 | 62 | ```ts 63 | let resp = await auth.userRegister({ 64 | user: { 65 | contacts: [ 66 | { 67 | url: "user@test.com", 68 | }, 69 | ], 70 | }, 71 | password: "123", 72 | }); 73 | 74 | await auth.userCreateOrganization({ 75 | token: resp.token.token, 76 | organization: { 77 | name: "Test Org", 78 | }, 79 | }); 80 | ``` 81 | 82 | Once the user creates their Organization an organization and a department is created for them and they get assigned the following roles: 83 | 84 | ``` 85 | organization:$orgId:admin 86 | department:$departmentId:admin 87 | ``` 88 | 89 | ## Roles 90 | 91 | There are no dictated roles in Actio, but a typical application will have the following roles: 92 | 93 | - admin: this is the site admin, ie. you 94 | - organization users: either all users for b2b apps, or your paying advertisers or similar in a b2c app 95 | - a normal user without any roles to start out 96 | 97 | ## Oauth 98 | 99 | ### Facebook oauth setup 100 | 101 | Based on https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow 102 | 103 | https://medium.com/@jackrobertscott/facebook-auth-with-node-js-c4bb90d03fc0 104 | 105 | https://stackoverflow.com/questions/13376710/facebook-app-domain-name-when-using-localhost 106 | 107 | Include the facebook app id in the `AuthenticationService` config or add `AUTHENTICATION_FACEBOOK_APP_ID` and `AUTHENTICATION_FACEBOOK_APP_SECRET` to your `.env`. 108 | 109 | 110 | #### Facebook oauth troubleshooting 111 | 112 | - It seems like one needs to use the `Facebook Login` app and not the `Facebook Login with Business` app because the latter does not work. 113 | 114 | - If you don't get an email back, look at the permissions of the app 115 | 116 | - For local development create a test app of your Facebook app. 117 | 118 | ## Envars 119 | 120 | ```sh 121 | # default admin email address that gets registered on onInit 122 | # if there are no admin users 123 | AUTHENTICATION_ADMIN_EMAIL=... 124 | 125 | # default admin password 126 | AUTHENTICATION_ADMIN_PASSWORD=... 127 | 128 | # default admin organization name 129 | AUTHENTICATION_ADMIN_ORGANIZATION=... 130 | 131 | # default admin full name 132 | AUTHENTICATION_ADMIN_FULLNAME=... 133 | 134 | # facebook oauth app id 135 | AUTHENTICATION_FACEBOOK_APP_ID=... 136 | 137 | # facebook oauth app secret 138 | AUTHENTICATION_FACEBOOK_APP_SECRET=... 139 | 140 | # facebook oauth redirect 141 | AUTHENTICATION_FACEBOOK_APP_REDIRECT_URL=... 142 | ``` 143 | 144 | -------------------------------------------------------------------------------- /src/service/authentication/department/departmentList.ts: -------------------------------------------------------------------------------- 1 | import { DataSource, SelectQueryBuilder } from "typeorm"; 2 | import { 3 | User, 4 | Department, 5 | Token, 6 | DepartmentListRequest, 7 | DepartmentListResponse, 8 | roleAdmin, 9 | } from "../models.js"; 10 | import { error } from "../../../util.js"; 11 | 12 | export default async ( 13 | connection: DataSource, 14 | req: DepartmentListRequest 15 | ): Promise => { 16 | let token: Token = await connection 17 | .createQueryBuilder(Token, "token") 18 | .where("token.token = :id", { id: req.token }) 19 | .getOne(); 20 | if (!token) { 21 | throw error("token not found", 400); 22 | } 23 | 24 | let user: User = await connection 25 | .createQueryBuilder(User, "user") 26 | .where("user.id = :id", { id: token.userId }) 27 | .leftJoinAndSelect("user.roles", "roles") 28 | .getOne(); 29 | 30 | let isAdmin = false; 31 | if (user.roles?.find((r) => r.id == roleAdmin.id)) { 32 | isAdmin = true; 33 | } 34 | // @todo check if user has access to department 35 | 36 | if (isAdmin && false) { 37 | let departments: Department[] = await join( 38 | connection 39 | .createQueryBuilder(Department, "department") 40 | .orderBy("department.createdAt", "DESC") 41 | .leftJoinAndSelect("department.users", "user") 42 | ).getMany(); 43 | 44 | return { 45 | departments: departments, 46 | }; 47 | } 48 | if (!isAdmin) { 49 | throw error("no access", 400); 50 | } 51 | 52 | let departments: Department[] = await connection 53 | .createQueryBuilder(Department, "department") 54 | .orderBy("department.createdAt", "DESC") 55 | .innerJoinAndSelect("department.users", "user", "user.id = :userId", { 56 | userId: user.id, 57 | }) 58 | .getMany(); 59 | 60 | return { 61 | departments: departments, 62 | }; 63 | }; 64 | 65 | export function join( 66 | s: SelectQueryBuilder 67 | ): SelectQueryBuilder { 68 | return s.leftJoinAndSelect("department.users", "user"); 69 | } 70 | -------------------------------------------------------------------------------- /src/service/authentication/oauth/facebookLogin.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { FacebookLoginRequest, FacebookLoginResponse } from "../models.js"; 3 | import registerOrLoginWithProvenIdentity from "./registerOrLoginWithProvenIdentity.js"; 4 | import axios from "axios"; 5 | 6 | interface MeResponse { 7 | id: string; 8 | email: string; 9 | first_name: string; 10 | last_name: string; 11 | } 12 | 13 | async function getFacebookUserData(access_token): Promise { 14 | const { data } = await axios({ 15 | url: "https://graph.facebook.com/me", 16 | method: "get", 17 | params: { 18 | // @todo make this configurable? 19 | // test response https://developers.facebook.com/tools/explorer/ 20 | fields: ["id", "email", "first_name", "last_name"].join(","), 21 | access_token: access_token, 22 | }, 23 | }); 24 | return data; 25 | } 26 | 27 | // https://medium.com/@jackrobertscott/facebook-auth-with-node-js-c4bb90d03fc0 28 | async function getAccessTokenFromCode(code, appId, appSecret, appRedirect) { 29 | const { data } = await axios({ 30 | url: "https://graph.facebook.com/v15.0/oauth/access_token", 31 | method: "get", 32 | params: { 33 | client_id: appId, 34 | client_secret: appSecret, 35 | redirect_uri: appRedirect, 36 | code, 37 | }, 38 | }); 39 | // data example: { access_token, token_type, expires_in } 40 | // { 41 | // access_token: 'EAAItvar5EZA...GAI1XOujNby9yVL7iyYEG4jEwzw1bJQZDZD', 42 | // token_type: 'bearer', 43 | // expires_in: 5178870 44 | // } 45 | 46 | return data.access_token; 47 | } 48 | 49 | export default async ( 50 | connection: DataSource, 51 | request: FacebookLoginRequest, 52 | facebookAppID: string, 53 | facebookAppSecret: string, 54 | facebookAppRedirectURL: string 55 | ): Promise => { 56 | let accessToken = await getAccessTokenFromCode( 57 | request.accessToken, 58 | facebookAppID, 59 | facebookAppSecret, 60 | facebookAppRedirectURL 61 | ); 62 | let rsp = await getFacebookUserData(accessToken); 63 | // @todo we could save the facebook access token but 64 | // we don't really need it apart from login or register 65 | return await registerOrLoginWithProvenIdentity(connection, { 66 | firstName: rsp.first_name, 67 | lastName: rsp.last_name, 68 | email: rsp.email, 69 | }); 70 | }; 71 | -------------------------------------------------------------------------------- /src/service/authentication/oauth/oauthInfo.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { OauthInfoRequest, OauthInfoResponse } from "../models.js"; 3 | 4 | export default async ( 5 | connection: DataSource, 6 | request: OauthInfoRequest, 7 | facebookAppID?: string 8 | ): Promise => { 9 | return { 10 | facebookAppID: facebookAppID, 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/service/authentication/oauth/registerOrLoginWithProvenIdentity.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | Contact, 4 | Token, 5 | User, 6 | platformEmail, 7 | RegisterOrLoginWithProvenIdentityRequest, 8 | RegisterOrLoginWithProvenIdentityResponse, 9 | } from "../models.js"; 10 | import { nanoid } from "nanoid"; 11 | 12 | // can be used for facebook and all oauth registrations after the 13 | // identity is proven 14 | export default async ( 15 | connection: DataSource, 16 | req: RegisterOrLoginWithProvenIdentityRequest 17 | ): Promise => { 18 | let contacts = await connection 19 | .createQueryBuilder(Contact, "contact") 20 | .where("contact.url = :url", { url: req.email }) 21 | .leftJoinAndSelect("contact.user", "user") 22 | .getMany(); 23 | 24 | // user is already registered 25 | if (contacts.length > 0) { 26 | let token = new Token(); 27 | token.id = nanoid(); 28 | token.token = nanoid(); 29 | token.userId = contacts[0].user.id; 30 | 31 | await connection.getRepository(Token).save(token); 32 | 33 | return { 34 | token: token, 35 | }; 36 | } 37 | 38 | let userId = nanoid(); 39 | let user = new User(); 40 | user.fullName = req.firstName + " " + req.lastName; 41 | // @todo 42 | user.slug = nanoid(); 43 | 44 | let contact = new Contact(); 45 | let contactId = nanoid(); 46 | contact.id = contactId; 47 | contact.platformId = platformEmail.id; 48 | contact.url = req.email; 49 | contact.verified = true; 50 | contact.userId = userId; 51 | 52 | let token = new Token(); 53 | token.id = nanoid(); 54 | token.userId = userId; 55 | token.token = nanoid(); 56 | 57 | await connection.transaction(async (tran) => { 58 | // roles will be saved (created even) 59 | // due to the cascade option, see the model 60 | await tran.save(user); 61 | await tran.save(contact); 62 | await tran.save(token); 63 | }); 64 | 65 | return { 66 | token: token, 67 | }; 68 | }; 69 | -------------------------------------------------------------------------------- /src/service/authentication/platform/platformList.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Platform, PlatformListResponse } from "../models.js"; 3 | 4 | export default async ( 5 | connection: DataSource, 6 | request: any 7 | ): Promise => { 8 | let platforms = await connection.getRepository(Platform).find({}); 9 | 10 | return { 11 | platforms: platforms, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/service/authentication/role/roleList.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Role, RoleListResponse } from "../models.js"; 3 | 4 | export default async ( 5 | connection: DataSource, 6 | request: any 7 | ): Promise => { 8 | let roles = await connection.getRepository(Role).find({}); 9 | 10 | return { 11 | roles: roles, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/service/authentication/token/tokenRead.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../../util.js"; 3 | import { Token, TokenReadRequest, TokenReadResponse } from "../models.js"; 4 | 5 | export default async ( 6 | connection: DataSource, 7 | req: TokenReadRequest 8 | ): Promise => { 9 | let token: Token = await connection 10 | .createQueryBuilder(Token, "token") 11 | .where("token.token = :id", { id: req.token }) 12 | .leftJoinAndSelect("token.user", "user") 13 | .leftJoinAndSelect("user.roles", "role") 14 | .leftJoinAndSelect("user.contacts", "contact") 15 | .leftJoinAndSelect("contact.platform", "platform") 16 | //.leftJoinAndSelect("user.thumbnail", "thumbnail") 17 | .leftJoinAndSelect("user.departments", "department") 18 | .leftJoinAndSelect("department.organization", "organization") 19 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail") 20 | .getOne(); 21 | if (!token) { 22 | throw error("token not found", 400); 23 | } 24 | 25 | return { 26 | token: token, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/service/authentication/user/passwordChange.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { nanoid } from "nanoid"; 3 | import { DataSource } from "typeorm"; 4 | import { error } from "../../../util.js"; 5 | import { 6 | Password, 7 | PasswordChangeRequest, 8 | PasswordChangeResponse, 9 | SecretCode, 10 | Token, 11 | User, 12 | } from "../models.js"; 13 | 14 | export default async ( 15 | connection: DataSource, 16 | request: PasswordChangeRequest 17 | ): Promise => { 18 | let code: SecretCode = await connection 19 | .createQueryBuilder(SecretCode, "code") 20 | .where("code.code = :id", { id: request.code }) 21 | .getOne(); 22 | 23 | if (!code || !code.id) { 24 | throw error("code not found", 400); 25 | } 26 | 27 | if (code.used) { 28 | throw error("code already used", 400); 29 | } 30 | 31 | let user: User = await connection 32 | .createQueryBuilder(User, "user") 33 | .where("user.id = :id", { id: code.userId }) 34 | .leftJoinAndSelect("user.roles", "role") 35 | .leftJoinAndSelect("user.contacts", "contact") 36 | .getOne(); 37 | 38 | let password = new Password(); 39 | password.id = await bcrypt.hash(request.newPassword, 10); 40 | password.userId = user.id; 41 | 42 | let token = new Token(); 43 | token.id = nanoid(); 44 | token.userId = user.id; 45 | token.token = nanoid(); 46 | 47 | await connection.transaction(async (tran) => { 48 | // @todo do not delete all old passwords 49 | await tran 50 | .createQueryBuilder(SecretCode, "secret_code") 51 | .update(SecretCode) 52 | .where(`secret_code.id = :codeId`, { 53 | codeId: code.id, 54 | }) 55 | .set({ 56 | used: true, 57 | }) 58 | .execute(); 59 | 60 | await tran 61 | .createQueryBuilder(Password, "password") 62 | .delete() 63 | .where(`password."userId" = :userId`, { 64 | userId: user.id, 65 | }) 66 | .execute(); 67 | 68 | await tran.save(password); 69 | await tran.save(token); 70 | }); 71 | 72 | return { token: token }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/service/authentication/user/passwordChangeWithOld.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { DataSource } from "typeorm"; 3 | import { error } from "../../../util.js"; 4 | import { 5 | Contact, 6 | Password, 7 | PasswordChangeWithOldRequest, 8 | PasswordChangeWithOldResponse, 9 | platformEmail, 10 | } from "../models.js"; 11 | 12 | const err = error("incorrect credentials", 400); 13 | 14 | export default async ( 15 | connection: DataSource, 16 | request: PasswordChangeWithOldRequest 17 | ): Promise => { 18 | // only email login supported for now 19 | let contacts = await connection 20 | .createQueryBuilder(Contact, "contact") 21 | .where("contact.url = :url AND contact.platformId = :platformId", { 22 | url: request.contactUrl, 23 | platformId: platformEmail.id, 24 | }) 25 | .leftJoinAndSelect("contact.user", "user") 26 | .getMany(); 27 | 28 | if (!contacts || contacts.length == 0) { 29 | return err; 30 | throw error("contact not found", 400); 31 | } 32 | let contact = contacts[0]; 33 | if (!contact.user) { 34 | return err; 35 | throw error("user for contact not found", 400); 36 | } 37 | if (!contact.user.id) { 38 | return err; 39 | } 40 | 41 | let passwords = await connection 42 | .createQueryBuilder(Password, "password") 43 | .where("password.userId = :id", { 44 | id: contact.user.id, 45 | }) 46 | .getMany(); 47 | if (!passwords || passwords.length == 0) { 48 | return err; 49 | throw error("password not found", 400); 50 | } 51 | // @todo be very careful with filter and other list ops when supporting 52 | // multiple passwords as most ops don't support promises 53 | let matched = await bcrypt.compare(request.oldPassword, passwords[0].id); 54 | 55 | if (!matched) { 56 | return err; 57 | throw error("login unsuccessful", 400); 58 | } 59 | 60 | let password = new Password(); 61 | password.id = await bcrypt.hash(request.newPassword, 10); 62 | password.userId = contact.user.id; 63 | 64 | await connection.transaction(async (tran) => { 65 | // @todo do not delete all old passwords 66 | await tran 67 | .createQueryBuilder(Password, "password") 68 | .delete() 69 | .where(`password."userId" = :userId`, { 70 | userId: contact.user.id, 71 | }) 72 | .execute(); 73 | 74 | await tran.save(password); 75 | }); 76 | 77 | return {}; 78 | }; 79 | -------------------------------------------------------------------------------- /src/service/authentication/user/passwordSendReset.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | PasswordSendResetRequest, 4 | PasswordSendResetResponse, 5 | User, 6 | SecretCode, 7 | } from "../models.js"; 8 | import sendgrid from "@sendgrid/mail"; 9 | import { Error, error } from "../../../util.js"; 10 | import { nanoid } from "nanoid"; 11 | import { default as configRead } from "../../config/configRead.js"; 12 | 13 | export default async ( 14 | connection: DataSource, 15 | request: PasswordSendResetRequest 16 | ): Promise => { 17 | let user: User = await connection 18 | .createQueryBuilder(User, "user") 19 | .innerJoinAndSelect( 20 | "user.contacts", 21 | "contact", 22 | "contact.url = :contactUrl", 23 | { contactUrl: request.contactUrl } 24 | ) 25 | .leftJoinAndSelect("user.roles", "role") 26 | .getOne(); 27 | if (!user || !user.id) { 28 | throw error("not found", 400); 29 | } 30 | 31 | let code = new SecretCode(); 32 | code.id = nanoid(); 33 | code.code = nanoid(); 34 | code.userId = user.id; 35 | 36 | let c = await configRead(connection, {}); 37 | if (c instanceof Error) { 38 | return c; 39 | } 40 | 41 | // @todo(janos) get sendgrid key 42 | sendgrid.setApiKey("get sendgrid key"); 43 | const msg: sendgrid.MailDataRequired = { 44 | // @todo this only supports a single contract 45 | to: user.contacts[0].url, // Change to your recipient 46 | from: c.config.contact.email, // Change to your verified sender 47 | subject: "Jelszó csere", 48 | text: 49 | "Szia,\n\nJelszó cserét kértél. Ezen a linken megteheted:\n\nhttps://" + 50 | c.config.domain + 51 | "/auth/password-reset?code=" + 52 | code.code + 53 | "\n\nHa mégsem te voltál, akkor hagyd figyelmen kívül ezt az emailt.\n\nA " + 54 | c.config.og.siteName + 55 | " csapata", 56 | }; 57 | 58 | await connection.transaction(async (tran) => { 59 | await tran.save(code); 60 | }); 61 | 62 | await sendgrid.send(msg); 63 | 64 | return {}; 65 | }; 66 | -------------------------------------------------------------------------------- /src/service/authentication/user/tokenAdminGet.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | roleAdmin, 4 | Token, 5 | TokenAdminGetRequest, 6 | TokenAdminGetResponse, 7 | } from "../models.js"; 8 | 9 | export default async ( 10 | connection: DataSource, 11 | request: TokenAdminGetRequest 12 | ): Promise => { 13 | let token: Token = await connection 14 | .createQueryBuilder(Token, "token") 15 | .innerJoinAndSelect("token.user", "user") 16 | .innerJoinAndSelect("user.roles", "role", "role.id = :id", { 17 | id: roleAdmin.id, 18 | }) 19 | .getOne(); 20 | 21 | return { 22 | token: token, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /src/service/authentication/user/userCreateOrganization.ts: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import slug from "slug"; 3 | import { DataSource } from "typeorm"; 4 | import { error } from "../../../util.js"; 5 | import { 6 | Department, 7 | Organization, 8 | Token, 9 | UserCreateOrganizationRequest, 10 | UserCreateOrganizationResponse, 11 | } from "../models.js"; 12 | 13 | export default async ( 14 | connection: DataSource, 15 | request: UserCreateOrganizationRequest 16 | ): Promise => { 17 | if (!request.organization) { 18 | throw error("missing organization", 400); 19 | } 20 | let token: Token = await connection 21 | .createQueryBuilder(Token, "token") 22 | .where("token.token = :id", { id: request.token }) 23 | .leftJoinAndSelect("token.user", "user") 24 | .leftJoinAndSelect("user.roles", "role") 25 | .leftJoinAndSelect("user.departments", "department") 26 | .getOne(); 27 | if (!token) { 28 | throw error("token not found", 400); 29 | } 30 | 31 | // check if organization exists 32 | let existingOrganization = await connection 33 | .createQueryBuilder(Organization, "organization") 34 | .where("organization.name = :name", { name: request.organization.name }) 35 | .leftJoinAndSelect("organization.departments", "department") 36 | .getOne(); 37 | if ( 38 | existingOrganization && 39 | existingOrganization.departments.length > 0 && 40 | existingOrganization.departments.find((d) => 41 | token.user.departments.find((ud) => ud.id == d.id) 42 | ) 43 | ) { 44 | return { 45 | organization: existingOrganization, 46 | }; 47 | } 48 | 49 | let organization = new Organization(); 50 | organization.id = nanoid(); 51 | organization.name = request.organization.name; 52 | organization.slug = slug(request.organization.name); 53 | 54 | // create the first department 55 | let department = new Department(); 56 | department.id = nanoid(); 57 | department.name = "Department #1"; 58 | department.organizationId = organization.id; 59 | department.slug = slug(department.name); 60 | department.balance = 0; 61 | 62 | let user = token.user; 63 | user.departments = [department]; 64 | user.roles.push( 65 | { 66 | id: nanoid(), 67 | key: `organization:${organization.id}:admin`, 68 | }, 69 | { id: nanoid(), key: `department:${department.id}:admin` } 70 | ); 71 | 72 | await connection.transaction(async (tran) => { 73 | // roles will be saved (created even) 74 | // due to the cascade option, see the model 75 | await tran.save(organization); 76 | await tran.save(department); 77 | await tran.save(user); 78 | }); 79 | 80 | return { 81 | organization: organization, 82 | }; 83 | }; 84 | -------------------------------------------------------------------------------- /src/service/authentication/user/userList.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../../util.js"; 3 | import { 4 | roleAdmin, 5 | Token, 6 | User, 7 | UserListRequest, 8 | UserListResponse, 9 | } from "../models.js"; 10 | 11 | export default async ( 12 | connection: DataSource, 13 | request: UserListRequest 14 | ): Promise => { 15 | let token: Token = await connection 16 | .createQueryBuilder(Token, "token") 17 | .where("token.token = :id", { id: request.token }) 18 | .getOne(); 19 | if (!token) { 20 | throw error("token not found", 400); 21 | } 22 | 23 | let user: User = await connection 24 | .createQueryBuilder(User, "user") 25 | .where("user.id = :id", { id: token.userId }) 26 | .leftJoinAndSelect("user.roles", "role") 27 | .leftJoinAndSelect("user.departments", "department") 28 | .getOne(); 29 | 30 | let isAdmin = false; 31 | if (user.roles?.find((r) => r.id == roleAdmin.id)) { 32 | isAdmin = true; 33 | } 34 | 35 | if (!isAdmin) { 36 | throw error("no rights", 400); 37 | } 38 | 39 | if (!request.orderByField) { 40 | request.orderByField = "createdAt"; 41 | } 42 | if (!request.skip) { 43 | request.skip = 0; 44 | } 45 | if (!request.limit) { 46 | request.limit = 100; 47 | } 48 | 49 | if ( 50 | request.departmentId && 51 | !user.departments.find((d) => { 52 | return (d.id = request.departmentId); 53 | }) 54 | ) { 55 | throw error("no access to department", 400); 56 | } 57 | 58 | if (request.departmentId) { 59 | let users: User[] = await connection 60 | .createQueryBuilder(User, "user") 61 | .orderBy("user." + request.orderByField, request.asc ? "ASC" : "DESC") 62 | .skip(request.skip) 63 | .limit(request.limit) 64 | .innerJoinAndSelect( 65 | "user.departments", 66 | "department", 67 | "department.id = :departmentId", 68 | { departmentId: request.departmentId } 69 | ) 70 | .leftJoinAndSelect("user.roles", "role") 71 | .leftJoinAndSelect("user.contacts", "contact") 72 | //.leftJoinAndSelect("user.thumbnail", "thumbnail") 73 | .leftJoinAndSelect("department.organization", "organization") 74 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail") 75 | .getMany(); 76 | 77 | return { 78 | users: users, 79 | }; 80 | } 81 | 82 | let users: User[] = await connection 83 | .createQueryBuilder(User, "user") 84 | .orderBy("user." + request.orderByField, request.asc ? "ASC" : "DESC") 85 | .skip(request.skip) 86 | .limit(request.limit) 87 | .leftJoinAndSelect("user.roles", "role") 88 | .leftJoinAndSelect("user.contacts", "contact") 89 | //.leftJoinAndSelect("user.thumbnail", "thumbnail") 90 | .leftJoinAndSelect("user.departments", "department") 91 | .leftJoinAndSelect("department.organization", "organization") 92 | //.leftJoinAndSelect("organization.thumbnail", "orgthumbnail") 93 | .getMany(); 94 | 95 | return { 96 | users: users, 97 | }; 98 | }; 99 | -------------------------------------------------------------------------------- /src/service/authentication/user/userLogin.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from "bcrypt"; 2 | import { nanoid } from "nanoid"; 3 | import { DataSource } from "typeorm"; 4 | import { error } from "../../../util.js"; 5 | import { 6 | Contact, 7 | Password, 8 | platformEmail, 9 | Token, 10 | UserLoginRequest, 11 | UserLoginResponse, 12 | } from "../models.js"; 13 | 14 | const err = error("incorrect credentials", 400); 15 | 16 | export default async ( 17 | connection: DataSource, 18 | request: UserLoginRequest 19 | ): Promise => { 20 | // only email login supported for now 21 | let contacts = await connection 22 | .createQueryBuilder(Contact, "contact") 23 | .where("contact.url = :url AND contact.platformId = :platformId", { 24 | url: request.contactUrl, 25 | platformId: platformEmail.id, 26 | }) 27 | .leftJoinAndSelect("contact.user", "user") 28 | .getMany(); 29 | 30 | if (!contacts || contacts.length == 0) { 31 | throw error("contact not found", 400); 32 | } 33 | let contact = contacts[0]; 34 | if (!contact.user) { 35 | throw error("user for contact not found", 400); 36 | } 37 | if (!contact.user.id) { 38 | throw err; 39 | } 40 | 41 | let passwords = await connection 42 | .createQueryBuilder(Password, "password") 43 | .where("password.userId = :id", { 44 | id: contact.user.id, 45 | }) 46 | .getMany(); 47 | if (!passwords || passwords.length == 0) { 48 | throw error("password not found", 400); 49 | } 50 | // @todo be very careful with filter and other list ops when supporting 51 | // multiple passwords as most ops don't support promises 52 | let matched = await bcrypt.compare(request.password, passwords[0].id); 53 | 54 | if (!matched) { 55 | throw error("login unsuccessful", 400); 56 | } 57 | let token = new Token(); 58 | // @todo Even the token should be hashed so even if the database leaks 59 | // token ID won't be usable 60 | // let unhashedTokenId = nanoid(); 61 | // token.id = await bcrypt.hash(unhashedTokenId, 10); 62 | token.id = nanoid(); 63 | token.token = nanoid(); 64 | token.userId = contact.user.id; 65 | 66 | await connection.getRepository(Token).save(token); 67 | 68 | return { 69 | token: token, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/service/authentication/user/userRegister.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | User, 4 | Contact, 5 | Token, 6 | Password, 7 | UserRegisterRequest, 8 | UserRegisterResponse, 9 | platformEmail, 10 | Secret, 11 | roleAdmin, 12 | } from "../models.js"; 13 | import { nanoid } from "nanoid"; 14 | import slug from "slug"; 15 | import * as bcrypt from "bcrypt"; 16 | import { error } from "../../../util.js"; 17 | // import verificationCodeSend from "./verificationCodeSend"; 18 | import userLogin from "./userLogin.js"; 19 | import { ConfigService } from "../../config/index.js"; 20 | 21 | export default async ( 22 | connection: DataSource, 23 | config: ConfigService, 24 | request: UserRegisterRequest, 25 | defaultConfig: Secret 26 | ): Promise => { 27 | let userId = nanoid(); 28 | let user = new User(); 29 | 30 | let password = new Password(); 31 | let contact = new Contact(); 32 | let conf = await config.configRead({}); 33 | 34 | if (!request.ghostRegister) { 35 | if (!request.password) { 36 | throw error("missing password", 400); 37 | } 38 | 39 | if (!request.user) { 40 | throw error("missing user", 400); 41 | } 42 | 43 | // check if user exists 44 | if (!request.user?.contacts) { 45 | throw error("missing contact", 400); 46 | } 47 | 48 | let cont = request.user.contacts[0]; 49 | 50 | if ( 51 | conf.config.isProduction && 52 | cont.platformId == platformEmail.id && 53 | cont.url.includes("+") 54 | ) { 55 | throw error( 56 | "email address cannot contain '+' in a production environment. " + 57 | "if this is a local environment set the config `isProduction` to false, because it is true for some reason. ", 58 | 400 59 | ); 60 | } 61 | 62 | if (!isValidEmail(cont.url)) { 63 | throw error("invalid email address", 400); 64 | } 65 | 66 | let existingContacts: Contact[] = await connection 67 | .createQueryBuilder(Contact, "contact") 68 | .where(`contact."platformId" = :platformId AND contact.url = :url`, { 69 | platformId: platformEmail.id, 70 | url: cont.url, 71 | }) 72 | .getMany(); 73 | 74 | if (existingContacts?.length > 0) { 75 | // @todo maybe we should just throw an error here 76 | let rsp = await userLogin(connection, { 77 | contactUrl: cont.url, 78 | password: request.password, 79 | }); 80 | return rsp; 81 | } 82 | 83 | let contactId = nanoid(); 84 | contact.id = contactId; 85 | contact.platformId = platformEmail.id; 86 | contact.url = cont.url; 87 | contact.verified = false; 88 | contact.userId = userId; 89 | 90 | password.id = await bcrypt.hash(request.password, 10); 91 | password.userId = userId; 92 | } else { 93 | user.ghost = true; 94 | user.slug = "ghost-" + nanoid(); 95 | } 96 | 97 | user.id = userId; 98 | if (request.user) { 99 | var slugable = request.user.slug || request.user.fullName || user.id; 100 | user.slug = slug(slugable); 101 | user.fullName = request.user.fullName; 102 | user.gender = request.user.gender; 103 | user.location = request.user.location; 104 | user.address = request.user.address; 105 | } 106 | 107 | let sc = await config.secretRead({}); 108 | // hack 109 | if ( 110 | (defaultConfig.adminPassword && 111 | request.password == defaultConfig.adminPassword) || 112 | (sc.secret?.data?.AuthenticationService?.adminPassword && 113 | request.password == sc.secret.data?.AuthenticationService?.adminPassword) 114 | ) { 115 | user.roles = [roleAdmin]; 116 | } 117 | 118 | let token = new Token(); 119 | // @todo Even the token id will be hashed so even if the database leaks 120 | // token ID won't be usable 121 | // let unhashedTokenId = nanoid(); 122 | // token.id = await bcrypt.hash(unhashedTokenId, 10); 123 | token.id = nanoid(); 124 | token.userId = userId; 125 | token.token = nanoid(); 126 | 127 | await connection.transaction(async (tran) => { 128 | // roles will be saved (created even) 129 | // due to the cascade option, see the model 130 | await tran.save(user); 131 | if (!request.ghostRegister) { 132 | await tran.save(password); 133 | await tran.save(contact); 134 | } 135 | await tran.save(token); 136 | }); 137 | 138 | //if (!cont.url.includes(ownerDomain())) { 139 | // await verificationCodeSend(connection, config, { 140 | // token: token.token, 141 | // }); 142 | //} 143 | 144 | return { 145 | token: token, 146 | }; 147 | }; 148 | 149 | const isValidEmail = (email) => { 150 | const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 151 | return emailRegex.test(email); 152 | }; 153 | -------------------------------------------------------------------------------- /src/service/authentication/user/userSave.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../../util.js"; 3 | import { 4 | roleAdmin, 5 | Token, 6 | User, 7 | UserSaveRequest, 8 | UserSaveResponse, 9 | } from "../models.js"; 10 | 11 | export default async ( 12 | connection: DataSource, 13 | request: UserSaveRequest 14 | ): Promise => { 15 | let token: Token = await connection 16 | .createQueryBuilder(Token, "token") 17 | .where("token.token = :id", { id: request.token }) 18 | .getOne(); 19 | if (!token) { 20 | throw error("token not found", 400); 21 | } 22 | 23 | let caller: User = await connection 24 | .createQueryBuilder(User, "user") 25 | .where("user.id = :id", { id: token.userId }) 26 | .leftJoinAndSelect("user.roles", "role") 27 | .getOne(); 28 | 29 | let user = new User(request.user); 30 | user.id = caller.id; 31 | 32 | let isAdmin = false; 33 | if (caller.roles?.find((r) => r.id == roleAdmin.id)) { 34 | isAdmin = true; 35 | } 36 | 37 | // @todo allow admins to update roles maybe? 38 | delete user.roles; 39 | 40 | // only allow admins to update others 41 | if (!isAdmin && caller.id != user.id) { 42 | throw error("not permitted", 400); 43 | } 44 | 45 | await connection.transaction(async (tran) => { 46 | if (user.meta) { 47 | let dbUser = await tran.findOne(User, { 48 | select: ["meta"], 49 | where: { id: user.id }, 50 | }); 51 | if (dbUser && dbUser.meta) { 52 | user.meta = mergeObjects(user.meta, dbUser.meta); 53 | } 54 | } 55 | 56 | await tran.save(user); 57 | }); 58 | 59 | return { user: user }; 60 | }; 61 | 62 | function mergeObjects(obj1, obj2) { 63 | if (typeof obj1 !== "object" || typeof obj2 !== "object") { 64 | // Return the non-object value if either input is not an object 65 | return obj2 !== undefined ? obj2 : obj1; 66 | } 67 | 68 | var mergedObj = {}; 69 | 70 | // Merge keys from obj1 71 | for (var key in obj1) { 72 | if (obj1.hasOwnProperty(key)) { 73 | mergedObj[key] = mergeObjects(obj1[key], obj2[key]); 74 | } 75 | } 76 | 77 | // Merge keys from obj2 78 | for (var key in obj2) { 79 | if (obj2.hasOwnProperty(key) && !obj1.hasOwnProperty(key)) { 80 | mergedObj[key] = mergeObjects(obj1[key], obj2[key]); 81 | } 82 | } 83 | 84 | return mergedObj; 85 | } 86 | -------------------------------------------------------------------------------- /src/service/authentication/user/userSlugCheck.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | User, 4 | UserSlugCheckRequest, 5 | UserSlugCheckResponse, 6 | } from "../models.js"; 7 | 8 | export default async ( 9 | connection: DataSource, 10 | request: UserSlugCheckRequest 11 | ): Promise => { 12 | let users = await connection 13 | .getRepository(User) 14 | .find({ where: { slug: request.slug } }); 15 | 16 | if (!users || users.length == 0) { 17 | return { 18 | taken: false, 19 | }; 20 | } 21 | 22 | return { 23 | taken: true, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/service/authentication/user/userUnGhost.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../../util.js"; 3 | import * as bcrypt from "bcrypt"; 4 | import { nanoid } from "nanoid"; 5 | import { 6 | Token, 7 | User, 8 | Password, 9 | Contact, 10 | UserUnGhostRequest, 11 | UserUnGhostResponse, 12 | platformEmail, 13 | } from "../models.js"; 14 | 15 | export default async ( 16 | connection: DataSource, 17 | request: UserUnGhostRequest 18 | ): Promise => { 19 | let token: Token = await connection 20 | .createQueryBuilder(Token, "token") 21 | .where("token.token = :id", { id: request.token }) 22 | .leftJoinAndSelect("token.user", "user") 23 | .getOne(); 24 | if (!token) { 25 | throw error("token not found", 400); 26 | } 27 | 28 | let user = new User(); 29 | user.id = token.user.id; 30 | 31 | let password = new Password(); 32 | let contact = new Contact(); 33 | contact.id = nanoid(); 34 | contact.platformId = platformEmail.id; 35 | contact.userId = user.id; 36 | contact.url = request.contact.url; 37 | 38 | password.id = await bcrypt.hash(request.password, 10); 39 | password.userId = user.id; 40 | 41 | await connection.transaction(async (tran) => { 42 | await tran.save(password); 43 | await tran.save(contact); 44 | }); 45 | 46 | return { user: user }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/service/authentication/user/verificationCodeSend.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { 3 | VerificationCodeSendRequest, 4 | VerificationCodeSendResponse, 5 | User, 6 | Token, 7 | SecretCode, 8 | Secret, 9 | } from "../models.js"; 10 | import sendgrid from "@sendgrid/mail"; 11 | import { Error, error } from "../../../util.js"; 12 | import { nanoid } from "nanoid"; 13 | import { ConfigService } from "../../config/index.js"; 14 | 15 | // @todo 16 | function getSendgridKey(): string { 17 | return "sg_key"; 18 | } 19 | 20 | export default async ( 21 | connection: DataSource, 22 | config: ConfigService, 23 | request: VerificationCodeSendRequest 24 | ): Promise => { 25 | let token: Token = await connection 26 | .createQueryBuilder(Token, "token") 27 | .where("token.token = :id", { id: request.token }) 28 | .getOne(); 29 | if (!token) { 30 | throw error("token not found", 400); 31 | } 32 | 33 | let user: User = await connection 34 | .createQueryBuilder(User, "user") 35 | .where("user.id = :id", { id: token.userId }) 36 | .leftJoinAndSelect("user.roles", "role") 37 | .leftJoinAndSelect("user.contacts", "contact") 38 | .getOne(); 39 | 40 | let code = new SecretCode(); 41 | code.id = nanoid(); 42 | code.code = nanoid(); 43 | code.userId = user.id; 44 | 45 | let c = await config.secretRead({}); 46 | if (c instanceof Error) { 47 | return c; 48 | } 49 | let conf: Secret = c.secret?.data?.AuthenticationService; 50 | 51 | sendgrid.setApiKey(getSendgridKey()); 52 | const msg = { 53 | // @todo this only supports a single contract 54 | to: user.contacts[0].url, // Change to your recipient 55 | from: conf?.email?.from, // Change to your verified sender 56 | subject: conf?.email?.register?.subject || "Email confirmation", 57 | text: 58 | conf?.email?.register.text || 59 | "Szia,\n\nKérünk erősítsd meg az emailedet erre a linkre kattintva:\n\nhttps://kuponjaim.hu/auth/email-verification?code=" + 60 | code.code + 61 | "\n\nA kuponjaim.hu csapata", 62 | }; 63 | 64 | await connection.transaction(async (tran) => { 65 | await tran.save(code); 66 | }); 67 | 68 | await sendgrid.send(msg); 69 | 70 | return {}; 71 | }; 72 | -------------------------------------------------------------------------------- /src/service/authentication/user/verificationCodeVerify.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../../util.js"; 3 | import { 4 | Contact, 5 | SecretCode, 6 | User, 7 | VerificationCodeVerifyRequest, 8 | VerificationCodeVerifyResponse, 9 | } from "../models.js"; 10 | 11 | export default async ( 12 | connection: DataSource, 13 | request: VerificationCodeVerifyRequest 14 | ): Promise => { 15 | let code: SecretCode = await connection 16 | .createQueryBuilder(SecretCode, "code") 17 | .where("code.code = :id", { id: request.code }) 18 | .getOne(); 19 | 20 | if (!code || !code.id) { 21 | throw error("code not found", 400); 22 | } 23 | 24 | let user: User = await connection 25 | .createQueryBuilder(User, "user") 26 | .where("user.id = :id", { id: code.userId }) 27 | .leftJoinAndSelect("user.contacts", "contact") 28 | .getOne(); 29 | 30 | if (code.userId != user.id) { 31 | throw error("code is wrong", 400); 32 | } 33 | 34 | await connection.transaction(async (tran) => { 35 | await tran 36 | .createQueryBuilder(Contact, "contact") 37 | .update(Contact) 38 | .where(`contact.url = :url AND contact."platformId" = :platform`, { 39 | url: user.contacts[0].url, 40 | platform: user.contacts[0].platformId, 41 | }) 42 | .set({ 43 | verified: true, 44 | }) 45 | .execute(); 46 | }); 47 | 48 | return {}; 49 | }; 50 | -------------------------------------------------------------------------------- /src/service/authentication/userSave.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { Injector } from "../../injector.js"; 3 | import { nanoid } from "nanoid"; 4 | import { AuthenticationService } from "./index.js"; 5 | 6 | describe("auth user update", () => { 7 | var auth: AuthenticationService; 8 | 9 | test("setup", async () => { 10 | let namespace = "t_" + nanoid().slice(0, 7); 11 | 12 | let i = new Injector([AuthenticationService]); 13 | auth = await i.getInstance("AuthenticationService", namespace); 14 | }); 15 | 16 | test("auth was initiated", async () => { 17 | expect(auth).toBeTruthy(); 18 | }); 19 | 20 | test("meta tests", async () => { 21 | let rsp = await auth.userRegister({ 22 | user: { 23 | contacts: [{ url: "test-3@test.com" }], 24 | }, 25 | password: "1011", 26 | }); 27 | 28 | await auth.userRegister({ 29 | user: { 30 | contacts: [{ url: "test-4@test.com" }], 31 | }, 32 | password: "1011", 33 | }); 34 | 35 | await auth.userSave({ 36 | token: rsp.token.token, 37 | user: { 38 | meta: { 39 | x: "y", 40 | y: true, 41 | obj: { 42 | a: "b", 43 | }, 44 | }, 45 | }, 46 | }); 47 | 48 | await auth.userSave({ 49 | token: rsp.token.token, 50 | user: { 51 | meta: { 52 | z: 3, 53 | }, 54 | }, 55 | }); 56 | let trsp = await auth.tokenRead({ token: rsp.token.token }); 57 | expect(trsp.token.user.meta.x).toBe("y"); 58 | expect(trsp.token.user.meta.y).toBe(true); 59 | expect(trsp.token.user.meta.z).toBe(3); 60 | 61 | await auth.userSave({ 62 | token: rsp.token.token, 63 | user: { 64 | meta: { 65 | obj: { 66 | b: "c", 67 | }, 68 | }, 69 | }, 70 | }); 71 | 72 | trsp = await auth.tokenRead({ token: rsp.token.token }); 73 | expect(trsp.token.user.meta.obj.a).toBe("b"); 74 | expect(trsp.token.user.meta.obj.b).toBe("c"); 75 | }); 76 | 77 | test("org update", async () => { 78 | let rsp = await auth.userRegister({ 79 | user: { 80 | contacts: [{ url: "test-5@test.com" }], 81 | }, 82 | password: "1015", 83 | }); 84 | 85 | await auth.userCreateOrganization({ 86 | token: rsp.token.token, 87 | organization: { 88 | name: "user test org", 89 | }, 90 | }); 91 | 92 | await auth.userCreateOrganization({ 93 | token: rsp.token.token, 94 | organization: { 95 | name: "user test org", 96 | }, 97 | }); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /src/service/config/README.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](../../../docs/README.md) 2 | # Config Service 3 | 4 | This is a config service that supports both loading data from the database or dotenv. 5 | 6 | - [Config Service](#config-service) 7 | - [Concepts](#concepts) 8 | - [Envars](#envars) 9 | - [Conventions](#conventions) 10 | - [Top level values](#top-level-values) 11 | - [Default config/secrets for your services](#default-configsecrets-for-your-services) 12 | 13 | ## Concepts 14 | 15 | The config service deals with two kind of data: `Config` and `Secrets`. 16 | 17 | `Config`s are meant to be readable without authorization. Prime examples are public keys, eg. Facebook app ID, Stripe public key etc. 18 | 19 | `Secrets` are meant to be read by services. 20 | 21 | Secrets are not readable through HTTP, but they can be updated through HTTP with admin rights. 22 | 23 | ## Envars 24 | 25 | All envars starting with `CONFIG_` are loaded into the `data` field of the config. 26 | 27 | All envars starting with `SECRET_` are loaded into the `data` field of the secret. 28 | 29 | ## Conventions 30 | 31 | We advise that you save data into the config service under a key that is unique to your service, the key should most probably be its name. 32 | 33 | See for example how the [`AuthenticationService`](../authentication/README.md) uses the config service: 34 | 35 | ```ts 36 | let srsp = await this.config.secretRead({}); 37 | let secret: Secret = srsp.secret.data.AuthenticationService; 38 | ``` 39 | 40 | ### Top level values 41 | 42 | There are a few notable top level values which fall outside of the convention mentioned in the previous section: 43 | 44 | Using the notation from the code above: 45 | 46 | ```ts 47 | // tells the system that this is a production instance 48 | // used by eg. the FileService 49 | srsp.secret.data.isProduction 50 | ``` 51 | 52 | ### Default config/secrets for your services 53 | 54 | We encourage you to define your own `Secret` type in your service for ease of understanding when someone explores your service, similar to what the [`AuthenticationService`](../authentication/README.md) is doing: 55 | 56 | ```ts 57 | export class Secret { 58 | /** Admin user fullname */ 59 | fullName?: string; 60 | /** Admin user email */ 61 | adminEmail?: string; 62 | /** Admin user password */ 63 | adminPassword?: string; 64 | /** Admin user organization name */ 65 | adminOrganization?: string; 66 | 67 | sendgrid?: { 68 | key?: string; 69 | }; 70 | email?: { 71 | from?: string; 72 | register?: { 73 | /** 74 | * Subject of 75 | */ 76 | subject?: string; 77 | text?: string; 78 | }; 79 | }; 80 | 81 | // https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow#login 82 | facebookAppID?: string; 83 | facebookAppSecret?: string; 84 | facebookAppRedirectURL?: string; 85 | } 86 | 87 | export const defaultSecret: Secret = { 88 | adminEmail: "example@example.com", 89 | adminPassword: "admin", 90 | adminOrganization: "Admin Org", 91 | fullName: "The Admin", 92 | }; 93 | ``` 94 | 95 | We advise you to reuse this `Secret` type across your service, ie. use the same structure to save secrets. 96 | 97 | So in practice, you might do something like this: 98 | 99 | ```ts 100 | let srsp = await this.config.secretRead({}); 101 | // using your own Secret type to understand data obtained from config service 102 | let secret: Secret = srsp.secret.data.YourServiceName; 103 | ``` -------------------------------------------------------------------------------- /src/service/config/config.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { Injector } from "../../injector.js"; 3 | import { nanoid } from "nanoid"; 4 | import { ConfigService } from "./index.js"; 5 | 6 | describe("Config tests", () => { 7 | var config: ConfigService; 8 | test("setup", async () => { 9 | let namespace = "t_" + nanoid().slice(0, 7); 10 | let i = new Injector([ConfigService]); 11 | config = await i.getInstance("ConfigService", namespace); 12 | }); 13 | 14 | test("config read basics", async () => { 15 | expect(config).toBeTruthy(); 16 | let rsp = await config.configRead({}); 17 | expect(rsp.config?.data).toBeTruthy(); 18 | }); 19 | 20 | // test if saved values get deeply merged 21 | test("config value merge test", async () => { 22 | expect(config).toBeTruthy(); 23 | let rsp = await config.configSaveS2S({ 24 | token: "", 25 | config: { 26 | slogan: "slogan1", 27 | data: { 28 | "test-val1": "test-val1", 29 | }, 30 | }, 31 | }); 32 | expect(rsp.config?.data).toBeTruthy(); 33 | expect(rsp.config?.data["test-val1"]).toBe("test-val1"); 34 | expect(rsp.config?.slogan).toBe("slogan1"); 35 | let rsp2 = await config.configSaveS2S({ 36 | token: "", 37 | config: { 38 | domain: "domain1", 39 | data: { 40 | "test-val2": "test-val2", 41 | }, 42 | }, 43 | }); 44 | expect(rsp2.config?.data).toBeTruthy(); 45 | expect(rsp2.config?.data["test-val2"]).toBe("test-val2"); 46 | expect(rsp2.config?.domain).toBe("domain1"); 47 | 48 | let readResp = await config.configRead({}); 49 | expect(readResp.config?.data).toBeTruthy(); 50 | expect(readResp.config?.data["test-val1"]).toBe("test-val1"); 51 | expect(readResp.config?.data["test-val2"]).toBe("test-val2"); 52 | expect(readResp.config?.domain).toBe("domain1"); 53 | expect(readResp.config?.slogan).toBe("slogan1"); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/service/config/configRead.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Config, ConfigReadResponse } from "./models.js"; 3 | 4 | export default async ( 5 | connection: DataSource, 6 | request: any 7 | ): Promise => { 8 | let config: Config; 9 | try { 10 | config = await connection.createQueryBuilder(Config, "config").getOne(); 11 | } catch (e) { 12 | console.log(e); 13 | return { 14 | config: { 15 | data: {}, 16 | }, 17 | }; 18 | } 19 | if (!config) { 20 | config = { 21 | data: {}, 22 | }; 23 | } 24 | 25 | // Add env variables to config for local development 26 | // see readme for more info 27 | Object.keys(process.env).forEach(function (key) { 28 | if (!key.startsWith("CONFIG_")) { 29 | return; 30 | } 31 | if (!config?.data) { 32 | config.data = {}; 33 | } 34 | let key2 = key 35 | .replace("CONFIG_", "") 36 | .toLowerCase() 37 | // https://stackoverflow.com/questions/6660977/convert-hyphens-to-camel-case-camelcase 38 | .replace(/_([a-z])/g, function (g) { 39 | return g[1].toUpperCase(); 40 | }); 41 | config.data[key2] = process.env[key]; 42 | }); 43 | 44 | return { 45 | config: config, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/service/config/configSave.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../util.js"; 3 | import { roleAdmin } from "../authentication/models.js"; 4 | import { AuthenticationService } from "../authentication/index.js"; 5 | import { ConfigSaveRequest, ConfigSaveResponse } from "./models.js"; 6 | import configSaveS2S from "./configSaveS2S.js"; 7 | 8 | export default async ( 9 | connection: DataSource, 10 | depProducer: (serviceName: string) => Promise, 11 | request: ConfigSaveRequest 12 | ): Promise => { 13 | let auth = await depProducer("AuthenticationService"); 14 | let token = await auth.tokenRead({ 15 | token: request.token, 16 | }); 17 | if (token.token.user.roles.find((r) => r.id == roleAdmin.id) == undefined) { 18 | throw error("not authorized", 400); 19 | } 20 | return configSaveS2S(connection, request); 21 | }; 22 | -------------------------------------------------------------------------------- /src/service/config/configSaveS2S.ts: -------------------------------------------------------------------------------- 1 | //import { nanoid } from "nanoid"; 2 | import { DataSource } from "typeorm"; 3 | import { copy } from "../../util.js"; 4 | import { Config, ConfigSaveRequest, ConfigSaveResponse } from "./models.js"; 5 | 6 | export default async ( 7 | connection: DataSource, 8 | request: ConfigSaveRequest 9 | ): Promise => { 10 | let dbConfig: Config; 11 | try { 12 | dbConfig = await connection.createQueryBuilder(Config, "config").getOne(); 13 | } catch (e) { 14 | console.log(e); 15 | return { 16 | config: {}, 17 | }; 18 | } 19 | if (!dbConfig) { 20 | dbConfig = { 21 | data: {}, 22 | }; 23 | } 24 | 25 | let config = new Config(); 26 | let result = mergeDeep(config, dbConfig, request.config); 27 | copy(result, config); 28 | if (!config.id) { 29 | config.id = "1"; 30 | } 31 | 32 | await connection.transaction(async (tran) => { 33 | await tran.save(config); 34 | }); 35 | 36 | return { 37 | config: config, 38 | }; 39 | }; 40 | 41 | // taken from https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge 42 | 43 | /** 44 | * Simple object check. 45 | * @param item 46 | * @returns {boolean} 47 | */ 48 | export function isObject(item) { 49 | return item && typeof item === "object" && !Array.isArray(item); 50 | } 51 | 52 | /** 53 | * Deep merge two objects. 54 | * @param target 55 | * @param ...sources 56 | */ 57 | export function mergeDeep(target, ...sources) { 58 | if (!sources.length) return target; 59 | const source = sources.shift(); 60 | 61 | if (isObject(target) && isObject(source)) { 62 | for (const key in source) { 63 | if (isObject(source[key])) { 64 | if (!target[key]) Object.assign(target, { [key]: {} }); 65 | mergeDeep(target[key], source[key]); 66 | } else { 67 | Object.assign(target, { [key]: source[key] }); 68 | } 69 | } 70 | } 71 | 72 | return mergeDeep(target, ...sources); 73 | } 74 | -------------------------------------------------------------------------------- /src/service/config/index.ts: -------------------------------------------------------------------------------- 1 | import { Service, Unexposed } from "../../reflect.js"; 2 | import { DataSource } from "typeorm"; 3 | 4 | import configRead from "./configRead.js"; 5 | import configSave from "./configSave.js"; 6 | import configSaveS2S from "./configSaveS2S.js"; 7 | import secretSaveS2S from "./secretSaveS2S.js"; 8 | 9 | import { 10 | Config, 11 | Secret, 12 | ConfigSaveRequest, 13 | ConfigReadRequest, 14 | SecretReadRequest, 15 | SecretSaveRequest, 16 | } from "./models.js"; 17 | import { Servicelike } from "../../util.js"; 18 | import { AuthenticationService } from "../authentication/index.js"; 19 | import secretRead from "./secretRead.js"; 20 | import secretSave from "./secretSave.js"; 21 | 22 | @Service() 23 | export class ConfigService implements Servicelike { 24 | meta = { 25 | name: "config", 26 | typeorm: { 27 | entities: [Config, Secret], 28 | }, 29 | }; 30 | 31 | private connection: DataSource; 32 | /** depProducer pattern is used here to get around circular 33 | * dependencies. The AuthenticationService depends on the 34 | * ConfigService, and the ConfigService depends on the 35 | * AuthenticationService. This is a workaround for that. 36 | */ 37 | private depProducer: (serviceName: string) => Promise; 38 | 39 | constructor( 40 | connection: DataSource, 41 | depProducer: (serviceName: string) => Promise 42 | ) { 43 | this.connection = connection; 44 | this.depProducer = depProducer; 45 | } 46 | 47 | configRead(req: ConfigReadRequest) { 48 | return configRead(this.connection, req); 49 | } 50 | 51 | configSave(req: ConfigSaveRequest) { 52 | return configSave(this.connection, this.depProducer, req); 53 | } 54 | 55 | @Unexposed() 56 | configSaveS2S(req: ConfigSaveRequest) { 57 | return configSaveS2S(this.connection, req); 58 | } 59 | 60 | @Unexposed() 61 | secretSaveS2S(req: SecretSaveRequest) { 62 | return secretSaveS2S(this.connection, req); 63 | } 64 | 65 | @Unexposed() 66 | /** Should not be exposed over http */ 67 | secretRead(req: SecretReadRequest) { 68 | return secretRead(this.connection, req); 69 | } 70 | 71 | /** Should be exposed over http but authenticated with admin */ 72 | secretSave(req: SecretSaveRequest) { 73 | return secretSave(this.connection, this.depProducer, req); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/service/config/models.ts: -------------------------------------------------------------------------------- 1 | // (like default missions, admin accounts etc.) should come from `contets.ts` 2 | import { Entity, PrimaryColumn, Column } from "typeorm"; 3 | 4 | export declare type Relation = T; 5 | 6 | /** Public site wide config. */ 7 | @Entity() 8 | export class Config { 9 | @PrimaryColumn() 10 | id?: string; 11 | 12 | @Column({ nullable: true }) 13 | domain?: string; 14 | 15 | @Column({ nullable: true }) 16 | isProduction?: boolean; 17 | 18 | @Column({ nullable: true }) 19 | template?: string; 20 | 21 | @Column({ nullable: true }) 22 | slogan?: string; 23 | 24 | @Column({ type: "jsonb", nullable: true }) 25 | contact?: Relation; 26 | 27 | @Column({ type: "jsonb", nullable: true }) 28 | favicon?: Relation; 29 | 30 | @Column({ type: "jsonb", nullable: true }) 31 | og?: Relation; 32 | 33 | @Column({ type: "jsonb", nullable: true }) 34 | /** Any unstructured data comes here */ 35 | data?: { [key: string]: any }; 36 | } 37 | 38 | export class FaviconConfig { 39 | url16?: string; 40 | url32?: string; 41 | url48?: string; 42 | } 43 | 44 | export class OgConfig { 45 | title?: string; 46 | description?: string; 47 | siteName?: string; 48 | type?: string; 49 | image?: string; 50 | } 51 | 52 | export class ContactConfig { 53 | /** Used for display on website and for emails sent from site. */ 54 | email?: string; 55 | phone?: string; 56 | } 57 | 58 | /** 59 | * Sercret. Can only be saved through HTTP but not read. 60 | * It is only read by other services. 61 | */ 62 | @Entity() 63 | export class Secret { 64 | @PrimaryColumn() 65 | id?: string; 66 | 67 | @Column({ type: "jsonb", nullable: true }) 68 | /** Any unstructured data comes here */ 69 | data?: { [key: string]: any }; 70 | } 71 | 72 | export interface ConfigReadRequest {} 73 | 74 | export interface ConfigReadResponse { 75 | config: Config; 76 | } 77 | 78 | export interface ConfigSaveRequest { 79 | token: string; 80 | config: Config; 81 | } 82 | 83 | export interface ConfigSaveResponse { 84 | config: Config; 85 | } 86 | 87 | // No token here as this is not exposed. 88 | export interface SecretReadRequest {} 89 | 90 | export interface SecretReadResponse { 91 | secret: Secret; 92 | } 93 | 94 | export interface SecretSaveRequest { 95 | token: string; 96 | secret: Secret; 97 | } 98 | 99 | export interface SecretSaveResponse { 100 | secret: Secret; 101 | } 102 | -------------------------------------------------------------------------------- /src/service/config/secretRead.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Secret, SecretReadResponse } from "./models.js"; 3 | 4 | export default async ( 5 | connection: DataSource, 6 | request: any 7 | ): Promise => { 8 | let secret: Secret; 9 | try { 10 | secret = await connection.createQueryBuilder(Secret, "secret").getOne(); 11 | } catch (e) { 12 | console.log(e); 13 | return { 14 | secret: { 15 | data: {}, 16 | }, 17 | }; 18 | } 19 | if (!secret) { 20 | secret = { 21 | data: {}, 22 | }; 23 | } 24 | 25 | // Add env variables to secret for local development 26 | // see readme for more info 27 | Object.keys(process.env).forEach(function (key) { 28 | if (!key.startsWith("SECRET_")) { 29 | return; 30 | } 31 | if (!secret?.data) { 32 | secret.data = {}; 33 | } 34 | let key2 = key 35 | .replace("SECRET_", "") 36 | .toLowerCase() 37 | // https://stackoverflow.com/questions/6660977/convert-hyphens-to-camel-case-camelcase 38 | .replace(/_([a-z])/g, function (g) { 39 | return g[1].toUpperCase(); 40 | }); 41 | secret.data[key2] = process.env[key]; 42 | }); 43 | 44 | return { 45 | secret: secret, 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/service/config/secretSave.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../util.js"; 3 | import { roleAdmin } from "../authentication/models.js"; 4 | import { AuthenticationService } from "../authentication/index.js"; 5 | import { SecretSaveRequest, SecretSaveResponse } from "./models.js"; 6 | import secretSaveS2S from "./secretSaveS2S.js"; 7 | 8 | export default async ( 9 | connection: DataSource, 10 | depProducer: (serviceName: string) => Promise, 11 | request: SecretSaveRequest 12 | ): Promise => { 13 | let auth = await depProducer("AuthenticationService"); 14 | let token = await auth.tokenRead({ 15 | token: request.token, 16 | }); 17 | if (token.token.user.roles.find((r) => r.id == roleAdmin.id) == undefined) { 18 | throw error("not authorized", 400); 19 | } 20 | return secretSaveS2S(connection, request); 21 | }; 22 | -------------------------------------------------------------------------------- /src/service/config/secretSaveS2S.ts: -------------------------------------------------------------------------------- 1 | //import { nanoid } from "nanoid"; 2 | import { DataSource } from "typeorm"; 3 | import { copy } from "../../util.js"; 4 | import { Secret, SecretSaveRequest, SecretSaveResponse } from "./models.js"; 5 | 6 | export default async ( 7 | connection: DataSource, 8 | request: SecretSaveRequest 9 | ): Promise => { 10 | let dbSecret: Secret; 11 | try { 12 | dbSecret = await connection.createQueryBuilder(Secret, "secret").getOne(); 13 | } catch (e) { 14 | console.log(e); 15 | return { 16 | secret: {}, 17 | }; 18 | } 19 | if (!dbSecret) { 20 | dbSecret = { 21 | data: {}, 22 | }; 23 | } 24 | 25 | let secret = new Secret(); 26 | let result = mergeDeep(secret, dbSecret, request.secret); 27 | copy(result, secret); 28 | if (!secret.id) { 29 | secret.id = "1"; 30 | } 31 | 32 | await connection.transaction(async (tran) => { 33 | await tran.save(secret); 34 | }); 35 | 36 | return { 37 | secret: secret, 38 | }; 39 | }; 40 | 41 | // taken from https://stackoverflow.com/questions/27936772/how-to-deep-merge-instead-of-shallow-merge 42 | 43 | /** 44 | * Simple object check. 45 | * @param item 46 | * @returns {boolean} 47 | */ 48 | export function isObject(item) { 49 | return item && typeof item === "object" && !Array.isArray(item); 50 | } 51 | 52 | 53 | /** 54 | * Deep merge two objects. 55 | * @param target 56 | * @param ...sources 57 | */ 58 | export function mergeDeep(target, ...sources) { 59 | if (!sources.length) return target; 60 | const source = sources.shift(); 61 | 62 | if (isObject(target) && isObject(source)) { 63 | for (const key in source) { 64 | if (isObject(source[key])) { 65 | if (!target[key]) Object.assign(target, { [key]: {} }); 66 | mergeDeep(target[key], source[key]); 67 | } else { 68 | Object.assign(target, { [key]: source[key] }); 69 | } 70 | } 71 | } 72 | 73 | return mergeDeep(target, ...sources); 74 | } 75 | -------------------------------------------------------------------------------- /src/service/file/README.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](../../../docs/README.md) 2 | # File Service 3 | 4 | - [File Service](#file-service) 5 | - [Concepts](#concepts) 6 | - [File upload](#file-upload) 7 | - [Configuration](#configuration) 8 | 9 | This service is aimed to be a zero config service for file uploads. 10 | 11 | Locally it saves files to your home folder in the `actio-files` folder. 12 | 13 | In production mode currently it supports Google Cloud Storage. 14 | More backends are coming, please open an issue to give ideas to the authors. 15 | 16 | ## Concepts 17 | 18 | As it can be seen my looking at the [models file](./models.ts), a `File` object consists of: 19 | 20 | ```ts 21 | id?: string; 22 | url?: string; 23 | originalName?: string; 24 | size?: number; 25 | ``` 26 | 27 | In your services using the `FileService` it is sufficient to save the file URL for simplicity. 28 | 29 | ## File upload 30 | 31 | To upload files, set the `Content-Type` request header to `"multipart/form-data"`. 32 | 33 | The service can accept multiple files. 34 | 35 | ## Configuration 36 | 37 | The service checks the top level `isProduction` config value to decide if to save to the local system or to Google Cloud Storage. 38 | 39 | See [ConfigService](../config/README.md#top-level-values) for more details. -------------------------------------------------------------------------------- /src/service/file/file.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "@jest/globals"; 2 | import { createApp } from "../../registrator.js"; 3 | import { default as request } from "supertest"; 4 | import { FileService } from "./index.js"; 5 | 6 | 7 | test("file upload", async () => { 8 | let appA = createApp([FileService]); 9 | 10 | const response = await request(appA) 11 | .post("/file/httpFileUpload") 12 | .set("Content-Type", "multipart/form-data") 13 | .attach("file", "/tmp/testfile.png"); 14 | 15 | console.log(response.body); 16 | }); 17 | -------------------------------------------------------------------------------- /src/service/file/fileUpload.ts: -------------------------------------------------------------------------------- 1 | // https://cloud.google.com/functions/docs/writing/http#multipart_data_and_file_uploads 2 | 3 | /** 4 | * Parses a 'multipart/form-data' upload request 5 | * 6 | * @param {Object} req Cloud Function request context. 7 | * @param {Object} res Cloud Function response context. 8 | */ 9 | import * as path from "path"; 10 | import * as os from "os"; 11 | import * as fs from "fs"; 12 | import { nanoid } from "nanoid"; 13 | import * as stream from "stream"; 14 | // Imports the Google Cloud client library 15 | import { Storage } from "@google-cloud/storage"; 16 | import * as express from "express"; 17 | 18 | // Node.js doesn't have a built-in multipart/form-data parsing library. 19 | // Instead, we can use the 'busboy' library from NPM to parse these requests. 20 | import busboy from "busboy"; 21 | import { default as env } from "../../env.js"; 22 | import { ConfigService } from "../config/index.js"; 23 | 24 | // @todo fix this 25 | const actioFolder = "actio-files"; 26 | 27 | interface File { 28 | name: string; 29 | path: string; 30 | } 31 | interface FileResponse { 32 | originalName: string; 33 | size: number; 34 | url: string; 35 | } 36 | 37 | export default async ( 38 | req: express.Request, 39 | res: express.Response, 40 | config: ConfigService 41 | ) => { 42 | let cfg = await config.configRead({}); 43 | 44 | if (req.method !== "POST") { 45 | // Return a "method not allowed" error 46 | return res.status(405).end(); 47 | } 48 | 49 | const bb = busboy({ headers: req.headers }); 50 | const tmpdir = os.tmpdir(); 51 | 52 | // This object will accumulate all the fields, keyed by their name 53 | const fields = {}; 54 | 55 | // This object will accumulate all the uploaded files, keyed by their name. 56 | const uploads = {}; 57 | 58 | // This code will process each non-file field in the form. 59 | bb.on("field", (fieldname, val) => { 60 | /** 61 | * TODO(developer): Process submitted field values here 62 | */ 63 | console.log(`Processed field ${fieldname}: ${val}.`); 64 | fields[fieldname] = val; 65 | }); 66 | 67 | const fileWrites = []; 68 | 69 | // This code will process each file uploaded. 70 | bb.on("file", (filename, state, file) => { 71 | // Note: os.tmpdir() points to an in-memory file system on GCF 72 | // Thus, any files in it must fit in the instance's memory. 73 | console.log(`Processed file ${filename}`); 74 | const filepath = path.join(tmpdir, filename); 75 | uploads[filename] = filepath; 76 | 77 | const writeStream = fs.createWriteStream(filepath); 78 | state.pipe(writeStream); 79 | 80 | // File was processed by Busboy; wait for it to be written. 81 | // Note: GCF may not persist saved files across invocations. 82 | // Persistent files must be kept in other locations 83 | // (such as Cloud Storage buckets). 84 | const promise = new Promise((resolve, reject) => { 85 | state.on("end", () => { 86 | writeStream.end(); 87 | }); 88 | writeStream.on("finish", resolve); 89 | writeStream.on("error", reject); 90 | }); 91 | fileWrites.push(promise); 92 | }); 93 | 94 | // Triggered once all uploaded files are processed by Busboy. 95 | // We still need to wait for the disk writes (saves) to complete. 96 | bb.on("close", async () => { 97 | console.log("Closing"); 98 | 99 | await Promise.all(fileWrites); 100 | 101 | // https://stackoverflow.com/questions/44945376/how-to-upload-an-in-memory-file-data-to-google-cloud-storage-using-nodejs 102 | 103 | var files: File[] = []; 104 | for (const filename in uploads) { 105 | files.push({ 106 | name: filename, 107 | path: uploads[filename], 108 | }); 109 | } 110 | 111 | var filesRsp: FileResponse[] = []; 112 | await Promise.all( 113 | files.map(async (file) => { 114 | // @todo make this not come from the data 115 | if (cfg.config?.data?.isProduction) { 116 | return uploadToGoogleStorage(filesRsp, cfg.config, file); 117 | } else { 118 | return saveFileToLocation(filesRsp, cfg.config, file); 119 | } 120 | }) 121 | ); 122 | res 123 | .send( 124 | JSON.stringify({ 125 | files: filesRsp, 126 | }) 127 | ) 128 | .end(); 129 | }); 130 | 131 | req.pipe(bb); 132 | }; 133 | 134 | function saveFileToLocation( 135 | filesRsp: FileResponse[], 136 | cfg: { [key: string]: any }, 137 | file: File 138 | ): Promise { 139 | let f = fs.readFileSync(file.path); 140 | fs.writeFile( 141 | path.join(os.homedir(), actioFolder, file.name), 142 | f, 143 | function (err) { 144 | if (err) { 145 | return console.log(err); 146 | } 147 | } 148 | ); 149 | // @todo make this async 150 | filesRsp.push({ 151 | originalName: file.name, 152 | size: f.length, 153 | url: "http://127.0.0.1:8080/file/httpFileServe/" + file.name, 154 | }); 155 | return null; 156 | } 157 | 158 | function uploadToGoogleStorage( 159 | filesRsp: FileResponse[], 160 | cfg: { [key: string]: any }, 161 | file: File 162 | ): Promise { 163 | const id = nanoid(); 164 | 165 | // Creates a client 166 | const cloudStorage = new Storage(); 167 | let dataStream = new stream.PassThrough(); 168 | 169 | let fileName = id + "." + file.name.split(".")[1]; 170 | let gcFile = cloudStorage 171 | .bucket(cfg.bucketName) 172 | // get extension from file 173 | .file(fileName); 174 | 175 | // file was sent under key "file" 176 | // this endpoint currently only support saving a single file 177 | let f = fs.readFileSync(file.path); 178 | // baseurl example https://storage.googleapis.com/storage/v1 179 | let baseUrl = cloudStorage.baseUrl; 180 | if (env.isProd) { 181 | // need to truncate this as baseurl != image link base url for some reason 182 | baseUrl = baseUrl.replace("/storage/v1", ""); 183 | } 184 | filesRsp.push({ 185 | originalName: file.name, 186 | size: f.length, 187 | url: baseUrl + "/" + cfg.bucketName + "/" + fileName, 188 | }); 189 | dataStream.push(f); 190 | dataStream.push(null); 191 | 192 | return new Promise((resolve, reject) => { 193 | dataStream 194 | .pipe( 195 | gcFile.createWriteStream({ 196 | resumable: false, 197 | validation: false, 198 | public: true, 199 | metadata: { "Cache-Control": "public, max-age=31536000" }, 200 | }) 201 | ) 202 | .on("error", (error: Error) => { 203 | reject(error); 204 | }) 205 | .on("finish", () => { 206 | //for (const file in uploads) { 207 | // fs.unlinkSync(uploads[file]); 208 | //} 209 | resolve(); 210 | }); 211 | }); 212 | } 213 | -------------------------------------------------------------------------------- /src/service/file/httpFileServe.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parses a 'multipart/form-data' upload request 3 | * 4 | * @param {Object} req Cloud Function request context. 5 | * @param {Object} res Cloud Function response context. 6 | */ 7 | import * as express from "express"; 8 | import * as path from "path"; 9 | import * as os from "os"; 10 | 11 | import { ConfigService } from "../config/index.js"; 12 | 13 | const actioFolder = "actio-files"; 14 | 15 | export default async ( 16 | req: express.Request, 17 | res: express.Response, 18 | config: ConfigService 19 | ) => { 20 | // example path "http://127.0.0.1:8080/file/httpFileServe/img-id-54321.jpeg" 21 | let parts = req.originalUrl.split("/"); 22 | 23 | if (parts.length < 4) { 24 | throw "file path malformed"; 25 | } 26 | 27 | console.log("serving file", path.basename(parts[3])); 28 | res; 29 | res 30 | .contentType(path.basename(parts[3])) 31 | // @todo get this from config 32 | .sendFile(path.join(os.homedir(), actioFolder, parts[3])); 33 | }; 34 | -------------------------------------------------------------------------------- /src/service/file/index.ts: -------------------------------------------------------------------------------- 1 | //import teardown from "./teardown"; 2 | import fileUpload from "./fileUpload.js"; 3 | import fileServe from "./httpFileServe.js"; 4 | 5 | import { File } from "./models.js"; 6 | import { Service, Raw } from "../../reflect.js"; 7 | import { Servicelike } from "../../util.js"; 8 | import { ConfigService } from "../config/index.js"; 9 | import * as fs from "fs"; 10 | import * as express from "express"; 11 | import * as path from "path"; 12 | import * as os from "os"; 13 | 14 | const actioFolder = "actio-files"; 15 | 16 | @Service() 17 | export class FileService implements Servicelike { 18 | private config: ConfigService; 19 | 20 | constructor(config: ConfigService) { 21 | this.config = config; 22 | } 23 | 24 | meta = { 25 | name: "file", 26 | typeorm: { 27 | entities: [File], 28 | }, 29 | }; 30 | 31 | @Raw() 32 | httpFileUpload(req: express.Request, rsp: express.Response) { 33 | fileUpload(req, rsp, this.config); 34 | } 35 | 36 | /** 37 | * Serve files in a local environments. 38 | * In production environments files will be served by Google Cloud 39 | * Storage or Amazon S3 etc (so this endpoint is only used durin 40 | * local development). 41 | * 42 | * In local environments the file upload returns an url like 43 | * "http:127.0.0.1:8080/file/httpFileServe/img-id-54321.jpeg" 44 | * 45 | * In production environments file upload returns an url like 46 | * "https://https://storage.googleapis.com/example-bucket/img-id-54321.jpg.jpeg" 47 | */ 48 | @Raw() 49 | httpFileServe(req: express.Request, rsp: express.Response) { 50 | fileServe(req, rsp, this.config); 51 | } 52 | 53 | _onInit(): Promise { 54 | // for local development only 55 | fs.promises.mkdir(path.join(os.homedir(), actioFolder), { 56 | recursive: true, 57 | }); 58 | return null; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/service/file/models.ts: -------------------------------------------------------------------------------- 1 | import { Column, Entity, PrimaryColumn } from "typeorm"; 2 | import { nanoid } from "nanoid"; 3 | 4 | export function copy(from, to) { 5 | if (!from || !to) { 6 | return; 7 | } 8 | for (const [key, value] of Object.entries(from)) { 9 | to[key] = value; 10 | } 11 | } 12 | @Entity() 13 | export class File { 14 | constructor(json?: any) { 15 | copy(json, this); 16 | if (!this.id) { 17 | this.id = nanoid(); 18 | } 19 | } 20 | @PrimaryColumn() 21 | id?: string; 22 | 23 | @Column() 24 | url?: string; 25 | 26 | @Column() 27 | originalName?: string; 28 | 29 | @Column() 30 | size?: number; 31 | 32 | //@CreateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) 33 | //createdAt?: string; 34 | // 35 | //@UpdateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) 36 | //updatedAt?: string; 37 | } 38 | 39 | export interface FileUploadResponse { 40 | files: File[]; 41 | } 42 | -------------------------------------------------------------------------------- /src/service/file/testfile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crufters/actio/a76073680f1682a2e45315fe2ad748d58ca72baf/src/service/file/testfile.png -------------------------------------------------------------------------------- /src/service/keyvalue/README.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](../../../docs/README.md) 2 | 3 | # KeyValue service 4 | 5 | - [KeyValue service](#keyvalue-service) 6 | - [Use case](#use-case) 7 | - [Fields](#fields) 8 | - [key](#key) 9 | - [namespace](#namespace) 10 | - [ownedByUser](#ownedbyuser) 11 | - [public](#public) 12 | - [publicWrite](#publicwrite) 13 | 14 | Sometimes it is useful for frontend engineers to be able to save unstructured data into the database without bothering backend engineers to add yet another tables/fields/endpoints. 15 | 16 | The `KeyValue` service serves this need. 17 | 18 | Unfortunately it is hard to write a generic service for this purpose without knowing the authorization rules of the specific application, but the `KeyValue` service sidesteps this issue by assigning ownership of a `Key` to the `User` or `Department` that first created it. 19 | 20 | ## Use case 21 | 22 | For an imperfect* example consider the following: 23 | 24 | You are a Reddit developer and want to save unstructured theming options for subreddits. Now assuming the moderators all belong to the same `Department`, all you have to do is to set the value like this: 25 | 26 | ```ts 27 | await keyValueService.set({ 28 | token: myToken, 29 | value: { 30 | key: "$subreddit-id", 31 | namespace: "themingOptions", 32 | // key will be owned by the department and not the 33 | // user that saves this key 34 | ownedByUser: false, 35 | // public means the options will be publicly readable 36 | // ie. without a token 37 | public: true, 38 | // department of the subreddit 39 | departmentId: "$subbredit-department-id" 40 | value: { 'background-color': '#ff0000' }, 41 | }, 42 | }); 43 | ``` 44 | 45 | After saving the above values will only be settable by the subreddit moderators. 46 | 47 | An important limitation here to understand is that the key must be "acquired" ideally before the subreddit ID is published, otherwise some other department might "take it". 48 | 49 | * imperfect because at the moment Actio doesn't work well with when a user belongs to a huge number of subreddits, but this is easy to fix and will be fixed later. 50 | 51 | ## Fields 52 | 53 | Below are the fields for the `Value` type that this service accepts (see [`models.ts`](./models.ts)). 54 | 55 | ### key 56 | 57 | The key : ). ([Everyone knows what a horse is](https://en.wikipedia.org/wiki/Nowe_Ateny#:~:text=Nowe%20Ateny%20is%20the%20source,a%20stinking%20kind%20of%20animal.)). 58 | 59 | This is most likely an object id that exists somewhere else in your application. A subreddit ID, a webshop ID, a user ID etc. 60 | 61 | ### namespace 62 | 63 | The namespace simply exists so you can save multiple things for a key. For a user ID key you could save namespaces like "preferences", "settings", "history" etc. 64 | 65 | ### ownedByUser 66 | 67 | If true, it is the user that owns the key and not their department. 68 | 69 | ### public 70 | 71 | If true then the key will be publicly (ie. without a token) readable. 72 | 73 | ### publicWrite 74 | 75 | If true, the key will have no concept of ownership, anyone can write or read it. -------------------------------------------------------------------------------- /src/service/keyvalue/get.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Value, GetRequest, GetResponse } from "./models.js"; 3 | import { AuthenticationService } from "../authentication/index.js"; 4 | import { Role } from "../authentication/models.js"; 5 | 6 | export default async function ( 7 | connection: DataSource, 8 | auth: AuthenticationService, 9 | req: GetRequest 10 | ): Promise { 11 | let v = await connection 12 | .createQueryBuilder(Value, "value") 13 | .where("value.key = :key", { key: req.key }) 14 | .andWhere("value.namespace = :namespace", { namespace: req.namespace }) 15 | .getOne(); 16 | 17 | if (!v) { 18 | return { 19 | value: null, 20 | }; 21 | } 22 | 23 | if (!v.public) { 24 | if (!req.token) { 25 | throw new Error("no permission"); 26 | } 27 | let tokenRsp = await auth.tokenRead({ 28 | token: req.token, 29 | }); 30 | if (v.ownedByUser && v.userId != tokenRsp.token.user.id) { 31 | throw new Error("no permission"); 32 | } 33 | if (!v.ownedByUser) { 34 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles); 35 | if (!departmentIds.includes(v.departmentId)) { 36 | throw new Error("no permission"); 37 | } 38 | } 39 | } 40 | 41 | return { 42 | value: v, 43 | }; 44 | } 45 | 46 | function getDepartmentIds(roles: Role[]): string[] { 47 | return roles 48 | .filter((r) => r.key.includes("department:")) 49 | .map((r) => r.key.split(":")[1]); 50 | } 51 | -------------------------------------------------------------------------------- /src/service/keyvalue/index.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import * as m from "./models.js"; 3 | import { Servicelike } from "../../util.js"; 4 | import { Service } from "../../reflect.js"; 5 | import { AuthenticationService } from "../authentication/index.js"; 6 | import get from "./get.js"; 7 | import set from "./set.js"; 8 | import list from "./list.js"; 9 | 10 | @Service() 11 | export class KeyValueService implements Servicelike { 12 | meta = { 13 | name: "key-value", 14 | typeorm: { 15 | entities: [m.Value], 16 | }, 17 | }; 18 | 19 | private connection: DataSource; 20 | private auth: AuthenticationService; 21 | 22 | constructor(connection: DataSource, auth: AuthenticationService) { 23 | this.connection = connection; 24 | this.auth = auth; 25 | } 26 | 27 | set(req: m.SetRequest) { 28 | return set(this.connection, this.auth, req); 29 | } 30 | 31 | get(req: m.GetRequest) { 32 | return get(this.connection, this.auth, req); 33 | } 34 | 35 | list(req: m.ListRequest) { 36 | return list(this.connection, this.auth, req); 37 | } 38 | 39 | async _onInit(): Promise { 40 | return null; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/service/keyvalue/kv.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { Injector } from "../../injector.js"; 3 | import { nanoid } from "nanoid"; 4 | import { KeyValueService } from "./index.js"; 5 | import { AuthenticationService } from "../authentication/index.js"; 6 | import { Value } from "./models.js"; 7 | 8 | describe("keyvalue", () => { 9 | var serv: KeyValueService; 10 | var auth: AuthenticationService; 11 | 12 | // users belonging to two different 13 | // departments 14 | var tok1; 15 | var tok2; 16 | test("setup", async () => { 17 | let namespace = "t_" + nanoid().slice(0, 7); 18 | let i = new Injector([KeyValueService]); 19 | serv = await i.getInstance("KeyValueService", namespace); 20 | auth = await i.getInstance("AuthenticationService", namespace); 21 | tok1 = (await auth.tokenAdminGet({})).token.token; 22 | let rsp = await auth.userRegister({ 23 | password: "123456", 24 | user: { 25 | contacts: [ 26 | { 27 | url: "something@something.com", 28 | }, 29 | ], 30 | }, 31 | }); 32 | tok2 = rsp.token.token; 33 | await auth.userCreateOrganization({ 34 | token: tok2, 35 | organization: { 36 | name: "some org", 37 | }, 38 | }); 39 | }); 40 | 41 | test("public value readable without token", async () => { 42 | await serv.set({ 43 | token: tok1, 44 | value: { 45 | key: "key1", 46 | namespace: "test", 47 | ownedByUser: true, 48 | public: true, 49 | value: { hi: "there" }, 50 | }, 51 | }); 52 | 53 | let readRsp = await serv.get({ 54 | key: "key1", 55 | namespace: "test", 56 | }); 57 | expect(readRsp.value.value).toEqual({ hi: "there" }); 58 | expect(readRsp.value.public).toBeTruthy(); 59 | expect(readRsp.value.ownedByUser).toBeTruthy(); 60 | expect(readRsp.value.userId).toBeTruthy(); 61 | expect(readRsp.value.key).toEqual("key1"); 62 | expect(readRsp.value.namespace).toEqual("test"); 63 | }); 64 | 65 | test("public value not writable by non owner", async () => { 66 | let val: Value = { 67 | key: "key2", 68 | namespace: "test", 69 | ownedByUser: true, 70 | public: true, 71 | value: { hi: "there" }, 72 | }; 73 | await serv.set({ 74 | token: tok1, 75 | value: val, 76 | }); 77 | 78 | // update by non owner should fail 79 | let errored = false; 80 | try { 81 | await serv.set({ 82 | token: tok2, 83 | value: val, 84 | }); 85 | } catch (e) { 86 | errored = true; 87 | } 88 | expect(errored).toBeTruthy(); 89 | 90 | // update by owner 91 | val.value = { hi2: "there2" }; 92 | await serv.set({ 93 | token: tok1, 94 | value: val, 95 | }); 96 | let readRsp = await serv.get({ 97 | key: "key2", 98 | namespace: "test", 99 | }); 100 | expect(readRsp.value.value).toEqual({ hi: "there", hi2: "there2" }); 101 | }); 102 | 103 | test("deep merge", async () => { 104 | let val: Value = { 105 | key: "key3", 106 | namespace: "test", 107 | ownedByUser: true, 108 | public: true, 109 | value: { 110 | level1: { 111 | level2: { 112 | a: 1, 113 | }, 114 | }, 115 | }, 116 | }; 117 | await serv.set({ 118 | token: tok1, 119 | value: val, 120 | }); 121 | 122 | let val1: Value = { 123 | key: "key3", 124 | namespace: "test", 125 | ownedByUser: true, 126 | public: true, 127 | value: { 128 | level1: { 129 | level2: { 130 | b: 2, 131 | }, 132 | }, 133 | }, 134 | }; 135 | await serv.set({ 136 | token: tok1, 137 | value: val1, 138 | }); 139 | 140 | let readRsp = await serv.get({ 141 | token: tok1, 142 | key: "key3", 143 | namespace: "test", 144 | }); 145 | expect(readRsp.value.value).toEqual({ 146 | level1: { 147 | level2: { 148 | a: 1, 149 | b: 2, 150 | }, 151 | }, 152 | }); 153 | 154 | let val3: Value = { 155 | key: "key3", 156 | namespace: "test", 157 | ownedByUser: true, 158 | public: true, 159 | value: { 160 | level1: { 161 | level2: { 162 | a: 3, 163 | }, 164 | }, 165 | }, 166 | }; 167 | await serv.set({ 168 | token: tok1, 169 | value: val3, 170 | }); 171 | 172 | readRsp = await serv.get({ 173 | token: tok1, 174 | key: "key3", 175 | namespace: "test", 176 | }); 177 | expect(readRsp.value.value).toEqual({ 178 | level1: { 179 | level2: { 180 | a: 3, 181 | b: 2, 182 | }, 183 | }, 184 | }); 185 | }); 186 | 187 | test("list public values", async () => { 188 | await serv.set({ 189 | token: tok1, 190 | value: { 191 | key: "publicKey1", 192 | namespace: "test2", 193 | public: true, 194 | value: { data: "value1" }, 195 | }, 196 | }); 197 | await serv.set({ 198 | token: tok1, 199 | value: { 200 | key: "publicKey2", 201 | namespace: "test2", 202 | public: true, 203 | value: { data: "value2" }, 204 | }, 205 | }); 206 | 207 | let listRsp = await serv.list({ namespace: "test2" }); 208 | expect(listRsp.values).toHaveLength(2); 209 | expect(listRsp.values.some((v) => v.key === "publicKey1")).toBeTruthy(); 210 | expect(listRsp.values.some((v) => v.key === "publicKey2")).toBeTruthy(); 211 | }); 212 | 213 | test("list private values with token", async () => { 214 | await serv.set({ 215 | token: tok1, 216 | value: { 217 | key: "privateKey1", 218 | namespace: "test3", 219 | public: false, 220 | value: { data: "value1" }, 221 | }, 222 | }); 223 | 224 | let listRsp = await serv.list({ namespace: "test3", token: tok1 }); 225 | expect(listRsp.values.some((v) => v.key === "privateKey1")).toBeTruthy(); 226 | }); 227 | 228 | test("unauthorized access to private values", async () => { 229 | let listRspNoToken = await serv.list({ namespace: "test3" }); 230 | 231 | expect(listRspNoToken.values.some((v) => v.public === false)).toBeFalsy(); 232 | }); 233 | }); 234 | -------------------------------------------------------------------------------- /src/service/keyvalue/list.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { Value, ListRequest, ListResponse } from "./models.js"; 3 | import { AuthenticationService } from "../authentication/index.js"; 4 | import { Role, roleAdmin } from "../authentication/models.js"; 5 | 6 | export default async function list( 7 | connection: DataSource, 8 | auth: AuthenticationService, 9 | req: ListRequest 10 | ): Promise { 11 | let values = await connection 12 | .createQueryBuilder(Value, "value") 13 | .where("value.namespace = :namespace", { namespace: req.namespace }) 14 | .getMany(); 15 | 16 | if (!values.length) { 17 | return { 18 | values: [], 19 | }; 20 | } 21 | 22 | let filteredValues = []; 23 | 24 | for (let v of values) { 25 | if (v.public) { 26 | filteredValues.push(v); 27 | } else { 28 | if (!req.token) { 29 | continue; 30 | } 31 | let tokenRsp = await auth.tokenRead({ token: req.token }); 32 | let isAdmin = false; 33 | if (tokenRsp.token.user.roles?.find((r) => r.id == roleAdmin.id)) { 34 | isAdmin = true; 35 | } 36 | if (!isAdmin && v.ownedByUser && v.userId !== tokenRsp.token.user.id) { 37 | continue; 38 | } 39 | if (!v.ownedByUser) { 40 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles); 41 | if (!departmentIds.includes(v.departmentId)) { 42 | continue; 43 | } 44 | } 45 | filteredValues.push(v); 46 | } 47 | } 48 | 49 | return { 50 | values: filteredValues, 51 | }; 52 | } 53 | 54 | function getDepartmentIds(roles: Role[]): string[] { 55 | return roles 56 | .filter((r) => r.key.includes("department:")) 57 | .map((r) => r.key.split(":")[1]); 58 | } 59 | -------------------------------------------------------------------------------- /src/service/keyvalue/models.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Entity, 3 | Column, 4 | PrimaryColumn, 5 | CreateDateColumn, 6 | UpdateDateColumn, 7 | } from "typeorm"; 8 | import { copy } from "../file/models.js"; 9 | import { nanoid } from "nanoid"; 10 | 11 | @Entity() 12 | export class Value { 13 | constructor(json?: any) { 14 | if (!json) return; 15 | copy(json, this); 16 | if (!this.id) { 17 | this.id = nanoid(); 18 | } 19 | } 20 | 21 | @PrimaryColumn() 22 | id?: string; 23 | 24 | @Column({ 25 | type: "jsonb", 26 | array: false, 27 | nullable: true, 28 | }) 29 | value?: { [key: string]: any }; 30 | 31 | /** 32 | * eg. "home", "apps", "notification" 33 | */ 34 | @Column({ nullable: true }) 35 | namespace?: string; 36 | 37 | @Column({ nullable: true }) 38 | key?: string; 39 | 40 | /** 41 | * Should this be readable by users who are not part 42 | * of the department? 43 | */ 44 | @Column({ nullable: true }) 45 | public?: boolean; 46 | 47 | /** 48 | * Should this be writable by users who are not part 49 | * of the department? 50 | */ 51 | @Column({ nullable: true }) 52 | publicWrite?: boolean; 53 | 54 | /** 55 | * Should this be writable only by the user who created it 56 | * or by anyone in the department? 57 | */ 58 | @Column({ nullable: true }) 59 | ownedByUser?: boolean; 60 | 61 | @Column({ nullable: true }) 62 | userId?: string; 63 | 64 | @Column({ nullable: true }) 65 | departmentId?: string; 66 | 67 | @CreateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) 68 | createdAt?: string; 69 | 70 | @UpdateDateColumn({ type: "timestamptz", default: () => "CURRENT_TIMESTAMP" }) 71 | updatedAt?: string; 72 | } 73 | 74 | export class GetRequest { 75 | token?: string; 76 | namespace?: string; 77 | key?: string; 78 | } 79 | 80 | export class GetResponse { 81 | value?: Value; 82 | } 83 | 84 | export class SetRequest { 85 | token?: string; 86 | value?: Value; 87 | } 88 | 89 | export class SetResponse { 90 | value?: Value; 91 | } 92 | 93 | export class ListRequest { 94 | token?: string; 95 | namespace?: string; 96 | } 97 | 98 | export class ListResponse { 99 | values: Value[]; 100 | } -------------------------------------------------------------------------------- /src/service/keyvalue/set.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationService } from "../authentication/index.js"; 2 | import { Role } from "../authentication/models.js"; 3 | import { DataSource } from "typeorm"; 4 | import { Value, SetRequest, SetResponse } from "./models.js"; 5 | 6 | export default async ( 7 | connection: DataSource, 8 | auth: AuthenticationService, 9 | req: SetRequest 10 | ): Promise => { 11 | let value: Value = new Value(req.value); 12 | 13 | let existingValue = await connection 14 | .createQueryBuilder(Value, "value") 15 | .where("value.key = :key", { key: value.key }) 16 | .andWhere("value.namespace = :namespace", { namespace: value.namespace }) 17 | .getOne(); 18 | 19 | if (!existingValue) { 20 | await saveNewValue(auth, connection, req, value); 21 | return { 22 | value: value, 23 | }; 24 | } 25 | 26 | // can't change publicity to hijack a public value 27 | value.publicWrite = existingValue.publicWrite; 28 | value.id = existingValue.id; 29 | 30 | // authorization 31 | if (!existingValue.publicWrite) { 32 | let tokenRsp = await auth.tokenRead({ 33 | token: req.token, 34 | }); 35 | 36 | if ( 37 | existingValue.ownedByUser && 38 | existingValue.userId != tokenRsp.token.user.id 39 | ) { 40 | throw new Error("no permission"); 41 | } 42 | if (!existingValue.ownedByUser) { 43 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles); 44 | if (!departmentIds.includes(existingValue.departmentId)) { 45 | throw new Error("no permission"); 46 | } 47 | } 48 | } 49 | 50 | value.value = mergeDeep(existingValue.value, value.value); 51 | 52 | await connection.transaction(async (tran) => { 53 | await tran.save(value); 54 | }); 55 | 56 | return { 57 | value: value, 58 | }; 59 | }; 60 | 61 | async function saveNewValue( 62 | auth: AuthenticationService, 63 | connection: DataSource, 64 | req: SetRequest, 65 | value: Value 66 | ) { 67 | // for non public write values we need to set up ownership 68 | if (!value.publicWrite) { 69 | let tokenRsp = await auth.tokenRead({ 70 | token: req.token, 71 | }); 72 | value.userId = tokenRsp.token.user.id; 73 | 74 | // if not owned by the user, we need to check the departments 75 | if (!value.ownedByUser) { 76 | let departmentIds = getDepartmentIds(tokenRsp.token.user.roles); 77 | if (value.departmentId && !departmentIds.includes(value.departmentId)) { 78 | throw new Error("no permission"); 79 | } 80 | if (!value.departmentId && departmentIds.length != 1) { 81 | throw new Error("cannot decide department"); 82 | } 83 | if (!value.departmentId) { 84 | value.departmentId = departmentIds[0]; 85 | } 86 | } 87 | } 88 | 89 | return connection.transaction(async (tran) => { 90 | await tran.save(value); 91 | }); 92 | } 93 | 94 | function getDepartmentIds(roles: Role[]): string[] { 95 | return roles 96 | .filter((r) => r.key.includes("department:")) 97 | .map((r) => r.key.split(":")[1]); 98 | } 99 | 100 | export function isObject(item) { 101 | return item && typeof item === "object" && !Array.isArray(item); 102 | } 103 | 104 | export function mergeDeep(target, ...sources) { 105 | if (!sources.length) return target; 106 | const source = sources.shift(); 107 | 108 | if (isObject(target) && isObject(source)) { 109 | for (const key in source) { 110 | if (isObject(source[key])) { 111 | if (!target[key]) Object.assign(target, { [key]: {} }); 112 | mergeDeep(target[key], source[key]); 113 | } else { 114 | Object.assign(target, { [key]: source[key] }); 115 | } 116 | } 117 | } 118 | 119 | return mergeDeep(target, ...sources); 120 | } 121 | -------------------------------------------------------------------------------- /src/service/payment/README.md: -------------------------------------------------------------------------------- 1 | # Payment 2 | 3 | This service is aims to abstract away payment providers. 4 | Currently only supports Stripe, and only supports payments where every time you have to pass in your card details. 5 | 6 | The token is created on the frontend and can be only used once. 7 | 8 | ## Config 9 | 10 | ```js 11 | config.data?.PaymentService.stripe_public_key; 12 | ``` 13 | 14 | or from envars 15 | 16 | ```sh 17 | PAYMENT_STRIPE_PUBLIC_KEY 18 | ``` 19 | 20 | ## Secrets 21 | 22 | Secrets are either read from the config service: 23 | 24 | ```js 25 | secret.data?.PaymentService.stripe_api_key; 26 | ``` 27 | 28 | or from envars 29 | 30 | ```sh 31 | SECRET_PAYMENT_STRIPE_SECRET_KEY 32 | ``` 33 | -------------------------------------------------------------------------------- /src/service/payment/balance.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { BalanceRequest, BalanceResponse } from "./models.js"; 3 | 4 | import { AuthenticationService } from "../authentication/index.js"; 5 | 6 | export default async ( 7 | connection: DataSource, 8 | auth: AuthenticationService, 9 | req: BalanceRequest 10 | ): Promise => { 11 | let token = await auth.tokenRead({ 12 | token: req.token, 13 | }); 14 | 15 | let result = await connection.query( 16 | `select sum(credit) - sum(debit) as balance from 17 | transaction_entry te 18 | inner join account a on a.id = te."accountId" 19 | where a."userId" = '${token.token.userId}' 20 | and a.type = 'internal'` 21 | ); 22 | 23 | return { 24 | balance: parseFloat(result[0].balance) || 0, 25 | }; 26 | }; 27 | -------------------------------------------------------------------------------- /src/service/payment/charges.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { ChargesRequest, ChargesResponse, Charge } from "./models.js"; 3 | 4 | import { AuthenticationService } from "../authentication/index.js"; 5 | 6 | export default async ( 7 | connection: DataSource, 8 | auth: AuthenticationService, 9 | req: ChargesRequest 10 | ): Promise => { 11 | let token = await auth.tokenRead({ 12 | token: req.token, 13 | }); 14 | 15 | let charges = await connection 16 | .createQueryBuilder(Charge, "payment") 17 | .where(`payment."userId" = :userId`, { userId: token.token.userId }) 18 | .getMany(); 19 | 20 | return { 21 | charges: charges, 22 | }; 23 | }; 24 | -------------------------------------------------------------------------------- /src/service/payment/index.ts: -------------------------------------------------------------------------------- 1 | import { Service } from "../../reflect.js"; 2 | import { Servicelike } from "../../util.js"; 3 | import { DataSource } from "typeorm"; 4 | 5 | import { 6 | Config, 7 | Secret, 8 | TopupRequest, 9 | PublicKeyRequest, 10 | BalanceRequest, 11 | ChargesRequest, 12 | Charge, 13 | Gateway, 14 | gateways, 15 | Account, 16 | accounts, 17 | Transaction, 18 | TransactionEntry, 19 | PayWithBalanceRequest, 20 | SystemBalanceRequest, 21 | } from "./models.js"; 22 | import { ConfigService } from "../config/index.js"; 23 | import topup from "./topup.js"; 24 | import balance from "./balance.js"; 25 | import payments from "./charges.js"; 26 | import payWithBalance from "./payWithBalance.js"; 27 | import systemBalance from "./systemBalance.js"; 28 | import { AuthenticationService } from "../authentication/index.js"; 29 | 30 | @Service() 31 | export class PaymentService implements Servicelike { 32 | meta = { 33 | name: "payment", 34 | typeorm: { 35 | entities: [Charge, Gateway, Account, Transaction, TransactionEntry], 36 | }, 37 | }; 38 | 39 | private connection: DataSource; 40 | private config: ConfigService; 41 | private auth: AuthenticationService; 42 | private stripeApiKey: string; 43 | private stripePublicKey: string; 44 | 45 | // if set to true, stripe calls will be skipped 46 | // useful for testing 47 | public skipStripe = false; 48 | 49 | constructor( 50 | connection: DataSource, 51 | config: ConfigService, 52 | auth: AuthenticationService 53 | ) { 54 | this.connection = connection; 55 | this.config = config; 56 | this.auth = auth; 57 | } 58 | 59 | async _onInit(): Promise { 60 | await Promise.all( 61 | gateways.map(async (p) => { 62 | return await this.connection.getRepository(Gateway).save(p); 63 | }) 64 | ); 65 | await Promise.all( 66 | accounts.map(async (p) => { 67 | return await this.connection.getRepository(Account).save(p); 68 | }) 69 | ); 70 | 71 | let cf = await this.config.configRead({}); 72 | let config: Config = cf.config.data?.PaymentService; 73 | this.stripePublicKey = config?.stripe_public_key; 74 | let secr = await this.config.secretRead({}); 75 | let secret: Secret = secr.secret.data?.PaymentService; 76 | this.stripeApiKey = secret?.stripe_api_key; 77 | if (!this.stripeApiKey) { 78 | this.stripeApiKey = process.env.PAYMENT_STRIPE_SECRET_KEY; 79 | } 80 | if (!this.stripePublicKey) { 81 | this.stripePublicKey = process.env.PAYMENT_STRIPE_PUBLIC_KEY; 82 | } 83 | } 84 | 85 | /** Charge a credit or debit card to pop up the user's balance */ 86 | topup(req: TopupRequest) { 87 | return topup( 88 | this.connection, 89 | this.auth, 90 | req, 91 | this.stripeApiKey, 92 | this.skipStripe 93 | ); 94 | } 95 | 96 | /** Payment provider public keys to be used on the frontend */ 97 | publicKey(req: PublicKeyRequest) { 98 | return { 99 | publicKey: this.stripePublicKey, 100 | }; 101 | } 102 | 103 | /** Returns the caller user's balance */ 104 | balance(req: BalanceRequest) { 105 | return balance(this.connection, this.auth, req); 106 | } 107 | 108 | /** Returns charges that happened to the caller user's card */ 109 | charges(req: ChargesRequest) { 110 | return payments(this.connection, this.auth, req); 111 | } 112 | 113 | payWithBalance(req: PayWithBalanceRequest) { 114 | return payWithBalance(this.connection, this.auth, req); 115 | } 116 | 117 | systemBalance(req: SystemBalanceRequest) { 118 | return systemBalance(this.connection, this.auth, req); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/service/payment/payWithBalance.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../util.js"; 3 | import { 4 | PayWithBalanceRequest, 5 | PayWithBalanceResponse, 6 | Account, 7 | AccountType, 8 | Transaction, 9 | TransactionEntry, 10 | accountCompanyStripe, 11 | } from "./models.js"; 12 | import { nanoid } from "nanoid"; 13 | import { AuthenticationService } from "../authentication/index.js"; 14 | 15 | export default async ( 16 | connection: DataSource, 17 | auth: AuthenticationService, 18 | req: PayWithBalanceRequest 19 | ): Promise => { 20 | let token = await auth.tokenRead({ 21 | token: req.token, 22 | }); 23 | 24 | if (!req.amount) { 25 | throw error("amount missing", 400); 26 | } 27 | if (!req.token) { 28 | throw error("token missing", 400); 29 | } 30 | if (!req.currency) { 31 | throw error("currency missing", 400); 32 | } 33 | 34 | let internalUserAccount = await connection 35 | .createQueryBuilder(Account, "account") 36 | .where(`account."userId" = :userId`, { userId: token.token.userId }) 37 | .andWhere(`account.type = :type`, { type: AccountType.Internal }) 38 | .getOne(); 39 | 40 | if (!internalUserAccount) { 41 | throw error("internal user account not found", 500); 42 | } 43 | 44 | let transaction = new Transaction(); 45 | transaction.id = nanoid(); 46 | transaction.itemId = req.itemId; 47 | 48 | let debitUserEntry = new TransactionEntry(); 49 | debitUserEntry.id = nanoid(); 50 | debitUserEntry.accountId = internalUserAccount.id; 51 | debitUserEntry.debit = req.amount; 52 | debitUserEntry.credit = 0; 53 | 54 | let creditCompanyEntry = new TransactionEntry(); 55 | creditCompanyEntry.id = nanoid(); 56 | creditCompanyEntry.accountId = accountCompanyStripe.id; 57 | creditCompanyEntry.debit = 0; 58 | creditCompanyEntry.credit = req.amount; 59 | 60 | await connection.transaction(async (tran) => { 61 | await tran.save(transaction); 62 | await tran.save(debitUserEntry); 63 | await tran.save(creditCompanyEntry); 64 | }); 65 | 66 | return {}; 67 | }; 68 | -------------------------------------------------------------------------------- /src/service/payment/payment.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from "@jest/globals"; 2 | import { Injector } from "../../injector.js"; 3 | import { nanoid } from "nanoid"; 4 | import { PaymentService } from "./index.js"; 5 | import { AuthenticationService } from "../authentication/index.js"; 6 | import { UserRegisterResponse } from "../authentication/models.js"; 7 | import { AccountType, gatewayStripe } from "./models.js"; 8 | // import Stripe from "stripe"; 9 | 10 | describe("Check balance and payment history", () => { 11 | var ps: PaymentService; 12 | var auth: AuthenticationService; 13 | test("setup", async () => { 14 | let namespace = "t_" + nanoid().slice(0, 7); 15 | 16 | let i = new Injector([PaymentService]); 17 | ps = await i.getInstance("PaymentService", namespace); 18 | ps.skipStripe = true; 19 | auth = await i.getInstance("AuthenticationService", namespace); 20 | }); 21 | 22 | test("payment service was initiated", async () => { 23 | expect(ps).toBeTruthy(); 24 | expect(auth).toBeTruthy(); 25 | }); 26 | 27 | let adminToken; 28 | test("get a token", async () => { 29 | let rsp = await auth.tokenAdminGet({}); 30 | adminToken = rsp.token.token; 31 | expect(adminToken.length > 1).toBe(true); 32 | }); 33 | 34 | test("pay works", async () => { 35 | await ps.topup({ 36 | amount: 100, 37 | token: adminToken, 38 | stripeToken: "fake stripe", 39 | currency: "usd", 40 | }); 41 | }); 42 | 43 | test("balance works", async () => { 44 | let rsp = await ps.balance({ 45 | token: adminToken, 46 | }); 47 | expect(rsp.balance).toBe(100); 48 | }); 49 | 50 | test("payment history works", async () => { 51 | let rsp = await ps.charges({ 52 | token: adminToken, 53 | }); 54 | expect(rsp.charges.length).toBe(1); 55 | }); 56 | 57 | test("pay works 2", async () => { 58 | await ps.topup({ 59 | amount: 200, 60 | token: adminToken, 61 | stripeToken: "fake stripe", 62 | currency: "usd", 63 | }); 64 | }); 65 | 66 | test("balance works 2", async () => { 67 | let rsp = await ps.balance({ 68 | token: adminToken, 69 | }); 70 | expect(rsp.balance).toBe(300); 71 | }); 72 | 73 | test("payment history works 2", async () => { 74 | let rsp = await ps.charges({ 75 | token: adminToken, 76 | }); 77 | expect(rsp.charges.length).toBe(2); 78 | }); 79 | 80 | let userRegRsp: UserRegisterResponse; 81 | let user2Token; 82 | test("register simple user", async () => { 83 | userRegRsp = await auth.userRegister({ 84 | user: { 85 | contacts: [{ url: "test-2@test.com" }], 86 | fullName: "Simple User Janey Jane", 87 | }, 88 | password: "1011", 89 | }); 90 | user2Token = userRegRsp.token.token; 91 | }); 92 | 93 | test("pay works 3", async () => { 94 | await ps.topup({ 95 | amount: 200, 96 | token: user2Token, 97 | stripeToken: "fake stripe", 98 | currency: "usd", 99 | }); 100 | }); 101 | 102 | test("user balance increased to 200 after a 200 topup", async () => { 103 | let rsp = await ps.balance({ 104 | token: user2Token, 105 | }); 106 | expect(rsp.balance).toBe(200); 107 | }); 108 | 109 | test("charge history is correct after topup", async () => { 110 | let rsp = await ps.charges({ 111 | token: user2Token, 112 | }); 113 | expect(rsp.charges.length).toBe(1); 114 | }); 115 | 116 | test("company internal balance should be 0 before payment", async () => { 117 | let rsp = await ps.systemBalance({ 118 | token: adminToken, 119 | gatewayId: gatewayStripe.id, 120 | type: AccountType.Internal, 121 | }); 122 | expect(rsp.balance).toBe(0); 123 | }); 124 | 125 | test("pay with balance", async () => { 126 | await ps.payWithBalance({ 127 | amount: 100, 128 | token: user2Token, 129 | currency: "usd", 130 | itemId: "test-item-1", 131 | }); 132 | }); 133 | 134 | test("user 2 balance decreased by 100", async () => { 135 | let rsp = await ps.balance({ 136 | token: user2Token, 137 | }); 138 | expect(rsp.balance).toBe(100); 139 | }); 140 | 141 | test("user 1 balance is intact", async () => { 142 | let rsp = await ps.balance({ 143 | token: adminToken, 144 | }); 145 | expect(rsp.balance).toBe(300); 146 | }); 147 | 148 | test("company internal balance increased from 0 to 100", async () => { 149 | let rsp = await ps.systemBalance({ 150 | token: adminToken, 151 | gatewayId: gatewayStripe.id, 152 | type: AccountType.Internal, 153 | }); 154 | expect(rsp.balance).toBe(100); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /src/service/payment/systemBalance.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { SystemBalanceRequest, SystemBalanceResponse } from "./models.js"; 3 | 4 | import { AuthenticationService } from "../authentication/index.js"; 5 | import { roleAdmin } from "../authentication/models.js"; 6 | import { error } from "../../util.js"; 7 | 8 | export default async ( 9 | connection: DataSource, 10 | auth: AuthenticationService, 11 | req: SystemBalanceRequest 12 | ): Promise => { 13 | let token = await auth.tokenRead({ 14 | token: req.token, 15 | }); 16 | if (!token.token.user.roles.find((r) => r.id === roleAdmin.id)) { 17 | throw error("not authorized", 401); 18 | } 19 | 20 | let result = await connection.query( 21 | `select sum(credit) - sum(debit) as balance from 22 | transaction_entry te 23 | inner join account a on a.id = te."accountId" 24 | and a."gatewayId" = $1 25 | and a.type = $2 26 | and a."userId" is null 27 | and a."organizationId" is null`, 28 | [req.gatewayId, req.type] 29 | ); 30 | 31 | return { 32 | balance: parseFloat(result[0].balance) || 0, 33 | }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/service/payment/topup.ts: -------------------------------------------------------------------------------- 1 | import { DataSource } from "typeorm"; 2 | import { error } from "../../util.js"; 3 | import { 4 | TopupRequest, 5 | TopupResponse, 6 | Charge, 7 | gatewayStripe, 8 | Account, 9 | AccountType, 10 | Transaction, 11 | TransactionEntry, 12 | Card, 13 | } from "./models.js"; 14 | import { nanoid } from "nanoid"; 15 | import Stripe from "stripe"; 16 | import { AuthenticationService } from "../authentication/index.js"; 17 | import { platformEmail } from "../authentication/models.js"; 18 | 19 | export default async ( 20 | connection: DataSource, 21 | auth: AuthenticationService, 22 | req: TopupRequest, 23 | apiKey: string, 24 | skipStripe: boolean 25 | ): Promise => { 26 | if (!skipStripe && !apiKey) { 27 | throw error("payment provider api key missing", 400); 28 | } 29 | 30 | let token = await auth.tokenRead({ 31 | token: req.token, 32 | }); 33 | 34 | if (!req.amount || req.amount <= 0) { 35 | throw error("amount missing", 400); 36 | } 37 | if (!req.token) { 38 | throw error("token missing", 400); 39 | } 40 | if (!req.currency) { 41 | throw error("currency missing", 400); 42 | } 43 | 44 | let internalUserAccount; 45 | let externalUserAccount; 46 | await connection.transaction(async (tran) => { 47 | let userAccounts = await connection 48 | .createQueryBuilder(Account, "account") 49 | .where(`account."userId" = :userId`, { userId: token.token.userId }) 50 | .getMany(); 51 | 52 | if (!userAccounts.find((a) => a.type === AccountType.Internal)) { 53 | let newAccount = new Account(); 54 | newAccount.id = nanoid(); 55 | newAccount.userId = token.token.userId; 56 | newAccount.type = AccountType.Internal; 57 | newAccount.gatewayId = gatewayStripe.id; 58 | await tran.save(newAccount); 59 | internalUserAccount = newAccount; 60 | } else { 61 | internalUserAccount = userAccounts.find( 62 | (a) => a.type === AccountType.Internal 63 | ); 64 | } 65 | if (!userAccounts.find((a) => a.type === AccountType.External)) { 66 | let newAccount = new Account(); 67 | newAccount.id = nanoid(); 68 | newAccount.userId = token.token.userId; 69 | newAccount.type = AccountType.External; 70 | await tran.save(newAccount); 71 | externalUserAccount = newAccount; 72 | } else { 73 | externalUserAccount = userAccounts.find( 74 | (a) => a.type === AccountType.External 75 | ); 76 | } 77 | }); 78 | if (!externalUserAccount) { 79 | throw error("external user account not found", 500); 80 | } 81 | if (!internalUserAccount) { 82 | throw error("internal user account not found", 500); 83 | } 84 | 85 | let charge: Stripe.Response; 86 | if (req.saveCard) { 87 | throw error("not implemented", 500); 88 | let stripe = new Stripe(apiKey, { apiVersion: "2022-11-15" }); 89 | let customer = await stripe.customers.create({ 90 | source: req.stripeToken, 91 | email: token.token.user.contacts.find( 92 | (c) => c.platformId == platformEmail.id 93 | ).url, 94 | name: token.token.user.fullName, 95 | }); 96 | let stripeCard = await stripe.customers.retrieveSource( 97 | customer.id, 98 | customer.default_source as string 99 | ); 100 | let card: Card = new Card(); 101 | card.id = nanoid(); 102 | card.userId = token.token.userId; 103 | card.gatewayId = gatewayStripe.id; 104 | card.gatewayChargableId = customer.id; 105 | console.log(stripeCard); 106 | // @todo crufter: finish 107 | } else { 108 | if (!skipStripe) { 109 | try { 110 | var stripe = new Stripe(apiKey, { apiVersion: "2022-11-15" }); 111 | 112 | charge = await stripe.charges.create({ 113 | source: req.stripeToken, 114 | currency: req.currency, 115 | amount: req.amount, 116 | capture: true, 117 | }); 118 | } catch (err) { 119 | if (err) { 120 | console.log("Problem charging customer: ", err); 121 | throw error(JSON.stringify(err), 500); 122 | } 123 | } 124 | } else { 125 | charge = { 126 | id: nanoid(), 127 | } as Stripe.Response; 128 | } 129 | } 130 | 131 | const newCharge: Charge = new Charge(); 132 | newCharge.id = nanoid(); 133 | newCharge.userId = token.token.userId; 134 | newCharge.amount = req.amount; 135 | newCharge.currency = req.currency; 136 | newCharge.description = "Top up"; 137 | newCharge.gatewayChargeId = charge.id; 138 | newCharge.gatewayResponse = charge; 139 | newCharge.gatewayId = gatewayStripe.id; 140 | 141 | let transaction = new Transaction(); 142 | transaction.id = nanoid(); 143 | transaction.chargeId = newCharge.id; 144 | 145 | let externalUserTransactionEntry = new TransactionEntry(); 146 | externalUserTransactionEntry.id = nanoid(); 147 | externalUserTransactionEntry.accountId = externalUserAccount.id; 148 | externalUserTransactionEntry.debit = req.amount; 149 | externalUserTransactionEntry.credit = 0; 150 | 151 | let internalUserTransactionEntry = new TransactionEntry(); 152 | internalUserTransactionEntry.id = nanoid(); 153 | internalUserTransactionEntry.accountId = internalUserAccount.id; 154 | internalUserTransactionEntry.debit = 0; 155 | internalUserTransactionEntry.credit = req.amount; 156 | 157 | // @todo add transaction for stripe account where the 158 | // fees go. not sure how to get the fees yet 159 | 160 | await connection.transaction(async (tran) => { 161 | await tran.save(newCharge); 162 | await tran.save(transaction); 163 | await tran.save(externalUserTransactionEntry); 164 | await tran.save(internalUserTransactionEntry); 165 | }); 166 | 167 | return {}; 168 | }; 169 | -------------------------------------------------------------------------------- /src/service/system/README.md: -------------------------------------------------------------------------------- 1 | [<- Back to Getting Started](../../../docs/README.md) 2 | # System service 3 | 4 | The system service is designed to give information about the running services, their types, the nodes to enable building tooling on top. 5 | 6 | It is essentially runtime reflection for Actio. 7 | 8 | ## Endpoints 9 | 10 | ### nodesRead 11 | 12 | Returns the list of nodes and the services they are running. 13 | 14 | ```sh 15 | $ curl 127.0.0.1:8080/SystemService/nodesRead 16 | { 17 | "nodes":[ 18 | { 19 | "services":[ 20 | { 21 | "name":"SystemService" 22 | }, 23 | { 24 | "name":"AuthenticationService" 25 | }, 26 | { 27 | "name":"ConfigService" 28 | }, 29 | { 30 | "name":"Function" 31 | } 32 | ] 33 | } 34 | ] 35 | } 36 | ``` 37 | 38 | When there are addresses set it returns the addresses too - both for nodes and their services. 39 | 40 | ### apiRead 41 | 42 | apiRead returns the type information for endpoints and there parameters. Here is information related to the `SystemService` `nodesRead` endpoint: 43 | 44 | ```sh 45 | { 46 | "services":{ 47 | "SystemService":{ 48 | "nodesRead":{ 49 | "info":{ 50 | "methodName":"nodesRead", 51 | "paramNames":[ 52 | "req" 53 | ], 54 | "paramTypes":[ 55 | "NodesReadRequest" 56 | ], 57 | "options":{ 58 | "returns":"NodesReadResponse" 59 | } 60 | }, 61 | "paramOptions":[ 62 | { 63 | 64 | } 65 | ] 66 | } 67 | } 68 | }, 69 | "types":{ 70 | "NodesReadRequest":{ 71 | "propagate":{ 72 | "data":{ 73 | "type":"Boolean", 74 | "name":"propagate" 75 | } 76 | } 77 | }, 78 | "NodesReadResponse":{ 79 | "nodes":{ 80 | "data":{ 81 | "hint":"Node", 82 | "type":"Array", 83 | "name":"nodes" 84 | } 85 | } 86 | }, 87 | "Node":{ 88 | "id":{ 89 | "data":{ 90 | "type":"String", 91 | "name":"id" 92 | } 93 | }, 94 | "address":{ 95 | "data":{ 96 | "type":"String", 97 | "name":"address" 98 | } 99 | }, 100 | "services":{ 101 | "data":{ 102 | "hint":"Service", 103 | "type":"Array", 104 | "name":"services" 105 | } 106 | } 107 | }, 108 | "Service":{ 109 | "address":{ 110 | "data":{ 111 | "type":"String", 112 | "name":"address" 113 | } 114 | }, 115 | "name":{ 116 | "data":{ 117 | "type":"String", 118 | "name":"name" 119 | } 120 | } 121 | } 122 | } 123 | } 124 | ``` 125 | 126 | ### Conventions 127 | 128 | Please note that types must be annotated with the `@Field` decorator for them to be visible to the runtime. 129 | 130 | Endpoints must be decorated with the `@Endpoint` decorator and the return value specified with the `returns` option. The reason for this is that higher order types are currently invisible for TypeScript's reflection, so it needs quiet a bit of handholding to get a complete picture about types. 131 | 132 | Similarly, unnamed types like `{[key: string]: any}` are at the moment invisible to Actio and a solution must be found to properly return them in `nodesRead`. 133 | 134 | As usual, the [models.ts](./models.ts) contains types for this service. -------------------------------------------------------------------------------- /src/service/system/apiRead.ts: -------------------------------------------------------------------------------- 1 | import { getAPIJSON } from "../../reflect.api.js"; 2 | 3 | export default async (request: any): Promise => { 4 | return getAPIJSON(); 5 | }; 6 | -------------------------------------------------------------------------------- /src/service/system/index.ts: -------------------------------------------------------------------------------- 1 | import { Endpoint, Service } from "../../reflect.js"; 2 | 3 | import apiRead from "./apiRead.js"; 4 | import nodesRead from "./nodesRead.js"; 5 | import { NodesReadRequest, NodesReadResponse } from "./models.js"; 6 | 7 | import { Servicelike } from "../../util.js"; 8 | import { Injector } from "../../injector.js"; 9 | 10 | interface ApiReadRequest {} 11 | 12 | @Service() 13 | export class SystemService implements Servicelike { 14 | meta = { 15 | name: "system", 16 | }; 17 | 18 | private injector: Injector; 19 | 20 | constructor(injector: Injector) { 21 | this.injector = injector; 22 | } 23 | 24 | apiRead(req: ApiReadRequest) { 25 | return apiRead(req); 26 | } 27 | 28 | @Endpoint({ 29 | returns: NodesReadResponse, 30 | }) 31 | async nodesRead(req: NodesReadRequest) { 32 | return nodesRead(this.injector, req); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/service/system/models.ts: -------------------------------------------------------------------------------- 1 | import { Field } from "../../reflect.field.js"; 2 | 3 | export class NodesReadRequest { 4 | @Field() 5 | propagate: boolean; 6 | } 7 | 8 | export class NodesReadResponse { 9 | @Field({ hint: () => Node }) 10 | nodes: Node[]; 11 | } 12 | 13 | export class Node { 14 | @Field() 15 | id?: string; 16 | 17 | @Field() 18 | address: string; 19 | 20 | @Field({ hint: () => Service}) 21 | services: Service[]; 22 | } 23 | 24 | export class Service { 25 | @Field() 26 | address: string; 27 | 28 | @Field() 29 | name: string; 30 | } 31 | -------------------------------------------------------------------------------- /src/service/system/nodes.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "@jest/globals"; 2 | 3 | import { createApp } from "../../registrator.js"; 4 | import { Service } from "../../reflect.js"; 5 | import { default as request } from "supertest"; 6 | import { SystemService } from "./index.js"; 7 | import _ from "lodash"; 8 | 9 | @Service() 10 | class NodeA { 11 | constructor() {} 12 | } 13 | 14 | @Service() 15 | class NodeB { 16 | constructor(a: NodeA) {} 17 | } 18 | 19 | @Service() 20 | class NodeC { 21 | constructor(b: NodeB) {} 22 | } 23 | 24 | test("node read", async () => { 25 | let randomPortNumberA = Math.floor(Math.random() * 10000) + 10000; 26 | let randomPortNumberB = Math.floor(Math.random() * 10000) + 10000; 27 | let randomPortNumberC = Math.floor(Math.random() * 10000) + 10000; 28 | 29 | // pass in node C because then it pulls in the other two services 30 | let appC = createApp([NodeC, SystemService], { 31 | addresses: new Map() 32 | .set("NodeA", `http://localhost:${randomPortNumberA}`) 33 | .set("NodeB", `http://localhost:${randomPortNumberB}`), 34 | nodeID: "1", 35 | }); 36 | 37 | let appB = createApp([NodeC, SystemService], { 38 | addresses: new Map() 39 | .set("NodeA", `http://localhost:${randomPortNumberA}`) 40 | .set("NodeC", `http://localhost:${randomPortNumberC}`), 41 | nodeID: "2", 42 | }); 43 | 44 | let appA = createApp([NodeC, SystemService], { 45 | addresses: new Map() 46 | .set("NodeB", `http://localhost:${randomPortNumberB}`) 47 | .set("NodeC", `http://localhost:${randomPortNumberC}`), 48 | nodeID: "3", 49 | selfAddress: `http://localhost:${randomPortNumberA}`, 50 | }); 51 | 52 | let serverA, serverB, serverC; 53 | setTimeout(() => { 54 | serverA = appA.listen(randomPortNumberA); 55 | }, 1); 56 | setTimeout(() => { 57 | serverB = appB.listen(randomPortNumberB); 58 | }, 1); 59 | setTimeout(() => { 60 | serverC = appC.listen(randomPortNumberC); 61 | }, 1); 62 | 63 | let response = await request(appA).post("/SystemService/nodesRead").send({ 64 | propagate: true, 65 | }); 66 | expect(response.status).toBe(200); 67 | expect({ 68 | nodes: response.body.nodes.sort((a, b) => { 69 | if (a.id < b.id) return -1; 70 | if (a.id > b.id) return 1; 71 | return 0; 72 | }), 73 | }).toEqual({ 74 | nodes: [ 75 | { 76 | id: "1", 77 | address: `http://localhost:${randomPortNumberC}`, 78 | services: [ 79 | { 80 | name: "NodeC", 81 | }, 82 | { 83 | name: "SystemService", 84 | }, 85 | { 86 | address: `http://localhost:${randomPortNumberB}`, 87 | name: "NodeB", 88 | }, 89 | { 90 | address: `http://localhost:${randomPortNumberA}`, 91 | name: "NodeA", 92 | }, 93 | ], 94 | }, 95 | { 96 | id: "2", 97 | address: `http://localhost:${randomPortNumberB}`, 98 | services: [ 99 | { 100 | address: `http://localhost:${randomPortNumberC}`, 101 | name: "NodeC", 102 | }, 103 | { 104 | name: "SystemService", 105 | }, 106 | { 107 | name: "NodeB", 108 | }, 109 | { 110 | address: `http://localhost:${randomPortNumberA}`, 111 | name: "NodeA", 112 | }, 113 | ], 114 | }, 115 | { 116 | id: "3", 117 | address: `http://localhost:${randomPortNumberA}`, 118 | services: [ 119 | { 120 | address: `http://localhost:${randomPortNumberC}`, 121 | name: "NodeC", 122 | }, 123 | { 124 | name: "SystemService", 125 | }, 126 | { 127 | address: `http://localhost:${randomPortNumberB}`, 128 | name: "NodeB", 129 | }, 130 | { 131 | name: "NodeA", 132 | }, 133 | ], 134 | }, 135 | ], 136 | }); 137 | 138 | serverA.close(); 139 | serverB.close(); 140 | serverC.close(); 141 | }); 142 | -------------------------------------------------------------------------------- /src/service/system/nodesRead.ts: -------------------------------------------------------------------------------- 1 | import { Injector, toSnakeCase } from "../../injector.js"; 2 | import { env } from "../../env.js"; 3 | import _ from "lodash"; 4 | import { NodesReadRequest, NodesReadResponse } from "./models.js"; 5 | 6 | export default async ( 7 | injector: Injector, 8 | request: NodesReadRequest 9 | ): Promise => { 10 | let addresses = new Map(); 11 | let rsp: NodesReadResponse = { 12 | nodes: [], 13 | }; 14 | let node = { 15 | id: injector.nodeID, 16 | address: env.selfAddress || injector.selfAddress, 17 | services: [], 18 | }; 19 | 20 | injector.availableClassNames().forEach((className) => { 21 | let address = 22 | injector.addresses.get(className) || 23 | injector.addresses.get(toSnakeCase(className)) || 24 | process.env[className] || 25 | process.env[toSnakeCase(className)]; 26 | if (address) { 27 | addresses.set(className, address); 28 | node.services.push({ 29 | name: className, 30 | address: address, 31 | }); 32 | } else { 33 | node.services.push({ 34 | name: className, 35 | }); 36 | } 37 | }); 38 | rsp.nodes.push(node); 39 | 40 | if (request.propagate) { 41 | await Promise.all( 42 | _.map(Array.from(addresses.values()), async (address) => { 43 | let resp: NodesReadResponse = (await injector.serviceCall( 44 | address, 45 | "System", 46 | "nodesRead", 47 | [] 48 | )) as NodesReadResponse; 49 | resp.nodes.forEach((n) => { 50 | n.address = address; 51 | rsp.nodes.push(n); 52 | }); 53 | }) 54 | ); 55 | } 56 | 57 | return rsp; 58 | }; 59 | -------------------------------------------------------------------------------- /src/typeorm.ts: -------------------------------------------------------------------------------- 1 | import { Connector } from "./db.js"; 2 | import { getMeta } from "./reflect.js"; 3 | import { DataSource } from "typeorm"; 4 | import { MixedList, EntitySchema } from "typeorm"; 5 | 6 | /** 7 | * Typeorm related configuration for services that use typeorm, 8 | * ie. have DataSource as a dependency. 9 | */ 10 | export interface TypeORMMeta { 11 | /** 12 | * Call DataSource.syncronize() when the service starts? 13 | * Defaults to true. 14 | */ 15 | syncronize?: boolean; 16 | /** 17 | * All typeorm entities that are used inside a service. 18 | */ 19 | entities: MixedList>; 20 | } 21 | 22 | /** 23 | * Each service has its own database in postgres, 24 | * so databases are both namespace and service specific, 25 | * ie. the database name is something like domain + serviceName, 26 | * or an actual example: "example-com--authentication" (this 27 | * is just to explain, the actual database name should not 28 | * matter for you, just assume you have your own database for your 29 | * service, in your own namespace). 30 | */ 31 | export class TypeORMHandler { 32 | public typeName = "DataSource"; 33 | private connectorsByService: Map = new Map(); 34 | 35 | constructor() {} 36 | 37 | async handle(serviceClass: any, config: any): Promise { 38 | let namespace = config as string; 39 | // We need to acquire a per service connection 40 | // because we need to react to the typeormDbCreated callback 41 | // on a per service case - we want to call the service _onFirstInit 42 | // method when it happens. 43 | // 44 | // @todo this is not true anymore as _onFirstInit was retired 45 | 46 | if (!this.connectorsByService.has(serviceClass.name)) { 47 | let conn = new Connector({ 48 | typeormDbCreated: (conn) => { 49 | // @todo this was left over here from the 50 | // time where we had _onFirstInit, we should 51 | // decide if we want to bring it back or not. 52 | // firstConnectionForThisService = true; 53 | }, 54 | }); 55 | this.connectorsByService.set(serviceClass.name, conn); 56 | } 57 | let conn = this.connectorsByService.get(serviceClass.name); 58 | 59 | // This assumes the constructor won't panic because the dependencies 60 | // are not supplied. 61 | let meta = getMeta(serviceClass); 62 | if (!meta) { 63 | throw new Error( 64 | `Service ${serviceClass.name} has no metadata, did you forget to decorate it with @Service()?` 65 | ); 66 | } 67 | if (!(meta as any).typeorm) { 68 | throw new Error( 69 | `Service ${serviceClass.name} has no typeorm metadata, did you forget to decorate it with @Service()?` 70 | ); 71 | } 72 | let conf = (meta as any).typeorm as TypeORMMeta; 73 | let connection = await conn.connect( 74 | namespace + "__" + serviceClass.name.toLowerCase().replace("service", ""), 75 | conf?.entities 76 | ); 77 | return connection; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export class Error { 2 | /** Error message */ 3 | message: string; 4 | /** HTTP code */ 5 | status: number; 6 | 7 | constructor(message: string, status: number) { 8 | this.message = message; 9 | this.status = status; 10 | } 11 | } 12 | 13 | export function error(message: string, status?: number): Error { 14 | return new Error(message, status); 15 | } 16 | 17 | export function copy(from, to) { 18 | if (!from || !to) { 19 | return; 20 | } 21 | for (const [key, value] of Object.entries(from)) { 22 | to[key] = value; 23 | } 24 | } 25 | 26 | export interface ServiceMeta { 27 | name?: string; 28 | } 29 | 30 | /** 31 | * Servicelike is an interface that is used to describe a service. 32 | * Services should not panic if they are not supplied with all the dependencies 33 | * at the time of construction. 34 | * 35 | * The endpoints of a service are its method: 36 | * - Methods annotated with the `@Raw()` decorator will be registered as 37 | * direct http methods and have access to http request and response types. 38 | * A prime example of this is a file upload. 39 | * - Method names starting with underscore are callbacks to specific events, 40 | * see examples below. 41 | * - All other methods will only have access to their JSON request data. 42 | * This is favorable to raw http methods as it is cleaner, enables testing etc. 43 | * 44 | */ 45 | export interface Servicelike { 46 | /** 47 | * Meta contains metadata about the service. 48 | * This can range from registration name to dependency config - 49 | * like entities for the `TypeORMHandler`. 50 | */ 51 | meta?: ServiceMeta; 52 | 53 | /** 54 | * Called when a service is initialized. 55 | * Typically this happens when the server starts up. 56 | */ 57 | _onInit?: () => Promise; 58 | } 59 | 60 | /** 61 | * Configurable contains config options all service 62 | * configs should ideally implement. 63 | */ 64 | export interface Configurable { 65 | skipSeed?: boolean; 66 | } 67 | -------------------------------------------------------------------------------- /tsconfig.dev.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [".eslintrc.js"] 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "noImplicitReturns": false, 5 | "noUnusedLocals": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | //"strictNullChecks": true, 9 | //"strict": true, 10 | "target": "esnext", 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "typeRoots": ["./node_modules/@types"], 14 | "declaration": true, 15 | //"lib": ["esnext"], 16 | "esModuleInterop": true, 17 | "moduleResolution": "nodenext" 18 | }, 19 | "compileOnSave": true, 20 | "include": ["src"], 21 | "exclude": ["node_modules", "lib"], 22 | "allowSyntheticDefaultImports": true 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | {} 2 | --------------------------------------------------------------------------------