Compare commits

..

148 Commits

Author SHA1 Message Date
ac6fd0ef1d 0.14.14: Misc bugfixes in migrations & AsyncCollection keys
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2023-11-07 21:08:05 -06:00
9a55623370 Modify isAwareOfContainerLifecycle to include function-type instances
All checks were successful
continuous-integration/drone/push Build is passing
2023-07-16 14:45:09 -05:00
743e81ae94 bump version 2023-06-13 01:25:51 -05:00
61c4d86fff Update dependencies with pnpm v8
All checks were successful
continuous-integration/drone/promote/production Build is passing
continuous-integration/drone Build is passing
2023-06-13 01:18:54 -05:00
9aa3f56340 Update package versions for node 18
Some checks failed
continuous-integration/drone Build is failing
2023-06-13 01:16:14 -05:00
899c8448fc Create HTTPFilesystem implementation and add support to universalPath helper to automatically use it
Some checks failed
continuous-integration/drone Build is failing
2023-06-13 01:09:33 -05:00
7c9b1ff212 Bump version
All checks were successful
continuous-integration/drone Build is passing
2022-11-29 11:48:17 -06:00
cae9a1acbe Drone: only rollout docs on tag
All checks were successful
continuous-integration/drone Build is passing
2022-11-29 11:46:44 -06:00
aeb37d711b Drone: add npm build pipeline
All checks were successful
continuous-integration/drone Build is passing
2022-11-29 11:44:46 -06:00
d66ae85f54 Drone: fix k8s rollout path
All checks were successful
continuous-integration/drone Build is passing
2022-11-29 11:01:55 -06:00
68b81106e6 Docs: update to latest typedoc (breaks theme)
Some checks failed
continuous-integration/drone Build is failing
2022-11-29 11:00:08 -06:00
5395fb9054 Drone: Add docs build
Some checks failed
continuous-integration/drone Build is failing
2022-11-29 10:37:23 -06:00
bbafd54dcc Add docs K8s spec 2022-11-29 10:30:27 -06:00
37fcaabdef Fix isHTTPMethod validator 2022-11-14 21:48:28 -06:00
bbf2807cfa Add options to HTTPMethod type 2022-11-14 21:41:30 -06:00
2d9f22b895 Fix left() response handling for parameter middleware 2022-11-14 16:58:17 -06:00
0484a586bd Update dependencies & fix misc formatting errors 2022-09-30 12:02:51 -05:00
52762bd4a1 Experimental SQLite support 2022-09-30 11:42:13 -05:00
c0595f3ef9 Centralize configure-able factory classes 2022-09-26 11:34:23 -05:00
5557aae543 Fix order of dates in redis expiration calculation 2022-09-13 23:21:44 -05:00
f1791b1d76 Add push$ to Collection; make Container listen for retroactive blueprint changes 2022-09-13 23:08:57 -05:00
a173393697 Fix property injection prototype hoisting bug 2022-09-13 22:34:16 -05:00
c966904418 finish TreeModel implementation and updateMany builder method 2022-09-12 12:36:33 -05:00
f63891ef99 Add TreeModel and HasSubtree implementation 2022-08-20 16:21:06 -05:00
3d836afa59 Improve error context for DI 2022-08-10 21:53:56 -05:00
9b47d2ac99 Fix async context loss in WebSocketBus 2022-08-10 00:06:48 -05:00
085fe04f90 Fix issue where WebSocketBus depends on Session before session factory registered 2022-08-09 23:59:43 -05:00
e339ec718d Support periodic auth checks in SecurityContext on web socket connections 2022-08-09 23:49:25 -05:00
efb9726470 Add CacheSession implementation & make WebSocketBus re-load the session when an event is received 2022-08-09 23:01:36 -05:00
8a153e3807 Properly unwrap StateEvent state before calling handler 2022-08-09 22:27:19 -05:00
b8cf8499d2 Update ShellDirective to use ts-node interpreter by default 2022-08-07 00:47:55 -05:00
710b6cb535 Add workaround for global registry async context in REPL 2022-08-06 21:57:29 -05:00
91d76f44b5 Make global registry better accessible externally 2022-08-06 15:53:38 -05:00
8774bd8d34 Fix serial format of StateEvent 2022-08-06 14:19:09 -05:00
3712fae979 Better comments 2022-08-06 13:20:22 -05:00
4aa33e8dd2 Add SocketRouteBuilder and make Route.socket(...) return it 2022-08-06 13:10:51 -05:00
ef405093dc Actually export StateEvent... 2022-08-06 01:45:10 -05:00
fc85c9d2c8 Add StateEvent implementation 2022-08-06 01:42:15 -05:00
d00e6a02e2 Remove unnecessary container call 2022-07-14 01:16:35 -05:00
ea81a37315 Bump version 2022-07-14 01:15:53 -05:00
33a64b99ff Implement websocket server 2022-07-14 01:15:16 -05:00
dc663ec8f5 Bump version 2022-07-13 21:35:34 -05:00
6476416c67 Add foreground service, some cleanup, and start websocket server 2022-07-13 21:35:18 -05:00
4d7769de56 Fix PostgreSQLDialect escaping of single quotes 2022-07-09 11:42:02 -05:00
6ca4bc1151 Fix buildMigrationNamespaceResolver to use isValidSuffix(...) instead of hard-coded check 2022-07-09 11:03:50 -05:00
7d3fde85eb Modify canonical loading to allow either suffix.js or suffix.ts endings 2022-07-09 11:01:34 -05:00
111ce0bcf9 Fix Routing suffix loading 2022-07-08 21:03:01 -05:00
d3a6a8495c Update application bootstrapping to load .env from repo root 2022-07-08 20:56:48 -05:00
2ff9354538 Make file extensions on Canonical resources runtime-agnostic 2022-07-08 20:27:18 -05:00
cf0ae260dc Fix syntax of postgres update queries 2022-06-29 22:14:00 -05:00
fe9170282f Add ArrayElement type helper 2022-06-28 22:03:15 -05:00
2e43b5bda9 Fix route parameter typing to use SuffixTypeArray 2022-06-28 21:05:01 -05:00
705bb20db1 Make Route.clone properly copy parameter definitions 2022-06-28 20:53:28 -05:00
1be73dd347 Add docs server Dockerfile 2022-06-25 21:10:15 -05:00
afbf6e7682 Add force SSL config flag 2022-06-07 22:45:16 -05:00
48ce1bfa2f Improve app URL detection 2022-06-07 22:03:05 -05:00
1d717e0eb9 bump version 2022-06-07 21:57:10 -05:00
90b16eef53 Add better debugging to routing service 2022-06-07 21:56:56 -05:00
1399399af9 Improve error handling in OAuth2 token issuer 2022-04-28 20:52:28 -05:00
30a23b1659 Temporarily cast migration AsyncCollection to Collection pending #17 2022-04-28 20:16:19 -05:00
de13030815 Fix AsyncCollection.filter to allow for async filter functions 2022-04-28 19:41:16 -05:00
fd77ad5cd3 Create migration for oauth2_tokens table 2022-04-28 19:17:25 -05:00
814a5763d9 fix formatting 2022-04-28 15:06:15 -05:00
9ede67cb12 Make orm token repo use make() to create token model instances 2022-04-28 15:04:16 -05:00
8a9264b9de Simplify /oauth2/issue logic 2022-04-28 14:58:06 -05:00
d210cba236 add oauth2 issue debugging and bump version 2022-04-28 14:29:23 -05:00
015d6fd6ae Fix Either<> pass in OAuth2Server; bump version 2022-04-28 13:07:27 -05:00
ce4133ff8e Fix scope() helper and bump version 2022-04-28 12:00:13 -05:00
9d8f43d8fb Bump version 2022-04-28 11:51:01 -05:00
940d50b89c Implement /oauth2/token endpoint; token auth middleware 2022-04-28 11:50:27 -05:00
36647a013d Add ability to get provider by name from auth service 2022-04-09 20:08:50 -05:00
5616b3cc1f Add suggestion for missing schema for validator error 2022-04-05 14:02:16 -05:00
2e7c927114 Make route extraction account for extraneous / matches 2022-04-05 12:39:33 -05:00
8b2ee1c949 Add debugging for route extraction 2022-04-05 12:35:41 -05:00
c7557cf5b6 Allow overriding content-type of plaintext response factory 2022-04-05 12:20:18 -05:00
771fed8002 Make parameter middleware handler call collection.toArray NON-recursively 2022-04-05 10:41:23 -05:00
7914a8f12e Fix incorrect flow-through if-else case 2022-04-05 10:37:03 -05:00
5fa4f614e2 Pass request to ParameterMiddleware make call 2022-04-05 10:35:14 -05:00
ee21811771 Add ability to pass arguments to parameter providing middleware 2022-04-05 10:25:07 -05:00
8b9f393405 Add support for parameter middleware classes 2022-04-05 09:55:20 -05:00
f6a7cac05c Improve ORM templates; improve StaticClass typedef; bump version 2022-04-04 14:45:45 -05:00
25265b5560 Only load .env if the file exists; bump version 2022-03-31 15:53:36 -05:00
a779ec1d09 Update content-length calculation to use buffer 2022-03-30 23:59:34 -05:00
bea48602f5 Fix content-length header set 2022-03-30 23:55:41 -05:00
445f16d973 Prevent duplicate bus shutdowns on container destroy; bump version 2022-03-30 23:40:10 -05:00
351a2e14b8 Prevent request Bus instances from creating new IORedis connections 2022-03-30 23:34:17 -05:00
b42e91533a Fix missed return in container.makeNew 2022-03-30 23:20:09 -05:00
78cb26fcb2 Add request container lifecycle handling 2022-03-30 23:04:00 -05:00
514a578260 Make HTTPServer ignore responses that cannot be sent 2022-03-30 22:19:33 -05:00
3d7d583367 Add Request logging 2022-03-30 22:05:46 -05:00
6f66126d38 Improve Response verbose/debug logging 2022-03-30 21:38:36 -05:00
10b3e1ecc3 Temporarily remove timeout logic from HTTPServer 2022-03-30 18:45:34 -05:00
795adac68b Add better detection for write-after-destroy errors on the response 2022-03-30 18:33:37 -05:00
ca348b2ff6 Add Trace logging level; bump version 2022-03-30 18:15:56 -05:00
508d92f759 Add better debugging for Bus connections 2022-03-30 18:02:17 -05:00
a590d78155 Reduce # of duplicate Bus.up() calls; bump version 2022-03-30 17:34:19 -05:00
dbe48ea8a5 Add debugging option to make bus warnings throw Error; bump version 2022-03-30 17:24:45 -05:00
467721f775 Include request parameters in input(...) sources; bump version 2022-03-30 15:35:09 -05:00
153f8f7685 Fix Controller request injection and bump version 2022-03-30 12:13:33 -05:00
ba87ea32c3 Add make helper and bump version 2022-03-29 15:29:27 -05:00
737d06f6f0 Fix preflight middleware ordering and bump version 2022-03-29 15:16:29 -05:00
6ee3e2a729 Fix preflight middleware ordering and bump version 2022-03-29 15:12:51 -05:00
1288e51de0 Disable broadcasting migration events and bump version 2022-03-29 08:46:24 -05:00
1fde692a65 Bump version 2022-03-29 02:31:31 -05:00
cdecb7e628 Fix hanging IORedis connections; add extollo.wontstop debugging helper 2022-03-29 02:30:48 -05:00
8f08b94f74 Error response enhancements, CoreID auth client backend 2022-03-29 01:14:46 -05:00
a039b1ff25 Add Safe value API and start OAuth2Server 2022-02-24 00:00:35 -06:00
70d67c2730 Add model serializer and coreid login provider 2022-02-23 15:15:02 -06:00
0774deea91 bump version
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-27 10:34:19 -06:00
16e5fa00aa Implement queue work and listen commands
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-27 10:34:01 -06:00
e098a5edb7 Version bump
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-26 19:39:00 -06:00
6d1cf18680 Refactor event bus and queue system; detect cycles in DI realization and make
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-26 19:37:54 -06:00
506fb55c74 Start auth provider system
Some checks failed
continuous-integration/drone/push Build is failing
2022-01-20 00:55:21 -06:00
cfd555723b Add whereDefined and mapCall methods to Collection class 2022-01-20 00:55:11 -06:00
32050cb2ce Fix route CLI commands 2022-01-20 00:54:55 -06:00
dc16dfdb81 Make new routing system the default
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-19 13:24:59 -06:00
8cf19792a6 Start routing and pipeline rewrite
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-17 15:57:40 -06:00
9b8333295f Version bump
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-14 14:28:25 -06:00
5ffb91329e Start new validation system and zodified types with excc
All checks were successful
continuous-integration/drone/push Build is passing
2022-01-14 01:04:13 -06:00
b105a61ca2 Add RequestClass to override AppClass
All checks were successful
continuous-integration/drone/push Build is passing
2021-12-16 14:02:15 -06:00
9204a02450 Modify GlobalRegistry to use async local storage to support multiple "global" containers
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-11-27 10:43:26 -06:00
463076d182 Introduce async local storage for request access, more view globals, and improved welcome view
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 10:30:49 -06:00
b5eb407b55 Prevent duplicate auto-discovery for nested packages
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 00:12:51 -06:00
0ed096c782 Add dependency on @extollo/ui and enable recursive discovery
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-27 00:10:48 -06:00
5175d64e36 Rework authentication system
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-26 14:32:25 -06:00
bd7d6a2dbd Start tests 2021-11-26 14:32:05 -06:00
d251f8bc15 Util: fix Pipe conditionals and add type-safe hasOwnProperty helper 2021-11-26 14:31:47 -06:00
bf4a675faa DI: add logic for static class overrides 2021-11-26 14:31:18 -06:00
6fc901b3ec bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 17:05:22 -06:00
50e0cf3090 Fix prototype access issue with model scopes property
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 17:02:43 -06:00
d245d15ad6 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-25 16:52:35 -06:00
265837b5cd Fix stupid typescript error...
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-25 16:50:01 -06:00
fe0b4d6d8f bump version
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2021-11-25 16:39:53 -06:00
ce1d22ff44 Make the linter happy
Some checks failed
continuous-integration/drone/push Build is failing
2021-11-25 16:39:25 -06:00
b7bfb3e153 Model: fix eager-loaded relation loading from static query 2021-11-25 16:39:17 -06:00
e57819d318 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-11-15 14:00:29 -06:00
0a9dd30909 Implement scopes on models and support interacting with them via ModelBuilder
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-11 16:42:37 -06:00
d92c8b5409 Start implementation of model relations
All checks were successful
continuous-integration/drone/push Build is passing
2021-11-10 21:30:59 -06:00
589cb7d579 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 22:29:34 -05:00
3680ad1914 Fix back-fill on insert for Model.save 2021-10-19 22:29:15 -05:00
96e13d85fc Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:59:14 -05:00
5a9283ad85 orm(PostgreSQLDialect): double-quote column names in INSERT field lists 2021-10-19 13:59:00 -05:00
b1ea489ccb Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 13:00:27 -05:00
c3f2779650 Add generic to APIResponse 2021-10-19 13:00:14 -05:00
248b24e612 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-19 10:26:47 -05:00
b4a9057e2b CLI invocation output better debugging infor 2021-10-19 10:26:32 -05:00
c078d695a8 Bump version
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-10-18 17:23:30 -05:00
55ffadc742 Export CLI decorators
All checks were successful
continuous-integration/drone/push Build is passing
2021-10-18 17:23:16 -05:00
286 changed files with 12672 additions and 7049 deletions

View File

@ -1,228 +1,87 @@
kind: pipeline
type: docker
name: docs
steps:
# ============ BUILD STEPS ===============
- name: build documentation
image: glmdev/node-pnpm:latest
commands:
- pnpm i --silent
- pnpm docs:build
- cd docs && tar czf ../extollo_api_documentation.tar.gz www
# =============== DEPLOY STEPS ===============
- name: copy artifacts to static host
image: appleboy/drone-scp
settings:
host:
from_secret: docs_deploy_host
username:
from_secret: docs_deploy_user
key:
from_secret: docs_deploy_key
port: 22
source: extollo_api_documentation.tar.gz
target: /var/nfs/storage/static/sites/extollo
when:
event: promote
target: docs
- name: deploy artifacts on static host
image: appleboy/drone-ssh
settings:
host:
from_secret: docs_deploy_host
username:
from_secret: docs_deploy_user
key:
from_secret: docs_deploy_key
port: 22
script:
- cd /var/nfs/storage/static/sites/extollo
- rm -rf docs
- tar xzf extollo_api_documentation.tar.gz
- rm -rf extollo_api_documentation.tar.gz
- mv www docs
when:
event: promote
target: docs
# =============== BUILD NOTIFICATIONS ===============
- name: send build success notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
"message": "Build & deploy completed successfully.",
"priority": 4
}
when:
status: success
event:
- promote
- name: send build error notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/docs @ ${DRONE_BUILD_NUMBER}]",
"message": "Documentation build failed!",
"priority": 6
}
when:
status: failure
--- ---
kind: pipeline kind: pipeline
name: default type: kubernetes
type: docker name: docs
metadata:
labels:
pod-security.kubernetes.io/audit: privileged
services:
- name: docker daemon
image: docker:dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
when:
event: tag
steps: steps:
- name: post build in progress comment to PR - name: typedoc build
image: tsakidev/giteacomment:latest image: node:18
settings:
gitea_token:
from_secret: gitea_token
gitea_base_url: https://code.garrettmills.dev
comment: "Build ${DRONE_BUILD_NUMBER} started."
when:
event: pull_request
- name: remove lockfile
image: glmdev/node-pnpm:latest
commands:
- rm -rf pnpm-lock.yaml
when:
event:
exclude: tag
- name: Install dependencies
image: glmdev/node-pnpm:latest
commands: commands:
- "node -v"
- "npm add --global pnpm"
- "pnpm --version"
- pnpm i - pnpm i
- pnpm run docs:build
- name: Lint code - name: container build
image: glmdev/node-pnpm:latest image: docker:latest
privileged: true
commands: commands:
- pnpm lint - "while ! docker stats --no-stream; do sleep 1; done"
- docker image build docs -t $DOCKER_REGISTRY/extollo/docs:latest
- docker push $DOCKER_REGISTRY/extollo/docs:latest
environment:
DOCKER_HOST: tcp://localhost:2375
DOCKER_REGISTRY:
from_secret: DOCKER_REGISTRY
when:
event: tag
status: success
- name: build module - name: k8s rollout
image: glmdev/node-pnpm:latest image: bitnami/kubectl
commands: commands:
- cd docs/deploy && kubectl apply -f .
- kubectl rollout restart -n extollo deployment/docs
when:
event: tag
status: success
---
kind: pipeline
type: kubernetes
name: npm
steps:
- name: node.js build
image: node:18
commands:
- "npm add --global pnpm"
- pnpm i
- pnpm build - pnpm build
- mkdir artifacts
- tar czf artifacts/extollo-lib.tar.gz lib
- name: create Gitea release - name: gitea release
image: plugins/gitea-release image: plugins/gitea-release
settings: settings:
api_key: api_key:
from_secret: gitea_token from_secret: GITEA_TOKEN
base_url: https://code.garrettmills.dev base_url: https://code.garrettmills.dev
checksum: md5 checksum: md5
title: ${DRONE_TAG} title: ${DRONE_TAG}
files: "artifacts/*"
when: when:
event: tag event: tag
status: success status: success
- name: prepare NPM release - name: npm release
image: glmdev/node-pnpm:latest
commands:
- rm -rf artifacts
when:
event: tag
status: success
- name: create NPM release
image: plugins/npm image: plugins/npm
settings: settings:
username: extollo_bot username: extollo_bot
password: password:
from_secret: npm_password from_secret: NPM_PASSWORD
email: extollo@garrettmills.dev email: extollo@garrettmills.dev
when: when:
event: tag event: tag
status: success status: success
- name: send build success notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
"message": "Build completed successfully.",
"priority": 4
}
when:
status: success
event:
exclude:
- pull_request
- tag
- name: send publish success notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
"message": "Successfully published tag ${DRONE_TAG}.",
"priority": 4
}
when:
status: success
event: tag
- name: post build success comment to PR
image: tsakidev/giteacomment:latest
settings:
gitea_token:
from_secret: gitea_token
gitea_base_url: https://code.garrettmills.dev
comment: "Build ${DRONE_BUILD_NUMBER} completed successfully."
when:
status: success
event: pull_request
- name: send build error notifications
image: plugins/webhook
settings:
urls:
from_secret: notify_webhook_url
content_type: application/json
template: |
{
"title": "Drone-CI [extollo/lib @ ${DRONE_BUILD_NUMBER}]",
"message": "Build failed!",
"priority": 6
}
when:
status: failure
event:
exclude:
- pull_request
- name: post build error comment to PR
image: tsakidev/giteacomment:latest
settings:
gitea_token:
from_secret: gitea_token
gitea_base_url: https://code.garrettmills.dev
comment: "Build ${DRONE_BUILD_NUMBER} failed!"
when:
status: failure
event: pull_request

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
.undodir
# ---> JetBrains # ---> JetBrains
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839

4
docs/Dockerfile Normal file
View File

@ -0,0 +1,4 @@
FROM joseluisq/static-web-server:2
COPY ./www /public

View File

@ -22,7 +22,7 @@ Node.js provides an excellent platform for quickly getting an application up and
## Getting Started ## Getting Started
Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel. Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel.
Check out the [Getting Started](https://extollo.garrettmills.dev/pages/Documentation/Getting-Started.html) page site for more information. Check out the [Getting Started](pages/Getting-Started.html) page site for more information.
## License & Philosophy ## License & Philosophy
The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information. The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information.

View File

@ -0,0 +1,5 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: extollo

View File

@ -0,0 +1,23 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs
namespace: extollo
spec:
selector:
matchLabels:
app: docs
template:
metadata:
name: docs
namespace: extollo
labels:
app: docs
spec:
containers:
- name: docs-www
image: registry.millslan.net/extollo/docs
imagePullPolicy: Always
ports:
- containerPort: 80

View File

@ -0,0 +1,25 @@
---
apiVersion: v1
kind: Service
metadata:
name: docs-service
namespace: extollo
spec:
selector:
app: docs
ports:
- port: 80
targetPort: 80
---
apiVersion: v1
kind: Service
metadata:
name: docs-service-lb
namespace: extollo
spec:
type: LoadBalancer
selector:
app: docs
ports:
- port: 80
targetPort: 80

View File

@ -0,0 +1,13 @@
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: docs-tls
namespace: extollo
spec:
secretName: docs-tls-secret
dnsNames:
- 'extollo.garrettmills.dev'
issuerRef:
name: letsencrypt-ca
kind: ClusterIssuer

View File

@ -0,0 +1,25 @@
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: docs-ingress
namespace: extollo
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: 'false'
spec:
tls:
- hosts:
- extollo.garrettmills.dev
secretName: docs-tls-secret
ingressClassName: nginx
rules:
- host: extollo.garrettmills.dev
http:
paths:
- pathType: Prefix
path: '/'
backend:
service:
name: docs-service
port:
number: 80

View File

@ -1 +0,0 @@
# About the Extollo Project

View File

@ -1,4 +1,3 @@
# Getting Started with Extollo
## Requirements ## Requirements

1608
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "@extollo/lib", "name": "@extollo/lib",
"version": "0.5.3", "version": "0.14.14",
"description": "The framework library that lifts up your code.", "description": "The framework library that lifts up your code.",
"main": "lib/index.js", "main": "lib/index.js",
"types": "lib/index.d.ts", "types": "lib/index.d.ts",
@ -8,50 +8,56 @@
"lib": "lib" "lib": "lib"
}, },
"dependencies": { "dependencies": {
"@atao60/fse-cli": "^0.1.6", "@atao60/fse-cli": "^0.1.7",
"@extollo/ui": "^0.1.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/busboy": "^0.2.3", "@types/busboy": "^0.2.4",
"@types/cli-table": "^0.3.0", "@types/cli-table": "^0.3.1",
"@types/ioredis": "^4.26.6", "@types/ioredis": "^4.28.10",
"@types/mime-types": "^2.1.0", "@types/jsonwebtoken": "^8.5.9",
"@types/mkdirp": "^1.0.1", "@types/mime-types": "^2.1.1",
"@types/mkdirp": "^1.0.2",
"@types/negotiator": "^0.6.1", "@types/negotiator": "^0.6.1",
"@types/node": "^14.17.4", "@types/node": "^14.18.51",
"@types/pg": "^8.6.0", "@types/pg": "^8.10.2",
"@types/pluralize": "^0.0.29", "@types/pluralize": "^0.0.29",
"@types/pug": "^2.0.4", "@types/pug": "^2.0.6",
"@types/rimraf": "^3.0.0", "@types/rimraf": "^3.0.2",
"@types/ssh2": "^0.5.46", "@types/ssh2": "^0.5.52",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.4",
"bcrypt": "^5.0.1", "@types/ws": "^8.5.5",
"bcrypt": "^5.1.0",
"busboy": "^0.3.1", "busboy": "^0.3.1",
"cli-table": "^0.3.6", "cli-table": "^0.3.11",
"colors": "^1.4.0", "colors": "^1.4.0",
"dotenv": "^8.2.0", "dotenv": "^8.6.0",
"ioredis": "^4.27.6", "ioredis": "^4.28.5",
"mime-types": "^2.1.31", "jsonwebtoken": "^8.5.1",
"mime-types": "^2.1.35",
"mkdirp": "^1.0.4", "mkdirp": "^1.0.4",
"negotiator": "^0.6.2", "negotiator": "^0.6.3",
"node-fetch": "^3", "node-fetch": "^3.3.1",
"pg": "^8.6.0", "pg": "^8.11.0",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"pug": "^3.0.2", "pug": "^3.0.2",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ssh2": "^1.1.0", "sqlite": "^4.2.1",
"ts-node": "^9.1.1", "sqlite3": "^5.1.6",
"typedoc": "^0.20.36", "ssh2": "^1.13.0",
"typedoc-plugin-pages-fork": "^0.0.1", "ts-node": "^10.9.1",
"typedoc-plugin-sourcefile-url": "^1.0.6", "typescript": "^4.9.5",
"typescript": "^4.2.3", "uuid": "^8.3.2",
"uuid": "^8.3.2" "ws": "^8.13.0",
"zod": "^3.21.4"
}, },
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources", "build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js", "app": "tsc && node lib/index.js",
"prepare": "pnpm run build", "prepare": "pnpm run build",
"docs:build": "typedoc --options typedoc.json", "docs:build": "typedoc --options typedoc.json",
"docs:build:docker": "pnpm run docs:build && docker image build docs -t ${DOCKER_REGISTRY}/extollo/docs:latest && docker push ${DOCKER_REGISTRY}/extollo/docs:latest",
"lint": "eslint . --ext .ts", "lint": "eslint . --ext .ts",
"lint:fix": "eslint --fix . --ext .ts" "lint:fix": "eslint --fix . --ext .ts"
}, },
@ -67,14 +73,28 @@
"author": "garrettmills <shout@garrettmills.dev>", "author": "garrettmills <shout@garrettmills.dev>",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^4.26.0", "@knodes/typedoc-plugin-pages": "^0.23.4",
"@typescript-eslint/parser": "^4.26.0", "@types/chai": "^4.3.5",
"eslint": "^7.27.0" "@types/mocha": "^9.1.1",
"@types/sinon": "^10.0.15",
"@types/wtfnode": "^0.7.0",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"chai": "^4.3.7",
"eslint": "^8.42.0",
"lunr": "^2.3.9",
"mocha": "^9.2.2",
"sinon": "^12.0.1",
"typedoc": "^0.23.28",
"wtfnode": "^0.9.1"
}, },
"extollo": { "extollo": {
"discover": true, "discover": true,
"units": { "units": {
"discover": false "discover": false
},
"recursiveDependencies": {
"discover": true
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,19 @@
import {Inject, Injectable, Instantiable, StaticClass} from '../di'
import {Unit} from '../lifecycle/Unit' import {Unit} from '../lifecycle/Unit'
import {Injectable, Inject, StaticInstantiable} from '../di'
import {Logging} from '../service/Logging' import {Logging} from '../service/Logging'
import {Middlewares} from '../service/Middlewares'
import {CanonicalResolver} from '../service/Canonical' import {CanonicalResolver} from '../service/Canonical'
import {Middleware} from '../http/routing/Middleware' import {Middleware} from '../http/routing/Middleware'
import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware' import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware' import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
import {Middlewares} from '../service/Middlewares' import {SessionAuthMiddleware} from './middleware/SessionAuthMiddleware'
import {ViewEngine} from '../views/ViewEngine'
import {SecurityContext} from './context/SecurityContext'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Config} from '../service/Config'
import {ErrorWithContext, hasOwnProperty} from '../util'
import {Route} from '../http/routing/Route'
/**
* Unit class that bootstraps the authentication framework.
*/
@Injectable() @Injectable()
export class Authentication extends Unit { export class Authentication extends Unit {
@Inject() @Inject()
@ -19,21 +22,64 @@ export class Authentication extends Unit {
@Inject() @Inject()
protected readonly middleware!: Middlewares protected readonly middleware!: Middlewares
async up(): Promise<void> { @Inject()
this.container() protected readonly config!: Config
this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
protected providers: {[name: string]: LoginProvider<LoginProviderConfig>} = {}
getProvider(name: string): LoginProvider<LoginProviderConfig> {
const provider = this.providers[name]
if ( !provider ) {
throw new ErrorWithContext('Invalid auth provider name: ' + name, { name })
} }
/** return provider
* Create the canonical namespace resolver for auth middleware. }
* @protected
*/ async up(): Promise<void> {
protected getMiddlewareResolver(): CanonicalResolver<StaticClass<Middleware, Instantiable<Middleware>>> { this.middleware.registerNamespace('@auth', this.getMiddlewareResolver())
this.container().onResolve<ViewEngine>(ViewEngine)
.then((engine: ViewEngine) => {
engine.registerGlobalFactory('user', req => {
return () => req?.make<SecurityContext>(SecurityContext)?.getUser()
})
})
const config = this.config.get('auth.providers', {})
const middleware = this.config.get('auth.middleware', SessionAuthMiddleware)
if ( !(middleware?.prototype instanceof Middleware) ) {
throw new ErrorWithContext('Auth middleware must extend Middleware base class', {
providedValue: middleware,
configKey: 'auth.middleware',
})
}
for ( const name in config ) {
if ( !hasOwnProperty(config, name) ) {
continue
}
if ( this.providers[name] ) {
this.logging.warn(`Registering duplicate authentication provider: ${name}`)
}
this.logging.verbose(`Registered authentication provider: ${name}`)
this.providers[name] = this.make(config[name].driver, name, config[name].config)
Route.group(`/auth/${name}`, () => {
this.providers[name].routes()
}).pre(middleware)
}
}
protected getMiddlewareResolver(): CanonicalResolver<StaticInstantiable<Middleware>> {
return (key: string) => { return (key: string) => {
return ({ return ({
web: SessionAuthMiddleware,
required: AuthRequiredMiddleware, required: AuthRequiredMiddleware,
guest: GuestRequiredMiddleware, guest: GuestRequiredMiddleware,
web: SessionAuthMiddleware,
})[key] })[key]
} }
} }

View File

@ -1,151 +0,0 @@
import {Inject, Injectable} from '../di'
import {EventBus} from '../event/EventBus'
import {Awaitable, Maybe} from '../util'
import {Authenticatable, AuthenticatableCredentials, AuthenticatableRepository} from './types'
import {UserAuthenticatedEvent} from './event/UserAuthenticatedEvent'
import {UserFlushedEvent} from './event/UserFlushedEvent'
import {UserAuthenticationResumedEvent} from './event/UserAuthenticationResumedEvent'
import {Logging} from '../service/Logging'
/**
* Base-class for a context that authenticates users and manages security.
*/
@Injectable()
export abstract class SecurityContext {
@Inject()
protected readonly bus!: EventBus
@Inject()
protected readonly logging!: Logging
/** The currently authenticated user, if one exists. */
private authenticatedUser?: Authenticatable
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
/** The name of this context. */
public readonly name: string,
) { }
/**
* Called when the context is created. Can be used by child-classes to do setup work.
*/
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Authenticate the given user, without persisting the authentication.
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
* @param user
*/
async authenticateOnce(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
}
/**
* Authenticate the given user and persist the authentication.
* @param user
*/
async authenticate(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.persist()
await this.bus.dispatch(new UserAuthenticatedEvent(user, this))
}
/**
* Attempt to authenticate a user based on their credentials.
* If the credentials are valid, the user will be authenticated, but the authentication
* will not be persisted. That is, when the lifecycle ends, the user will be
* unauthenticated implicitly.
* @param credentials
*/
async attemptOnce(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticateOnce(user)
return user
}
}
/**
* Attempt to authenticate a user based on their credentials.
* If the credentials are valid, the user will be authenticated and the
* authentication will be persisted.
* @param credentials
*/
async attempt(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
await this.authenticate(user)
return user
}
}
/**
* Unauthenticate the current user, if one exists, but do not persist the change.
*/
async flushOnce(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.bus.dispatch(new UserFlushedEvent(user, this))
}
}
/**
* Unauthenticate the current user, if one exists, and persist the change.
*/
async flush(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.persist()
await this.bus.dispatch(new UserFlushedEvent(user, this))
}
}
/**
* Assuming a user is still authenticated in the context,
* try to look up and fill in the user.
*/
async resume(): Promise<void> {
const credentials = await this.getCredentials()
this.logging.debug('resume:')
this.logging.debug(credentials)
const user = await this.repository.getByCredentials(credentials)
if ( user ) {
this.authenticatedUser = user
await this.bus.dispatch(new UserAuthenticationResumedEvent(user, this))
}
}
/**
* Write the current state of the security context to whatever storage
* medium the context's host provides.
*/
abstract persist(): Awaitable<void>
/**
* Get the credentials for the current user from whatever storage medium
* the context's host provides.
*/
abstract getCredentials(): Awaitable<AuthenticatableCredentials>
/**
* Get the currently authenticated user, if one exists.
*/
getUser(): Maybe<Authenticatable> {
return this.authenticatedUser
}
/**
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
this.logging.debug('hasUser?')
this.logging.debug(this.authenticatedUser)
return Boolean(this.authenticatedUser)
}
}

View File

@ -1,145 +0,0 @@
import {Controller} from '../../http/Controller'
import {Inject, Injectable} from '../../di'
import {ResponseObject, Route} from '../../http/routing/Route'
import {Request} from '../../http/lifecycle/Request'
import {view} from '../../http/response/ViewResponseFactory'
import {ResponseFactory} from '../../http/response/ResponseFactory'
import {SecurityContext} from '../SecurityContext'
import {BasicLoginFormRequest} from './BasicLoginFormRequest'
import {Routing} from '../../service/Routing'
import {Valid, ValidationError} from '../../forms'
import {AuthenticatableCredentials} from '../types'
import {BasicRegisterFormRequest} from './BasicRegisterFormRequest'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
import {Session} from '../../http/session/Session'
import {temporary} from '../../http/response/TemporaryRedirectResponseFactory'
@Injectable()
export class BasicLoginController extends Controller {
public static routes({ enableRegistration = true } = {}): void {
Route.group('auth', () => {
Route.get('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getLogin()
})
.pre('@auth:guest')
.alias('@auth.login')
Route.post('login', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptLogin()
})
.pre('@auth:guest')
.alias('@auth.login.attempt')
Route.any('logout', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptLogout()
})
.pre('@auth:required')
.alias('@auth.logout')
if ( enableRegistration ) {
Route.get('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.getRegistration()
})
.pre('@auth:guest')
.alias('@auth.register')
Route.post('register', (request: Request) => {
const controller = <BasicLoginController> request.make(BasicLoginController)
return controller.attemptRegister()
})
.pre('@auth:guest')
.alias('@auth.register.attempt')
}
}).pre('@auth:web')
}
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
@Inject()
protected readonly session!: Session
public getLogin(): ResponseFactory {
return this.getLoginView()
}
public getRegistration(): ResponseFactory {
return this.getRegistrationView()
}
public async attemptLogin(): Promise<ResponseObject> {
const form = <BasicLoginFormRequest> this.request.make(BasicLoginFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.attempt(data)
if ( user ) {
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
}
return this.getLoginView(['Invalid username/password.'])
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getLoginView(e.errors.all())
}
throw e
}
}
public async attemptLogout(): Promise<ResponseObject> {
await this.security.flush()
return this.getMessageView('You have been logged out.')
}
public async attemptRegister(): Promise<ResponseObject> {
const form = <BasicRegisterFormRequest> this.request.make(BasicRegisterFormRequest)
try {
const data: Valid<AuthenticatableCredentials> = await form.get()
const user = await this.security.repository.createByCredentials(data)
await this.security.authenticate(user)
const intention = this.session.get('auth.intention', '/')
this.session.forget('auth.intention')
return temporary(intention)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return this.getRegistrationView(e.errors.all())
} else if ( e instanceof AuthenticatableAlreadyExistsError ) {
return this.getRegistrationView(['A user with that username already exists.'])
}
throw e
}
}
protected getLoginView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:login', {
formAction: this.routing.getNamedPath('@auth.login.attempt').toRemote,
errors,
})
}
protected getRegistrationView(errors?: string[]): ResponseFactory {
return view('@extollo:auth:register', {
formAction: this.routing.getNamedPath('@auth.register.attempt').toRemote,
errors,
})
}
protected getMessageView(message: string): ResponseFactory {
return view('@extollo:auth:message', {
message,
})
}
}

View File

@ -1,20 +0,0 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicLoginFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
],
credential: [
Is.required,
Str.lengthMin(1),
],
}
}
}

View File

@ -1,22 +0,0 @@
import {FormRequest, ValidationRules} from '../../forms'
import {Is, Str} from '../../forms/rules/rules'
import {Singleton} from '../../di'
import {AuthenticatableCredentials} from '../types'
@Singleton()
export class BasicRegisterFormRequest extends FormRequest<AuthenticatableCredentials> {
protected getRules(): ValidationRules {
return {
identifier: [
Is.required,
Str.lengthMin(1),
Str.alphaNum,
],
credential: [
Is.required,
Str.lengthMin(8),
Str.confirmed,
],
}
}
}

View File

@ -1,29 +1,51 @@
import {Instantiable} from '../di' import {Instantiable, isInstantiable} from '../di'
import {ORMUserRepository} from './orm/ORMUserRepository' import {AuthenticatableRepository} from './types'
import {OAuth2LoginConfig} from './external/oauth2/OAuth2LoginController' import {hasOwnProperty} from '../util'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Middleware} from '../http/routing/Middleware'
/** export interface AuthenticationConfig {
* Inferface for type-checking the AuthenticatableRepositories values. storage: Instantiable<AuthenticatableRepository>,
*/ middleware?: Instantiable<Middleware>,
export interface AuthenticatableRepositoryMapping { providers?: {
orm: Instantiable<ORMUserRepository>, [key: string]: {
} driver: Instantiable<LoginProvider<LoginProviderConfig>>,
config: LoginProviderConfig,
/**
* String mapping of AuthenticatableRepository implementations.
*/
export const AuthenticatableRepositories: AuthenticatableRepositoryMapping = {
orm: ORMUserRepository,
}
/**
* Interface for making the auth config type-safe.
*/
export interface AuthConfig {
repositories: {
session: keyof AuthenticatableRepositoryMapping,
}, },
sources?: {
[key: string]: OAuth2LoginConfig,
}, },
} }
export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig {
if ( typeof what !== 'object' || !what ) {
return false
}
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
return false
}
if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) {
return false
}
if ( typeof what.providers !== 'object' ) {
return false
}
for ( const key in what.providers ) {
if ( !hasOwnProperty(what.providers, key) ) {
continue
}
const source = what.providers[key]
if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) {
return false
}
if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) {
return false
}
}
return true
}

View File

@ -0,0 +1,122 @@
import {Inject, Injectable} from '../../di'
import {Awaitable, HTTPStatus, Maybe} from '../../util'
import {Authenticatable, AuthenticatableRepository} from '../types'
import {Logging} from '../../service/Logging'
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
import {UserFlushedEvent} from '../event/UserFlushedEvent'
import {Bus} from '../../support/bus'
import {HTTPError} from '../../http/HTTPError'
/**
* Base-class for a context that authenticates users and manages security.
*/
@Injectable()
export abstract class SecurityContext {
@Inject()
protected readonly bus!: Bus
@Inject()
protected readonly logging!: Logging
/** The currently authenticated user, if one exists. */
protected authenticatedUser?: Authenticatable
constructor(
/** The repository where users are persisted. */
public readonly repository: AuthenticatableRepository,
/** The name of this context. */
public readonly name: string,
) { }
/**
* Called when the context is created. Can be used by child-classes to do setup work.
*/
initialize(): Awaitable<void> {} // eslint-disable-line @typescript-eslint/no-empty-function
/**
* Authenticate the given user, without persisting the authentication.
* That is, when the lifecycle ends, the user will be unauthenticated implicitly.
* @param user
*/
async authenticateOnce(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
* Authenticate the given user and persist the authentication.
* @param user
*/
async authenticate(user: Authenticatable): Promise<void> {
this.authenticatedUser = user
await this.persist()
await this.bus.push(new UserAuthenticatedEvent(user, this))
}
/**
* Unauthenticate the current user, if one exists, but do not persist the change.
*/
async flushOnce(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.bus.push(new UserFlushedEvent(user, this))
}
}
/**
* Unauthenticate the current user, if one exists, and persist the change.
*/
async flush(): Promise<void> {
const user = this.authenticatedUser
if ( user ) {
this.authenticatedUser = undefined
await this.persist()
await this.bus.push(new UserFlushedEvent(user, this))
}
}
/**
* Assuming a user is still authenticated in the context,
* try to look up and fill in the user.
*
* If there is NO USER to be resumed, then the method should flush
* the user from this context.
*/
abstract resume(): Awaitable<void>
/**
* Write the current state of the security context to whatever storage
* medium the context's host provides.
*/
abstract persist(): Awaitable<void>
/**
* Get the currently authenticated user, if one exists.
*/
getUser(): Maybe<Authenticatable> {
return this.authenticatedUser
}
/** Get the current user or throw an authorization error. */
user(): Authenticatable {
if ( !this.hasUser() ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
const user = this.getUser()
if ( !user ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
return user
}
/**
* Returns true if there is a currently authenticated user.
*/
hasUser(): boolean {
return Boolean(this.authenticatedUser)
}
}

View File

@ -0,0 +1,42 @@
import {SecurityContext} from './SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableRepository} from '../types'
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
export const EXTOLLO_AUTH_SESSION_KEY = '@extollo:auth.securityIdentifier'
/**
* Security context implementation that uses the session as storage.
*/
@Injectable()
export class SessionSecurityContext extends SecurityContext {
@Inject()
protected readonly session!: Session
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'session')
}
persist(): Awaitable<void> {
this.session.set(EXTOLLO_AUTH_SESSION_KEY, this.getUser()?.getIdentifier())
}
async resume(): Promise<void> {
const identifier = this.session.get(EXTOLLO_AUTH_SESSION_KEY)
if ( identifier ) {
const user = await this.repository.getByIdentifier(identifier)
if ( user ) {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
return
}
}
this.authenticatedUser = undefined
}
}

View File

@ -0,0 +1,44 @@
import {SecurityContext} from './SecurityContext'
import {AuthenticatableRepository} from '../types'
import {Awaitable} from '../../util'
import {Inject} from '../../di'
import {Request} from '../../http/lifecycle/Request'
import {OAuth2Token, TokenRepository} from '../server/types'
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
export class TokenSecurityContext extends SecurityContext {
@Inject()
protected readonly request!: Request
@Inject()
protected readonly tokens!: TokenRepository
constructor(
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'token')
}
// eslint-disable-next-line @typescript-eslint/no-empty-function
persist(): Awaitable<void> {}
async resume(): Promise<void> {
if ( !this.request.hasInstance(OAuth2Token) ) {
return
}
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
if ( !token.userId ) {
return
}
const user = await this.repository.getByIdentifier(token.userId)
if ( user ) {
this.authenticatedUser = user
await this.bus.push(new UserAuthenticationResumedEvent(user, this))
return
}
this.authenticatedUser = undefined
}
}

View File

@ -1,32 +0,0 @@
import {SecurityContext} from '../SecurityContext'
import {Inject, Injectable} from '../../di'
import {Session} from '../../http/session/Session'
import {Awaitable} from '../../util'
import {AuthenticatableCredentials, AuthenticatableRepository} from '../types'
/**
* Security context implementation that uses the session as storage.
*/
@Injectable()
export class SessionSecurityContext extends SecurityContext {
@Inject()
protected readonly session!: Session
constructor(
/** The repository from which to draw users. */
public readonly repository: AuthenticatableRepository,
) {
super(repository, 'session')
}
getCredentials(): Awaitable<AuthenticatableCredentials> {
return {
identifier: '',
credential: this.session.get('extollo.auth.securityIdentifier'),
}
}
persist(): Awaitable<void> {
this.session.set('extollo.auth.securityIdentifier', this.getUser()?.getIdentifier())
}
}

View File

@ -0,0 +1,27 @@
import {BaseEvent, BaseSerializer, ObjectSerializer} from '../../support/bus'
import {Awaitable} from '../../util'
/** An event raised when a required auth check has failed. */
export class AuthCheckFailed extends BaseEvent {
eventName = '@extollo/lib:AuthCheckFailed'
}
/** Serializes AuthCheckFailed events. */
@ObjectSerializer()
export class AuthCheckFailedSerializer extends BaseSerializer<AuthCheckFailed, { authCheckFailed: true }> {
protected decodeSerial(): Awaitable<AuthCheckFailed> {
return new AuthCheckFailed()
}
protected encodeActual(): Awaitable<{ authCheckFailed: true }> {
return { authCheckFailed: true }
}
protected getName(): string {
return '@extollo/lib:AuthCheckFailedSerializer'
}
matchActual(some: AuthCheckFailed): boolean {
return some instanceof AuthCheckFailed
}
}

View File

@ -0,0 +1,12 @@
import {SecurityContext} from '../context/SecurityContext'
import {Authenticatable} from '../types'
import {BaseEvent} from '../../support/bus'
export abstract class AuthenticationEvent extends BaseEvent {
constructor(
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
}

View File

@ -1,27 +1,8 @@
import {Event} from '../../event/Event' import {AuthenticationEvent} from './AuthenticationEvent'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/** /**
* Event fired when a user is authenticated. * Event fired when a user is authenticated.
*/ */
export class UserAuthenticatedEvent extends Event { export class UserAuthenticatedEvent extends AuthenticationEvent {
constructor( public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
} }

View File

@ -1,27 +1,8 @@
import {Event} from '../../event/Event' import {AuthenticationEvent} from './AuthenticationEvent'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/** /**
* Event fired when a security context for a given user is resumed. * Event raised when a user is re-authenticated to a security context
*/ */
export class UserAuthenticationResumedEvent extends Event { export class UserAuthenticationResumedEvent extends AuthenticationEvent {
constructor( public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
} }

View File

@ -1,27 +1,8 @@
import {Event} from '../../event/Event' import {AuthenticationEvent} from './AuthenticationEvent'
import {SecurityContext} from '../SecurityContext'
import {Awaitable, JSONState} from '../../util'
import {Authenticatable} from '../types'
/** /**
* Event fired when a user is unauthenticated. * Event fired when a user is unauthenticated.
*/ */
export class UserFlushedEvent extends Event { export class UserFlushedEvent extends AuthenticationEvent {
constructor( public readonly eventName = '@extollo/lib:UserFlushedEvent'
public readonly user: Authenticatable,
public readonly context: SecurityContext,
) {
super()
}
async dehydrate(): Promise<JSONState> {
return {
user: await this.user.dehydrate(),
contextName: this.context.name,
}
}
rehydrate(state: JSONState): Awaitable<void> { // eslint-disable-line @typescript-eslint/no-unused-vars
// TODO fill this in
}
} }

View File

@ -1,95 +0,0 @@
import {Controller} from '../../../http/Controller'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
import {Request} from '../../../http/lifecycle/Request'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {ErrorWithContext} from '../../../util'
import {OAuth2Repository} from './OAuth2Repository'
import {json} from '../../../http/response/JSONResponseFactory'
export interface OAuth2LoginConfig {
name: string,
clientId: string,
clientSecret: string,
redirectUrl: string,
authorizationCodeField: string,
tokenEndpoint: string,
tokenEndpointMapping?: {
clientId?: string,
clientSecret?: string,
grantType?: string,
codeKey?: string,
},
tokenEndpointResponseMapping?: {
token?: string,
expiresIn?: string,
expiresAt?: string,
},
userEndpoint: string,
userEndpointResponseMapping?: {
identifier?: string,
display?: string,
},
}
export function isOAuth2LoginConfig(what: unknown): what is OAuth2LoginConfig {
return (
Boolean(what)
&& typeof (what as any).name === 'string'
&& typeof (what as any).clientId === 'string'
&& typeof (what as any).clientSecret === 'string'
&& typeof (what as any).redirectUrl === 'string'
&& typeof (what as any).authorizationCodeField === 'string'
&& typeof (what as any).tokenEndpoint === 'string'
&& typeof (what as any).userEndpoint === 'string'
)
}
@Injectable()
export class OAuth2LoginController extends Controller {
public static routes(configName: string): void {
Route.group(`/auth/${configName}`, () => {
Route.get('login', (request: Request) => {
const controller = <OAuth2LoginController> request.make(OAuth2LoginController, configName)
return controller.getLogin()
}).pre('@auth:guest')
}).pre('@auth:web')
}
@Inject()
protected readonly config!: Config
constructor(
protected readonly request: Request,
protected readonly configName: string,
) {
super(request)
}
public async getLogin(): Promise<ResponseObject> {
const repo = this.getRepository()
if ( repo.shouldRedirect() ) {
return repo.redirect()
}
// We were redirected from the auth source
const user = await repo.redeem()
return json(user)
}
protected getRepository(): OAuth2Repository {
return this.request.make(OAuth2Repository, this.getConfig())
}
protected getConfig(): OAuth2LoginConfig {
const config = this.config.get(`auth.sources.${this.configName}`)
if ( !isOAuth2LoginConfig(config) ) {
throw new ErrorWithContext('Invalid OAuth2 source config.', {
configName: this.configName,
config,
})
}
return config
}
}

View File

@ -1,156 +0,0 @@
import {
Authenticatable,
AuthenticatableCredentials,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../../types'
import {Inject, Injectable} from '../../../di'
import {
Awaitable,
dataGetUnsafe,
fetch,
Maybe,
MethodNotSupportedError,
UniversalPath,
universalPath,
uuid4,
} from '../../../util'
import {OAuth2LoginConfig} from './OAuth2LoginController'
import {Session} from '../../../http/session/Session'
import {ResponseObject} from '../../../http/routing/Route'
import {temporary} from '../../../http/response/TemporaryRedirectResponseFactory'
import {Request} from '../../../http/lifecycle/Request'
import {Logging} from '../../../service/Logging'
import {OAuth2User} from './OAuth2User'
@Injectable()
export class OAuth2Repository implements AuthenticatableRepository {
@Inject()
protected readonly session!: Session
@Inject()
protected readonly request!: Request
@Inject()
protected readonly logging!: Logging
constructor(
protected readonly config: OAuth2LoginConfig,
) { }
public createByCredentials(): Awaitable<Authenticatable> {
throw new MethodNotSupportedError()
}
getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>> {
return this.getAuthenticatableFromBearer(credentials.credential)
}
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return undefined
}
public getRedirectUrl(state?: string): UniversalPath {
const url = universalPath(this.config.redirectUrl)
if ( state ) {
url.query.append('state', state)
}
return url
}
public getTokenEndpoint(): UniversalPath {
return universalPath(this.config.tokenEndpoint)
}
public getUserEndpoint(): UniversalPath {
return universalPath(this.config.userEndpoint)
}
public async redeem(): Promise<Maybe<OAuth2User>> {
if ( !this.stateIsValid() ) {
return // FIXME throw
}
const body = new URLSearchParams()
if ( this.config.tokenEndpointMapping ) {
if ( this.config.tokenEndpointMapping.clientId ) {
body.append(this.config.tokenEndpointMapping.clientId, this.config.clientId)
}
if ( this.config.tokenEndpointMapping.clientSecret ) {
body.append(this.config.tokenEndpointMapping.clientSecret, this.config.clientSecret)
}
if ( this.config.tokenEndpointMapping.codeKey ) {
body.append(this.config.tokenEndpointMapping.codeKey, String(this.request.input(this.config.authorizationCodeField)))
}
if ( this.config.tokenEndpointMapping.grantType ) {
body.append(this.config.tokenEndpointMapping.grantType, 'authorization_code')
}
}
this.logging.debug(`Redeeming auth code: ${body.toString()}`)
const response = await fetch(this.getTokenEndpoint().toRemote, {
method: 'post',
body: body,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
this.logging.debug(data)
const bearer = String(dataGetUnsafe(data, this.config.tokenEndpointResponseMapping?.token ?? 'bearer'))
this.logging.debug(bearer)
if ( !bearer || typeof bearer !== 'string' ) {
throw new Error()
}
return this.getAuthenticatableFromBearer(bearer)
}
public async getAuthenticatableFromBearer(bearer: string): Promise<Maybe<OAuth2User>> {
const response = await fetch(this.getUserEndpoint().toRemote, {
method: 'get',
headers: {
'Accept': 'application/json',
'Authorization': `Bearer ${bearer}`,
},
})
const data = await response.json()
if ( typeof data !== 'object' || data === null ) {
throw new Error()
}
return new OAuth2User(data, this.config)
}
public stateIsValid(): boolean {
const correctState = this.session.get('extollo.auth.oauth2.state', '')
const inputState = this.request.input('state') || ''
return correctState === inputState
}
public shouldRedirect(): boolean {
const codeField = this.config.authorizationCodeField
const code = this.request.input(codeField)
return !code
}
public async redirect(): Promise<ResponseObject> {
const state = uuid4()
await this.session.set('extollo.auth.oauth2.state', state)
return temporary(this.getRedirectUrl(state).toRemote)
}
}

View File

@ -1,50 +0,0 @@
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
import {OAuth2LoginConfig} from './OAuth2LoginController'
import {Awaitable, dataGetUnsafe, InvalidJSONStateError, JSONState} from '../../../util'
export class OAuth2User implements Authenticatable {
protected displayField: string
protected identifierField: string
constructor(
protected data: {[key: string]: any},
config: OAuth2LoginConfig,
) {
this.displayField = config.userEndpointResponseMapping?.display || 'name'
this.identifierField = config.userEndpointResponseMapping?.identifier || 'id'
}
getDisplayIdentifier(): string {
return String(dataGetUnsafe(this.data, this.displayField || 'name', ''))
}
getIdentifier(): AuthenticatableIdentifier {
return String(dataGetUnsafe(this.data, this.identifierField || 'id', ''))
}
async dehydrate(): Promise<JSONState> {
return {
isOAuth2User: true,
data: this.data,
displayField: this.displayField,
identifierField: this.identifierField,
}
}
rehydrate(state: JSONState): Awaitable<void> {
if (
!state.isOAuth2User
|| typeof state.data !== 'object'
|| state.data === null
|| typeof state.displayField !== 'string'
|| typeof state.identifierField !== 'string'
) {
throw new InvalidJSONStateError('OAuth2User state is invalid', { state })
}
this.data = state.data
this.identifierField = state.identifierField
this.displayField = state.identifierField
}
}

View File

@ -1,26 +1,49 @@
export * from './types' export * from './types'
export * from './AuthenticatableAlreadyExistsError'
export * from './NotAuthorizedError' export * from './NotAuthorizedError'
export * from './Authentication'
export * from './repository/AuthenticatableRepositoryFactory'
export * from './SecurityContext' export * from './context/SecurityContext'
export * from './context/SessionSecurityContext'
export * from './context/TokenSecurityContext'
export * from './event/AuthenticationEvent'
export * from './event/UserAuthenticatedEvent' export * from './event/UserAuthenticatedEvent'
export * from './event/UserFlushedEvent'
export * from './event/UserAuthenticationResumedEvent' export * from './event/UserAuthenticationResumedEvent'
export * from './event/UserFlushedEvent'
export * from './contexts/SessionSecurityContext' export * from './event/AuthCheckFailed'
export * from './orm/ORMUser'
export * from './orm/ORMUserRepository'
export * from './middleware/AuthRequiredMiddleware' export * from './middleware/AuthRequiredMiddleware'
export * from './middleware/GuestRequiredMiddleware' export * from './middleware/GuestRequiredMiddleware'
export * from './middleware/SessionAuthMiddleware' export * from './middleware/SessionAuthMiddleware'
export * from './middleware/TokenAuthMiddleware'
export * from './middleware/ScopeRequiredMiddleware'
export * from './Authentication' export * from './provider/basic/BasicLoginAttempt'
export * from './provider/basic/BasicLoginProvider'
export * from './provider/basic/BasicRegistrationAttempt'
export * from './provider/oauth/OAuth2LoginProvider'
export * from './provider/oauth/CoreIDLoginProvider'
export * from './serial/AuthenticationEventSerializer'
export * from './repository/orm/ORMUser'
export * from './repository/orm/ORMUserRepository'
export * from './config' export * from './config'
export * from './basic-ui/BasicLoginFormRequest' export * from './webSocketAuthCheck'
export * from './basic-ui/BasicLoginController'
export * from './external/oauth2/OAuth2LoginController' export * from './server/types'
export * from './server/models/OAuth2TokenModel'
export * from './server/repositories/ConfigClientRepository'
export * from './server/repositories/ConfigScopeRepository'
export * from './server/repositories/ClientRepositoryFactory'
export * from './server/repositories/ScopeRepositoryFactory'
export * from './server/repositories/ORMTokenRepository'
export * from './server/repositories/TokenRepositoryFactory'
export * from './server/repositories/CacheRedemptionCodeRepository'
export * from './server/repositories/RedemptionCodeRepositoryFactory'
export * from './server/OAuth2Server'

View File

@ -1,6 +1,6 @@
import {Middleware} from '../../http/routing/Middleware' import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di' import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../SecurityContext' import {SecurityContext} from '../context/SecurityContext'
import {ResponseObject} from '../../http/routing/Route' import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory' import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError' import {NotAuthorizedError} from '../NotAuthorizedError'
@ -9,6 +9,8 @@ import {redirect} from '../../http/response/RedirectResponseFactory'
import {Routing} from '../../service/Routing' import {Routing} from '../../service/Routing'
import {Session} from '../../http/session/Session' import {Session} from '../../http/session/Session'
// TODO handle JSON and non-web
@Injectable() @Injectable()
export class AuthRequiredMiddleware extends Middleware { export class AuthRequiredMiddleware extends Middleware {
@Inject() @Inject()
@ -22,10 +24,10 @@ export class AuthRequiredMiddleware extends Middleware {
async apply(): Promise<ResponseObject> { async apply(): Promise<ResponseObject> {
if ( !this.security.hasUser() ) { if ( !this.security.hasUser() ) {
this.session.set('auth.intention', this.request.url) this.session.set('@extollo:auth.intention', this.request.url)
if ( this.routing.hasNamedRoute('@auth.login') ) { if ( this.routing.hasNamedRoute('@auth:login') ) {
return redirect(this.routing.getNamedPath('@auth.login').toRemote) return redirect(this.routing.getNamedPath('@auth:login').toRemote)
} else { } else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN) return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
} }

View File

@ -1,6 +1,6 @@
import {Middleware} from '../../http/routing/Middleware' import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di' import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../SecurityContext' import {SecurityContext} from '../context/SecurityContext'
import {ResponseObject} from '../../http/routing/Route' import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory' import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError' import {NotAuthorizedError} from '../NotAuthorizedError'
@ -8,6 +8,8 @@ import {HTTPStatus} from '../../util'
import {Routing} from '../../service/Routing' import {Routing} from '../../service/Routing'
import {redirect} from '../../http/response/RedirectResponseFactory' import {redirect} from '../../http/response/RedirectResponseFactory'
// TODO handle JSON and non-web
@Injectable() @Injectable()
export class GuestRequiredMiddleware extends Middleware { export class GuestRequiredMiddleware extends Middleware {
@Inject() @Inject()

View File

@ -0,0 +1,33 @@
import {Middleware} from '../../http/routing/Middleware'
import {ResponseObject} from '../../http/routing/Route'
import {OAuth2Token} from '../server/types'
import {HTTPError} from '../../http/HTTPError'
import {HTTPStatus, Pipeline} from '../../util'
import {Request} from '../../http/lifecycle/Request'
import {Constructable, Container} from '../../di'
export class ScopeRequiredMiddleware extends Middleware {
constructor(
protected readonly request: Request,
protected readonly scope: string,
) {
super(request)
}
apply(): ResponseObject {
if ( !this.request.hasInstance(OAuth2Token) ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Must specify an OAuth2 token.')
}
const token: OAuth2Token = this.request.getExistingInstance(OAuth2Token)
if ( typeof token.scope !== 'undefined' && token.scope !== this.scope ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED, 'Insufficient token permissions (requires: ' + this.scope + ')')
}
}
}
export const scope = (name: string): Constructable<ScopeRequiredMiddleware> => {
return new Pipeline<Container, ScopeRequiredMiddleware>(
container => container.make(ScopeRequiredMiddleware, container, name),
)
}

View File

@ -1,13 +1,11 @@
import {Middleware} from '../../http/routing/Middleware' import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di' import {Inject, Injectable} from '../../di'
import {ResponseObject} from '../../http/routing/Route'
import {Config} from '../../service/Config' import {Config} from '../../service/Config'
import {AuthenticatableRepository} from '../types'
import {SessionSecurityContext} from '../contexts/SessionSecurityContext'
import {SecurityContext} from '../SecurityContext'
import {ORMUserRepository} from '../orm/ORMUserRepository'
import {AuthConfig, AuthenticatableRepositories} from '../config'
import {Logging} from '../../service/Logging' import {Logging} from '../../service/Logging'
import {AuthenticatableRepository} from '../types'
import {ResponseObject} from '../../http/routing/Route'
import {SessionSecurityContext} from '../context/SessionSecurityContext'
import {SecurityContext} from '../context/SecurityContext'
/** /**
* Injects a SessionSecurityContext into the request and attempts to * Injects a SessionSecurityContext into the request and attempts to
@ -22,19 +20,10 @@ export class SessionAuthMiddleware extends Middleware {
protected readonly logging!: Logging protected readonly logging!: Logging
async apply(): Promise<ResponseObject> { async apply(): Promise<ResponseObject> {
this.logging.debug('Applying session auth middleware...') this.logging.debug('Applying session auth middleware.')
const context = <SessionSecurityContext> this.make(SessionSecurityContext, this.getRepository()) const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
const context = <SessionSecurityContext> this.make(SessionSecurityContext, repo)
this.request.registerSingletonInstance(SecurityContext, context) this.request.registerSingletonInstance(SecurityContext, context)
await context.resume() await context.resume()
} }
/**
* Build the correct AuthenticatableRepository based on the auth config.
* @protected
*/
protected getRepository(): AuthenticatableRepository {
const config: AuthConfig | undefined = this.config.get('auth')
const repo: typeof AuthenticatableRepository = AuthenticatableRepositories[config?.repositories?.session ?? 'orm']
return this.make<AuthenticatableRepository>(repo ?? ORMUserRepository)
}
} }

View File

@ -0,0 +1,45 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {Config} from '../../service/Config'
import {Logging} from '../../service/Logging'
import {AuthenticatableRepository} from '../types'
import {ResponseObject} from '../../http/routing/Route'
import {SecurityContext} from '../context/SecurityContext'
import {TokenSecurityContext} from '../context/TokenSecurityContext'
import {OAuth2Token, oauth2TokenString, TokenRepository} from '../server/types'
/**
* Injects a TokenSecurityContext into the request and attempts to
* resume the user's authentication.
*/
@Injectable()
export class TokenAuthMiddleware extends Middleware {
@Inject()
protected readonly config!: Config
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly tokens!: TokenRepository
async apply(): Promise<ResponseObject> {
this.logging.debug('Applying token auth middleware.')
let tokenString = this.request.getHeader('Authorization')
if ( Array.isArray(tokenString) ) {
tokenString = tokenString[0]
}
if ( tokenString ) {
const token = await this.tokens.decode(oauth2TokenString(tokenString))
if ( token ) {
this.request.registerSingletonInstance(OAuth2Token, token)
}
}
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
const context = <TokenSecurityContext> this.make(TokenSecurityContext, repo)
this.request.registerSingletonInstance(SecurityContext, context)
await context.resume()
}
}

View File

@ -1,65 +0,0 @@
import {
Authenticatable,
AuthenticatableCredentials,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../types'
import {Awaitable, Maybe} from '../../util'
import {ORMUser} from './ORMUser'
import {Container, Inject, Injectable} from '../../di'
import {AuthenticatableAlreadyExistsError} from '../AuthenticatableAlreadyExistsError'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
@Inject('injector')
protected readonly injector!: Container
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return ORMUser.query<ORMUser>()
.where('username', '=', id)
.first()
}
/**
* Try to look up a user by the credentials provided.
* If a securityIdentifier is specified, look up the user by username.
* If username/password are specified, look up the user and verify the password.
* @param credentials
*/
async getByCredentials(credentials: AuthenticatableCredentials): Promise<Maybe<Authenticatable>> {
if ( !credentials.identifier && credentials.credential ) {
return ORMUser.query<ORMUser>()
.where('username', '=', credentials.credential)
.first()
}
if ( credentials.identifier && credentials.credential ) {
const user = await ORMUser.query<ORMUser>()
.where('username', '=', credentials.identifier)
.first()
if ( user && await user.verifyPassword(credentials.credential) ) {
return user
}
}
}
async createByCredentials(credentials: AuthenticatableCredentials): Promise<Authenticatable> {
if ( await this.getByCredentials(credentials) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
identifier: credentials.identifier,
})
}
const user = <ORMUser> this.injector.make(ORMUser)
user.username = credentials.identifier
await user.setPassword(credentials.credential)
await user.save()
return user
}
}

View File

@ -0,0 +1,74 @@
import {Request} from '../../http/lifecycle/Request'
import {ResponseObject, Route} from '../../http/routing/Route'
import {GuestRequiredMiddleware} from '../middleware/GuestRequiredMiddleware'
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {RequestLocalStorage} from '../../http/RequestLocalStorage'
import {Session} from '../../http/session/Session'
export interface LoginProviderConfig {
default: boolean,
allow?: {
login?: boolean,
registration?: boolean,
},
}
@Injectable()
export abstract class LoginProvider<TConfig extends LoginProviderConfig> {
@Inject()
protected readonly request!: RequestLocalStorage
protected get security(): SecurityContext {
return this.request.get().make(SecurityContext)
}
constructor(
protected name: string,
protected config: TConfig,
) {}
public routes(): void {
Route.get('login')
.alias(`@auth:${this.name}:login`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:login')))
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.login.bind(this))
Route.any('logout')
.alias(`@auth:${this.name}:logout`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:logout')))
.pre(AuthRequiredMiddleware)
.passingRequest()
.handledBy(this.logout.bind(this))
Route.get('register')
.alias(`@auth:${this.name}:register`)
.pipe(line => line.when(this.config.default, route => route.alias('@auth:register')))
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.registration.bind(this))
}
public abstract login(request: Request): ResponseObject
public abstract logout(request: Request): ResponseObject
public registration(request: Request): ResponseObject {
return this.login(request)
}
protected redirectToIntendedRoute(): ResponseObject {
const intent = this.request
.get()
.make<Session>(Session)
.safe('@extollo:auth.intention')
.or('/')
.string()
return redirect(intent)
}
}

View File

@ -0,0 +1,8 @@
import { z } from 'zod'
export type BasicLoginAttempt = z.infer<typeof BasicLoginAttemptType>
export const BasicLoginAttemptType = z.object({
username: z.string().nonempty(),
password: z.string().nonempty(),
})

View File

@ -0,0 +1,75 @@
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {view} from '../../../http/response/ViewResponseFactory'
import {Valid, Validator} from '../../../validation/Validator'
import {BasicLoginAttempt, BasicLoginAttemptType} from './BasicLoginAttempt'
import {BasicRegistrationAttempt, BasicRegistrationAttemptType} from './BasicRegistrationAttempt'
/**
* LoginProvider implementation that provides basic username/password login.
*/
export class BasicLoginProvider extends LoginProvider<LoginProviderConfig> {
public routes(): void {
super.routes()
Route.post('/login')
.alias(`@auth:${this.name}:login.submit`)
.input(Validator.fromSchema<BasicLoginAttempt>(BasicLoginAttemptType))
.handledBy((...p) => this.attemptLogin(...p))
Route.post('/register')
.alias(`@auth:${this.name}:register.submit`)
.input(Validator.fromSchema<BasicRegistrationAttempt>(BasicRegistrationAttemptType))
.handledBy((...p) => this.attemptRegistration(...p))
}
public login(): ResponseObject {
return view('@extollo:auth:login')
}
public async logout(): Promise<ResponseObject> {
await this.security.flush()
return view('@extollo:auth:logout')
}
public registration(): ResponseObject {
return view('@extollo:auth:register')
}
/** Attempt to authenticate the user with a username/password. */
public async attemptLogin(attempt: Valid<BasicLoginAttempt>): Promise<ResponseObject> {
const user = await this.security.repository.getByIdentifier(attempt.username)
if ( !user ) {
throw new Error('TODO')
}
if ( !(await user.validateCredential(attempt.password)) ) {
throw new Error('TODO')
}
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
/** Attempt to register the user with a username/password. */
public async attemptRegistration(attempt: Valid<BasicRegistrationAttempt>): Promise<ResponseObject> {
const existingUser = await this.security.repository.getByIdentifier(attempt.username)
if ( existingUser ) {
throw new Error('TODO')
}
if ( attempt.password !== attempt.passwordConfirmation ) {
throw new Error('TODO')
}
const user = await this.security.repository.createFromCredentials(attempt.username, attempt.password)
;(user as any).firstName = attempt.firstName
;(user as any).lastName = attempt.lastName
if ( typeof (user as any).save === 'function' ) {
await (user as any).save()
}
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
}

View File

@ -0,0 +1,19 @@
import { z } from 'zod'
export type BasicRegistrationAttempt = z.infer<typeof BasicRegistrationAttemptType>
export const BasicRegistrationAttemptType = z.object({
firstName: z.string().nonempty(),
lastName: z.string().nonempty(),
username: z.string().nonempty(),
password: z.string()
.nonempty()
.min(8),
passwordConfirmation: z.string()
.nonempty()
.min(8),
})

View File

@ -0,0 +1,99 @@
/* eslint camelcase: 0 */
import {OAuth2LoginProvider, OAuth2LoginProviderConfig} from './OAuth2LoginProvider'
import {Authenticatable} from '../../types'
import {Request} from '../../../http/lifecycle/Request'
import {ErrorWithContext, uuid4, fetch} from '../../../util'
/**
* OAuth2LoginProvider implementation that authenticates users against a
* Starship CoreID server.
*/
export class CoreIDLoginProvider extends OAuth2LoginProvider<OAuth2LoginProviderConfig> {
protected async callback(request: Request): Promise<Authenticatable> {
// Get authentication_code from the request
const code = request.safe('code').string()
// Get OAuth2 token from CoreID
const token = await this.getToken(code)
// Get user from endpoint
const userData = await this.getUserData(token)
// Return authenticatable instance
const existing = await this.security.repository.getByIdentifier(userData.uid)
if ( existing ) {
this.updateUser(existing, userData)
return existing
}
const user = await this.security.repository.createFromCredentials(userData.uid, uuid4())
this.updateUser(user, userData)
return user
}
/** Given an access token, look up the associated user's information. */
protected async getUserData(token: string): Promise<any> {
const userResponse = await fetch(
this.config.userUrl,
{
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
},
)
const userData: any = await userResponse.json()
if ( !userData?.data?.uid ) {
throw new ErrorWithContext('Unable to extract user from response', {
userData,
})
}
return userData.data
}
/** Given a login code, redeem it for an access token. */
protected async getToken(code: string): Promise<string> {
const body: string[] = [
'code=' + encodeURIComponent(code),
'client_id=' + encodeURIComponent(this.config.clientId),
'client_secret=' + encodeURIComponent(this.config.clientSecret),
'grant_type=authorization_code',
]
const response = await fetch(
this.config.tokenUrl,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: body.join('&'),
},
)
const data = await response.json()
const token = (data as any).access_token
if ( !token ) {
throw new ErrorWithContext('Unable to obtain access token from response', {
data,
})
}
return String(token)
}
/** Update values on the Authenticatable from user data. */
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
protected updateUser(user: any, data: any): void {
user.firstName = data.first_name
user.lastName = data.last_name
user.email = data.email
user.tagline = data.tagline
user.photoUrl = data.profile_photo
if ( typeof user.save === 'function' ) {
user.save()
}
}
}

View File

@ -0,0 +1,98 @@
import {LoginProvider, LoginProviderConfig} from '../LoginProvider'
import {ResponseObject, Route} from '../../../http/routing/Route'
import {Inject, Injectable} from '../../../di'
import {Routing} from '../../../service/Routing'
import {GuestRequiredMiddleware} from '../../middleware/GuestRequiredMiddleware'
import {redirect} from '../../../http/response/RedirectResponseFactory'
import {view} from '../../../http/response/ViewResponseFactory'
import {Request} from '../../../http/lifecycle/Request'
import {Awaitable} from '../../../util'
import {Authenticatable} from '../../types'
export interface OAuth2LoginProviderConfig extends LoginProviderConfig {
displayName: string,
clientId: string|number
clientSecret: string
loginUrl: string
loginMessage?: string
logoutUrl?: string
tokenUrl: string,
userUrl: string,
}
/**
* LoginProvider implementation for OAuth2-based logins.
*/
@Injectable()
export abstract class OAuth2LoginProvider<TConfig extends OAuth2LoginProviderConfig> extends LoginProvider<TConfig> {
@Inject()
protected readonly routing!: Routing
public routes(): void {
super.routes()
Route.any('redirect')
.alias(`@auth:${this.name}:redirect`)
.pre(GuestRequiredMiddleware)
.handledBy(() => redirect(this.getLoginUrl()))
Route.any('callback')
.alias(`@auth:${this.name}:callback`)
.pre(GuestRequiredMiddleware)
.passingRequest()
.handledBy(this.handleCallback.bind(this))
}
protected async handleCallback(request: Request): Promise<ResponseObject> {
const user = await this.callback(request)
if ( user ) {
await this.security.authenticate(user)
return this.redirectToIntendedRoute()
}
return redirect(this.routing.getNamedPath(`@auth:${this.name}:login`).toRemote)
}
/**
* After redirecting back from the OAuth2 server, look up the user information.
* @param request
* @protected
*/
protected abstract callback(request: Request): Awaitable<Authenticatable>
public login(): ResponseObject {
const buttonUrl = this.routing
.getNamedPath(`@auth:${this.name}:redirect`)
.toRemote
return view('@extollo:auth:message', {
message: this.config.loginMessage ?? `Sign-in with ${this.config.displayName} to continue`,
buttonText: 'Sign-in',
buttonUrl,
})
}
public async logout(): Promise<ResponseObject> {
await this.security.flush()
if ( this.config.logoutUrl ) {
return redirect(this.config.logoutUrl)
}
return view('@extollo:auth:message', {
message: 'You have been signed-out',
})
}
/**
* Get the URL where the user should be redirected to sign-in.
* @protected
*/
protected getLoginUrl(): string {
const callbackRoute = this.routing.getNamedPath(`@auth:${this.name}:callback`)
return this.config.loginUrl
.replace(/%c/g, String(this.config.clientId))
.replace(/%r/g, callbackRoute.toRemote)
}
}

View File

@ -0,0 +1,23 @@
import {Instantiable, FactoryProducer} from '../../di'
import {AuthenticatableRepository} from '../types'
import {ORMUserRepository} from './orm/ORMUserRepository'
import {ConfiguredSingletonFactory} from '../../di/factory/ConfiguredSingletonFactory'
/**
* A dependency injection factory that matches the abstract ClientRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class AuthenticatableRepositoryFactory extends ConfiguredSingletonFactory<AuthenticatableRepository> {
protected getConfigKey(): string {
return 'auth.storage'
}
protected getDefaultImplementation(): Instantiable<AuthenticatableRepository> {
return ORMUserRepository
}
protected getAbstractImplementation(): any {
return AuthenticatableRepository
}
}

View File

@ -1,8 +1,8 @@
import {Field, FieldType, Model} from '../../orm'
import {Authenticatable, AuthenticatableIdentifier} from '../types'
import {Injectable} from '../../di'
import * as bcrypt from 'bcrypt' import * as bcrypt from 'bcrypt'
import {Awaitable, JSONState} from '../../util' import {Field, FieldType, Model} from '../../../orm'
import {Authenticatable, AuthenticatableIdentifier} from '../../types'
import {Injectable} from '../../../di'
import {Awaitable, JSONState} from '../../../util'
/** /**
* A basic ORM-driven user class. * A basic ORM-driven user class.
@ -35,10 +35,19 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
public passwordHash!: string public passwordHash!: string
/** Human-readable display name of the user. */ /** Human-readable display name of the user. */
getDisplayIdentifier(): string { getDisplay(): string {
if ( this.firstName || this.lastName ) {
return `${this.firstName} ${this.lastName}` return `${this.firstName} ${this.lastName}`
} }
return this.username
}
/** Globally-unique identifier of the user. */
getUniqueIdentifier(): AuthenticatableIdentifier {
return `user-${this.userId}`
}
/** Unique identifier of the user. */ /** Unique identifier of the user. */
getIdentifier(): AuthenticatableIdentifier { getIdentifier(): AuthenticatableIdentifier {
return this.username return this.username
@ -54,6 +63,10 @@ export class ORMUser extends Model<ORMUser> implements Authenticatable {
this.passwordHash = await bcrypt.hash(password, 10) this.passwordHash = await bcrypt.hash(password, 10)
} }
validateCredential(credential: string): Awaitable<boolean> {
return this.verifyPassword(credential)
}
async dehydrate(): Promise<JSONState> { async dehydrate(): Promise<JSONState> {
return this.toQueryRow() return this.toQueryRow()
} }

View File

@ -0,0 +1,51 @@
import {
Authenticatable,
AuthenticatableIdentifier,
AuthenticatableRepository,
} from '../../types'
import {Awaitable, Maybe, uuid4} from '../../../util'
import {ORMUser} from './ORMUser'
import {Container, Inject, Injectable} from '../../../di'
import {AuthenticatableAlreadyExistsError} from '../../AuthenticatableAlreadyExistsError'
/**
* A user repository implementation that looks up users stored in the database.
*/
@Injectable()
export class ORMUserRepository extends AuthenticatableRepository {
@Inject('injector')
protected readonly injector!: Container
/** Look up the user by their username. */
getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> {
return (this.injector.getStaticOverride(ORMUser) as typeof ORMUser).query<ORMUser>()
.where('username', '=', id)
.first()
}
/** Returns true if this repository supports registering users. */
supportsRegistration(): boolean {
return true
}
/** Create a user in this repository from basic credentials. */
async createFromCredentials(username: string, password: string): Promise<Authenticatable> {
if ( await this.getByIdentifier(username) ) {
throw new AuthenticatableAlreadyExistsError(`Authenticatable already exists with credentials.`, {
username,
})
}
const user = <ORMUser> this.injector.makeByStaticOverride(ORMUser)
user.username = username
await user.setPassword(password)
await user.save()
return user
}
/** Create a user in this repository from an external Authenticatable instance. */
async createFromExternal(user: Authenticatable): Promise<Authenticatable> {
return this.createFromCredentials(String(user.getUniqueIdentifier()), uuid4())
}
}

View File

@ -0,0 +1,54 @@
import {BaseSerializer, ObjectSerializer, SerialPayload} from '../../support/bus'
import {AuthenticationEvent} from '../event/AuthenticationEvent'
import {ErrorWithContext, JSONState} from '../../util'
import {Authenticatable} from '../types'
import {StaticInstantiable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {UserAuthenticatedEvent} from '../event/UserAuthenticatedEvent'
import {UserAuthenticationResumedEvent} from '../event/UserAuthenticationResumedEvent'
import {UserFlushedEvent} from '../event/UserFlushedEvent'
export interface AuthenticationEventSerialPayload extends JSONState {
user: SerialPayload<Authenticatable, JSONState>
eventName: string
}
@ObjectSerializer()
export class AuthenticationEventSerializer extends BaseSerializer<AuthenticationEvent, AuthenticationEventSerialPayload> {
protected async decodeSerial(serial: AuthenticationEventSerialPayload): Promise<AuthenticationEvent> {
const user = await this.getSerialization().decode(serial.user)
const context = await this.getRequest().make(SecurityContext)
const EventClass = this.getEventClass(serial.eventName)
return new EventClass(user, context)
}
protected async encodeActual(actual: AuthenticationEvent): Promise<AuthenticationEventSerialPayload> {
return {
eventName: actual.eventName,
user: await this.getSerialization().encode(actual.user),
}
}
protected getName(): string {
return '@extollo/lib:AuthenticationEventSerializer'
}
matchActual(some: AuthenticationEvent): boolean {
return some instanceof AuthenticationEvent
}
protected getEventClass(name: string): StaticInstantiable<AuthenticationEvent> {
if ( name === '@extollo/lib:UserAuthenticatedEvent' ) {
return UserAuthenticatedEvent
} else if ( name === '@extollo/lib:UserAuthenticationResumedEvent' ) {
return UserAuthenticationResumedEvent
} else if ( name === '@extollo/lib:UserFlushedEvent' ) {
return UserFlushedEvent
}
throw new ErrorWithContext('Unable to map event name to AuthenticationEvent implementation', {
eventName: name,
})
}
}

View File

@ -0,0 +1,241 @@
import {Controller} from '../../http/Controller'
import {Inject, Injectable} from '../../di'
import {ResponseObject, Route} from '../../http/routing/Route'
import {Request} from '../../http/lifecycle/Request'
import {Session} from '../../http/session/Session'
import {
ClientRepository,
OAuth2Client,
OAuth2FlowType,
OAuth2Scope,
RedemptionCodeRepository,
ScopeRepository,
TokenRepository,
} from './types'
import {HTTPError} from '../../http/HTTPError'
import {HTTPStatus, Maybe} from '../../util'
import {view} from '../../http/response/ViewResponseFactory'
import {SecurityContext} from '../context/SecurityContext'
import {redirect} from '../../http/response/RedirectResponseFactory'
import {AuthRequiredMiddleware} from '../middleware/AuthRequiredMiddleware'
import {one} from '../../http/response/api'
import {AuthenticatableRepository} from '../types'
import {Logging} from '../../service/Logging'
export enum GrantType {
Client = 'client_credentials',
Password = 'password',
Code = 'authorization_code',
}
export const grantTypes: GrantType[] = [GrantType.Client, GrantType.Code, GrantType.Password]
@Injectable()
export class OAuth2Server extends Controller {
@Inject()
protected readonly logging!: Logging
public static routes(): void {
Route.get('/oauth2/authorize')
.alias('@oauth2:authorize')
.pre(AuthRequiredMiddleware)
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.promptForAuthorization)
Route.post('/oauth2/authorize')
.alias('@oauth2:authorize:submit')
.pre(AuthRequiredMiddleware)
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.authorizeAndRedirect)
Route.post('/oauth2/token')
.alias('@oauth2:token')
.passingRequest()
.calls<OAuth2Server>(OAuth2Server, x => x.issue)
}
async issue(request: Request): Promise<ResponseObject> {
const grant = request.safe('grant_type').in(grantTypes)
const client = await this.getClientFromRequest(request)
if ( grant === GrantType.Client ) {
return this.issueFromClient(request, client)
} else if ( grant === GrantType.Code ) {
return this.issueFromCode(request, client)
} else if ( grant === GrantType.Password ) {
return this.issueFromCredential(request, client)
}
}
protected async issueFromCredential(request: Request, client: OAuth2Client): Promise<ResponseObject> {
const scope = String(this.request.input('scope') ?? '') || undefined
const username = this.request.safe('username').string()
const password = this.request.safe('password').string()
this.logging.verbose('Attempting password grant token issue...')
this.logging.verbose({
scope,
username,
client,
})
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
const user = await userRepo.getByIdentifier(username)
if ( !user || !(await user.validateCredential(password)) ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
const tokenRepo = <TokenRepository> request.make(TokenRepository)
const token = await tokenRepo.issue(user, client, scope)
return one({
token: await tokenRepo.encode(token),
})
}
protected async issueFromCode(request: Request, client: OAuth2Client): Promise<ResponseObject> {
const scope = String(this.request.input('scope') ?? '') || undefined
const codeRepo = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
const codeString = request.safe('code').string()
const code = await codeRepo.find(codeString)
if ( !code ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
const userRepo = <AuthenticatableRepository> request.make(AuthenticatableRepository)
const user = await userRepo.getByIdentifier(code.userId)
if ( !user ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
const tokenRepo = <TokenRepository> request.make(TokenRepository)
const token = await tokenRepo.issue(user, client, scope)
return one({
token: await tokenRepo.encode(token),
})
}
protected async issueFromClient(request: Request, client: OAuth2Client): Promise<ResponseObject> {
const scope = String(this.request.input('scope') ?? '') || undefined
const tokenRepo = <TokenRepository> request.make(TokenRepository)
const token = await tokenRepo.issue(undefined, client, scope)
return one({
token: await tokenRepo.encode(token),
})
}
protected async getClientFromRequest(request: Request): Promise<OAuth2Client> {
const authParts = String(request.getHeader('Authorization')).split(':')
if ( authParts.length !== 2 ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST)
}
this.logging.debug('Client auth parts:')
this.logging.debug(authParts)
const clientRepo = <ClientRepository> request.make(ClientRepository)
const [clientId, clientSecret] = authParts
const client = await clientRepo.find(clientId)
this.logging.verbose('Client:')
this.logging.verbose(client)
if ( !client || client.secret !== clientSecret ) {
throw new HTTPError(HTTPStatus.UNAUTHORIZED)
}
return client
}
async authorizeAndRedirect(request: Request): Promise<ResponseObject> {
// Look up the client in the client repo
const session = <Session> request.make(Session)
const client = await this.getClientFromRequest(request)
const flowType = session.safe('oauth2.authorize.flow').in(client.allowedFlows)
if ( flowType === OAuth2FlowType.code ) {
return this.authorizeCodeFlow(request, client)
}
}
protected async authorizeCodeFlow(request: Request, client: OAuth2Client): Promise<ResponseObject> {
const session = <Session> request.make(Session)
const security = <SecurityContext> request.make(SecurityContext)
const codeRepository = <RedemptionCodeRepository> request.make(RedemptionCodeRepository)
const user = security.user()
const scope = session.get('oauth2.authorize.scope')
const redirectUri = session.safe('oauth2.authorize.redirectUri').in(client.allowedRedirectUris)
// FIXME store authorization
const code = await codeRepository.issue(user, client, scope)
const uri = new URL(redirectUri)
uri.searchParams.set('code', code.code)
return redirect(uri)
}
async promptForAuthorization(request: Request): Promise<ResponseObject> {
// Look up the client in the client repo
const clientId = request.safe('client_id').string()
const client = await this.getClient(request, clientId)
// Make sure the requested flow type is valid for this client
const session = <Session> request.make(Session)
const flowType = request.safe('response_type').in(client.allowedFlows)
const redirectUri = request.safe('redirect_uri').in(client.allowedRedirectUris)
session.set('oauth2.authorize.clientId', client.id)
session.set('oauth2.authorize.flow', flowType)
session.set('oauth2.authorize.redirectUri', redirectUri)
// Set the state if necessary
const state = request.input('state') || ''
if ( state ) {
session.set('oauth2.authorize.state', String(state))
} else {
session.forget('oauth2.authorize.state')
}
// If the request specified a scope, validate it and set it in the session
const scope = await this.getScope(request, client)
// Show a view prompting the user to approve the access
return view('@extollo:oauth2:authorize', {
clientName: client.display,
scopeDescription: scope?.description,
redirectDomain: (new URL(redirectUri)).host,
})
}
protected async getClient(request: Request, clientId: string): Promise<OAuth2Client> {
const clientRepo = <ClientRepository> request.make(ClientRepository)
const client = await clientRepo.find(clientId)
if ( !client ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid client configuration', {
clientId,
})
}
return client
}
protected async getScope(request: Request, client: OAuth2Client): Promise<Maybe<OAuth2Scope>> {
const session = <Session> request.make(Session)
const scopeName = String(request.input('scope') || '')
let scope: Maybe<OAuth2Scope> = undefined
if ( scopeName ) {
const scopeRepo = <ScopeRepository> request.make(ScopeRepository)
scope = await scopeRepo.findByName(scopeName)
if ( !scope || !client.allowedScopeIds.includes(scope.id) ) {
throw new HTTPError(HTTPStatus.BAD_REQUEST, 'Invalid scope', {
scopeName,
})
}
session.set('oauth2.authorize.scope', scope.id)
} else {
session.forget('oauth2.authorize.state')
}
return scope
}
}

View File

@ -0,0 +1,30 @@
import {Field, FieldType, Model} from '../../../orm'
import {OAuth2Token} from '../types'
export class OAuth2TokenModel extends Model<OAuth2TokenModel> implements OAuth2Token {
public static table = 'oauth2_tokens'
public static key = 'oauth2_token_id'
@Field(FieldType.serial, 'oauth2_token_id')
protected oauth2TokenId!: number
public get id(): string {
return String(this.oauth2TokenId)
}
@Field(FieldType.varchar, 'user_id')
public userId?: string
@Field(FieldType.varchar, 'client_id')
public clientId!: string
@Field(FieldType.timestamp)
public issued!: Date
@Field(FieldType.timestamp)
public expires!: Date
@Field(FieldType.varchar)
public scope?: string
}

View File

@ -0,0 +1,33 @@
import {isOAuth2RedemptionCode, OAuth2Client, OAuth2RedemptionCode, RedemptionCodeRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {Cache, Maybe, uuid4} from '../../../util'
import {Authenticatable} from '../../types'
@Injectable()
export class CacheRedemptionCodeRepository extends RedemptionCodeRepository {
@Inject()
protected readonly cache!: Cache
async find(codeString: string): Promise<Maybe<OAuth2RedemptionCode>> {
const cacheKey = `@extollo:oauth2:redemption:${codeString}`
if ( await this.cache.has(cacheKey) ) {
const code = await this.cache.safe(cacheKey).then(x => x.json())
if ( isOAuth2RedemptionCode(code) ) {
return code
}
}
}
async issue(user: Authenticatable, client: OAuth2Client, scope?: string): Promise<OAuth2RedemptionCode> {
const code = {
scope,
clientId: client.id,
userId: user.getUniqueIdentifier(),
code: uuid4(),
}
const cacheKey = `@extollo:oauth2:redemption:${code.code}`
await this.cache.put(cacheKey, JSON.stringify(code))
return code
}
}

View File

@ -0,0 +1,23 @@
import {Instantiable, FactoryProducer} from '../../../di'
import {ClientRepository} from '../types'
import {ConfigClientRepository} from './ConfigClientRepository'
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
/**
* A dependency injection factory that matches the abstract ClientRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class ClientRepositoryFactory extends ConfiguredSingletonFactory<ClientRepository> {
protected getConfigKey(): string {
return 'oauth2.repository.client'
}
protected getDefaultImplementation(): Instantiable<ClientRepository> {
return ConfigClientRepository
}
protected getAbstractImplementation(): any {
return ClientRepository
}
}

View File

@ -0,0 +1,22 @@
import {ClientRepository, OAuth2Client, isOAuth2Client} from '../types'
import {Awaitable, ErrorWithContext, Maybe} from '../../../util'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
@Injectable()
export class ConfigClientRepository extends ClientRepository {
@Inject()
protected readonly config!: Config
find(id: string): Awaitable<Maybe<OAuth2Client>> {
const client = this.config.get(`oauth2.clients.${id}`)
if ( !isOAuth2Client(client) ) {
throw new ErrorWithContext('Invalid OAuth2 client configuration', {
id,
client,
})
}
return client
}
}

View File

@ -0,0 +1,21 @@
import {isOAuth2Scope, OAuth2Scope, ScopeRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {Config} from '../../../service/Config'
import {Awaitable, Maybe} from '../../../util'
@Injectable()
export class ConfigScopeRepository extends ScopeRepository {
@Inject()
protected readonly config!: Config
find(id: string): Awaitable<Maybe<OAuth2Scope>> {
const scope = this.config.get(`oauth2.scopes.${id}`)
if ( isOAuth2Scope(scope) ) {
return scope
}
}
findByName(name: string): Awaitable<Maybe<OAuth2Scope>> {
return this.find(name)
}
}

View File

@ -0,0 +1,94 @@
import {isOAuth2Token, OAuth2Client, OAuth2Token, oauth2TokenString, OAuth2TokenString, TokenRepository} from '../types'
import {Inject, Injectable} from '../../../di'
import {ErrorWithContext, Maybe} from '../../../util'
import {OAuth2TokenModel} from '../models/OAuth2TokenModel'
import {Config} from '../../../service/Config'
import * as jwt from 'jsonwebtoken'
import {Authenticatable} from '../../types'
import {make} from '../../../make'
@Injectable()
export class ORMTokenRepository extends TokenRepository {
@Inject()
protected readonly config!: Config
async find(id: string): Promise<Maybe<OAuth2Token>> {
const idNum = parseInt(id, 10)
if ( !isNaN(idNum) ) {
return OAuth2TokenModel.query<OAuth2TokenModel>()
.whereKey(idNum)
.first()
}
}
async issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Promise<OAuth2Token> {
const expiration = this.config.safe('outh2.token.lifetimeSeconds')
.or(60 * 60 * 6)
.integer() * 1000
const token = make<OAuth2TokenModel>(OAuth2TokenModel)
token.scope = scope
token.clientId = client.id
token.issued = new Date()
token.expires = new Date(Math.floor(Date.now() + expiration))
if ( user ) {
token.userId = String(user.getUniqueIdentifier())
}
await token.save()
return token
}
async encode(token: OAuth2Token): Promise<OAuth2TokenString> {
const secret = this.config.safe('oauth2.secret').string()
const payload = {
id: token.id,
clientId: token.clientId,
iat: Math.floor(token.issued.valueOf() / 1000),
exp: Math.floor(token.expires.valueOf() / 1000),
...(token.userId ? { userId: token.userId } : {}),
...(token.scope ? { scope: token.scope } : {}),
}
const generated = await new Promise<string>((res, rej) => {
jwt.sign(payload, secret, {}, (err, gen) => {
if (err || !gen) {
rej(err || new ErrorWithContext('Unable to encode JWT.', {
payload,
gen,
}))
} else {
res(gen)
}
})
})
return oauth2TokenString(generated)
}
async decode(token: OAuth2TokenString): Promise<Maybe<OAuth2Token>> {
const secret = this.config.safe('oauth2.secret').string()
const decoded = await new Promise<any>((res, rej) => {
jwt.verify(token, secret, {}, (err, payload) => {
if ( err ) {
rej(err)
} else {
res(payload)
}
})
})
const value = {
id: decoded.id,
clientId: decoded.clientId,
issued: new Date(decoded.iat * 1000),
expires: new Date(decoded.exp * 1000),
...(decoded.userId ? { userId: decoded.userId } : {}),
...(decoded.scope ? { scope: decoded.scope } : {}),
}
if ( isOAuth2Token(value) ) {
return value
}
}
}

View File

@ -0,0 +1,23 @@
import {Instantiable, FactoryProducer} from '../../../di'
import {RedemptionCodeRepository} from '../types'
import {CacheRedemptionCodeRepository} from './CacheRedemptionCodeRepository'
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
/**
* A dependency injection factory that matches the abstract RedemptionCodeRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class RedemptionCodeRepositoryFactory extends ConfiguredSingletonFactory<RedemptionCodeRepository> {
protected getConfigKey(): string {
return 'oauth2.repository.client'
}
protected getDefaultImplementation(): Instantiable<RedemptionCodeRepository> {
return CacheRedemptionCodeRepository
}
protected getAbstractImplementation(): any {
return RedemptionCodeRepository
}
}

View File

@ -0,0 +1,23 @@
import {Instantiable, FactoryProducer} from '../../../di'
import {ScopeRepository} from '../types'
import {ConfigScopeRepository} from './ConfigScopeRepository'
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
/**
* A dependency injection factory that matches the abstract ScopeRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class ScopeRepositoryFactory extends ConfiguredSingletonFactory<ScopeRepository> {
protected getConfigKey(): string {
return 'oauth2.repository.scope'
}
protected getDefaultImplementation(): Instantiable<ScopeRepository> {
return ConfigScopeRepository
}
protected getAbstractImplementation(): any {
return ScopeRepository
}
}

View File

@ -0,0 +1,23 @@
import {Instantiable, FactoryProducer} from '../../../di'
import {TokenRepository} from '../types'
import {ORMTokenRepository} from './ORMTokenRepository'
import {ConfiguredSingletonFactory} from '../../../di/factory/ConfiguredSingletonFactory'
/**
* A dependency injection factory that matches the abstract TokenRepository class
* and produces an instance of the configured repository driver implementation.
*/
@FactoryProducer()
export class TokenRepositoryFactory extends ConfiguredSingletonFactory<TokenRepository> {
protected getConfigKey(): string {
return 'oauth2.repository.token'
}
protected getDefaultImplementation(): Instantiable<TokenRepository> {
return ORMTokenRepository
}
protected getAbstractImplementation(): any {
return TokenRepository
}
}

179
src/auth/server/types.ts Normal file
View File

@ -0,0 +1,179 @@
import {Awaitable, hasOwnProperty, Maybe, TypeTag} from '../../util'
import {Authenticatable, AuthenticatableIdentifier} from '../types'
export enum OAuth2FlowType {
code = 'code',
}
// export const oauth2FlowTypes: OAuth2FlowType[] = Object.entries(OAuth2FlowType).map(([_, value]) => value)
export function isOAuth2FlowType(what: unknown): what is OAuth2FlowType {
return [OAuth2FlowType.code].includes(what as any)
}
export interface OAuth2Client {
id: string
display: string
secret: string
allowedFlows: OAuth2FlowType[]
allowedScopeIds: string[]
allowedRedirectUris: string[]
}
export function isOAuth2Client(what: unknown): what is OAuth2Client {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'id')
|| !hasOwnProperty(what, 'display')
|| !hasOwnProperty(what, 'secret')
|| !hasOwnProperty(what, 'allowedFlows')
|| !hasOwnProperty(what, 'allowedScopeIds')
|| !hasOwnProperty(what, 'allowedRedirectUris')
) {
return false
}
if ( typeof what.id !== 'string' || typeof what.display !== 'string' || typeof what.secret !== 'string' ) {
return false
}
if ( !Array.isArray(what.allowedScopeIds) || !what.allowedScopeIds.every(x => typeof x === 'string') ) {
return false
}
if ( !Array.isArray(what.allowedRedirectUris) || !what.allowedRedirectUris.every(x => typeof x === 'string') ) {
return false
}
return !(!Array.isArray(what.allowedFlows) || !what.allowedFlows.every(x => isOAuth2FlowType(x)))
}
export abstract class ClientRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Client>>
}
export interface OAuth2Scope {
id: string
name: string
description?: string
}
export function isOAuth2Scope(what: unknown): what is OAuth2Scope {
if ( typeof what !== 'object' || what === null ) {
return false
}
if ( !hasOwnProperty(what, 'id') || !hasOwnProperty(what, 'name') ) {
return false
}
if ( typeof what.id !== 'string' || typeof what.name !== 'string' ) {
return false
}
return !hasOwnProperty(what, 'description') || typeof what.description === 'string'
}
export abstract class ScopeRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Scope>>
abstract findByName(name: string): Awaitable<Maybe<OAuth2Scope>>
}
export abstract class OAuth2Token {
abstract id: string
/** When undefined, these are client credentials. */
abstract userId?: AuthenticatableIdentifier
abstract clientId: string
abstract issued: Date
abstract expires: Date
abstract scope?: string
}
export type OAuth2TokenString = TypeTag<'@extollo/lib.OAuth2TokenString'> & string
export function oauth2TokenString(s: string): OAuth2TokenString {
return s as OAuth2TokenString
}
export function isOAuth2Token(what: unknown): what is OAuth2Token {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'id')
|| !hasOwnProperty(what, 'clientId')
|| !hasOwnProperty(what, 'issued')
|| !hasOwnProperty(what, 'expires')
) {
return false
}
if (
typeof what.id !== 'string'
|| (hasOwnProperty(what, 'userId') && !(typeof what.userId === 'string' || typeof what.userId === 'number'))
|| typeof what.clientId !== 'string'
|| !(what.issued instanceof Date)
|| !(what.expires instanceof Date)
) {
return false
}
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
}
export abstract class TokenRepository {
abstract find(id: string): Awaitable<Maybe<OAuth2Token>>
abstract issue(user: Authenticatable|undefined, client: OAuth2Client, scope?: string): Awaitable<OAuth2Token>
abstract decode(token: OAuth2TokenString): Awaitable<Maybe<OAuth2Token>>
abstract encode(token: OAuth2Token): Awaitable<OAuth2TokenString>
}
export interface OAuth2RedemptionCode {
clientId: string
userId: AuthenticatableIdentifier
code: string
scope?: string
}
export function isOAuth2RedemptionCode(what: unknown): what is OAuth2RedemptionCode {
if ( typeof what !== 'object' || what === null ) {
return false
}
if (
!hasOwnProperty(what, 'clientId')
|| !hasOwnProperty(what, 'userId')
|| !hasOwnProperty(what, 'code')
) {
return false
}
if (
typeof what.clientId !== 'string'
|| !(typeof what.userId === 'number' || typeof what.userId === 'string')
|| typeof what.code !== 'string'
) {
return false
}
return !hasOwnProperty(what, 'scope') || typeof what.scope === 'string'
}
export abstract class RedemptionCodeRepository {
abstract find(code: string): Awaitable<Maybe<OAuth2RedemptionCode>>
abstract issue(user: Authenticatable, client: OAuth2Client, scope?: string): Awaitable<OAuth2RedemptionCode>
}

View File

@ -3,21 +3,22 @@ import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
/** Value that can be used to uniquely identify a user. */ /** Value that can be used to uniquely identify a user. */
export type AuthenticatableIdentifier = string | number export type AuthenticatableIdentifier = string | number
export interface AuthenticatableCredentials {
identifier: string,
credential: string,
}
/** /**
* Base class for entities that can be authenticated. * Base class for entities that can be authenticated.
*/ */
export abstract class Authenticatable implements Rehydratable { export abstract class Authenticatable implements Rehydratable {
/** Get the unique identifier of the user. */ /** Get the globally-unique identifier of the user. */
abstract getUniqueIdentifier(): AuthenticatableIdentifier
/** Get the repository-unique identifier of the user. */
abstract getIdentifier(): AuthenticatableIdentifier abstract getIdentifier(): AuthenticatableIdentifier
/** Get the human-readable identifier of the user. */ /** Get the human-readable identifier of the user. */
abstract getDisplayIdentifier(): string abstract getDisplay(): string
/** Attempt to validate a credential of the user. */
abstract validateCredential(credential: string): Awaitable<boolean>
abstract dehydrate(): Promise<JSONState> abstract dehydrate(): Promise<JSONState>
@ -28,16 +29,15 @@ export abstract class Authenticatable implements Rehydratable {
* Base class for a repository that stores and recalls users. * Base class for a repository that stores and recalls users.
*/ */
export abstract class AuthenticatableRepository { export abstract class AuthenticatableRepository {
/** Look up the user by their unique identifier. */ /** Look up the user by their unique identifier. */
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>> abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
/** /** Returns true if this repository supports registering users. */
* Attempt to look up and verify a user by their credentials. abstract supportsRegistration(): boolean
* Returns the user if the credentials are valid.
* @param credentials
*/
abstract getByCredentials(credentials: AuthenticatableCredentials): Awaitable<Maybe<Authenticatable>>
abstract createByCredentials(credentials: AuthenticatableCredentials): Awaitable<Authenticatable> /** Create a user in this repository from an external Authenticatable instance. */
abstract createFromExternal(user: Authenticatable): Awaitable<Authenticatable>
/** Create a user in this repository from basic credentials. */
abstract createFromCredentials(username: string, password: string): Awaitable<Authenticatable>
} }

View File

@ -0,0 +1,36 @@
import {Container} from '../di'
import {RequestLocalStorage} from '../http/RequestLocalStorage'
import {Session} from '../http/session/Session'
import {Logging} from '../service/Logging'
import {SecurityContext} from './context/SecurityContext'
import {Bus} from '../support/bus'
import {AuthCheckFailed} from './event/AuthCheckFailed'
/**
* Check if the security context for the current request's web socket is still valid.
* If not, raise an `AuthCheckFailed` event. This is meant to be used as a subscriber
* to `WebSocketHealthCheckEvent` on the request.
*
* @see AuthCheckFailed
*/
export async function webSocketAuthCheck(): Promise<void> {
const request = Container.getContainer()
.make<RequestLocalStorage>(RequestLocalStorage)
.get()
const logging = request.make<Logging>(Logging)
try {
// Try to re-load the session in case we're using the SessionSecurityContext
await request.make<Session>(Session).load()
} catch (e: unknown) {
logging.error(e)
}
const security = request.make<SecurityContext>(SecurityContext)
await security.resume()
if ( !security.hasUser() ) {
await request.make<Bus>(Bus).push(new AuthCheckFailed())
}
}

View File

@ -172,6 +172,7 @@ export abstract class Directive extends AppClass {
} catch (e: unknown) { } catch (e: unknown) {
if ( e instanceof Error ) { if ( e instanceof Error ) {
this.nativeOutput(e.message) this.nativeOutput(e.message)
this.error(e)
} }
if ( e instanceof OptionValidationError ) { if ( e instanceof OptionValidationError ) {
@ -467,4 +468,22 @@ export abstract class Directive extends AppClass {
protected nativeOutput(...outputs: any[]): void { protected nativeOutput(...outputs: any[]): void {
console.log(...outputs) // eslint-disable-line no-console console.log(...outputs) // eslint-disable-line no-console
} }
/**
* Get a promise that resolves after SIGINT is received, executing a
* callback beforehand.
* @param callback
* @protected
*/
protected async untilInterrupt(callback?: () => unknown): Promise<void> {
return new Promise<void>(res => {
process.on('SIGINT', async () => {
if ( callback ) {
await callback()
}
res()
})
})
}
} }

View File

@ -13,7 +13,7 @@ export const CLIDirective = (): ClassDecorator => {
if ( isInstantiableOf(target, Directive) ) { if ( isInstantiableOf(target, Directive) ) {
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target) logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
ContainerBlueprint.getContainerBlueprint() ContainerBlueprint.getContainerBlueprint()
.onResolve<CommandLine>(CommandLine, cli => { .onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
cli.registerDirective(target as Instantiable<Directive>) cli.registerDirective(target as Instantiable<Directive>)
}) })
} else { } else {

View File

@ -2,7 +2,7 @@ import {Directive, OptionDefinition} from '../Directive'
import {Inject, Injectable} from '../../di' import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing' import {Routing} from '../../service/Routing'
import Table = require('cli-table') import Table = require('cli-table')
import {RouteHandler} from '../../http/routing/Route' import {HTTPMethod} from '../../http/lifecycle/Request'
@Injectable() @Injectable()
export class RouteDirective extends Directive { export class RouteDirective extends Directive {
@ -33,39 +33,40 @@ export class RouteDirective extends Directive {
.toLowerCase() .toLowerCase()
.trim() .trim()
this.routing.getCompiled() const matched = this.routing.getCompiled()
.filter(match => match.getRoute().trim() === route && (!method || match.getMethod() === method)) .filter(match => {
.tap(matches => { if ( !method ) {
if ( !matches.length ) { return match.getRoute().trim() === route
this.error('No matching routes found. (Use `./ex routes` to list)')
process.exitCode = 1
} }
return (
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|| match.match(method as HTTPMethod, route)
)
}) })
.each(match => { .some(match => {
const pre = match.getMiddlewares() const displays = match.getDisplays()
.where('stage', '=', 'pre') .map<[string, string]>(ware => [ware.stage, ware.display])
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)])
const post = match.getMiddlewares() if ( displays.isEmpty() ) {
.where('stage', '=', 'post') return
.map<[string, string]>(ware => [ware.stage, this.handlerToString(ware.handler)]) }
const maxLen = match.getMiddlewares().max(ware => this.handlerToString(ware.handler).length) const maxLen = displays.max(x => x[1].length)
const table = new Table({ const table = new Table({
head: ['Stage', 'Handler'], head: ['Stage', 'Handler'],
colWidths: [10, Math.max(maxLen, match.getDisplayableHandler().length) + 2], colWidths: [10, maxLen + 2],
}) })
table.push(...pre.toArray()) displays.each(x => table.push(x))
table.push(['handler', match.getDisplayableHandler()])
table.push(...post.toArray())
this.info(`\nRoute: ${match}\n\n${table}`) this.info(`\nRoute: ${match}\n\n${table}`)
return true
}) })
}
protected handlerToString(handler: RouteHandler): string { if ( !matched ) {
return typeof handler === 'string' ? handler : '(anonymous function)' this.error('No matching routes found.')
}
} }
} }

View File

@ -17,13 +17,21 @@ export class RoutesDirective extends Directive {
} }
async handle(): Promise<void> { async handle(): Promise<void> {
const maxRouteLength = this.routing.getCompiled().max(route => String(route).length) const compiled = this.routing.getCompiled()
const maxHandlerLength = this.routing.getCompiled().max(route => route.getDisplayableHandler().length)
const rows = this.routing.getCompiled().map<[string, string]>(route => [String(route), route.getDisplayableHandler()]) const maxRouteLength = compiled.strings().max('length')
const maxHandlerLength = compiled.mapCall('getHandlerDisplay')
.whereDefined()
.max('length')
const maxNameLength = compiled.mapCall('getAlias')
.whereDefined()
.max('length')
const rows = compiled.map(route => [String(route), route.getHandlerDisplay(), route.getAlias() || ''])
const table = new Table({ const table = new Table({
head: ['Route', 'Handler'], head: ['Route', 'Handler', 'Name'],
colWidths: [maxRouteLength + 2, maxHandlerLength + 2], colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
}) })
table.push(...rows.toArray()) table.push(...rows.toArray())

View File

@ -1,15 +1,25 @@
import {Directive} from '../Directive' import {Directive, OptionDefinition} from '../Directive'
import * as colors from 'colors/safe' import * as colors from 'colors/safe'
import * as repl from 'repl' import * as repl from 'repl'
import {DependencyKey} from '../../di' // import * as tsNode from 'ts-node'
import {globalRegistry} from '../../util'
/** /**
* Launch an interactive REPL shell from within the application. * Launch an interactive REPL shell from within the application.
* This is very useful for debugging and testing things during development. * This is very useful for debugging and testing things during development.
*
* By default, the shell launches a TypeScript interpreter, but you can use
* the `--js` flag to get a JavaScript interpreter.
*
* @example
* ```sh
* pnpm cli -- shell
* pnpm cli -- shell --js
* ```
*/ */
export class ShellDirective extends Directive { export class ShellDirective extends Directive {
protected options: any = { protected options: any = {
welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`, welcome: `powered by Extollo, © ${(new Date()).getFullYear()} Garrett Mills\nAccess your application using the "app" global and @extollo/lib using the "lib" global.`,
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`, prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
} }
@ -31,17 +41,57 @@ export class ShellDirective extends Directive {
return '' return ''
} }
getOptions(): OptionDefinition[] {
return [
'--js | launch in JavaScript mode instead of TypeScript',
]
}
async handle(): Promise<void> { async handle(): Promise<void> {
const state: any = { const state: any = {
globalRegistry,
app: this.app(), app: this.app(),
lib: await import('../../index'), lib: await import('../../index'),
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters), exports: {},
} }
await new Promise<void>(res => { await new Promise<void>(res => {
// Currently, there's no way to programmatically access the async context
// of the REPL from this directive w/o requiring the user to perform manual
// actions. So, instead, override the context on the GlobalRegistry to make
// the current one the global default.
globalRegistry.forceContextOverride()
// Create the ts-node compiler service.
// const replService = tsNode.createRepl()
// const service = tsNode.create({...replService.evalAwarePartialHost})
// replService.setService(service)
// We global these values into the REPL's state directly (using the `state` object
// above), but since we're using a separate ts-node interpreter, we need to make it
// aware of the globals using declaration syntax.
// replService.evalCode(`
// declare const lib: typeof import('@extollo/lib');
// declare const app: typeof lib['Application'];
// declare const globalRegistry: typeof lib['globalRegistry'];
// `)
// Print the welome message and start the interpreter
this.nativeOutput(this.options.welcome) this.nativeOutput(this.options.welcome)
this.repl = repl.start(this.options.prompt) this.repl = repl.start({
// Causes the REPL to use the ts-node interpreter service:
// eval: !this.option('js', false) ? (...args) => replService.nodeEval(...args) : undefined,
prompt: this.options.prompt,
useGlobal: true,
useColors: true,
terminal: true,
preview: true,
})
// Add our globals into the REPL's context
Object.assign(this.repl.context, state) Object.assign(this.repl.context, state)
// Wait for the REPL to exit
this.repl.on('exit', () => res()) this.repl.on('exit', () => res())
}) })
} }

View File

@ -0,0 +1,64 @@
import {Directive, OptionDefinition} from '../../Directive'
import {Inject, Injectable} from '../../../di'
import {Bus, PushedToQueue, Queue} from '../../../support/bus'
import {Queueables} from '../../../service/Queueables'
@Injectable()
export class ListenDirective extends Directive {
@Inject()
protected readonly queue!: Queue
@Inject()
protected readonly queueables!: Queueables
@Inject()
protected readonly bus!: Bus
getDescription(): string {
return 'listen for jobs pushed to the queue and attempt to execute them'
}
getKeywords(): string | string[] {
return 'queue-listen'
}
getOptions(): OptionDefinition[] {
return []
}
async handle(): Promise<void> {
this.info('Subscribing to queue events...')
await this.bus.subscribe(PushedToQueue, async () => {
// A new job has been pushed to the queue, so try to pop it and execute it.
// We may get undefined if some other worker is running and picked up this job first.
await this.tryExecuteJob()
})
this.info('Setting periodic poll...')
const handle = setInterval(async () => {
await this.tryExecuteJob()
}, 5000)
this.info('Listening for jobs...')
await this.untilInterrupt()
this.info('Shutting down...')
clearInterval(handle)
}
protected async tryExecuteJob(): Promise<void> {
try {
const job = await this.queue.pop()
if ( !job ) {
return // Some other worker already picked up this job
}
this.info(`Executing: ${job.constructor?.name || 'unknown job'}`)
await job.execute()
this.success('Execution finished.')
} catch (e: unknown) {
this.error('Failed to execute job.')
this.error(e)
}
}
}

View File

@ -0,0 +1,43 @@
import {Directive, OptionDefinition} from '../../Directive'
import {Inject, Injectable} from '../../../di'
import {Queue} from '../../../support/bus'
import {Queueables} from '../../../service/Queueables'
@Injectable()
export class WorkDirective extends Directive {
@Inject()
protected readonly queue!: Queue
@Inject()
protected readonly queueables!: Queueables
getDescription(): string {
return 'pop a single item from the queue and execute it'
}
getKeywords(): string | string[] {
return 'queue-work'
}
getOptions(): OptionDefinition[] {
return []
}
async handle(): Promise<void> {
try {
const queueable = await this.queue.pop()
if ( !queueable ) {
this.info('There are no items in the queue.')
return
}
this.info(`Fetched 1 item from the queue`)
await queueable.execute()
this.success('Executed 1 item from the queue')
} catch (e: unknown) {
this.error('Failed to execute queueable:')
this.error(e)
process.exitCode = 1
}
}
}

View File

@ -11,3 +11,8 @@ export * from './directive/options/PositionalOption'
export * from './directive/ShellDirective' export * from './directive/ShellDirective'
export * from './directive/TemplateDirective' export * from './directive/TemplateDirective'
export * from './directive/UsageDirective' export * from './directive/UsageDirective'
export * from './decorators'
export * from './directive/queue/ListenDirective'
export * from './directive/queue/WorkDirective'

View File

@ -1,6 +1,6 @@
import {Unit} from '../../lifecycle/Unit' import {Unit} from '../../lifecycle/Unit'
import {Logging} from '../../service/Logging' import {Logging} from '../../service/Logging'
import {Singleton, Inject} from '../../di/decorator/injection' import {Singleton, Inject} from '../../di'
import {CommandLine} from './CommandLine' import {CommandLine} from './CommandLine'
import {UsageDirective} from '../directive/UsageDirective' import {UsageDirective} from '../directive/UsageDirective'
import {Directive} from '../Directive' import {Directive} from '../Directive'
@ -9,6 +9,8 @@ import {TemplateDirective} from '../directive/TemplateDirective'
import {RunDirective} from '../directive/RunDirective' import {RunDirective} from '../directive/RunDirective'
import {RoutesDirective} from '../directive/RoutesDirective' import {RoutesDirective} from '../directive/RoutesDirective'
import {RouteDirective} from '../directive/RouteDirective' import {RouteDirective} from '../directive/RouteDirective'
import {WorkDirective} from '../directive/queue/WorkDirective'
import {ListenDirective} from '../directive/queue/ListenDirective'
/** /**
* Unit that takes the place of the final unit in the application that handles * Unit that takes the place of the final unit in the application that handles
@ -46,6 +48,8 @@ export class CommandLineApplication extends Unit {
this.cli.registerDirective(RunDirective) this.cli.registerDirective(RunDirective)
this.cli.registerDirective(RoutesDirective) this.cli.registerDirective(RoutesDirective)
this.cli.registerDirective(RouteDirective) this.cli.registerDirective(RouteDirective)
this.cli.registerDirective(WorkDirective)
this.cli.registerDirective(ListenDirective)
const argv = process.argv.slice(2) const argv = process.argv.slice(2)
const match = this.cli.getDirectives() const match = this.cli.getDirectives()

View File

@ -1,6 +1,22 @@
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass, TypedDependencyKey} from './types' import {
DependencyKey,
InstanceRef,
Instantiable,
isInstantiable,
StaticClass,
StaticInstantiable,
TypedDependencyKey,
} from './types'
import {AbstractFactory} from './factory/AbstractFactory' import {AbstractFactory} from './factory/AbstractFactory'
import {collect, Collection, globalRegistry, logIfDebugging} from '../util' import {
Awaitable,
collect,
Collection,
globalRegistry,
hasOwnProperty,
logIfDebugging, Unsubscribe,
} from '../util'
import {ErrorWithContext, withErrorContext} from '../util/error/ErrorWithContext'
import {Factory} from './factory/Factory' import {Factory} from './factory/Factory'
import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError' import {DuplicateFactoryKeyError} from './error/DuplicateFactoryKeyError'
import {ClosureFactory} from './factory/ClosureFactory' import {ClosureFactory} from './factory/ClosureFactory'
@ -13,10 +29,57 @@ export type MaybeFactory<T> = AbstractFactory<T> | undefined
export type MaybeDependency = any | undefined export type MaybeDependency = any | undefined
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any } export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
/**
* Singletons that implement this interface receive callbacks for
* structural container events.
*/
export interface AwareOfContainerLifecycle {
awareOfContainerLifecycle: true
/** Called when this key is realized by its parent container. */
onContainerRealize?(): Awaitable<unknown>
/** Called before the parent container of this instance is destroyed. */
onContainerDestroy?(): Awaitable<unknown>
/** Called before an instance of a key is released from the container. */
onContainerRelease?(): Awaitable<unknown>
}
export function isAwareOfContainerLifecycle(what: unknown): what is AwareOfContainerLifecycle {
return Boolean(
(typeof what === 'object' || typeof what === 'function')
&& what !== null
&& hasOwnProperty(what, 'awareOfContainerLifecycle')
&& what.awareOfContainerLifecycle,
)
}
/** /**
* A container of resolve-able dependencies that are created via inversion-of-control. * A container of resolve-able dependencies that are created via inversion-of-control.
*/ */
export class Container { export class Container {
/**
* Set to true when we're realizing a container.
* Used to prevent infinite recursion when `getContainer()` is accidentally called
* from somewhere within the `realizeContainer()` call.
*/
protected static realizingContainer = false
/**
* List of dependency keys currently being `make`'d as a reverse stack.
* This is used to detect dependency cycles.
* @protected
*/
protected static makeStack?: Collection<DependencyKey>
/**
* The 100 most recent dependency keys that were `make`'d. Used to help with
* debugging cyclic dependency errors.
* @protected
*/
protected static makeHistory?: Collection<DependencyKey>
/** /**
* Given a Container instance, apply the ContainerBlueprint to it. * Given a Container instance, apply the ContainerBlueprint to it.
* @param container * @param container
@ -37,6 +100,8 @@ export class Container {
.then(value => listener.callback(value)) .then(value => listener.callback(value))
}) })
container.subscribeToBlueprintChanges(ContainerBlueprint.getContainerBlueprint())
return container return container
} }
@ -46,8 +111,14 @@ export class Container {
public static getContainer(): Container { public static getContainer(): Container {
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector') const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
if ( !existing ) { if ( !existing ) {
if ( this.realizingContainer ) {
throw new ErrorWithContext('Attempted getContainer call during container realization.')
}
this.realizingContainer = true
const container = Container.realizeContainer(new Container()) const container = Container.realizeContainer(new Container())
globalRegistry.setGlobal('extollo/injector', container) globalRegistry.setGlobal('extollo/injector', container)
this.realizingContainer = false
return container return container
} }
@ -66,17 +137,51 @@ export class Container {
*/ */
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>() protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
/**
* Collection of static-class overrides registered with this container.
* @protected
*/
protected staticOverrides: Collection<{ base: StaticInstantiable<any>, override: StaticInstantiable<any> }> = new Collection<{base: StaticInstantiable<any>; override: StaticInstantiable<any>}>()
/** /**
* Collection of callbacks waiting for a dependency key to be resolved. * Collection of callbacks waiting for a dependency key to be resolved.
* @protected * @protected
*/ */
protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>(); protected waitingResolveCallbacks: Collection<{ key: DependencyKey, callback: (t: unknown) => unknown }> = new Collection<{key: DependencyKey; callback:(t: unknown) => unknown}>()
/**
* Collection of created objects that should have lifecycle events called on them, if they still exist.
* @protected
*/
protected waitingLifecycleCallbacks: Collection<WeakRef<AwareOfContainerLifecycle>> = new Collection()
/**
* Collection of subscriptions to ContainerBlueprint events.
* We keep this around so we can remove the subscriptions when the container is destroyed.
* @protected
*/
protected blueprintSubscribers: Collection<Unsubscribe> = new Collection()
constructor() { constructor() {
this.registerSingletonInstance<Container>(Container, this) this.registerSingletonInstance<Container>(Container, this)
this.registerSingleton('injector', this) this.registerSingleton('injector', this)
} }
/** Make the container listen to changes in the given blueprint. */
private subscribeToBlueprintChanges(blueprint: ContainerBlueprint): void {
this.blueprintSubscribers.push(
blueprint.resolve$(factory => this.registerFactory(factory())),
)
this.blueprintSubscribers.push(
blueprint.resolveConstructable$(factoryClass => this.registerFactory(this.make(factoryClass))),
)
this.blueprintSubscribers.push(
blueprint.resolveResolutionCallbacks$(listener => this.onResolve(listener.key).then(value => listener.callback(value))),
)
}
/** /**
* Purge all factories and instances of the given key from this container. * Purge all factories and instances of the given key from this container.
* @param key * @param key
@ -92,7 +197,14 @@ export class Container {
* @param key * @param key
*/ */
release(key: DependencyKey): this { release(key: DependencyKey): this {
this.instances = this.instances.filter(x => x.key !== key) this.instances = this.instances.filter(x => {
if ( x.key === key && isAwareOfContainerLifecycle(x.value) ) {
x.value.onContainerRelease?.()
}
return x.key !== key
})
return this return this
} }
@ -110,6 +222,52 @@ export class Container {
return this return this
} }
/**
* Register a static class as an override of some base class.
* @param base
* @param override
*/
registerStaticOverride<T>(base: StaticInstantiable<T>, override: StaticInstantiable<T>): this {
if ( this.hasStaticOverride(base) ) {
throw new DuplicateFactoryKeyError(base)
}
this.staticOverrides.push({
base,
override,
})
return this
}
/** Returns true if a static override exists for the given base class. */
hasStaticOverride<T>(base: StaticInstantiable<T>): boolean {
return this.staticOverrides.where('base', '=', base).isNotEmpty()
}
/**
* Get the static class overriding the base class.
* @param base
*/
getStaticOverride<T>(base: StaticInstantiable<T>): StaticInstantiable<T> {
const override = this.staticOverrides.firstWhere('base', '=', base)
if ( override ) {
return override.override
}
return base
}
/**
* Get the registered instance of the static override of a given class.
* @param base
* @param parameters
*/
makeByStaticOverride<T>(base: StaticInstantiable<T>, ...parameters: any[]): T {
const key = this.getStaticOverride(base)
return this.make(key, ...parameters)
}
/** /**
* Register the given function as a factory within the container. * Register the given function as a factory within the container.
* @param {string} name - unique name to identify the factory in the container * @param {string} name - unique name to identify the factory in the container
@ -244,13 +402,13 @@ export class Container {
if ( factory ) { if ( factory ) {
return factory return factory
} else { } else {
logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories) logIfDebugging('extollo.di.injector', 'unable to resolve factory', key, factory, this.factories)
} }
} }
/** /**
* Resolve the dependency key. If a singleton value for that key already exists in this container, * Resolve the dependency key. If a singleton value for that key already exists in this container,
* return that value. Otherwise, use the factory an given parameters to produce and return the value. * return that value. Otherwise, use the factory and given parameters to produce and return the value.
* @param {DependencyKey} key * @param {DependencyKey} key
* @param {...any} parameters * @param {...any} parameters
*/ */
@ -278,6 +436,10 @@ export class Container {
value: newInstance, value: newInstance,
}) })
if ( isAwareOfContainerLifecycle(newInstance) ) {
newInstance.onContainerRealize?.()
}
this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => { this.waitingResolveCallbacks = this.waitingResolveCallbacks.filter(waiter => {
if ( waiter.key === key ) { if ( waiter.key === key ) {
waiter.callback(newInstance) waiter.callback(newInstance)
@ -297,14 +459,21 @@ export class Container {
* @param {array} parameters * @param {array} parameters
*/ */
protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T { protected produceFactory<T>(factory: AbstractFactory<T>, parameters: any[]): T {
logIfDebugging('extollo.di.injector', 'Make stack', Container.makeStack)
// Create the dependencies for the factory // Create the dependencies for the factory
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key)) const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
const dependencies = keys.map<ResolvedDependency>(req => { const dependencies = keys.map<ResolvedDependency>(req => {
return withErrorContext(() => {
return { return {
paramIndex: req.paramIndex, paramIndex: req.paramIndex,
key: req.key, key: req.key,
resolved: this.resolveAndCreate(req.key), resolved: this.resolveAndCreate(req.key),
} }
}, {
producingToken: factory.getTokenName(),
constructorDependency: req,
})
}).sortBy('paramIndex') }).sortBy('paramIndex')
// Build the arguments for the factory, using dependencies in the // Build the arguments for the factory, using dependencies in the
@ -324,12 +493,23 @@ export class Container {
// Produce a new instance // Produce a new instance
const inst = factory.produce(constructorArguments, params.reverse().all()) const inst = factory.produce(constructorArguments, params.reverse().all())
logIfDebugging('extollo.di.injector', 'Resolving dependencies for factory', factory)
factory.getInjectedProperties().each(dependency => { factory.getInjectedProperties().each(dependency => {
logIfDebugging('extollo.di.injector', 'Resolving injected dependency:', dependency)
if ( dependency.key && inst ) { if ( dependency.key && inst ) {
withErrorContext(() => {
(inst as any)[dependency.property] = this.resolveAndCreate(dependency.key) (inst as any)[dependency.property] = this.resolveAndCreate(dependency.key)
}, {
producingToken: factory.getTokenName(),
propertyDependency: dependency,
})
} }
}) })
if ( isAwareOfContainerLifecycle(inst) ) {
this.waitingLifecycleCallbacks.push(new WeakRef<AwareOfContainerLifecycle>(inst))
}
return inst return inst
} }
@ -343,13 +523,109 @@ export class Container {
* @param {...any} parameters * @param {...any} parameters
*/ */
make<T>(target: DependencyKey, ...parameters: any[]): T { make<T>(target: DependencyKey, ...parameters: any[]): T {
if ( this.hasKey(target) ) { if ( !Container.makeStack ) {
return this.resolveAndCreate(target, ...parameters) Container.makeStack = new Collection()
} else if ( typeof target !== 'string' && isInstantiable(target) ) { }
return this.produceFactory(new Factory(target), parameters)
} else { if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
Container.makeStack.push(target)
if ( Container.makeHistory.length > 100 ) {
Container.makeHistory = Container.makeHistory.slice(1, 100)
}
Container.makeHistory.push(target)
this.checkForMakeCycles()
try {
const result = withErrorContext(() => {
if (this.hasKey(target)) {
const realized = this.resolveAndCreate(target, ...parameters)
Container.makeStack?.pop()
return realized
} else if (typeof target !== 'string' && isInstantiable(target)) {
const realized = this.produceFactory(new Factory(target), parameters)
Container.makeStack?.pop()
return realized
}
}, {
makeStack: Container.makeStack.map(x => typeof x === 'string' ? x : (x?.name || 'unknown')).toArray(),
})
if ( result ) {
return result
}
} catch (e: unknown) {
Container.makeStack.pop()
throw e
}
Container.makeStack.pop()
throw new TypeError(`Invalid or unknown make target: ${target}`) throw new TypeError(`Invalid or unknown make target: ${target}`)
} }
/**
* Check the `makeStack` for duplicates and throw an error if a dependency cycle is
* detected. This is used to prevent infinite mutual recursion when cyclic dependencies
* occur.
* @protected
*/
protected checkForMakeCycles(): void {
if ( !Container.makeStack ) {
Container.makeStack = new Collection()
}
if ( !Container.makeHistory ) {
Container.makeHistory = new Collection()
}
if ( Container.makeStack.unique().length !== Container.makeStack.length ) {
const displayKey = (key: DependencyKey) => {
if ( typeof key === 'string' ) {
return 'key: `' + key + '`'
} else {
return `key: ${key.name}`
}
}
const makeStack = Container.makeStack
.reverse()
.map(displayKey)
const makeHistory = Container.makeHistory
.reverse()
.map(displayKey)
console.error('Make Stack:') // eslint-disable-line no-console
console.error(makeStack.join('\n')) // eslint-disable-line no-console
console.error('Make History:') // eslint-disable-line no-console
console.error(makeHistory.join('\n')) // eslint-disable-line no-console
throw new ErrorWithContext('Cyclic dependency chain detected', {
makeStack,
makeHistory,
})
}
}
/**
* Create a new instance of the dependency key using this container, ignoring any pre-existing instances
* in this container.
* @param key
* @param parameters
*/
makeNew<T>(key: TypedDependencyKey<T>, ...parameters: any[]): T {
if ( isInstantiable(key) ) {
const result = this.produceFactory(new Factory(key), parameters)
if ( isAwareOfContainerLifecycle(result) ) {
result.onContainerRealize?.()
}
return result
}
throw new TypeError(`Invalid or unknown make target: ${key}`)
} }
/** /**
@ -366,6 +642,23 @@ export class Container {
return factory.getDependencyKeys().pluck('key') return factory.getDependencyKeys().pluck('key')
} }
/**
* Perform any cleanup necessary to destroy this container instance.
*/
destroy(): void {
this.blueprintSubscribers.mapCall('unsubscribe')
this.waitingLifecycleCallbacks
.mapCall('deref')
.whereDefined()
.each(inst => {
if ( isAwareOfContainerLifecycle(inst) ) {
inst.onContainerRelease?.()
inst.onContainerDestroy?.()
}
})
}
/** /**
* Given a different container, copy the factories and instances from this container over to it. * Given a different container, copy the factories and instances from this container over to it.
* @param container * @param container

View File

@ -3,6 +3,8 @@ import NamedFactory from './factory/NamedFactory'
import {AbstractFactory} from './factory/AbstractFactory' import {AbstractFactory} from './factory/AbstractFactory'
import {Factory} from './factory/Factory' import {Factory} from './factory/Factory'
import {ClosureFactory} from './factory/ClosureFactory' import {ClosureFactory} from './factory/ClosureFactory'
import {Collection, collect} from '../util/collection/Collection'
import {Subscription, Unsubscribe} from '../util/support/BehaviorSubject'
/** Simple type alias for a callback to a container's onResolve method. */ /** Simple type alias for a callback to a container's onResolve method. */
export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown) export type ContainerResolutionCallback<T> = (() => unknown) | ((t: T) => unknown)
@ -25,11 +27,11 @@ export class ContainerBlueprint {
return this.instance return this.instance
} }
protected factories: (() => AbstractFactory<any>)[] = [] protected factories: Collection<(() => AbstractFactory<any>)> = collect()
protected constructableFactories: StaticClass<AbstractFactory<any>, any>[] = [] protected constructableFactories: Collection<StaticClass<AbstractFactory<any>, any>> = collect()
protected resolutionCallbacks: ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] = [] protected resolutionCallbacks: Collection<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}> = collect()
/** /**
* Register some factory class with the container. Should take no construction params. * Register some factory class with the container. Should take no construction params.
@ -74,7 +76,16 @@ export class ContainerBlueprint {
* Get an array of factory instances in the blueprint. * Get an array of factory instances in the blueprint.
*/ */
resolve(): AbstractFactory<any>[] { resolve(): AbstractFactory<any>[] {
return this.factories.map(x => x()) return this.factories.map(x => x()).all()
}
/**
* Subscribe to new factories being registered.
* Used by `Container` implementations to listen for factories being registered after the container is realized.
* @param sub
*/
resolve$(sub: Subscription<() => AbstractFactory<any>>): Unsubscribe {
return this.factories.push$(sub)
} }
/** /**
@ -94,14 +105,32 @@ export class ContainerBlueprint {
* Get an array of static Factory classes that need to be instantiated by * Get an array of static Factory classes that need to be instantiated by
* the container itself. * the container itself.
*/ */
resolveConstructable(): StaticClass<AbstractFactory<any>, any> { resolveConstructable(): StaticClass<AbstractFactory<any>, any>[] {
return [...this.constructableFactories] return this.constructableFactories.all()
}
/**
* Subscribe to new constructable factories being registered.
* Used by `Container` implementations to listen for factories registered after the container is realized.
* @param sub
*/
resolveConstructable$(sub: Subscription<StaticClass<AbstractFactory<any>, any>>): Unsubscribe {
return this.constructableFactories.push$(sub)
} }
/** /**
* Get an array of DependencyKey-callback pairs to register with new containers. * Get an array of DependencyKey-callback pairs to register with new containers.
*/ */
resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] { resolveResolutionCallbacks(): ({key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>})[] {
return [...this.resolutionCallbacks] return this.resolutionCallbacks.all()
}
/**
* Subscribe to new resolution callbacks being registered.
* Used by `Container` implementations to listen for callbacks registered after the container is realized.
* @param sub
*/
resolveResolutionCallbacks$(sub: Subscription<{key: TypedDependencyKey<any>, callback: ContainerResolutionCallback<any>}>): Unsubscribe {
return this.resolutionCallbacks.push$(sub)
} }
} }

37
src/di/InjectionAware.ts Normal file
View File

@ -0,0 +1,37 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
/**
* Base class for Injection-aware classes that automatically
* pass along their configured container to instances created
* via their `make` method.
*/
export class InjectionAware {
private ci: Container
constructor() {
this.ci = Container.getContainer()
}
/** Set the container for this instance. */
public setContainer(ci: Container): this {
this.ci = ci
return this
}
/** Get the container for this instance. */
public getContainer(): Container {
return this.ci
}
/** Instantiate a new injectable using the container. */
public make<T>(target: TypedDependencyKey<T>, ...parameters: any[]): T {
const inst = this.ci.make<T>(target, ...parameters)
if ( inst instanceof InjectionAware ) {
inst.setContainer(this.ci)
}
return inst
}
}

11
src/di/constructable.ts Normal file
View File

@ -0,0 +1,11 @@
import {Container} from './Container'
import {TypedDependencyKey} from './types'
import {Pipeline} from '../util'
export type Constructable<T> = Pipeline<Container, T>
export function constructable<T>(key: TypedDependencyKey<T>): Constructable<T> {
return new Pipeline<Container, T>(
container => container.make(key),
)
}

View File

@ -0,0 +1,9 @@
import {Instantiable, PropertyDependency} from '../types'
import {Collection, logIfDebugging} from '../../util'
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
export function getPropertyInjectionMetadata(token: Instantiable<any>): Collection<PropertyDependency> {
const loadedMeta = ((token as any)[propertyInjectionMetadata] || new Collection()) as Collection<PropertyDependency>
logIfDebugging('extollo.di.injection', 'getPropertyInjectionMetadata() target:', token, 'loaded:', loadedMeta)
return loadedMeta
}

View File

@ -1,16 +1,17 @@
import 'reflect-metadata' import 'reflect-metadata'
import {collect, Collection, logIfDebugging} from '../../util' import {collect, Collection} from '../../util/collection/Collection'
import {logIfDebugging} from '../../util/support/debug'
import { import {
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
DependencyKey, DependencyKey,
DependencyRequirement, DependencyRequirement,
DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
isInstantiable,
InjectionType, InjectionType,
DEPENDENCY_KEYS_SERVICE_TYPE_KEY, isInstantiable,
PropertyDependency, PropertyDependency,
} from '../types' } from '../types'
import {ContainerBlueprint} from '../ContainerBlueprint' import {ContainerBlueprint} from '../ContainerBlueprint'
import {propertyInjectionMetadata} from './propertyInjectionMetadata'
/** /**
* Get a collection of dependency requirements for the given target object. * Get a collection of dependency requirements for the given target object.
@ -66,6 +67,7 @@ export const Injectable = (): ClassDecorator => {
} }
} }
/** /**
* Mark the given class property to be injected by the container. * Mark the given class property to be injected by the container.
* If a `key` is specified, that DependencyKey will be injected. * If a `key` is specified, that DependencyKey will be injected.
@ -76,10 +78,27 @@ export const Injectable = (): ClassDecorator => {
*/ */
export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => { export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDecorator => {
return (target, property) => { return (target, property) => {
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency> if ( !target?.constructor ) {
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject(): target has no constructor', target)
throw new Error('Unable to define property injection: target has no constructor. Enable `extollo.di.decoration` logging to debug')
}
const propertyTarget = target.constructor
// let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyTarget) as Collection<PropertyDependency>
// Okay, this is a little fucky. We can't use Reflect's metadata capabilities because we need to write the metadata to
// the constructor, not the `target`. Because Reflect is using the prototype to store data, defining a metadata key on the constructor
// will define it for its parent constructors as well.
// So, if you have class A, class B extends A, and class C extends A, the properties for B and C will be defined on A, causing
// BOTH B and C's properties to be injected on any class extending A.
// To get around this, we instead define a custom property on the constructor itself, then use hasOwnProperty to make sure we're not
// getting the one for the parent class via the prototype chain.
let propertyMetadata = Object.prototype.hasOwnProperty.call(propertyTarget, propertyInjectionMetadata) ?
(propertyTarget as any)[propertyInjectionMetadata] as Collection<PropertyDependency> : undefined
if ( !propertyMetadata ) { if ( !propertyMetadata ) {
propertyMetadata = new Collection<PropertyDependency>() propertyMetadata = new Collection<PropertyDependency>()
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target) ;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
} }
const type = Reflect.getMetadata('design:type', target, property) const type = Reflect.getMetadata('design:type', target, property)
@ -100,11 +119,9 @@ export const Inject = (key?: DependencyKey, { debug = false } = {}): PropertyDec
} }
} }
if ( debug ) {
logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type) logIfDebugging('extollo.di.decoration', '[DEBUG] @Inject() - key:', key, 'property:', property, 'target:', target, 'target constructor:', target?.constructor, 'type:', type)
}
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target) ;(propertyTarget as any)[propertyInjectionMetadata] = propertyMetadata
} }
} }
@ -149,6 +166,7 @@ export const Singleton = (name?: string): ClassDecorator => {
...(name ? { name } : {}), ...(name ? { name } : {}),
} }
logIfDebugging('extollo.di.singleton', 'Registering singleton target:', target, 'injectionType:', injectionType)
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target) Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
Injectable()(target) Injectable()(target)
@ -167,6 +185,7 @@ export const Singleton = (name?: string): ClassDecorator => {
*/ */
export const FactoryProducer = (): ClassDecorator => { export const FactoryProducer = (): ClassDecorator => {
return (target) => { return (target) => {
logIfDebugging('extollo.di.injector', 'Registering factory producer for target:', target)
if ( isInstantiable(target) ) { if ( isInstantiable(target) ) {
ContainerBlueprint.getContainerBlueprint().registerFactory(target) ContainerBlueprint.getContainerBlueprint().registerFactory(target)
} }

View File

@ -0,0 +1,2 @@
export const propertyInjectionMetadata = Symbol('@extollo/lib:propertyInjectionMetadata')

View File

@ -1,11 +1,12 @@
import {DependencyKey} from '../types' import {DependencyKey} from '../types'
import {ErrorWithContext} from '../../util/error/ErrorWithContext'
/** /**
* Error thrown when a dependency key that has not been registered is passed to a resolver. * Error thrown when a dependency key that has not been registered is passed to a resolver.
* @extends Error * @extends Error
*/ */
export class InvalidDependencyKeyError extends Error { export class InvalidDependencyKeyError extends ErrorWithContext {
constructor(key: DependencyKey) { constructor(key: DependencyKey, context: {[key: string]: any} = {}) {
super(`No such dependency is registered with this container: ${key}`) super(`No such dependency is registered with this container: ${key}`, context)
} }
} }

View File

@ -1,5 +1,6 @@
import {DependencyKey, DependencyRequirement, PropertyDependency} from '../types' import {DependencyKey, DependencyRequirement, Instantiable, PropertyDependency} from '../types'
import { Collection } from '../../util' import {Collection, logIfDebugging} from '../../util'
import {getPropertyInjectionMetadata} from '../decorator/getPropertyInjectionMetadata'
/** /**
* Abstract base class for dependency container factories. * Abstract base class for dependency container factories.
@ -41,4 +42,32 @@ export abstract class AbstractFactory<T> {
* @return Collection<PropertyDependency> * @return Collection<PropertyDependency>
*/ */
abstract getInjectedProperties(): Collection<PropertyDependency> abstract getInjectedProperties(): Collection<PropertyDependency>
/** Helper method that returns all `@Inject()`'ed properties for a token and its prototypical ancestors. */
protected getInjectedPropertiesForPrototypeChain(token: Instantiable<any>): Collection<PropertyDependency> {
const meta = new Collection<PropertyDependency>()
do {
const loadedMeta = getPropertyInjectionMetadata(token)
if ( loadedMeta ) {
meta.concat(loadedMeta)
}
token = Object.getPrototypeOf(token)
logIfDebugging('extollo.di.injection', 'next currentToken:', token)
} while (token !== Function.prototype && token !== Object.prototype)
return meta
}
/**
* Get a human-readable name of the token this factory produces.
* This is meant for debugging output only.
*/
public getTokenName(): string {
if ( typeof this.token === 'string' ) {
return this.token
}
return this.token?.name ?? '(unknown token)'
}
} }

View File

@ -0,0 +1,84 @@
import {AbstractFactory} from './AbstractFactory'
import {Inject, Injectable} from '../decorator/injection'
import {Logging} from '../../service/Logging'
import {Config} from '../../service/Config'
import {
DEPENDENCY_KEYS_METADATA_KEY,
DependencyRequirement,
Instantiable,
isInstantiable,
PropertyDependency,
} from '../types'
import {Collection, ErrorWithContext, Maybe} from '../../util'
import 'reflect-metadata'
@Injectable()
export abstract class ConfiguredSingletonFactory<T> extends AbstractFactory<T> {
protected static loggedDefaultImplementationWarningOnce = false
@Inject()
protected readonly logging!: Logging
@Inject()
protected readonly config!: Config
constructor() {
super({})
}
protected abstract getConfigKey(): string
protected abstract getDefaultImplementation(): Instantiable<T>
protected abstract getAbstractImplementation(): any
protected getDefaultImplementationWarning(): Maybe<string> {
return undefined
}
produce(dependencies: any[], parameters: any[]): T {
return new (this.getImplementation())(...dependencies, ...parameters)
}
match(something: unknown): boolean {
return something === this.getAbstractImplementation()
}
getDependencyKeys(): Collection<DependencyRequirement> {
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.getImplementation())
if ( meta ) {
return meta
}
return new Collection<DependencyRequirement>()
}
getInjectedProperties(): Collection<PropertyDependency> {
return this.getInjectedPropertiesForPrototypeChain(this.getImplementation())
}
protected getImplementation(): Instantiable<T> {
const ctor = this.constructor as typeof ConfiguredSingletonFactory
const ImplementationClass = this.config.get(this.getConfigKey(), this.getDefaultImplementation())
if ( ImplementationClass === this.getDefaultImplementation() ) {
const warning = this.getDefaultImplementationWarning()
if ( warning && !ctor.loggedDefaultImplementationWarningOnce ) {
this.logging.warn(warning)
ctor.loggedDefaultImplementationWarningOnce = true
}
}
if (
!isInstantiable(ImplementationClass)
|| !(ImplementationClass.prototype instanceof this.getAbstractImplementation())
) {
throw new ErrorWithContext('Configured service clas does not properly extend from implementation base class.', {
configKey: this.getConfigKey(),
class: `${ImplementationClass}`,
mustExtendBase: `${this.getAbstractImplementation()}`,
})
}
return ImplementationClass as Instantiable<T>
}
}

View File

@ -1,7 +1,6 @@
import {AbstractFactory} from './AbstractFactory' import {AbstractFactory} from './AbstractFactory'
import { import {
DEPENDENCY_KEYS_METADATA_KEY, DEPENDENCY_KEYS_METADATA_KEY,
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
DependencyRequirement, DependencyRequirement,
Instantiable, Instantiable,
PropertyDependency, PropertyDependency,
@ -53,17 +52,6 @@ export class Factory<T> extends AbstractFactory<T> {
} }
getInjectedProperties(): Collection<PropertyDependency> { getInjectedProperties(): Collection<PropertyDependency> {
const meta = new Collection<PropertyDependency>() return this.getInjectedPropertiesForPrototypeChain(this.token)
let currentToken = this.token
do {
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
if ( loadedMeta ) {
meta.concat(loadedMeta)
}
currentToken = Object.getPrototypeOf(currentToken)
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
return meta
} }
} }

View File

@ -7,9 +7,13 @@ export * from './factory/Factory'
export * from './factory/NamedFactory' export * from './factory/NamedFactory'
export * from './factory/SingletonFactory' export * from './factory/SingletonFactory'
export * from './types'
export * from './ContainerBlueprint' export * from './ContainerBlueprint'
export * from './decorator/getPropertyInjectionMetadata'
export * from './decorator/injection'
export * from './Container' export * from './Container'
export * from './ScopedContainer' export * from './ScopedContainer'
export * from './types'
export * from './decorator/injection' export * from './InjectionAware'
export * from './constructable'

View File

@ -36,7 +36,12 @@ export function isInstantiableOf<T>(what: unknown, type: StaticClass<T, any>): w
/** /**
* Type that identifies a value as a static class, even if it is not instantiable. * Type that identifies a value as a static class, even if it is not instantiable.
*/ */
export type StaticClass<T, T2> = Function & {prototype: T} & T2 // eslint-disable-line @typescript-eslint/ban-types export type StaticClass<T, T2, TCtorParams extends any[] = any[]> = Function & {prototype: T} & { new (...args: TCtorParams) : T } & T2 // eslint-disable-line @typescript-eslint/ban-types
/**
* Type that identifies a value as a static class that instantiates to itself
*/
export type StaticInstantiable<T> = StaticClass<T, Instantiable<T>>
/** /**
* Returns true if the parameter is a static class. * Returns true if the parameter is a static class.

View File

@ -1,13 +0,0 @@
import {Dispatchable} from './types'
import {Awaitable, JSONState} from '../util'
/**
* Abstract class representing an event that may be fired.
*/
export abstract class Event implements Dispatchable {
abstract dehydrate(): Awaitable<JSONState>
abstract rehydrate(state: JSONState): Awaitable<void>
}

View File

@ -1,53 +0,0 @@
import {Instantiable, Singleton, StaticClass} from '../di'
import {Bus, Dispatchable, EventSubscriber, EventSubscriberEntry, EventSubscription} from './types'
import {Awaitable, Collection, uuid4} from '../util'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
*/
@Singleton()
export class EventBus implements Bus {
/**
* Collection of subscribers, by their events.
* @protected
*/
protected subscribers: Collection<EventSubscriberEntry<any>> = new Collection<EventSubscriberEntry<any>>()
subscribe<T extends Dispatchable>(event: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription> {
const entry: EventSubscriberEntry<T> = {
id: uuid4(),
event,
subscriber,
}
this.subscribers.push(entry)
return this.buildSubscription(entry.id)
}
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void> {
this.subscribers = this.subscribers.where('subscriber', '!=', subscriber)
}
async dispatch(event: Dispatchable): Promise<void> {
const eventClass: StaticClass<typeof event, typeof event> = event.constructor as StaticClass<Dispatchable, Dispatchable>
await this.subscribers.where('event', '=', eventClass)
.promiseMap(entry => entry.subscriber(event))
}
/**
* Build an EventSubscription object for the subscriber of the given ID.
* @param id
* @protected
*/
protected buildSubscription(id: string): EventSubscription {
let subscribed = true
return {
unsubscribe: (): Awaitable<void> => {
if ( subscribed ) {
this.subscribers = this.subscribers.where('id', '!=', id)
subscribed = false
}
},
}
}
}

View File

@ -1,28 +0,0 @@
import {EventBus} from './EventBus'
import {Collection} from '../util'
import {Bus, Dispatchable} from './types'
/**
* A non-queued bus implementation that executes subscribers immediately in the main thread.
* This bus also supports "propagating" events along to any other connected buses.
* Such behavior is useful, e.g., if we want to have a semi-isolated request-
* level bus whose events still reach the global EventBus instance.
*/
export class PropagatingEventBus extends EventBus {
protected recipients: Collection<Bus> = new Collection<Bus>()
async dispatch(event: Dispatchable): Promise<void> {
await super.dispatch(event)
await this.recipients.promiseMap(bus => bus.dispatch(event))
}
/**
* Register the given bus to receive events fired on this bus.
* @param recipient
*/
connect(recipient: Bus): void {
if ( !this.recipients.includes(recipient) ) {
this.recipients.push(recipient)
}
}
}

View File

@ -1,47 +0,0 @@
import {Awaitable, Rehydratable} from '../util'
import {Instantiable, StaticClass} from '../di'
/**
* A closure that should be executed with the given event is fired.
*/
export type EventSubscriber<T extends Dispatchable> = (event: T) => Awaitable<void>
/**
* An object used to track event subscriptions internally.
*/
export interface EventSubscriberEntry<T extends Dispatchable> {
/** Globally unique ID of this subscription. */
id: string
/** The event class subscribed to. */
event: StaticClass<T, Instantiable<T>>
/** The closure to execute when the event is fired. */
subscriber: EventSubscriber<T>
}
/**
* An object returned upon subscription, used to unsubscribe.
*/
export interface EventSubscription {
/**
* Unsubscribe the associated listener from the event bus.
*/
unsubscribe(): Awaitable<void>
}
/**
* An instance of something that can be fired on an event bus.
*/
export interface Dispatchable extends Rehydratable {
shouldQueue?: boolean
}
/**
* An event-driven bus that manages subscribers and dispatched items.
*/
export interface Bus {
subscribe<T extends Dispatchable>(eventClass: StaticClass<T, Instantiable<T>>, subscriber: EventSubscriber<T>): Awaitable<EventSubscription>
unsubscribe<T extends Dispatchable>(subscriber: EventSubscriber<T>): Awaitable<void>
dispatch(event: Dispatchable): Awaitable<void>
}

View File

View File

@ -1,62 +0,0 @@
import {Container, Injectable, InjectParam} from '../di'
import {Request} from '../http/lifecycle/Request'
import {Valid, ValidationRules} from './rules/types'
import {Validator} from './Validator'
import {AppClass} from '../lifecycle/AppClass'
import {DataContainer} from '../http/lifecycle/Request'
/**
* Base class for defining reusable validators for request routes.
* If instantiated with a container, it must be a request-level container,
* but the type interface allows any data-container to be used when creating
* manually.
*
* You should mark implementations of this class as singleton to avoid
* re-validating the input data every time it is accessed.
*
* @example
* ```ts
* // Instantiate with the request:
* const data = <MyFormRequest> request.make(MyFormRequest)
*
* // Instantiate with some container:
* const data = new MyFormRequest(someDataContainer)
* ```
*/
@Injectable()
export abstract class FormRequest<T> extends AppClass {
/** The cached validation result. */
protected cachedResult?: Valid<T>
constructor(
@InjectParam(Request)
protected readonly data: DataContainer,
) {
super()
}
protected container(): Container {
return (this.data as unknown) as Container
}
/**
* The validation rules that should be applied to the request to guarantee
* that it contains the given data type.
* @protected
*/
protected abstract getRules(): ValidationRules | Promise<ValidationRules>
/**
* Validate and get the request input. Throws a validation error on fail.
* Internally, caches the result after the first validation. So, singleton
* validators will avoid re-processing their rules every time.
*/
public async get(): Promise<Valid<T>> {
if ( !this.cachedResult ) {
const validator = <Validator<T>> this.make(Validator, await this.getRules())
this.cachedResult = await validator.validate(this.data.input())
}
return this.cachedResult
}
}

View File

@ -1,109 +0,0 @@
import {Valid, ValidationResult, ValidationRules, ValidatorFunction, ValidatorFunctionParams} from './rules/types'
import {Messages, ErrorWithContext, dataWalkUnsafe, dataSetUnsafe} from '../util'
/**
* An error thrown thrown when an object fails its validation.
*/
export class ValidationError<T> extends ErrorWithContext {
constructor(
/** The original input data. */
public readonly data: unknown,
/** The validator instance used. */
public readonly validator: Validator<T>,
/** Validation error messages, by field. */
public readonly errors: Messages,
) {
super('One or more fields were invalid.', { data,
messages: errors.all() })
}
}
/**
* A class to validate arbitrary data using functional rules.
*/
export class Validator<T> {
constructor(
/** The rules used to validate input objects. */
protected readonly rules: ValidationRules,
) {}
/**
* Attempt to validate the input data.
* If it is valid, it is type aliased as Valid<T>.
* If it is invalid, a ValidationError is thrown.
* @param data
*/
public async validate(data: unknown): Promise<Valid<T>> {
const messages = await this.validateAndGetErrors(data)
if ( messages.any() ) {
throw new ValidationError<T>(data, this, messages)
}
return data as Valid<T>
}
/**
* Returns true if the given data is valid and type aliases it as Valid<T>.
* @param data
*/
public async isValid(data: unknown): Promise<boolean> {
return !(await this.validateAndGetErrors(data)).any()
}
/**
* Apply the validation rules to the data object and return any error messages.
* @param data
* @protected
*/
protected async validateAndGetErrors(data: unknown): Promise<Messages> {
const messages = new Messages()
const params: ValidatorFunctionParams = { data }
for ( const key in this.rules ) {
if ( !Object.prototype.hasOwnProperty.call(this.rules, key) ) {
continue
}
// This walks over all of the values in the data structure using the nested
// key notation. It's not type-safe, but neither is the original input object
// yet, so it's useful here.
for ( const walkEntry of dataWalkUnsafe<any>(data as any, key) ) {
let [entry, dataKey] = walkEntry // eslint-disable-line prefer-const
const rules = (Array.isArray(this.rules[key]) ? this.rules[key] : [this.rules[key]]) as ValidatorFunction[]
for ( const rule of rules ) {
const result: ValidationResult = await rule(dataKey, entry, params)
if ( !result.valid ) {
let errors = ['is invalid']
if ( Array.isArray(result.message) && result.message.length ) {
errors = result.message
} else if ( !Array.isArray(result.message) && result.message ) {
errors = [result.message]
}
for ( const error of errors ) {
if ( !messages.has(dataKey, error) ) {
messages.put(dataKey, error)
}
}
}
if ( result.valid && result.castValue ) {
entry = result.castValue
data = dataSetUnsafe(dataKey, entry, data as any)
}
if ( result.stopValidation ) {
break // move on to the next field
}
}
}
}
return messages
}
}

View File

@ -1,9 +0,0 @@
export * from './rules/types'
export * as Rule from './rules/rules'
export * from './unit/Forms'
export * from './Validator'
export * from './FormRequest'
export * from './middleware'

View File

@ -1,34 +0,0 @@
import {Instantiable} from '../di'
import {FormRequest} from './FormRequest'
import {ValidationError} from './Validator'
import {ResponseObject, RouteHandler} from '../http/routing/Route'
import {Request} from '../http/lifecycle/Request'
/**
* Builds a middleware function that validates a request's input against
* the given form request class and registers the FormRequest class into
* the request container.
*
* @example
* ```typescript
* Route.group(...).pre(formRequest(MyFormRequestClass))
* ```
*
* @param formRequestClass
*/
export function formRequest<T>(formRequestClass: Instantiable<FormRequest<T>>): RouteHandler {
return async function formRequestRouteHandler(request: Request): Promise<ResponseObject> {
const formRequestInstance = <FormRequest<T>> request.make(formRequestClass)
try {
await formRequestInstance.get()
request.registerSingletonInstance<FormRequest<T>>(formRequestClass, formRequestInstance)
} catch (e: unknown) {
if ( e instanceof ValidationError ) {
return e.errors.toJSON()
}
throw e
}
}
}

View File

@ -1,150 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
/** Requires the input value to be an array. */
function is(fieldName: string, inputValue: unknown): ValidationResult {
if ( Array.isArray(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be an array',
}
}
/** Requires the values in the input value array to be distinct. */
function distinct(fieldName: string, inputValue: unknown): ValidationResult {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && (new Set(inputValue)).size === inputValue.length ) {
return { valid: true }
}
return {
valid: false,
message: 'must not contain duplicate values',
}
}
/**
* Builds a validator function that requires the input array to contain the given value.
* @param value
*/
function includes(value: unknown): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.includes(value) ) {
return { valid: true }
}
return {
valid: false,
message: `must include ${value}`,
}
}
}
/**
* Builds a validator function that requires the input array NOT to contain the given value.
* @param value
*/
function excludes(value: unknown): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && !inputValue.includes(value) ) {
return { valid: true }
}
return {
valid: false,
message: `must not include ${value}`,
}
}
}
/**
* Builds a validator function that requires the input array to have exactly `len` many entries.
* @param len
*/
function length(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length === len ) {
return { valid: true }
}
return {
valid: false,
message: `must be exactly of length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input array to have at least `len` many entries.
* @param len
*/
function lengthMin(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length >= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input array to have at most `len` many entries.
* @param len
*/
function lengthMax(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
const arr = is(fieldName, inputValue)
if ( !arr.valid ) {
return arr
}
if ( Array.isArray(inputValue) && inputValue.length <= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most length ${len}`,
}
}
}
export const Arr = {
is,
distinct,
includes,
excludes,
length,
lengthMin,
lengthMax,
}

View File

@ -1,80 +0,0 @@
import {infer as inferUtil} from '../../util'
import {ValidationResult} from './types'
/** Attempt to infer the native type of a string value. */
function infer(fieldName: string, inputValue: unknown): ValidationResult {
return {
valid: true,
castValue: typeof inputValue === 'string' ? inferUtil(inputValue) : inputValue,
}
}
/**
* Casts the input value to a boolean.
* Note that this assumes the value may be boolish. The strings "true", "True",
* "TRUE", and "1" evaluate to `true`, while "false", "False", "FALSE", and "0"
* evaluate to `false`.
* @param fieldName
* @param inputValue
*/
function boolean(fieldName: string, inputValue: unknown): ValidationResult {
let castValue = Boolean(inputValue)
if ( ['true', 'True', 'TRUE', '1'].includes(String(inputValue)) ) {
castValue = true
}
if ( ['false', 'False', 'FALSE', '0'].includes(String(inputValue)) ) {
castValue = false
}
return {
valid: true,
castValue,
}
}
/** Casts the input value to a string. */
function string(fieldName: string, inputValue: unknown): ValidationResult {
return {
valid: true,
castValue: String(inputValue),
}
}
/** Casts the input value to a number, if it is numerical. Fails otherwise. */
function numeric(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseFloat(String(inputValue))) ) {
return {
valid: true,
castValue: parseFloat(String(inputValue)),
}
}
return {
valid: false,
message: 'must be numeric',
}
}
/** Casts the input value to an integer. Fails otherwise. */
function integer(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseInt(String(inputValue), 10)) ) {
return {
valid: true,
castValue: parseInt(String(inputValue), 10),
}
}
return {
valid: false,
message: 'must be an integer',
}
}
export const Cast = {
infer,
boolean,
string,
numeric,
integer,
}

View File

@ -1,210 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
/**
* Builds a validator function that requires the input value to be greater than some value.
* @param value
*/
function greaterThan(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) > value ) {
return { valid: true }
}
return {
valid: false,
message: `must be greater than ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be at least some value.
* @param value
*/
function atLeast(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) >= value ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be less than some value.
* @param value
*/
function lessThan(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) < value ) {
return { valid: true }
}
return {
valid: false,
message: `must be less than ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to be at most some value.
* @param value
*/
function atMost(value: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( Number(inputValue) <= value ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most ${value}`,
}
}
}
/**
* Builds a validator function that requires the input value to have exactly `num` many digits.
* @param num
*/
function digits(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length === num ) {
return { valid: true }
}
return {
valid: false,
message: `must have exactly ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to have at least `num` many digits.
* @param num
*/
function digitsMin(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length >= num ) {
return { valid: true }
}
return {
valid: false,
message: `must have at least ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to have at most `num` many digits.
* @param num
*/
function digitsMax(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).replace('.', '').length <= num ) {
return { valid: true }
}
return {
valid: false,
message: `must have at most ${num} digits`,
}
}
}
/**
* Builds a validator function that requires the input value to end with the given number sequence.
* @param num
*/
function ends(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).endsWith(String(num)) ) {
return { valid: true }
}
return {
valid: false,
message: `must end with "${num}"`,
}
}
}
/**
* Builds a validator function that requires the input value to begin with the given number sequence.
* @param num
*/
function begins(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).startsWith(String(num)) ) {
return { valid: true }
}
return {
valid: false,
message: `must begin with "${num}"`,
}
}
}
/**
* Builds a validator function that requires the input value to be a multiple of the given number.
* @param num
*/
function multipleOf(num: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( parseFloat(String(inputValue)) % num === 0 ) {
return { valid: true }
}
return {
valid: false,
message: `must be a multiple of ${num}`,
}
}
}
/** Requires the input value to be even. */
function even(fieldName: string, inputValue: unknown): ValidationResult {
if ( parseFloat(String(inputValue)) % 2 === 0 ) {
return { valid: true }
}
return {
valid: false,
message: 'must be even',
}
}
/** Requires the input value to be odd. */
function odd(fieldName: string, inputValue: unknown): ValidationResult {
if ( parseFloat(String(inputValue)) % 2 === 0 ) {
return { valid: true }
}
return {
valid: false,
message: 'must be odd',
}
}
export const Num = {
greaterThan,
atLeast,
lessThan,
atMost,
digits,
digitsMin,
digitsMax,
ends,
begins,
multipleOf,
even,
odd,
}

View File

@ -1,191 +0,0 @@
import {ValidationResult, ValidatorFunction} from './types'
import {UniversalPath} from '../../util'
/** Requires the given input value to be some form of affirmative boolean. */
function accepted(fieldName: string, inputValue: unknown): ValidationResult {
if ( ['yes', 'Yes', 'YES', 1, true, 'true', 'True', 'TRUE'].includes(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be accepted',
}
}
/** Requires the given input value to be some form of boolean. */
function boolean(fieldName: string, inputValue: unknown): ValidationResult {
const boolish = ['true', 'True', 'TRUE', '1', 'false', 'False', 'FALSE', '0', true, false, 1, 0]
if ( boolish.includes(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be true or false',
}
}
/** Requires the input value to be of type string. */
function string(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue === 'string' ) {
return { valid: true }
}
return {
valid: false,
message: 'must be a string',
}
}
/** Requires the given input value to be present and non-nullish. */
function required(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue !== 'undefined' && inputValue !== null && inputValue !== '' ) {
return { valid: true }
}
return {
valid: false,
message: 'is required',
stopValidation: true,
}
}
/** Alias of required(). */
function present(fieldName: string, inputValue: unknown): ValidationResult {
return required(fieldName, inputValue)
}
/** Alias of required(). */
function filled(fieldName: string, inputValue: unknown): ValidationResult {
return required(fieldName, inputValue)
}
/** Requires the given input value to be absent or nullish. */
function prohibited(fieldName: string, inputValue: unknown): ValidationResult {
if ( typeof inputValue === 'undefined' || inputValue === null || inputValue === '' ) {
return { valid: true }
}
return {
valid: false,
message: 'is not allowed',
stopValidation: true,
}
}
/** Alias of prohibited(). */
function absent(fieldName: string, inputValue: unknown): ValidationResult {
return prohibited(fieldName, inputValue)
}
/** Alias of prohibited(). */
function empty(fieldName: string, inputValue: unknown): ValidationResult {
return prohibited(fieldName, inputValue)
}
/**
* Builds a validator function that requires the given input to be found in an array of values.
* @param values
*/
function foundIn(values: any[]): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( values.includes(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: `must be one of: ${values.join(', ')}`,
}
}
}
/**
* Builds a validator function that requires the given input NOT to be found in an array of values.
* @param values
*/
function notFoundIn(values: any[]): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( values.includes(inputValue) ) {
return { valid: true }
}
return {
valid: false,
message: `must be one of: ${values.join(', ')}`,
}
}
}
/** Requires the input value to be number-like. */
function numeric(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseFloat(String(inputValue))) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be numeric',
}
}
/** Requires the given input value to be integer-like. */
function integer(fieldName: string, inputValue: unknown): ValidationResult {
if ( !isNaN(parseInt(String(inputValue), 10)) && parseInt(String(inputValue), 10) === parseFloat(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be an integer',
}
}
/** Requires the given input value to be a UniversalPath. */
function file(fieldName: string, inputValue: unknown): ValidationResult {
if ( inputValue instanceof UniversalPath ) {
return { valid: true }
}
return {
valid: false,
message: 'must be a file',
}
}
/**
* A special validator function that marks a field as optional.
* If the value of the field is nullish, no further validation rules will be applied.
* If it is non-nullish, validation will continue.
* @param fieldName
* @param inputValue
*/
function optional(fieldName: string, inputValue: unknown): ValidationResult {
if ( inputValue ?? true ) {
return {
valid: true,
stopValidation: true,
}
}
return { valid: true }
}
export const Is = {
accepted,
boolean,
string,
required,
present,
filled,
prohibited,
absent,
empty,
foundIn,
notFoundIn,
numeric,
integer,
file,
optional,
}

View File

@ -1,31 +0,0 @@
/*
import {Injectable} from '@extollo/di'
import {Validator} from '../Validator'
import {ValidationResult} from "../types";
@Injectable()
export class DateValidator extends Validator {
protected names: string[] = [
'date',
'date.after',
'date.at_least',
'date.before',
'date.at_most',
'date.equals',
'date.format',
]
public matchName(name: string): boolean {
return this.names.includes(name)
}
validate(fieldName: string, inputValue: any, params: { name: string; params: any }): ValidationResult {
switch ( params.name ) {
}
return { valid: false }
}
}
*/

View File

@ -1,5 +0,0 @@
export { Arr } from './arrays'
export { Cast } from './inference'
export { Num } from './numeric'
export { Is } from './presence'
export { Str } from './strings'

View File

@ -1,264 +0,0 @@
import {ValidationResult, ValidatorFunction, ValidatorFunctionParams} from './types'
import {isJSON} from '../../util'
/**
* String-related validation rules.
*/
const regexes: {[key: string]: RegExp} = {
'string.is.alpha': /[a-zA-Z]*/,
'string.is.alpha_num': /[a-zA-Z0-9]*/,
'string.is.alpha_dash': /[a-zA-Z-]*/,
'string.is.alpha_score': /[a-zA-Z_]*/,
'string.is.alpha_num_dash_score': /[a-zA-Z\-_0-9]*/,
'string.is.email': /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)])/, // eslint-disable-line no-control-regex
'string.is.ip': /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/,
'string.is.ip.v4': /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/,
'string.is.ip.v6': /(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))/,
'string.is.mime': /^(?=[-a-z]{1,127}\/[-.a-z0-9]{1,127}$)[a-z]+(-[a-z]+)*\/[a-z0-9]+([-.][a-z0-9]+)*$/,
'string.is.url': /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www\.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w\-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[.!/\\\w]*))?)/,
'string.is.uuid': /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,
}
function validateRex(key: string, inputValue: unknown, message: string): ValidationResult {
if ( regexes[key].test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message,
}
}
/** Requires the input value to be alphabetical characters only. */
function alpha(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha', inputValue, 'must be alphabetical only')
}
/** Requires the input value to be alphanumeric characters only. */
function alphaNum(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_num', inputValue, 'must be alphanumeric only')
}
/** Requires the input value to be alphabetical characters or the "-" character only. */
function alphaDash(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_dash', inputValue, 'must be alphabetical and dashes only')
}
/** Requires the input value to be alphabetical characters or the "_" character only. */
function alphaScore(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_score', inputValue, 'must be alphabetical and underscores only')
}
/** Requires the input value to be alphabetical characters, numeric characters, "-", or "_" only. */
function alphaNumDashScore(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.alpha_num_dash_score', inputValue, 'must be alphanumeric, dashes, and underscores only')
}
/** Requires the input value to be a valid RFC email address format. */
function email(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.email', inputValue, 'must be an email address')
}
/** Requires the input value to be a valid IPv4 or IPv6 address. */
function ip(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip', inputValue, 'must be a valid IP address')
}
/** Requires the input value to be a valid IPv4 address. */
function ipv4(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip.v4', inputValue, 'must be a valid IP version 4 address')
}
/** Requires the input value to be a valid IPv6 address. */
function ipv6(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.ip.v6', inputValue, 'must be a valid IP version 6 address')
}
/** Requires the input value to be a valid file MIME type. */
function mime(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.mime', inputValue, 'must be a valid MIME-type')
}
/** Requires the input value to be a valid RFC URL format. */
function url(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.url', inputValue, 'must be a valid URL')
}
/** Requires the input value to be a valid RFC UUID format. */
function uuid(fieldName: string, inputValue: unknown): ValidationResult {
return validateRex('string.is.uuid', inputValue, 'must be a valid UUID')
}
/**
* Builds a validation function that requires the input value to match the given regex.
* @param rex
*/
function regex(rex: RegExp): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( rex.test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'is not valid',
}
}
}
/**
* Builds a validation function that requires the input to NOT match the given regex.
* @param rex
*/
function notRegex(rex: RegExp): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( !rex.test(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'is not valid',
}
}
}
/**
* Builds a validation function that requires the given input to end with the substring.
* @param substr
*/
function ends(substr: string): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).endsWith(substr) ) {
return { valid: true }
}
return {
valid: false,
message: `must end with "${substr}"`,
}
}
}
/**
* Builds a validation function that requires the given input to begin with the substring.
* @param substr
*/
function begins(substr: string): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).startsWith(substr) ) {
return { valid: true }
}
return {
valid: false,
message: `must begin with "${substr}"`,
}
}
}
/** Requires the input value to be a valid JSON string. */
function json(fieldName: string, inputValue: unknown): ValidationResult {
if ( isJSON(String(inputValue)) ) {
return { valid: true }
}
return {
valid: false,
message: 'must be valid JSON',
}
}
/**
* Builds a validator function that requires the input value to have exactly len many characters.
* @param len
*/
function length(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length === len ) {
return { valid: true }
}
return {
valid: false,
message: `must be exactly of length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input value to have at least len many characters.
* @param len
*/
function lengthMin(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length >= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at least length ${len}`,
}
}
}
/**
* Builds a validator function that requires the input value to have at most len many characters.
* @param len
*/
function lengthMax(len: number): ValidatorFunction {
return (fieldName: string, inputValue: unknown): ValidationResult => {
if ( String(inputValue).length <= len ) {
return { valid: true }
}
return {
valid: false,
message: `must be at most length ${len}`,
}
}
}
/**
* Validator function that requires the input value to match a `${field}Confirm` field's value.
* @param fieldName
* @param inputValue
* @param params
*/
function confirmed(fieldName: string, inputValue: unknown, params: ValidatorFunctionParams): ValidationResult {
const confirmedFieldName = `${fieldName}Confirm`
if ( inputValue === params.data[confirmedFieldName] ) {
return { valid: true }
}
return {
valid: false,
message: `confirmation does not match`,
}
}
export const Str = {
alpha,
alphaNum,
alphaDash,
alphaScore,
alphaNumDashScore,
email,
ip,
ipv4,
ipv6,
mime,
url,
uuid,
regex,
notRegex,
ends,
begins,
json,
length,
lengthMin,
lengthMax,
confirmed,
}

Some files were not shown because too many files have changed in this diff Show More