Compare commits
194 Commits
Author | SHA1 | Date | |
---|---|---|---|
ac6fd0ef1d | |||
9a55623370 | |||
743e81ae94 | |||
61c4d86fff | |||
9aa3f56340 | |||
899c8448fc | |||
7c9b1ff212 | |||
cae9a1acbe | |||
aeb37d711b | |||
d66ae85f54 | |||
68b81106e6 | |||
5395fb9054 | |||
bbafd54dcc | |||
37fcaabdef | |||
bbf2807cfa | |||
2d9f22b895 | |||
0484a586bd | |||
52762bd4a1 | |||
c0595f3ef9 | |||
5557aae543 | |||
f1791b1d76 | |||
a173393697 | |||
c966904418 | |||
f63891ef99 | |||
3d836afa59 | |||
9b47d2ac99 | |||
085fe04f90 | |||
e339ec718d | |||
efb9726470 | |||
8a153e3807 | |||
b8cf8499d2 | |||
710b6cb535 | |||
91d76f44b5 | |||
8774bd8d34 | |||
3712fae979 | |||
4aa33e8dd2 | |||
ef405093dc | |||
fc85c9d2c8 | |||
d00e6a02e2 | |||
ea81a37315 | |||
33a64b99ff | |||
dc663ec8f5 | |||
6476416c67 | |||
4d7769de56 | |||
6ca4bc1151 | |||
7d3fde85eb | |||
111ce0bcf9 | |||
d3a6a8495c | |||
2ff9354538 | |||
cf0ae260dc | |||
fe9170282f | |||
2e43b5bda9 | |||
705bb20db1 | |||
1be73dd347 | |||
afbf6e7682 | |||
48ce1bfa2f | |||
1d717e0eb9 | |||
90b16eef53 | |||
1399399af9 | |||
30a23b1659 | |||
de13030815 | |||
fd77ad5cd3 | |||
814a5763d9 | |||
9ede67cb12 | |||
8a9264b9de | |||
d210cba236 | |||
015d6fd6ae | |||
ce4133ff8e | |||
9d8f43d8fb | |||
940d50b89c | |||
36647a013d | |||
5616b3cc1f | |||
2e7c927114 | |||
8b2ee1c949 | |||
c7557cf5b6 | |||
771fed8002 | |||
7914a8f12e | |||
5fa4f614e2 | |||
ee21811771 | |||
8b9f393405 | |||
f6a7cac05c | |||
25265b5560 | |||
a779ec1d09 | |||
bea48602f5 | |||
445f16d973 | |||
351a2e14b8 | |||
b42e91533a | |||
78cb26fcb2 | |||
514a578260 | |||
3d7d583367 | |||
6f66126d38 | |||
10b3e1ecc3 | |||
795adac68b | |||
ca348b2ff6 | |||
508d92f759 | |||
a590d78155 | |||
dbe48ea8a5 | |||
467721f775 | |||
153f8f7685 | |||
ba87ea32c3 | |||
737d06f6f0 | |||
6ee3e2a729 | |||
1288e51de0 | |||
1fde692a65 | |||
cdecb7e628 | |||
8f08b94f74 | |||
a039b1ff25 | |||
70d67c2730 | |||
0774deea91 | |||
16e5fa00aa | |||
e098a5edb7 | |||
6d1cf18680 | |||
506fb55c74 | |||
cfd555723b | |||
32050cb2ce | |||
dc16dfdb81 | |||
8cf19792a6 | |||
9b8333295f | |||
5ffb91329e | |||
b105a61ca2 | |||
9204a02450 | |||
463076d182 | |||
b5eb407b55 | |||
0ed096c782 | |||
5175d64e36 | |||
bd7d6a2dbd | |||
d251f8bc15 | |||
bf4a675faa | |||
6fc901b3ec | |||
50e0cf3090 | |||
d245d15ad6 | |||
265837b5cd | |||
fe0b4d6d8f | |||
ce1d22ff44 | |||
b7bfb3e153 | |||
e57819d318 | |||
0a9dd30909 | |||
d92c8b5409 | |||
589cb7d579 | |||
3680ad1914 | |||
96e13d85fc | |||
5a9283ad85 | |||
b1ea489ccb | |||
c3f2779650 | |||
248b24e612 | |||
b4a9057e2b | |||
c078d695a8 | |||
55ffadc742 | |||
56574d43ce | |||
e16f02ce12 | |||
c34fad3502 | |||
156006053b | |||
22cf6aa953 | |||
b35eb8d6a1 | |||
9ee4c42e43 | |||
8d1dcc87fb | |||
3efbfecf9d | |||
a1d04d652e | |||
5940b6e2b3 | |||
074a3187eb | |||
26e0444e40 | |||
fcce28081b | |||
e86cf420df | |||
e33d8dee8f | |||
39d97d6e14 | |||
f496046461 | |||
b3b5b169e8 | |||
5d960e6186 | |||
cf6d14abca | |||
faa8a31102 | |||
7506d6567d | |||
a69c81ed35 | |||
36b451c32b | |||
9796a7277e | |||
f00233d49a | |||
91abcdf8ef | |||
c264d45927 | |||
61731c4ebd | |||
dab3d006c8 | |||
cd9bec7c5e | |||
0b86d796e8 | |||
1d5056b753 | |||
82e7a1f299 | |||
4849016784 | |||
0dde436b4c | |||
4d39637f30 | |||
9be9c44a32 | |||
26d54033af | |||
574ddbe9cb | |||
aca4c8aa4d | |||
adf21e67ef | |||
8e65a5f669 | |||
cab2967cf6 | |||
a4edecee00 |
182
.drone.yml
@ -1,143 +1,87 @@
|
|||||||
|
---
|
||||||
kind: pipeline
|
kind: pipeline
|
||||||
name: default
|
type: kubernetes
|
||||||
type: docker
|
name: docs
|
||||||
steps:
|
|
||||||
- name: post build in progress 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} started."
|
|
||||||
when:
|
|
||||||
event: pull_request
|
|
||||||
|
|
||||||
- name: build module
|
metadata:
|
||||||
image: glmdev/node-pnpm:latest
|
labels:
|
||||||
|
pod-security.kubernetes.io/audit: privileged
|
||||||
|
|
||||||
|
services:
|
||||||
|
- name: docker daemon
|
||||||
|
image: docker:dind
|
||||||
|
privileged: true
|
||||||
|
environment:
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
when:
|
||||||
|
event: tag
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: typedoc build
|
||||||
|
image: node:18
|
||||||
commands:
|
commands:
|
||||||
|
- "node -v"
|
||||||
|
- "npm add --global pnpm"
|
||||||
|
- "pnpm --version"
|
||||||
|
- pnpm i
|
||||||
|
- pnpm run docs:build
|
||||||
|
|
||||||
|
- name: container build
|
||||||
|
image: docker:latest
|
||||||
|
privileged: true
|
||||||
|
commands:
|
||||||
|
- "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: k8s rollout
|
||||||
|
image: bitnami/kubectl
|
||||||
|
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 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
|
|
||||||
|
|
||||||
- name: trigger documentation build
|
|
||||||
image: plugins/downstream
|
|
||||||
settings:
|
|
||||||
server: https://ci.garrettmills.dev
|
|
||||||
token:
|
|
||||||
from_secret: drone_token
|
|
||||||
fork: false
|
|
||||||
last_successful: true
|
|
||||||
deploy: production
|
|
||||||
repositories:
|
|
||||||
- Extollo/docs@master
|
|
||||||
when:
|
|
||||||
status: success
|
|
||||||
event: tag
|
|
||||||
|
3
.eslintignore
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
lib
|
||||||
|
dist
|
113
.eslintrc.json
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es2021": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/eslint-recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 12,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"indent": [
|
||||||
|
"error",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"linebreak-style": [
|
||||||
|
"error",
|
||||||
|
"unix"
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
"error",
|
||||||
|
"single",
|
||||||
|
{
|
||||||
|
"allowTemplateLiterals": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
"error",
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"no-console": "error",
|
||||||
|
"curly": "error",
|
||||||
|
"eqeqeq": "error",
|
||||||
|
"guard-for-in": "error",
|
||||||
|
"no-alert": "error",
|
||||||
|
"no-caller": "error",
|
||||||
|
"no-constructor-return": "error",
|
||||||
|
"no-eval": "error",
|
||||||
|
"no-implicit-coercion": "error",
|
||||||
|
"no-implied-eval": "error",
|
||||||
|
"no-invalid-this": "error",
|
||||||
|
"no-return-await": "error",
|
||||||
|
"no-throw-literal": "error",
|
||||||
|
"no-useless-call": "error",
|
||||||
|
"radix": "error",
|
||||||
|
"yoda": "error",
|
||||||
|
"@typescript-eslint/no-shadow": "error",
|
||||||
|
"brace-style": "error",
|
||||||
|
"camelcase": "error",
|
||||||
|
"comma-dangle": [
|
||||||
|
"error",
|
||||||
|
"always-multiline"
|
||||||
|
],
|
||||||
|
"comma-spacing": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"before": false,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comma-style": [
|
||||||
|
"error",
|
||||||
|
"last"
|
||||||
|
],
|
||||||
|
"computed-property-spacing": [
|
||||||
|
"error",
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"eol-last": "error",
|
||||||
|
"func-call-spacing": [
|
||||||
|
"error",
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"keyword-spacing": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"before": true,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"lines-between-class-members": "error",
|
||||||
|
"max-params": [
|
||||||
|
"error",
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"new-parens": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"newline-per-chained-call": "error",
|
||||||
|
"no-trailing-spaces": "error",
|
||||||
|
"no-underscore-dangle": "error",
|
||||||
|
"no-unneeded-ternary": "error",
|
||||||
|
"no-whitespace-before-property": "error",
|
||||||
|
"object-property-newline": "error",
|
||||||
|
"prefer-exponentiation-operator": "error",
|
||||||
|
"prefer-object-spread": "error",
|
||||||
|
"spaced-comment": [
|
||||||
|
"error",
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"prefer-const": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off"
|
||||||
|
}
|
||||||
|
}
|
2
.gitignore
vendored
@ -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
|
||||||
|
55
.idea/codeStyles/Project.xml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<code_scheme name="Project" version="173">
|
||||||
|
<JSCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</JSCodeStyleSettings>
|
||||||
|
<TypeScriptCodeStyleSettings version="0">
|
||||||
|
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
|
||||||
|
<option name="FORCE_SEMICOLON_STYLE" value="true" />
|
||||||
|
<option name="USE_DOUBLE_QUOTES" value="false" />
|
||||||
|
<option name="FORCE_QUOTE_STYlE" value="true" />
|
||||||
|
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
|
||||||
|
<option name="OBJECT_LITERAL_WRAP" value="2" />
|
||||||
|
</TypeScriptCodeStyleSettings>
|
||||||
|
<editorconfig>
|
||||||
|
<option name="ENABLED" value="false" />
|
||||||
|
</editorconfig>
|
||||||
|
<codeStyleSettings language="JavaScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="PHP">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="USE_TAB_CHARACTER" value="true" />
|
||||||
|
<option name="SMART_TABS" value="true" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="Shell Script">
|
||||||
|
<indentOptions>
|
||||||
|
<option name="INDENT_SIZE" value="4" />
|
||||||
|
<option name="TAB_SIZE" value="4" />
|
||||||
|
</indentOptions>
|
||||||
|
</codeStyleSettings>
|
||||||
|
<codeStyleSettings language="TypeScript">
|
||||||
|
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
|
||||||
|
<option name="ALIGN_MULTILINE_FOR" value="false" />
|
||||||
|
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
|
||||||
|
<option name="IF_BRACE_FORCE" value="3" />
|
||||||
|
<option name="DOWHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="WHILE_BRACE_FORCE" value="3" />
|
||||||
|
<option name="FOR_BRACE_FORCE" value="3" />
|
||||||
|
</codeStyleSettings>
|
||||||
|
</code_scheme>
|
||||||
|
</component>
|
5
.idea/codeStyles/codeStyleConfig.xml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<component name="ProjectCodeStyleConfiguration">
|
||||||
|
<state>
|
||||||
|
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||||
|
</state>
|
||||||
|
</component>
|
19
.idea/dataSources.xml
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
|
||||||
|
<driver-ref>mongo</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
|
||||||
|
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -4,5 +4,6 @@
|
|||||||
<content url="file://$MODULE_DIR$" />
|
<content url="file://$MODULE_DIR$" />
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
<orderEntry type="module" module-name="extollo" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
@ -2,6 +2,7 @@
|
|||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="ProjectModuleManager">
|
<component name="ProjectModuleManager">
|
||||||
<modules>
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/../app/.idea/extollo.iml" filepath="$PROJECT_DIR$/../app/.idea/extollo.iml" />
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
<module fileurl="file://$PROJECT_DIR$/.idea/lib.iml" filepath="$PROJECT_DIR$/.idea/lib.iml" />
|
||||||
</modules>
|
</modules>
|
||||||
</component>
|
</component>
|
||||||
|
6
.idea/vcs.xml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
1
docs/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
www*
|
4
docs/Dockerfile
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
FROM joseluisq/static-web-server:2
|
||||||
|
|
||||||
|
COPY ./www /public
|
||||||
|
|
31
docs/HOME.md
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<center>
|
||||||
|
<br>
|
||||||
|
<img alt="The Extollo logo" src="https://static.garrettmills.dev/sites/extollo/docs/assets/logo/svg/Extollo-Icon-and-Text-LIGHT-Final.svg" height="150">
|
||||||
|
<br><br>
|
||||||
|
<b>extollo</b> - (v. <em>latin</em>) - to lift up, to elevate
|
||||||
|
<br><br>
|
||||||
|
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
|
||||||
|
</center>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
Built on principles of modularity, strict-typing, inversion-of-control, and developer ergonomics, Extollo enables developers to build maintainable, scalable, and expressive applications.
|
||||||
|
|
||||||
|
Node.js provides an excellent platform for quickly getting an application up and running, but this loose minimalism can lead to larger, more unweildy code-bases as your application grows. Extollo fixes this by providing an opinionated, robust framework and first-party modules that provide, among other things:
|
||||||
|
|
||||||
|
- Type-based dependency injection
|
||||||
|
- Strongly-typed ORM with an expressive query-builder and models
|
||||||
|
- Customizable session & caching interfaces
|
||||||
|
- Modular, pre-compiled, nest-able routes
|
||||||
|
- First-party, extensible command line tools
|
||||||
|
- Unit-based application structure
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
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](pages/Getting-Started.html) page site for more information.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
Have an improvement or fix to Extollo? Contributors are always welcome. See the CONTRIBUTING.md file for next steps.
|
5
docs/deploy/00-namespace.yaml
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: extollo
|
23
docs/deploy/10-deployment.yaml
Normal 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
|
25
docs/deploy/15-service.yaml
Normal 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
|
13
docs/deploy/20-certificate.yaml
Normal 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
|
25
docs/deploy/20-ingress.yaml
Normal 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
|
0
docs/pages/About-Extollo.md
Normal file
6
docs/pages/Getting-Started.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Node.js v14 or later
|
||||||
|
- [PNPM](https://pnpm.js.org/) (not NPM/Yarn)
|
||||||
|
- Postgres credentials (if you want to use [@extollo/orm](../modules/orm_src.html))
|
6
docs/sourcefile-map.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"pattern": "^",
|
||||||
|
"replace": "https://code.garrettmills.dev/extollo/lib/src/branch/master/"
|
||||||
|
}
|
||||||
|
]
|
BIN
docs/static/favicon.ico
vendored
Normal file
After Width: | Height: | Size: 206 KiB |
19
docs/static/humans.txt
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
/* PROJECT */
|
||||||
|
|
||||||
|
Site Name: The Extollo Framework
|
||||||
|
Site URL: https://extollo.garrettmills.dev/
|
||||||
|
Created: 2021/03/24
|
||||||
|
Standards: HTML5, CSS3
|
||||||
|
Software: TypeDoc
|
||||||
|
|
||||||
|
/* AUTHOR */
|
||||||
|
|
||||||
|
Name: Garrett Mills
|
||||||
|
Location: Lawrence, Kansas
|
||||||
|
Site: https://garrettmills.dev/
|
||||||
|
Blog: https://garrettmills.dev/blog/
|
||||||
|
Contact: https://garrettmills.dev/#contact
|
||||||
|
|
||||||
|
/* THANKS */
|
||||||
|
|
||||||
|
To Piper Mills for the excellent font, color, and logo design.
|
2727
docs/theme/assets/css/main.css
vendored
Normal file
64
docs/theme/assets/css/pages.css
vendored
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
h2 code {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 code {
|
||||||
|
font-size: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation.primary ul {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation.primary li {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.label.pp-nav.pp-group:first-child span {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.label.pp-nav.pp-group {
|
||||||
|
font-weight: 700;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.label.pp-nav.pp-group span {
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.pp-nav.pp-page.current {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
border-left: 2px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.pp-nav.pp-page.current a {
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.pp-nav.pp-page.pp-parent.pp-active {
|
||||||
|
border-left: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.pp-nav.pp-page.pp-child {
|
||||||
|
border-left: 2px solid #eee;
|
||||||
|
padding-left: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-navigation li.pp-nav.pp-page.pp-child.current {
|
||||||
|
border-left: 2px solid #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tsd-kind-page .tsd-kind-icon:before {
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: middle;
|
||||||
|
height: 16px;
|
||||||
|
width: 16px;
|
||||||
|
content: "";
|
||||||
|
background-image: url("../images/page-icon.svg");
|
||||||
|
background-size: 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tsd-search .results span.parent {
|
||||||
|
color: #b3b2b2 !important;
|
||||||
|
}
|
BIN
docs/theme/assets/font/Extatica-Bold.otf
vendored
Normal file
BIN
docs/theme/assets/font/Extatica-Regular.otf
vendored
Normal file
BIN
docs/theme/assets/images/icons.png
vendored
Normal file
After Width: | Height: | Size: 9.4 KiB |
BIN
docs/theme/assets/images/icons@2x.png
vendored
Normal file
After Width: | Height: | Size: 28 KiB |
1
docs/theme/assets/images/page-icon.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#AA43FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>
|
After Width: | Height: | Size: 468 B |
BIN
docs/theme/assets/images/widgets.png
vendored
Normal file
After Width: | Height: | Size: 480 B |
BIN
docs/theme/assets/images/widgets@2x.png
vendored
Normal file
After Width: | Height: | Size: 855 B |
1
docs/theme/assets/js/main.js
vendored
Normal file
BIN
docs/theme/assets/logo/png/Extollo-Icon-NO-TEXT-light-and-dark-Final.png
vendored
Normal file
After Width: | Height: | Size: 9.3 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Icon-and-Text-DARK-Final.png
vendored
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Icon-and-Text-LIGHT-Final.png
vendored
Normal file
After Width: | Height: | Size: 11 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Text-NO-ICON-Dark-final.png
vendored
Normal file
After Width: | Height: | Size: 4.8 KiB |
BIN
docs/theme/assets/logo/png/Extollo-Text-NO-ICON-Light-final.png
vendored
Normal file
After Width: | Height: | Size: 4.6 KiB |
1
docs/theme/assets/logo/svg/Extollo-Icon-NO-TEXT-light-and-dark-Final.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 336.67 432"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="39.28 359.8 202.72 4.89 336.67 303.76 280.63 303.76 194.94 129.42 98.43 359.8 39.28 359.8"/><polygon class="cls-2" points="335.04 310.28 308.57 366.31 252.54 366.31 279 310.28 335.04 310.28"/><polygon class="cls-3" points="246.02 363.06 272.49 307.02 194.94 145.7 165.56 215.83 246.02 363.06"/><polygon class="cls-3" points="194.58 0 140.1 0 0 298.88 31.13 354.92 194.58 0"/><ellipse class="cls-4" cx="170.63" cy="420.6" rx="122.95" ry="11.4"/></svg>
|
After Width: | Height: | Size: 691 B |
1
docs/theme/assets/logo/svg/Extollo-Icon-and-Text-DARK-Final.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 646.49 291.04"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.46 242.4 136.57 3.29 226.81 204.64 189.06 204.64 131.33 87.19 66.31 242.4 26.46 242.4"/><polygon class="cls-2" points="225.72 209.03 207.89 246.79 170.13 246.79 187.96 209.03 225.72 209.03"/><polygon class="cls-3" points="165.75 244.59 183.57 206.84 131.33 98.16 111.54 145.41 165.75 244.59"/><polygon class="cls-3" points="131.09 0 94.38 0 0 201.35 20.97 239.11 131.09 0"/><ellipse class="cls-4" cx="114.95" cy="283.36" rx="82.83" ry="7.68"/><path class="cls-2" d="M290.79,131.24H344v14.32h-35.6v27.38h32.06V187.4H308.37v28.79H344v14.33H290.79Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M353.19,220.59l15.46-27-13.9-24.11V159.6h13.33l14.75,27.66,14.75-27.66h13.33v9.93L397,193.64l15.46,27v9.93H399.14L382.83,200l-16.31,30.49H353.19Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M426.93,172.94h-9.5V159.6h9.5V144h19.15L438,159.6h20v13.34H443.39V211.8a5.11,5.11,0,0,0,5.39,5.39H458v13.33H445.66q-8.37,0-13.55-5.18t-5.18-13.54Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M475,167.05q8.65-8.73,23-8.72,14.17,0,22.83,8.72T529.48,190v10.21q0,14.18-8.66,22.9T498,231.79q-14.33,0-23-8.72t-8.65-22.9V190Q466.36,175.77,475,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.83,14.83,0,0,0,513,202.44V187.69q0-6.81-4.11-10.93T498,172.65a15,15,0,0,0-11,4.11q-4.19,4.13-4.19,10.93v14.75A14.69,14.69,0,0,0,487,213.29Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M541.53,127.69H558V230.52H541.53Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M573,127.69h16.45V230.52H573Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M610,167.05q8.64-8.73,23-8.72t22.84,8.72q8.65,8.72,8.65,22.91v10.21q0,14.18-8.65,22.9T633,231.79q-14.32,0-23-8.72t-8.65-22.9V190Q601.38,175.77,610,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.8,14.8,0,0,0,648,202.44V187.69q0-6.81-4.12-10.93T633,172.65a15,15,0,0,0-11,4.11q-4.18,4.13-4.18,10.93v14.75A14.68,14.68,0,0,0,622,213.29Z" transform="translate(-18 -34.48)"/></svg>
|
After Width: | Height: | Size: 2.2 KiB |
1
docs/theme/assets/logo/svg/Extollo-Icon-and-Text-LIGHT-Final.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 648 290.82"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.44 242.22 136.47 3.29 226.64 204.49 188.92 204.49 131.23 87.12 66.26 242.22 26.44 242.22"/><polygon class="cls-2" points="225.54 208.88 207.73 246.6 170 246.6 187.82 208.88 225.54 208.88"/><polygon class="cls-3" points="165.62 244.41 183.43 206.68 131.23 98.09 111.45 145.3 165.62 244.41"/><polygon class="cls-3" points="130.99 0 94.31 0 0 201.2 20.96 238.93 130.99 0"/><ellipse class="cls-4" cx="114.87" cy="283.14" rx="82.77" ry="7.67"/><path class="cls-3" d="M292.58,131.27h53.14v14.32H310.15v27.35h32V187.4h-32v28.77h35.57v14.31H292.58Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M354.93,220.56l15.45-26.93-13.89-24.09v-9.92h13.32l14.74,27.64,14.74-27.64h13.32v9.92l-13.89,24.09,15.45,26.93v9.92H400.85L384.55,200l-16.3,30.47H354.93Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M428.62,172.94h-9.49V159.62h9.49V144h19.14l-8.08,15.59h20v13.32h-14.6v38.83a5.11,5.11,0,0,0,5.39,5.39h9.21v13.32H447.33q-8.35,0-13.53-5.17t-5.18-13.54Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M476.67,167.06q8.64-8.72,22.95-8.72t22.82,8.72q8.65,8.72,8.65,22.89v10.2q0,14.17-8.65,22.89t-22.82,8.72q-14.31,0-22.95-8.72T468,200.15V190Q468,175.78,476.67,167.06Zm12,46.2a14.84,14.84,0,0,0,11,4.18q6.81,0,10.92-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.92-4.11a15,15,0,0,0-11,4.11q-4.18,4.11-4.18,10.91v14.74A14.66,14.66,0,0,0,488.64,213.26Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M543.13,127.73h16.44V230.48H543.13Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M574.59,127.73H591V230.48H574.59Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M611.58,167.06q8.64-8.72,23-8.72t22.81,8.72Q666,175.78,666,190v10.2q0,14.17-8.65,22.89t-22.81,8.72q-14.32,0-23-8.72t-8.65-22.89V190Q602.93,175.78,611.58,167.06Zm12,46.2a14.85,14.85,0,0,0,11,4.18q6.8,0,10.91-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.91-4.11a15,15,0,0,0-11,4.11q-4.17,4.11-4.18,10.91v14.74A14.7,14.7,0,0,0,623.55,213.26Z" transform="translate(-18 -34.59)"/></svg>
|
After Width: | Height: | Size: 2.2 KiB |
1
docs/theme/assets/logo/svg/Extollo-Text-NO-ICON-Dark-final.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#49686a;opacity:0.75;}.cls-2{fill:#f2c373;}</style></defs><ellipse class="cls-1" cx="214.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M5,9H66.44V25.53H25.29V57.17H62.34V73.89H25.29v33.29H66.44v16.56H5Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M77.09,112.26,95,81.11,78.9,53.23V41.76H94.31l17,32,17-32h15.41V53.23L127.75,81.11l17.88,31.15v11.48H130.21L111.36,88.49,92.5,123.74H77.09Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M162.34,57.17h-11V41.76h11v-18h22.14l-9.35,18h23.12V57.17H181.36v44.92a5.9,5.9,0,0,0,6.23,6.23h10.66v15.42H184q-9.68,0-15.66-6t-6-15.66Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M217.92,50.36q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.4,10.08q-16.56,0-26.56-10.08t-10-26.48V76.84Q207.92,60.44,217.92,50.36Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.62t-12.63-4.76q-7.86,0-12.7,4.76t-4.84,12.62v17A16.93,16.93,0,0,0,231.78,103.81Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M294.81,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M331.21,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M374,50.36q10-10.08,26.56-10.08,16.4,0,26.39,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.39,10.08q-16.56,0-26.56-10.08T364,88.65V76.84Q364,60.44,374,50.36Zm13.85,53.45q4.84,4.84,12.71,4.84t12.62-4.84q4.76-4.83,4.76-12.54v-17q0-7.87-4.76-12.62t-12.62-4.76q-7.87,0-12.71,4.76T383,74.22v17A17,17,0,0,0,387.85,103.81Z" transform="translate(-4.96 -4.87)"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
docs/theme/assets/logo/svg/Extollo-Text-NO-ICON-Light-final.svg
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#ffe293;}.cls-2{fill:#2e5252;}</style></defs><ellipse class="cls-1" cx="213.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M3.6,9H65.08V25.56H23.93V57.21H61V73.93h-37v33.28H65.08v16.56H3.6Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M75.73,112.3,93.61,81.14,77.54,53.27V41.79H93l17,32,17-32h15.42V53.27L126.4,81.14l17.87,31.16v11.47H128.86L110,88.52,91.15,123.77H75.73Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M161,57.21H150V41.79h11v-18h22.13l-9.34,18h23.11V57.21H180v44.92a5.9,5.9,0,0,0,6.23,6.23h10.65v15.41H182.63q-9.67,0-15.66-6t-6-15.66Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M216.57,50.4q10-10.08,26.56-10.08,16.39,0,26.39,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.39,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q206.56,60.49,216.57,50.4Zm13.85,53.45q4.83,4.84,12.71,4.84t12.62-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.63t-12.62-4.75q-7.87,0-12.71,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,230.42,103.85Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M293.45,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M329.85,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M372.64,50.4q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.4,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q362.64,60.49,372.64,50.4Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.76-4.83,4.75-12.54v-17q0-7.87-4.75-12.63T399.2,56.88q-7.87,0-12.7,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,386.5,103.85Z" transform="translate(-3.6 -4.9)"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
51
docs/theme/layouts/default.hbs
vendored
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html class="default no-js">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<title>{{#ifCond model.name '==' project.name}}{{project.name}}{{else}}{{model.name}} | {{project.name}}{{/ifCond}}</title>
|
||||||
|
<meta name="description" content="Documentation for {{project.name}}">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{{relativeURL "assets/css/main.css"}}">
|
||||||
|
<link rel="author" href="{{relativeURL "humans.txt"}}">
|
||||||
|
<script async src="{{relativeURL "assets/js/search.js"}}" id="search-script"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{{> header}}
|
||||||
|
|
||||||
|
<div class="container container-main">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-8 col-content">
|
||||||
|
{{{contents}}}
|
||||||
|
</div>
|
||||||
|
<div class="col-4 col-menu menu-sticky-wrap menu-highlight">
|
||||||
|
<nav class="tsd-navigation primary">
|
||||||
|
<ul>
|
||||||
|
{{#each navigation.children}}
|
||||||
|
{{> navigation}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav class="tsd-navigation secondary menu-sticky">
|
||||||
|
<ul class="before-current">
|
||||||
|
{{#each toc.children}}
|
||||||
|
{{> toc.root}}
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{> footer}}
|
||||||
|
|
||||||
|
<div class="overlay"></div>
|
||||||
|
<script src="{{relativeURL "assets/js/main.js"}}"></script>
|
||||||
|
|
||||||
|
{{> analytics}}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
35
docs/theme/partials/footer.hbs
vendored
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
|
||||||
|
<footer>
|
||||||
|
<div class="container">
|
||||||
|
<h2>Legend</h2>
|
||||||
|
<div class="tsd-legend-group">
|
||||||
|
{{#each legend}}
|
||||||
|
<ul class="tsd-legend">
|
||||||
|
{{#each .}}
|
||||||
|
<li class="{{#compact}}{{#each classes}} {{.}}{{/each}}{{/compact}}"><span class="tsd-kind-icon">{{name}}</span></li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{{#unless settings.hideGenerator}}
|
||||||
|
<div class="tsd-generator extollo-end-cap">
|
||||||
|
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-and-Text-DARK-Final.svg"}}" style="max-height: 100px" class="svg-filter-white" alt="Extollo Logo">
|
||||||
|
<p><b>extollo</b> (v. <em>latin</em>) - to lift up, to elevate</p>
|
||||||
|
<p>
|
||||||
|
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
|
||||||
|
</p>
|
||||||
|
<p class="list-of-links">
|
||||||
|
<ul>
|
||||||
|
<li><a href="{{relativeURL "/"}}">Home</a></li>
|
||||||
|
<li><a href="{{relativeURL "pages/Documentation/Getting-Started.html"}}">Getting Started</a></li>
|
||||||
|
<li><a href="{{relativeURL "pages/Documentation/About-Extollo.html"}}">About Extollo</a></li>
|
||||||
|
<li><a href="https://code.garrettmills.dev/Extollo" target="_blank">Source Code</a></li>
|
||||||
|
<li><a href="https://code.garrettmills.dev/extollo/extollo/src/branch/master/CONTRIBUTING.md" target="_blank">Contributing</a></li>
|
||||||
|
<li><a href="https://code.garrettmills.dev/Extollo/docs" target="_blank">Build These Docs</a></li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{/unless}}
|
71
docs/theme/partials/header.hbs
vendored
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<header>
|
||||||
|
<div class="tsd-page-toolbar">
|
||||||
|
<div class="container">
|
||||||
|
<div class="table-wrap">
|
||||||
|
<div class="table-cell" id="tsd-search" data-index="{{relativeURL "assets/js/search.json"}}" data-base="{{relativeURL "./"}}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="tsd-search-field" class="tsd-widget search no-caption">Search</label>
|
||||||
|
<input id="tsd-search-field" type="text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="results">
|
||||||
|
<li class="state loading">Preparing search index...</li>
|
||||||
|
<li class="state failure">The search index is not available</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-NO-TEXT-light-and-dark-Final.svg"}}" alt="Extollo Icon" class="token-logo" style="max-height: 30px; margin-bottom: -10px; padding-right: 10px;">
|
||||||
|
<a href="{{relativeURL "index.html"}}" class="title">{{project.name}}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-cell" id="tsd-widgets">
|
||||||
|
<div id="tsd-filter">
|
||||||
|
<a href="#" class="tsd-widget options no-caption" data-toggle="options">Options</a>
|
||||||
|
<div class="tsd-filter-group">
|
||||||
|
<div class="tsd-select" id="tsd-filter-visibility">
|
||||||
|
<span class="tsd-select-label">All</span>
|
||||||
|
<ul class="tsd-select-list">
|
||||||
|
<li data-value="public">Public</li>
|
||||||
|
<li data-value="protected">Public/Protected</li>
|
||||||
|
<li data-value="private" class="selected">All</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="checkbox" id="tsd-filter-inherited" checked />
|
||||||
|
<label class="tsd-widget" for="tsd-filter-inherited">Inherited</label>
|
||||||
|
|
||||||
|
{{#unless settings.excludeExternals}}
|
||||||
|
<input type="checkbox" id="tsd-filter-externals" checked />
|
||||||
|
<label class="tsd-widget" for="tsd-filter-externals">Externals</label>
|
||||||
|
{{/unless}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="#" class="tsd-widget menu no-caption" data-toggle="menu">Menu</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tsd-page-title">
|
||||||
|
<div class="container">
|
||||||
|
{{#if model.parent}} {{! Don't show breadcrumbs on main project page, it is the root page. !}}
|
||||||
|
<ul class="tsd-breadcrumb">
|
||||||
|
{{#with model}}{{> breadcrumb}}{{/with}}
|
||||||
|
</ul>
|
||||||
|
{{/if}}
|
||||||
|
<h1>{{#compact}}
|
||||||
|
{{#ifCond model.kindString "!==" "Project" }}
|
||||||
|
{{model.kindString}}
|
||||||
|
{{/ifCond}}
|
||||||
|
{{model.name}}
|
||||||
|
{{#if model.typeParameters}}
|
||||||
|
<
|
||||||
|
{{#each model.typeParameters}}
|
||||||
|
{{#if @index}}, {{/if}}
|
||||||
|
{{name}}
|
||||||
|
{{/each}}
|
||||||
|
>
|
||||||
|
{{/if}}
|
||||||
|
{{/compact}}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
3
docs/theme/templates/markdown-page.hbs
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<div class="tsd-panel tsd-typography">
|
||||||
|
{{#markdown}}{{{model.pagesPlugin.item.contents}}}{{/markdown}}
|
||||||
|
</div>
|
444
package-lock.json
generated
@ -1,444 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@extollo/lib",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"requires": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/helper-validator-identifier": {
|
|
||||||
"version": "7.12.11",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz",
|
|
||||||
"integrity": "sha512-np/lG3uARFybkoHokJUmf1QfEvRVCPbmQeUQpKow5cQ3xWrV9i3rUHodKDJPQfTVX61qKi+UdYk8kik84n7XOw=="
|
|
||||||
},
|
|
||||||
"@babel/parser": {
|
|
||||||
"version": "7.13.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.13.9.tgz",
|
|
||||||
"integrity": "sha512-nEUfRiARCcaVo3ny3ZQjURjHQZUo/JkEw7rLlSZy/psWGnvwXFtPcr6jb7Yb41DVW5LTe6KRq9LGleRNsg1Frw=="
|
|
||||||
},
|
|
||||||
"@babel/types": {
|
|
||||||
"version": "7.13.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.13.0.tgz",
|
|
||||||
"integrity": "sha512-hE+HE8rnG1Z6Wzo+MhaKE5lM5eMx71T4EHJgku2E3xIfaULhDcxiiRxUYgwX8qwP1BBSlag+TdGOt6JAidIZTA==",
|
|
||||||
"requires": {
|
|
||||||
"@babel/helper-validator-identifier": "^7.12.11",
|
|
||||||
"lodash": "^4.17.19",
|
|
||||||
"to-fast-properties": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@extollo/di": {
|
|
||||||
"version": "file:../di",
|
|
||||||
"requires": {
|
|
||||||
"@extollo/util": "file:../util",
|
|
||||||
"reflect-metadata": "^0.1.13",
|
|
||||||
"typescript": "^4.1.3"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@extollo/util": {
|
|
||||||
"version": "file:../util",
|
|
||||||
"requires": {
|
|
||||||
"@types/node": "^14.14.20",
|
|
||||||
"@types/uuid": "^8.3.0",
|
|
||||||
"colors": "^1.4.0",
|
|
||||||
"typescript": "^4.1.3",
|
|
||||||
"uuid": "^8.3.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": {
|
|
||||||
"version": "14.14.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
|
|
||||||
"integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
|
|
||||||
},
|
|
||||||
"@types/uuid": {
|
|
||||||
"version": "8.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
|
|
||||||
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ=="
|
|
||||||
},
|
|
||||||
"colors": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg=="
|
|
||||||
},
|
|
||||||
"uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"reflect-metadata": {
|
|
||||||
"version": "0.1.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
|
|
||||||
"integrity": "sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg=="
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@extollo/util": {
|
|
||||||
"version": "file:../util",
|
|
||||||
"requires": {
|
|
||||||
"@types/node": "^14.14.20",
|
|
||||||
"@types/uuid": "^8.3.0",
|
|
||||||
"colors": "^1.4.0",
|
|
||||||
"typescript": "^4.1.3",
|
|
||||||
"uuid": "^8.3.2"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@types/node": {
|
|
||||||
"version": "14.14.22",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
|
|
||||||
"integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
|
|
||||||
},
|
|
||||||
"@types/uuid": {
|
|
||||||
"version": "8.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.0.tgz",
|
|
||||||
"integrity": "sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ=="
|
|
||||||
},
|
|
||||||
"colors": {
|
|
||||||
"version": "1.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
|
|
||||||
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg=="
|
|
||||||
},
|
|
||||||
"uuid": {
|
|
||||||
"version": "8.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
|
||||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"@types/negotiator": {
|
|
||||||
"version": "0.6.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/negotiator/-/negotiator-0.6.1.tgz",
|
|
||||||
"integrity": "sha512-c4mvXFByghezQ/eVGN5HvH/jI63vm3B7FiE81BUzDAWmuiohRecCO6ddU60dfq29oKUMiQujsoB2h0JQC7JHKA=="
|
|
||||||
},
|
|
||||||
"@types/pug": {
|
|
||||||
"version": "2.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.4.tgz",
|
|
||||||
"integrity": "sha1-h3L80EGOPNLMFxVV1zAHQVBR9LI="
|
|
||||||
},
|
|
||||||
"acorn": {
|
|
||||||
"version": "7.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
|
|
||||||
"integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="
|
|
||||||
},
|
|
||||||
"asap": {
|
|
||||||
"version": "2.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
|
||||||
"integrity": "sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY="
|
|
||||||
},
|
|
||||||
"assert-never": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-TaTivMB6pYI1kXwrFlEhLeGfOqoDNdTxjCdwRfFFkEA30Eu+k48W34nlok2EYWJfFFzqaEmichdNM7th6M5HNw=="
|
|
||||||
},
|
|
||||||
"babel-walk": {
|
|
||||||
"version": "3.0.0-canary-5",
|
|
||||||
"resolved": "https://registry.npmjs.org/babel-walk/-/babel-walk-3.0.0-canary-5.tgz",
|
|
||||||
"integrity": "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==",
|
|
||||||
"requires": {
|
|
||||||
"@babel/types": "^7.9.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"call-bind": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
|
|
||||||
"requires": {
|
|
||||||
"function-bind": "^1.1.1",
|
|
||||||
"get-intrinsic": "^1.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"character-parser": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/character-parser/-/character-parser-2.2.0.tgz",
|
|
||||||
"integrity": "sha1-x84o821LzZdE5f/CxfzeHHMmH8A=",
|
|
||||||
"requires": {
|
|
||||||
"is-regex": "^1.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"constantinople": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/constantinople/-/constantinople-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==",
|
|
||||||
"requires": {
|
|
||||||
"@babel/parser": "^7.6.0",
|
|
||||||
"@babel/types": "^7.6.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"doctypes": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/doctypes/-/doctypes-1.1.0.tgz",
|
|
||||||
"integrity": "sha1-6oCxBqh1OHdOijpKWv4pPeSJ4Kk="
|
|
||||||
},
|
|
||||||
"dotenv": {
|
|
||||||
"version": "8.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
|
|
||||||
"integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw=="
|
|
||||||
},
|
|
||||||
"function-bind": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
|
|
||||||
},
|
|
||||||
"get-intrinsic": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
|
|
||||||
"requires": {
|
|
||||||
"function-bind": "^1.1.1",
|
|
||||||
"has": "^1.0.3",
|
|
||||||
"has-symbols": "^1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"has": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
|
|
||||||
"requires": {
|
|
||||||
"function-bind": "^1.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"has-symbols": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw=="
|
|
||||||
},
|
|
||||||
"is-core-module": {
|
|
||||||
"version": "2.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.2.0.tgz",
|
|
||||||
"integrity": "sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==",
|
|
||||||
"requires": {
|
|
||||||
"has": "^1.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"is-expression": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-expression/-/is-expression-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==",
|
|
||||||
"requires": {
|
|
||||||
"acorn": "^7.1.1",
|
|
||||||
"object-assign": "^4.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"is-promise": {
|
|
||||||
"version": "2.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
|
|
||||||
"integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="
|
|
||||||
},
|
|
||||||
"is-regex": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-axvdhb5pdhEVThqJzYXwMlVuZwC+FF2DpcOhTS+y/8jVq4trxyPgfcwIxIKiyeuLlSQYKkmUaPQJ8ZE4yNKXDg==",
|
|
||||||
"requires": {
|
|
||||||
"call-bind": "^1.0.2",
|
|
||||||
"has-symbols": "^1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"js-stringify": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
|
|
||||||
"integrity": "sha1-Fzb939lyTyijaCrcYjCufk6Weds="
|
|
||||||
},
|
|
||||||
"jstransformer": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/jstransformer/-/jstransformer-1.0.0.tgz",
|
|
||||||
"integrity": "sha1-7Yvwkh4vPx7U1cGkT2hwntJHIsM=",
|
|
||||||
"requires": {
|
|
||||||
"is-promise": "^2.0.0",
|
|
||||||
"promise": "^7.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lodash": {
|
|
||||||
"version": "4.17.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
|
||||||
},
|
|
||||||
"negotiator": {
|
|
||||||
"version": "0.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.2.tgz",
|
|
||||||
"integrity": "sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw=="
|
|
||||||
},
|
|
||||||
"object-assign": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
|
||||||
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
|
|
||||||
},
|
|
||||||
"path-parse": {
|
|
||||||
"version": "1.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",
|
|
||||||
"integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="
|
|
||||||
},
|
|
||||||
"promise": {
|
|
||||||
"version": "7.3.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz",
|
|
||||||
"integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==",
|
|
||||||
"requires": {
|
|
||||||
"asap": "~2.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug/-/pug-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw==",
|
|
||||||
"requires": {
|
|
||||||
"pug-code-gen": "^3.0.2",
|
|
||||||
"pug-filters": "^4.0.0",
|
|
||||||
"pug-lexer": "^5.0.1",
|
|
||||||
"pug-linker": "^4.0.0",
|
|
||||||
"pug-load": "^3.0.0",
|
|
||||||
"pug-parser": "^6.0.0",
|
|
||||||
"pug-runtime": "^3.0.1",
|
|
||||||
"pug-strip-comments": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-attrs": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-attrs/-/pug-attrs-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==",
|
|
||||||
"requires": {
|
|
||||||
"constantinople": "^4.0.1",
|
|
||||||
"js-stringify": "^1.0.2",
|
|
||||||
"pug-runtime": "^3.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-code-gen": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-code-gen/-/pug-code-gen-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-nJMhW16MbiGRiyR4miDTQMRWDgKplnHyeLvioEJYbk1RsPI3FuA3saEP8uwnTb2nTJEKBU90NFVWJBk4OU5qyg==",
|
|
||||||
"requires": {
|
|
||||||
"constantinople": "^4.0.1",
|
|
||||||
"doctypes": "^1.1.0",
|
|
||||||
"js-stringify": "^1.0.2",
|
|
||||||
"pug-attrs": "^3.0.0",
|
|
||||||
"pug-error": "^2.0.0",
|
|
||||||
"pug-runtime": "^3.0.0",
|
|
||||||
"void-elements": "^3.1.0",
|
|
||||||
"with": "^7.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-error": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-error/-/pug-error-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-sjiUsi9M4RAGHktC1drQfCr5C5eriu24Lfbt4s+7SykztEOwVZtbFk1RRq0tzLxcMxMYTBR+zMQaG07J/btayQ=="
|
|
||||||
},
|
|
||||||
"pug-filters": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-filters/-/pug-filters-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==",
|
|
||||||
"requires": {
|
|
||||||
"constantinople": "^4.0.1",
|
|
||||||
"jstransformer": "1.0.0",
|
|
||||||
"pug-error": "^2.0.0",
|
|
||||||
"pug-walk": "^2.0.0",
|
|
||||||
"resolve": "^1.15.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-lexer": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-lexer/-/pug-lexer-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==",
|
|
||||||
"requires": {
|
|
||||||
"character-parser": "^2.2.0",
|
|
||||||
"is-expression": "^4.0.0",
|
|
||||||
"pug-error": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-linker": {
|
|
||||||
"version": "4.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-linker/-/pug-linker-4.0.0.tgz",
|
|
||||||
"integrity": "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==",
|
|
||||||
"requires": {
|
|
||||||
"pug-error": "^2.0.0",
|
|
||||||
"pug-walk": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-load": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-load/-/pug-load-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==",
|
|
||||||
"requires": {
|
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"pug-walk": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-parser": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-parser/-/pug-parser-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==",
|
|
||||||
"requires": {
|
|
||||||
"pug-error": "^2.0.0",
|
|
||||||
"token-stream": "1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-runtime": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-runtime/-/pug-runtime-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg=="
|
|
||||||
},
|
|
||||||
"pug-strip-comments": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-strip-comments/-/pug-strip-comments-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==",
|
|
||||||
"requires": {
|
|
||||||
"pug-error": "^2.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pug-walk": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/pug-walk/-/pug-walk-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="
|
|
||||||
},
|
|
||||||
"resolve": {
|
|
||||||
"version": "1.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.20.0.tgz",
|
|
||||||
"integrity": "sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==",
|
|
||||||
"requires": {
|
|
||||||
"is-core-module": "^2.2.0",
|
|
||||||
"path-parse": "^1.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"to-fast-properties": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
|
|
||||||
"integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
|
|
||||||
},
|
|
||||||
"token-stream": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/token-stream/-/token-stream-1.0.0.tgz",
|
|
||||||
"integrity": "sha1-zCAOqyYT9BZtJ/+a/HylbUnfbrQ="
|
|
||||||
},
|
|
||||||
"typescript": {
|
|
||||||
"version": "4.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.3.tgz",
|
|
||||||
"integrity": "sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg=="
|
|
||||||
},
|
|
||||||
"void-elements": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
|
||||||
"integrity": "sha1-YU9/v42AHwu18GYfWy9XhXUOTwk="
|
|
||||||
},
|
|
||||||
"with": {
|
|
||||||
"version": "7.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/with/-/with-7.0.2.tgz",
|
|
||||||
"integrity": "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==",
|
|
||||||
"requires": {
|
|
||||||
"@babel/parser": "^7.9.6",
|
|
||||||
"@babel/types": "^7.9.6",
|
|
||||||
"assert-never": "^1.2.1",
|
|
||||||
"babel-walk": "3.0.0-canary-5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
87
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@extollo/lib",
|
"name": "@extollo/lib",
|
||||||
"version": "0.1.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,22 +8,58 @@
|
|||||||
"lib": "lib"
|
"lib": "lib"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@extollo/di": "^0.4.4",
|
"@atao60/fse-cli": "^0.1.7",
|
||||||
"@extollo/util": "^0.3.2",
|
"@extollo/ui": "^0.1.0",
|
||||||
|
"@types/bcrypt": "^5.0.0",
|
||||||
|
"@types/busboy": "^0.2.4",
|
||||||
|
"@types/cli-table": "^0.3.1",
|
||||||
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"@types/jsonwebtoken": "^8.5.9",
|
||||||
|
"@types/mime-types": "^2.1.1",
|
||||||
|
"@types/mkdirp": "^1.0.2",
|
||||||
"@types/negotiator": "^0.6.1",
|
"@types/negotiator": "^0.6.1",
|
||||||
"@types/node": "^14.14.37",
|
"@types/node": "^14.18.51",
|
||||||
"@types/pug": "^2.0.4",
|
"@types/pg": "^8.10.2",
|
||||||
|
"@types/pluralize": "^0.0.29",
|
||||||
|
"@types/pug": "^2.0.6",
|
||||||
|
"@types/rimraf": "^3.0.2",
|
||||||
|
"@types/ssh2": "^0.5.52",
|
||||||
|
"@types/uuid": "^8.3.4",
|
||||||
|
"@types/ws": "^8.5.5",
|
||||||
|
"bcrypt": "^5.1.0",
|
||||||
|
"busboy": "^0.3.1",
|
||||||
|
"cli-table": "^0.3.11",
|
||||||
"colors": "^1.4.0",
|
"colors": "^1.4.0",
|
||||||
"dotenv": "^8.2.0",
|
"dotenv": "^8.6.0",
|
||||||
"negotiator": "^0.6.2",
|
"ioredis": "^4.28.5",
|
||||||
|
"jsonwebtoken": "^8.5.1",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"mkdirp": "^1.0.4",
|
||||||
|
"negotiator": "^0.6.3",
|
||||||
|
"node-fetch": "^3.3.1",
|
||||||
|
"pg": "^8.11.0",
|
||||||
|
"pluralize": "^8.0.0",
|
||||||
"pug": "^3.0.2",
|
"pug": "^3.0.2",
|
||||||
"ts-node": "^9.1.1",
|
"reflect-metadata": "^0.1.13",
|
||||||
"typescript": "^4.2.3"
|
"rimraf": "^3.0.2",
|
||||||
|
"sqlite": "^4.2.1",
|
||||||
|
"sqlite3": "^5.1.6",
|
||||||
|
"ssh2": "^1.13.0",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"typescript": "^4.9.5",
|
||||||
|
"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": "tsc",
|
"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",
|
||||||
|
"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:fix": "eslint --fix . --ext .ts"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib/**/*"
|
"lib/**/*"
|
||||||
@ -35,5 +71,30 @@
|
|||||||
"url": "https://code.garrettmills.dev/extollo/lib"
|
"url": "https://code.garrettmills.dev/extollo/lib"
|
||||||
},
|
},
|
||||||
"author": "garrettmills <shout@garrettmills.dev>",
|
"author": "garrettmills <shout@garrettmills.dev>",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@knodes/typedoc-plugin-pages": "^0.23.4",
|
||||||
|
"@types/chai": "^4.3.5",
|
||||||
|
"@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": {
|
||||||
|
"discover": true,
|
||||||
|
"units": {
|
||||||
|
"discover": false
|
||||||
|
},
|
||||||
|
"recursiveDependencies": {
|
||||||
|
"discover": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
17
pagesconfig.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"title": "Documentation",
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"title": "Getting Started",
|
||||||
|
"source": "./docs/pages/Getting-Started.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "About Extollo",
|
||||||
|
"source": "./docs/pages/About-Extollo.md"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
3835
pnpm-lock.yaml
5
src/auth/AuthenticatableAlreadyExistsError.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import {ErrorWithContext} from '../util'
|
||||||
|
|
||||||
|
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
|
||||||
|
|
||||||
|
}
|
86
src/auth/Authentication.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import {Unit} from '../lifecycle/Unit'
|
||||||
|
import {Injectable, Inject, StaticInstantiable} from '../di'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
import {Middlewares} from '../service/Middlewares'
|
||||||
|
import {CanonicalResolver} from '../service/Canonical'
|
||||||
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
|
import {AuthRequiredMiddleware} from './middleware/AuthRequiredMiddleware'
|
||||||
|
import {GuestRequiredMiddleware} from './middleware/GuestRequiredMiddleware'
|
||||||
|
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'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class Authentication extends Unit {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly middleware!: Middlewares
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async up(): Promise<void> {
|
||||||
|
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 ({
|
||||||
|
required: AuthRequiredMiddleware,
|
||||||
|
guest: GuestRequiredMiddleware,
|
||||||
|
web: SessionAuthMiddleware,
|
||||||
|
})[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
11
src/auth/NotAuthorizedError.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import {HTTPError} from '../http/HTTPError'
|
||||||
|
import {HTTPStatus} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error thrown when a user attempts an action that they are not authorized to perform.
|
||||||
|
*/
|
||||||
|
export class NotAuthorizedError extends HTTPError {
|
||||||
|
constructor(message = 'Not Authorized') {
|
||||||
|
super(HTTPStatus.FORBIDDEN, message)
|
||||||
|
}
|
||||||
|
}
|
51
src/auth/config.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import {Instantiable, isInstantiable} from '../di'
|
||||||
|
import {AuthenticatableRepository} from './types'
|
||||||
|
import {hasOwnProperty} from '../util'
|
||||||
|
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
|
||||||
|
import {Middleware} from '../http/routing/Middleware'
|
||||||
|
|
||||||
|
export interface AuthenticationConfig {
|
||||||
|
storage: Instantiable<AuthenticatableRepository>,
|
||||||
|
middleware?: Instantiable<Middleware>,
|
||||||
|
providers?: {
|
||||||
|
[key: string]: {
|
||||||
|
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
|
||||||
|
config: LoginProviderConfig,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
122
src/auth/context/SecurityContext.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
42
src/auth/context/SessionSecurityContext.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
44
src/auth/context/TokenSecurityContext.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
27
src/auth/event/AuthCheckFailed.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
12
src/auth/event/AuthenticationEvent.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
8
src/auth/event/UserAuthenticatedEvent.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a user is authenticated.
|
||||||
|
*/
|
||||||
|
export class UserAuthenticatedEvent extends AuthenticationEvent {
|
||||||
|
public readonly eventName = '@extollo/lib:UserAuthenticatedEvent'
|
||||||
|
}
|
8
src/auth/event/UserAuthenticationResumedEvent.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event raised when a user is re-authenticated to a security context
|
||||||
|
*/
|
||||||
|
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
|
||||||
|
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
|
||||||
|
}
|
8
src/auth/event/UserFlushedEvent.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import {AuthenticationEvent} from './AuthenticationEvent'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event fired when a user is unauthenticated.
|
||||||
|
*/
|
||||||
|
export class UserFlushedEvent extends AuthenticationEvent {
|
||||||
|
public readonly eventName = '@extollo/lib:UserFlushedEvent'
|
||||||
|
}
|
49
src/auth/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
export * from './types'
|
||||||
|
export * from './AuthenticatableAlreadyExistsError'
|
||||||
|
export * from './NotAuthorizedError'
|
||||||
|
export * from './Authentication'
|
||||||
|
export * from './repository/AuthenticatableRepositoryFactory'
|
||||||
|
|
||||||
|
export * from './context/SecurityContext'
|
||||||
|
export * from './context/SessionSecurityContext'
|
||||||
|
export * from './context/TokenSecurityContext'
|
||||||
|
|
||||||
|
export * from './event/AuthenticationEvent'
|
||||||
|
export * from './event/UserAuthenticatedEvent'
|
||||||
|
export * from './event/UserAuthenticationResumedEvent'
|
||||||
|
export * from './event/UserFlushedEvent'
|
||||||
|
export * from './event/AuthCheckFailed'
|
||||||
|
|
||||||
|
export * from './middleware/AuthRequiredMiddleware'
|
||||||
|
export * from './middleware/GuestRequiredMiddleware'
|
||||||
|
export * from './middleware/SessionAuthMiddleware'
|
||||||
|
export * from './middleware/TokenAuthMiddleware'
|
||||||
|
export * from './middleware/ScopeRequiredMiddleware'
|
||||||
|
|
||||||
|
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 './webSocketAuthCheck'
|
||||||
|
|
||||||
|
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'
|
36
src/auth/middleware/AuthRequiredMiddleware.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {Session} from '../../http/session/Session'
|
||||||
|
|
||||||
|
// TODO handle JSON and non-web
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRequiredMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly session!: Session
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
if ( !this.security.hasUser() ) {
|
||||||
|
this.session.set('@extollo:auth.intention', this.request.url)
|
||||||
|
|
||||||
|
if ( this.routing.hasNamedRoute('@auth:login') ) {
|
||||||
|
return redirect(this.routing.getNamedPath('@auth:login').toRemote)
|
||||||
|
} else {
|
||||||
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
src/auth/middleware/GuestRequiredMiddleware.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {Middleware} from '../../http/routing/Middleware'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
import {ResponseObject} from '../../http/routing/Route'
|
||||||
|
import {error} from '../../http/response/ErrorResponseFactory'
|
||||||
|
import {NotAuthorizedError} from '../NotAuthorizedError'
|
||||||
|
import {HTTPStatus} from '../../util'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import {redirect} from '../../http/response/RedirectResponseFactory'
|
||||||
|
|
||||||
|
// TODO handle JSON and non-web
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GuestRequiredMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly security!: SecurityContext
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
if ( this.security.hasUser() ) {
|
||||||
|
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
|
||||||
|
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
|
||||||
|
} else {
|
||||||
|
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
src/auth/middleware/ScopeRequiredMiddleware.ts
Normal 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),
|
||||||
|
)
|
||||||
|
}
|
29
src/auth/middleware/SessionAuthMiddleware.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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 {SessionSecurityContext} from '../context/SessionSecurityContext'
|
||||||
|
import {SecurityContext} from '../context/SecurityContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Injects a SessionSecurityContext into the request and attempts to
|
||||||
|
* resume the user's authentication.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SessionAuthMiddleware extends Middleware {
|
||||||
|
@Inject()
|
||||||
|
protected readonly config!: Config
|
||||||
|
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
async apply(): Promise<ResponseObject> {
|
||||||
|
this.logging.debug('Applying session auth middleware.')
|
||||||
|
const repo = <AuthenticatableRepository> this.make(AuthenticatableRepository)
|
||||||
|
const context = <SessionSecurityContext> this.make(SessionSecurityContext, repo)
|
||||||
|
this.request.registerSingletonInstance(SecurityContext, context)
|
||||||
|
await context.resume()
|
||||||
|
}
|
||||||
|
}
|
45
src/auth/middleware/TokenAuthMiddleware.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
74
src/auth/provider/LoginProvider.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
8
src/auth/provider/basic/BasicLoginAttempt.ts
Normal 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(),
|
||||||
|
})
|
75
src/auth/provider/basic/BasicLoginProvider.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
19
src/auth/provider/basic/BasicRegistrationAttempt.ts
Normal 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),
|
||||||
|
})
|
99
src/auth/provider/oauth/CoreIDLoginProvider.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
98
src/auth/provider/oauth/OAuth2LoginProvider.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/repository/AuthenticatableRepositoryFactory.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
77
src/auth/repository/orm/ORMUser.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import * as bcrypt from 'bcrypt'
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ORMUser extends Model<ORMUser> implements Authenticatable {
|
||||||
|
|
||||||
|
protected static table = 'users'
|
||||||
|
|
||||||
|
protected static key = 'user_id'
|
||||||
|
|
||||||
|
/** The primary key of the user in the table. */
|
||||||
|
@Field(FieldType.serial, 'user_id')
|
||||||
|
public userId!: number
|
||||||
|
|
||||||
|
/** The unique string-identifier of the user. */
|
||||||
|
@Field(FieldType.varchar)
|
||||||
|
public username!: string
|
||||||
|
|
||||||
|
/** The user's first name. */
|
||||||
|
@Field(FieldType.varchar, 'first_name')
|
||||||
|
public firstName?: string
|
||||||
|
|
||||||
|
/** The user's last name. */
|
||||||
|
@Field(FieldType.varchar, 'last_name')
|
||||||
|
public lastName?: string
|
||||||
|
|
||||||
|
/** The hashed and salted password of the user. */
|
||||||
|
@Field(FieldType.varchar, 'password_hash')
|
||||||
|
public passwordHash!: string
|
||||||
|
|
||||||
|
/** Human-readable display name of the user. */
|
||||||
|
getDisplay(): string {
|
||||||
|
if ( 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. */
|
||||||
|
getIdentifier(): AuthenticatableIdentifier {
|
||||||
|
return this.username
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if the provided password is valid for the user. */
|
||||||
|
verifyPassword(password: string): Awaitable<boolean> {
|
||||||
|
return bcrypt.compare(password, this.passwordHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Change the user's password, hashing it. */
|
||||||
|
async setPassword(password: string): Promise<void> {
|
||||||
|
this.passwordHash = await bcrypt.hash(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
validateCredential(credential: string): Awaitable<boolean> {
|
||||||
|
return this.verifyPassword(credential)
|
||||||
|
}
|
||||||
|
|
||||||
|
async dehydrate(): Promise<JSONState> {
|
||||||
|
return this.toQueryRow()
|
||||||
|
}
|
||||||
|
|
||||||
|
async rehydrate(state: JSONState): Promise<void> {
|
||||||
|
await this.assumeFromSource(state)
|
||||||
|
}
|
||||||
|
}
|
51
src/auth/repository/orm/ORMUserRepository.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
54
src/auth/serial/AuthenticationEventSerializer.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
241
src/auth/server/OAuth2Server.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
30
src/auth/server/models/OAuth2TokenModel.ts
Normal 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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/ClientRepositoryFactory.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
22
src/auth/server/repositories/ConfigClientRepository.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
21
src/auth/server/repositories/ConfigScopeRepository.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
94
src/auth/server/repositories/ORMTokenRepository.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/ScopeRepositoryFactory.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
23
src/auth/server/repositories/TokenRepositoryFactory.ts
Normal 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
@ -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>
|
||||||
|
}
|
43
src/auth/types.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import {Awaitable, JSONState, Maybe, Rehydratable} from '../util'
|
||||||
|
|
||||||
|
/** Value that can be used to uniquely identify a user. */
|
||||||
|
export type AuthenticatableIdentifier = string | number
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for entities that can be authenticated.
|
||||||
|
*/
|
||||||
|
export abstract class Authenticatable implements Rehydratable {
|
||||||
|
|
||||||
|
/** Get the globally-unique identifier of the user. */
|
||||||
|
abstract getUniqueIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
|
/** Get the repository-unique identifier of the user. */
|
||||||
|
abstract getIdentifier(): AuthenticatableIdentifier
|
||||||
|
|
||||||
|
/** Get the human-readable identifier of the user. */
|
||||||
|
abstract getDisplay(): string
|
||||||
|
|
||||||
|
/** Attempt to validate a credential of the user. */
|
||||||
|
abstract validateCredential(credential: string): Awaitable<boolean>
|
||||||
|
|
||||||
|
abstract dehydrate(): Promise<JSONState>
|
||||||
|
|
||||||
|
abstract rehydrate(state: JSONState): Awaitable<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for a repository that stores and recalls users.
|
||||||
|
*/
|
||||||
|
export abstract class AuthenticatableRepository {
|
||||||
|
/** Look up the user by their unique identifier. */
|
||||||
|
abstract getByIdentifier(id: AuthenticatableIdentifier): Awaitable<Maybe<Authenticatable>>
|
||||||
|
|
||||||
|
/** Returns true if this repository supports registering users. */
|
||||||
|
abstract supportsRegistration(): boolean
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
}
|
36
src/auth/webSocketAuthCheck.ts
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
489
src/cli/Directive.ts
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
import {Injectable, Inject} from '../di'
|
||||||
|
import {infer, ErrorWithContext} from '../util'
|
||||||
|
import {CLIOption} from './directive/options/CLIOption'
|
||||||
|
import {PositionalOption} from './directive/options/PositionalOption'
|
||||||
|
import {FlagOption} from './directive/options/FlagOption'
|
||||||
|
import {AppClass} from '../lifecycle/AppClass'
|
||||||
|
import {Logging} from '../service/Logging'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type alias for a definition of a command-line option.
|
||||||
|
*
|
||||||
|
* This can be either an instance of CLIOption or a string describing an option.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* Some examples of positional/flag options defined by strings:
|
||||||
|
* `'{file name} | canonical name of the resource to create'`
|
||||||
|
*
|
||||||
|
* `'--push -p {value} | the value to be pushed'`
|
||||||
|
*
|
||||||
|
* `'--force -f | do a force push'`
|
||||||
|
*/
|
||||||
|
export type OptionDefinition = CLIOption<any> | string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error thrown when an invalid option was detected.
|
||||||
|
*/
|
||||||
|
export class OptionValidationError extends ErrorWithContext {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A base class representing a sub-command in the command-line utility.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Directive extends AppClass {
|
||||||
|
@Inject()
|
||||||
|
protected readonly logging!: Logging
|
||||||
|
|
||||||
|
/** Parsed option values. */
|
||||||
|
private optionValues: any
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the keyword or array of keywords that will specify this directive.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* If this returns `['up', 'start']`, the directive can be run by either of:
|
||||||
|
*
|
||||||
|
* ```shell
|
||||||
|
* ./ex up
|
||||||
|
* ./ex start
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public abstract getKeywords(): string | string[]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the usage description of this directive. Should be brief (1 sentence).
|
||||||
|
*/
|
||||||
|
public abstract getDescription(): string
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optionally, specify a longer usage text that is shown on the directive's `--help` page.
|
||||||
|
*/
|
||||||
|
public getHelpText(): string {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get an array of options defined for this command.
|
||||||
|
*/
|
||||||
|
public getOptions(): OptionDefinition[] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the directive is run from the command line.
|
||||||
|
*
|
||||||
|
* The raw arguments are provided as `argv`, but you are encouraged to use
|
||||||
|
* `getOptions()` and `option()` helpers to access the parsed options instead.
|
||||||
|
*
|
||||||
|
* @param argv
|
||||||
|
*/
|
||||||
|
public abstract handle(argv: string[]): void | Promise<void>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the parsed option values.
|
||||||
|
* @param optionValues
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
private setOptionValues(optionValues: any) {
|
||||||
|
this.optionValues = optionValues
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the value of a parsed option. If none exists, return `defaultValue`.
|
||||||
|
* @param name
|
||||||
|
* @param defaultValue
|
||||||
|
*/
|
||||||
|
public option(name: string, defaultValue?: unknown): any {
|
||||||
|
if ( name in this.optionValues ) {
|
||||||
|
return this.optionValues[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invoke this directive with the specified arguments.
|
||||||
|
*
|
||||||
|
* If usage was requested (see `didRequestUsage()`), it prints the extended usage info.
|
||||||
|
*
|
||||||
|
* Otherwise, it parses the options from `argv` and calls `handle()`.
|
||||||
|
*
|
||||||
|
* @param argv
|
||||||
|
*/
|
||||||
|
async invoke(argv: string[]): Promise<void> {
|
||||||
|
const options = this.getResolvedOptions()
|
||||||
|
|
||||||
|
if ( this.didRequestUsage(argv) ) {
|
||||||
|
const positionalArguments: PositionalOption<any>[] = []
|
||||||
|
options.forEach(opt => {
|
||||||
|
if ( opt instanceof PositionalOption ) {
|
||||||
|
positionalArguments.push(opt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const flagArguments: FlagOption<any>[] = []
|
||||||
|
options.forEach(opt => {
|
||||||
|
if ( opt instanceof FlagOption ) {
|
||||||
|
flagArguments.push(opt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
|
||||||
|
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
|
||||||
|
|
||||||
|
this.nativeOutput([
|
||||||
|
'',
|
||||||
|
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
|
||||||
|
'',
|
||||||
|
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
|
||||||
|
].join('\n'))
|
||||||
|
|
||||||
|
if ( positionalArguments.length ) {
|
||||||
|
this.nativeOutput([
|
||||||
|
'',
|
||||||
|
`POSITIONAL ARGUMENTS:`,
|
||||||
|
...(positionalArguments.map(arg => {
|
||||||
|
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
|
||||||
|
})),
|
||||||
|
].join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( flagArguments.length ) {
|
||||||
|
this.nativeOutput([
|
||||||
|
'',
|
||||||
|
`FLAGS:`,
|
||||||
|
...(flagArguments.map(arg => {
|
||||||
|
return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}`
|
||||||
|
})),
|
||||||
|
].join('\n'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const help = this.getHelpText()
|
||||||
|
if ( help ) {
|
||||||
|
this.nativeOutput('\n' + help)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nativeOutput('\n')
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const optionValues = this.parseOptions(options, argv)
|
||||||
|
this.setOptionValues(optionValues)
|
||||||
|
await this.handle(argv)
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if ( e instanceof Error ) {
|
||||||
|
this.nativeOutput(e.message)
|
||||||
|
this.error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( e instanceof OptionValidationError ) {
|
||||||
|
// expecting, value, requirements
|
||||||
|
if ( e.context.expecting ) {
|
||||||
|
this.nativeOutput(` - Expecting: ${e.context.expecting}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
|
||||||
|
for ( const req of e.context.requirements ) {
|
||||||
|
this.nativeOutput(` - ${req}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( e.context.value ) {
|
||||||
|
this.nativeOutput(` - ${e.context.value}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.nativeOutput('\nUse --help for more info.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the array of option definitions to CLIOption instances.
|
||||||
|
* Of note, this resolves the string-form definitions to actual CLIOption instances.
|
||||||
|
*/
|
||||||
|
public getResolvedOptions(): CLIOption<any>[] {
|
||||||
|
return this.getOptions().map(option => {
|
||||||
|
if ( typeof option === 'string' ) {
|
||||||
|
return this.instantiateOptionFromString(option)
|
||||||
|
} else {
|
||||||
|
return option
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the main keyword displayed for this directive.
|
||||||
|
* @example
|
||||||
|
* If `getKeywords()` returns `['up', 'start']`, this will return `'up'`.
|
||||||
|
*/
|
||||||
|
public getMainKeyword(): string {
|
||||||
|
const kws = this.getKeywords()
|
||||||
|
|
||||||
|
if ( Array.isArray(kws) ) {
|
||||||
|
return kws[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
return kws
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the given keyword should invoke this directive.
|
||||||
|
* @param name
|
||||||
|
*/
|
||||||
|
public matchesKeyword(name: string): boolean {
|
||||||
|
let kws = this.getKeywords()
|
||||||
|
if ( !Array.isArray(kws) ) {
|
||||||
|
kws = [kws]
|
||||||
|
}
|
||||||
|
return kws.includes(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as success text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
success(output: unknown): void {
|
||||||
|
this.logging.success(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as error text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
error(output: unknown): void {
|
||||||
|
this.logging.error(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as warning text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
warn(output: unknown): void {
|
||||||
|
this.logging.warn(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as info text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
info(output: unknown): void {
|
||||||
|
this.logging.info(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as debugging text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
debug(output: unknown): void {
|
||||||
|
this.logging.debug(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print the given output to the log as verbose text.
|
||||||
|
* @param output
|
||||||
|
*/
|
||||||
|
verbose(output: unknown): void {
|
||||||
|
this.logging.verbose(output, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the flag option that signals help. Usually, this is named 'help'
|
||||||
|
* and supports the flags '--help' and '-?'.
|
||||||
|
*/
|
||||||
|
getHelpOption(): FlagOption<any> {
|
||||||
|
return new FlagOption('--help', '-?', 'usage information about this directive')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the raw CLI arguments using an array of option class instances to build
|
||||||
|
* a mapping of option names to provided values.
|
||||||
|
*/
|
||||||
|
parseOptions(options: CLIOption<any>[], args: string[]): {[key: string]: any} {
|
||||||
|
let positionalArguments: PositionalOption<any>[] = []
|
||||||
|
options.forEach(opt => {
|
||||||
|
if ( opt instanceof PositionalOption ) {
|
||||||
|
positionalArguments.push(opt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const flagArguments: FlagOption<any>[] = []
|
||||||
|
options.forEach(opt => {
|
||||||
|
if ( opt instanceof FlagOption ) {
|
||||||
|
flagArguments.push(opt)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const optionValue: any = {}
|
||||||
|
|
||||||
|
flagArguments.push(this.getHelpOption())
|
||||||
|
|
||||||
|
let expectingFlagArgument = false
|
||||||
|
let positionalFlagName = ''
|
||||||
|
for ( const value of args ) {
|
||||||
|
if ( value.startsWith('--') ) {
|
||||||
|
if ( expectingFlagArgument ) {
|
||||||
|
throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, {
|
||||||
|
expecting: positionalFlagName,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const flagArgument = flagArguments.filter(x => x.longFlag === value)
|
||||||
|
if ( flagArgument.length < 1 ) {
|
||||||
|
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if ( flagArgument[0].argumentDescription ) {
|
||||||
|
positionalFlagName = flagArgument[0].getArgumentName()
|
||||||
|
expectingFlagArgument = true
|
||||||
|
} else {
|
||||||
|
optionValue[flagArgument[0].getArgumentName()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ( value.startsWith('-') ) {
|
||||||
|
if ( expectingFlagArgument ) {
|
||||||
|
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||||
|
expecting: positionalFlagName,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
|
||||||
|
if ( flagArgument.length < 1 ) {
|
||||||
|
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
if ( flagArgument[0].argumentDescription ) {
|
||||||
|
positionalFlagName = flagArgument[0].getArgumentName()
|
||||||
|
expectingFlagArgument = true
|
||||||
|
} else {
|
||||||
|
optionValue[flagArgument[0].getArgumentName()] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if ( expectingFlagArgument ) {
|
||||||
|
const inferredValue = infer(value)
|
||||||
|
const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0]
|
||||||
|
if ( !optionInstance.validate(inferredValue) ) {
|
||||||
|
throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, {
|
||||||
|
requirements: optionInstance.getRequirementDisplays(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
optionValue[positionalFlagName] = inferredValue
|
||||||
|
expectingFlagArgument = false
|
||||||
|
} else {
|
||||||
|
if ( positionalArguments.length < 1 ) {
|
||||||
|
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
|
||||||
|
value,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const inferredValue = infer(value)
|
||||||
|
if ( !positionalArguments[0].validate(inferredValue) ) {
|
||||||
|
throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||||
|
requirements: positionalArguments[0].getRequirementDisplays(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
optionValue[positionalArguments[0].getArgumentName()] = infer(value)
|
||||||
|
positionalArguments = positionalArguments.slice(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( expectingFlagArgument ) {
|
||||||
|
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
|
||||||
|
expecting: positionalFlagName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( positionalArguments.length > 0 ) {
|
||||||
|
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||||
|
expecting: positionalArguments[0].getArgumentName(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return optionValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an instance of CLIOption based on a string definition of a particular format.
|
||||||
|
*
|
||||||
|
* e.g. '{file name} | canonical name of the resource to create'
|
||||||
|
* e.g. '--push -p {value} | the value to be pushed'
|
||||||
|
* e.g. '--force -f | do a force push'
|
||||||
|
*
|
||||||
|
* @param string
|
||||||
|
*/
|
||||||
|
protected instantiateOptionFromString(string: string): CLIOption<any> {
|
||||||
|
if ( string.startsWith('{') ) {
|
||||||
|
// The string is a positional argument
|
||||||
|
const stringParts = string.split('|').map(x => x.trim())
|
||||||
|
const name = stringParts[0].replace(/\{|\}/g, '')
|
||||||
|
return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name))
|
||||||
|
} else {
|
||||||
|
// The string is a flag argument
|
||||||
|
const stringParts = string.split('|').map(x => x.trim())
|
||||||
|
|
||||||
|
// Parse the flag parts first
|
||||||
|
const hasArgument = stringParts[0].indexOf('{') >= 0
|
||||||
|
const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim()
|
||||||
|
const flagParts = flagString.split(' ')
|
||||||
|
|
||||||
|
let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined
|
||||||
|
if ( !longFlag && flagParts.length > 1 ) {
|
||||||
|
if ( flagParts[1].startsWith('--') ) {
|
||||||
|
longFlag = flagParts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined
|
||||||
|
if ( !shortFlag && flagParts.length > 1 ) {
|
||||||
|
if ( flagParts[1].length === 2 ) {
|
||||||
|
shortFlag = flagParts[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined
|
||||||
|
const description = stringParts.length > 1 ? stringParts[1] : undefined
|
||||||
|
|
||||||
|
return new FlagOption(longFlag, shortFlag, description, argumentDescription)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if, at any point in the arguments, the help option's short or long flag appears.
|
||||||
|
* @returns {boolean} - true if the help flag appeared
|
||||||
|
*/
|
||||||
|
didRequestUsage(argv: string[]): boolean {
|
||||||
|
const helpOption = this.getHelpOption()
|
||||||
|
for ( const arg of argv ) {
|
||||||
|
if ( arg.trim() === helpOption.longFlag || arg.trim() === helpOption.shortFlag ) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
protected nativeOutput(...outputs: any[]): void {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
64
src/cli/Template.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import {UniversalPath} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface defining a template that can be generated using the TemplateDirective.
|
||||||
|
*/
|
||||||
|
export interface Template {
|
||||||
|
/**
|
||||||
|
* The name of the template as it will be specified from the command line.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* If this is `'mytemplate'`, then the template will be created with:
|
||||||
|
*
|
||||||
|
* ```shell
|
||||||
|
* ./ex new mytemplate some:path
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
name: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The suffix of the file generated by this template.
|
||||||
|
* @example `.mytemplate.ts`
|
||||||
|
* @example `.controller.ts`
|
||||||
|
*/
|
||||||
|
fileSuffix: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Brief description of the template displayed on the --help page for the TemplateDirective.
|
||||||
|
* Should be brief (1 sentence).
|
||||||
|
*/
|
||||||
|
description: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Array of path-strings that are resolved relative to the base `app` directory.
|
||||||
|
* @example `['http', 'controllers']`
|
||||||
|
* @example `['units']`
|
||||||
|
*/
|
||||||
|
baseAppPath: string[],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the given template to a string which will be written to the file.
|
||||||
|
* Note: this method should NOT write the contents to `targetFilePath`.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* If the user enters:
|
||||||
|
*
|
||||||
|
* ```shell
|
||||||
|
* ./ex new mytemplate path:to:NewInstance
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Then, the following params are:
|
||||||
|
* ```typescript
|
||||||
|
* {
|
||||||
|
* name: 'NewInstance',
|
||||||
|
* fullCanonicalPath: 'path:to:NewInstance',
|
||||||
|
* targetFilePath: UniversalPath { }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param name - the singular name of the resource
|
||||||
|
* @param fullCanonicalName - the full canonical name of the resource
|
||||||
|
* @param targetFilePath - the UniversalPath where the file will be written
|
||||||
|
*/
|
||||||
|
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
|
||||||
|
}
|
23
src/cli/decorators.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
|
||||||
|
import {CommandLine} from './service'
|
||||||
|
import {Directive} from './Directive'
|
||||||
|
import {logIfDebugging} from '../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a class as a command-line Directive.
|
||||||
|
* The class must extend Directive.
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
export const CLIDirective = (): ClassDecorator => {
|
||||||
|
return (target) => {
|
||||||
|
if ( isInstantiableOf(target, Directive) ) {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
|
||||||
|
ContainerBlueprint.getContainerBlueprint()
|
||||||
|
.onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
|
||||||
|
cli.registerDirective(target as Instantiable<Directive>)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
72
src/cli/directive/RouteDirective.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../Directive'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import Table = require('cli-table')
|
||||||
|
import {HTTPMethod} from '../../http/lifecycle/Request'
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RouteDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'Get information about a specific route'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['route']
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'{route} | the path of the route',
|
||||||
|
'--method -m {value} | the HTTP method of the route',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const method: string | undefined = this.option('method')
|
||||||
|
?.toLowerCase()
|
||||||
|
?.trim()
|
||||||
|
|
||||||
|
const route: string = this.option('route')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
|
||||||
|
const matched = this.routing.getCompiled()
|
||||||
|
.filter(match => {
|
||||||
|
if ( !method ) {
|
||||||
|
return match.getRoute().trim() === route
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|
||||||
|
|| match.match(method as HTTPMethod, route)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.some(match => {
|
||||||
|
const displays = match.getDisplays()
|
||||||
|
.map<[string, string]>(ware => [ware.stage, ware.display])
|
||||||
|
|
||||||
|
if ( displays.isEmpty() ) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLen = displays.max(x => x[1].length)
|
||||||
|
|
||||||
|
const table = new Table({
|
||||||
|
head: ['Stage', 'Handler'],
|
||||||
|
colWidths: [10, maxLen + 2],
|
||||||
|
})
|
||||||
|
|
||||||
|
displays.each(x => table.push(x))
|
||||||
|
|
||||||
|
this.info(`\nRoute: ${match}\n\n${table}`)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if ( !matched ) {
|
||||||
|
this.error('No matching routes found.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
41
src/cli/directive/RoutesDirective.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {Directive} from '../Directive'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {Routing} from '../../service/Routing'
|
||||||
|
import Table = require('cli-table')
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoutesDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly routing!: Routing
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'List routes registered in the application'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['routes']
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const compiled = this.routing.getCompiled()
|
||||||
|
|
||||||
|
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({
|
||||||
|
head: ['Route', 'Handler', 'Name'],
|
||||||
|
colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
|
||||||
|
})
|
||||||
|
|
||||||
|
table.push(...rows.toArray())
|
||||||
|
|
||||||
|
this.info('\n' + table)
|
||||||
|
}
|
||||||
|
}
|
31
src/cli/directive/RunDirective.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import {Directive} from '../Directive'
|
||||||
|
import {CommandLineApplication} from '../service'
|
||||||
|
import {Injectable} from '../../di'
|
||||||
|
import {ErrorWithContext} from '../../util'
|
||||||
|
import {Unit} from '../../lifecycle/Unit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directive that starts the framework's final target normally.
|
||||||
|
* In most cases, this runs the HTTP server, which would have been replaced
|
||||||
|
* by the CommandLineApplication unit.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class RunDirective extends Directive {
|
||||||
|
getDescription(): string {
|
||||||
|
return 'run the application normally'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['run', 'up']
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
if ( !CommandLineApplication.getReplacement() ) {
|
||||||
|
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
|
||||||
|
await this.app().startUnit(unit)
|
||||||
|
await this.app().stopUnit(unit)
|
||||||
|
}
|
||||||
|
}
|
98
src/cli/directive/ShellDirective.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../Directive'
|
||||||
|
import * as colors from 'colors/safe'
|
||||||
|
import * as repl from 'repl'
|
||||||
|
// import * as tsNode from 'ts-node'
|
||||||
|
import {globalRegistry} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Launch an interactive REPL shell from within the application.
|
||||||
|
* 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 {
|
||||||
|
protected options: any = {
|
||||||
|
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(') ➤ ')}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The created Node.js REPL server.
|
||||||
|
* @protected
|
||||||
|
*/
|
||||||
|
protected repl?: repl.REPLServer
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'launch an interactive shell inside your application'
|
||||||
|
}
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['shell']
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
return [
|
||||||
|
'--js | launch in JavaScript mode instead of TypeScript',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const state: any = {
|
||||||
|
globalRegistry,
|
||||||
|
app: this.app(),
|
||||||
|
lib: await import('../../index'),
|
||||||
|
exports: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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)
|
||||||
|
|
||||||
|
// Wait for the REPL to exit
|
||||||
|
this.repl.on('exit', () => res())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
90
src/cli/directive/TemplateDirective.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import {Directive, OptionDefinition} from '../Directive'
|
||||||
|
import {PositionalOption} from './options/PositionalOption'
|
||||||
|
import {CommandLine} from '../service'
|
||||||
|
import {Inject, Injectable} from '../../di'
|
||||||
|
import {ErrorWithContext} from '../../util'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new file based on a template registered with the CommandLine service.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class TemplateDirective extends Directive {
|
||||||
|
@Inject()
|
||||||
|
protected readonly cli!: CommandLine
|
||||||
|
|
||||||
|
getKeywords(): string | string[] {
|
||||||
|
return ['new', 'make']
|
||||||
|
}
|
||||||
|
|
||||||
|
getDescription(): string {
|
||||||
|
return 'create a new file from a registered template'
|
||||||
|
}
|
||||||
|
|
||||||
|
getOptions(): OptionDefinition[] {
|
||||||
|
const registeredTemplates = this.cli.getTemplates()
|
||||||
|
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
|
||||||
|
template.whitelist(...registeredTemplates.pluck('name').all())
|
||||||
|
|
||||||
|
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
|
||||||
|
|
||||||
|
return [template, destination]
|
||||||
|
}
|
||||||
|
|
||||||
|
getHelpText(): string {
|
||||||
|
const registeredTemplates = this.cli.getTemplates()
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Modules in Extollo register templates that can be used to quickly create common file types.',
|
||||||
|
'',
|
||||||
|
'For example, you can create a new model from @extollo/orm using the "model" template:',
|
||||||
|
'',
|
||||||
|
'./ex new model auth:Group',
|
||||||
|
'',
|
||||||
|
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
|
||||||
|
'',
|
||||||
|
'AVAILABLE TEMPLATES:',
|
||||||
|
'',
|
||||||
|
...(registeredTemplates.map(template => {
|
||||||
|
return ` - ${template.name}: ${template.description}`
|
||||||
|
}).all()),
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async handle(): Promise<void> {
|
||||||
|
const templateName: string = this.option('template_name')
|
||||||
|
const destinationName: string = this.option('file_name')
|
||||||
|
|
||||||
|
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
|
||||||
|
this.error(`The destination should be a canonical name, not a file path.`)
|
||||||
|
this.error(`Reference sub-directories using the : character instead.`)
|
||||||
|
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const template = this.cli.getTemplate(templateName)
|
||||||
|
if ( !template ) {
|
||||||
|
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
|
||||||
|
templateName,
|
||||||
|
destinationName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = destinationName.split(':').reverse()[0]
|
||||||
|
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
|
||||||
|
|
||||||
|
if ( await path.exists() ) {
|
||||||
|
this.error(`File already exists: ${path}`)
|
||||||
|
process.exitCode = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the parent direction exists
|
||||||
|
await path.concat('..').mkdir()
|
||||||
|
|
||||||
|
const contents = await template.render(name, destinationName, path.clone())
|
||||||
|
await path.write(contents)
|
||||||
|
|
||||||
|
this.success(`Created new ${template.name} in ${path}`)
|
||||||
|
}
|
||||||
|
}
|