Compare commits

...

189 Commits

Author SHA1 Message Date
Garrett Mills ac6fd0ef1d 0.14.14: Misc bugfixes in migrations & AsyncCollection keys
continuous-integration/drone/tag Build is failing Details
continuous-integration/drone/push Build is passing Details
6 months ago
Garrett Mills 9a55623370 Modify isAwareOfContainerLifecycle to include function-type instances
continuous-integration/drone/push Build is passing Details
10 months ago
Garrett Mills 743e81ae94 bump version
11 months ago
Garrett Mills 61c4d86fff Update dependencies with pnpm v8
continuous-integration/drone/promote/production Build is passing Details
continuous-integration/drone Build is passing Details
11 months ago
Garrett Mills 9aa3f56340 Update package versions for node 18
continuous-integration/drone Build is failing Details
11 months ago
Garrett Mills 899c8448fc Create HTTPFilesystem implementation and add support to universalPath helper to automatically use it
continuous-integration/drone Build is failing Details
11 months ago
Garrett Mills 7c9b1ff212 Bump version
continuous-integration/drone Build is passing Details
1 year ago
Garrett Mills cae9a1acbe Drone: only rollout docs on tag
continuous-integration/drone Build is passing Details
1 year ago
Garrett Mills aeb37d711b Drone: add npm build pipeline
continuous-integration/drone Build is passing Details
1 year ago
Garrett Mills d66ae85f54 Drone: fix k8s rollout path
continuous-integration/drone Build is passing Details
1 year ago
Garrett Mills 68b81106e6 Docs: update to latest typedoc (breaks theme)
continuous-integration/drone Build is failing Details
1 year ago
Garrett Mills 5395fb9054 Drone: Add docs build
continuous-integration/drone Build is failing Details
1 year ago
Garrett Mills bbafd54dcc Add docs K8s spec
1 year ago
Garrett Mills 37fcaabdef Fix isHTTPMethod validator
1 year ago
Garrett Mills bbf2807cfa Add `options` to HTTPMethod type
1 year ago
Garrett Mills 2d9f22b895 Fix left() response handling for parameter middleware
1 year ago
Garrett Mills 0484a586bd Update dependencies & fix misc formatting errors
2 years ago
Garrett Mills 52762bd4a1 Experimental SQLite support
2 years ago
Garrett Mills c0595f3ef9 Centralize configure-able factory classes
2 years ago
Garrett Mills 5557aae543 Fix order of dates in redis expiration calculation
2 years ago
Garrett Mills f1791b1d76 Add push$ to Collection; make Container listen for retroactive blueprint changes
2 years ago
Garrett Mills a173393697 Fix property injection prototype hoisting bug
2 years ago
Garrett Mills c966904418 finish TreeModel implementation and updateMany builder method
2 years ago
Garrett Mills f63891ef99 Add TreeModel and HasSubtree implementation
2 years ago
Garrett Mills 3d836afa59 Improve error context for DI
2 years ago
Garrett Mills 9b47d2ac99 Fix async context loss in WebSocketBus
2 years ago
Garrett Mills 085fe04f90 Fix issue where WebSocketBus depends on Session before session factory registered
2 years ago
Garrett Mills e339ec718d Support periodic auth checks in SecurityContext on web socket connections
2 years ago
Garrett Mills efb9726470 Add CacheSession implementation & make WebSocketBus re-load the session when an event is received
2 years ago
Garrett Mills 8a153e3807 Properly unwrap StateEvent state before calling handler
2 years ago
Garrett Mills b8cf8499d2 Update ShellDirective to use ts-node interpreter by default
2 years ago
Garrett Mills 710b6cb535 Add workaround for global registry async context in REPL
2 years ago
Garrett Mills 91d76f44b5 Make global registry better accessible externally
2 years ago
Garrett Mills 8774bd8d34 Fix serial format of StateEvent
2 years ago
Garrett Mills 3712fae979 Better comments
2 years ago
Garrett Mills 4aa33e8dd2 Add SocketRouteBuilder and make Route.socket(...) return it
2 years ago
Garrett Mills ef405093dc Actually export StateEvent...
2 years ago
Garrett Mills fc85c9d2c8 Add StateEvent implementation
2 years ago
Garrett Mills d00e6a02e2 Remove unnecessary container call
2 years ago
Garrett Mills ea81a37315 Bump version
2 years ago
Garrett Mills 33a64b99ff Implement websocket server
2 years ago
Garrett Mills dc663ec8f5 Bump version
2 years ago
Garrett Mills 6476416c67 Add foreground service, some cleanup, and start websocket server
2 years ago
Garrett Mills 4d7769de56 Fix PostgreSQLDialect escaping of single quotes
2 years ago
Garrett Mills 6ca4bc1151 Fix buildMigrationNamespaceResolver to use isValidSuffix(...) instead of hard-coded check
2 years ago
Garrett Mills 7d3fde85eb Modify canonical loading to allow either suffix.js or suffix.ts endings
2 years ago
Garrett Mills 111ce0bcf9 Fix Routing suffix loading
2 years ago
Garrett Mills d3a6a8495c Update application bootstrapping to load .env from repo root
2 years ago
Garrett Mills 2ff9354538 Make file extensions on Canonical resources runtime-agnostic
2 years ago
Garrett Mills cf0ae260dc Fix syntax of postgres update queries
2 years ago
Garrett Mills fe9170282f Add ArrayElement type helper
2 years ago
Garrett Mills 2e43b5bda9 Fix route parameter typing to use SuffixTypeArray
2 years ago
Garrett Mills 705bb20db1 Make Route.clone properly copy parameter definitions
2 years ago
Garrett Mills 1be73dd347 Add docs server Dockerfile
2 years ago
Garrett Mills afbf6e7682 Add force SSL config flag
2 years ago
Garrett Mills 48ce1bfa2f Improve app URL detection
2 years ago
Garrett Mills 1d717e0eb9 bump version
2 years ago
Garrett Mills 90b16eef53 Add better debugging to routing service
2 years ago
Garrett Mills 1399399af9 Improve error handling in OAuth2 token issuer
2 years ago
Garrett Mills 30a23b1659 Temporarily cast migration AsyncCollection to Collection pending #17
2 years ago
Garrett Mills de13030815 Fix AsyncCollection.filter to allow for async filter functions
2 years ago
Garrett Mills fd77ad5cd3 Create migration for oauth2_tokens table
2 years ago
Garrett Mills 814a5763d9 fix formatting
2 years ago
Garrett Mills 9ede67cb12 Make orm token repo use make() to create token model instances
2 years ago
Garrett Mills 8a9264b9de Simplify /oauth2/issue logic
2 years ago
Garrett Mills d210cba236 add oauth2 issue debugging and bump version
2 years ago
Garrett Mills 015d6fd6ae Fix Either<> pass in OAuth2Server; bump version
2 years ago
Garrett Mills ce4133ff8e Fix scope() helper and bump version
2 years ago
Garrett Mills 9d8f43d8fb Bump version
2 years ago
Garrett Mills 940d50b89c Implement /oauth2/token endpoint; token auth middleware
2 years ago
Garrett Mills 36647a013d Add ability to get provider by name from auth service
2 years ago
Garrett Mills 5616b3cc1f Add suggestion for missing schema for validator error
2 years ago
Garrett Mills 2e7c927114 Make route extraction account for extraneous / matches
2 years ago
Garrett Mills 8b2ee1c949 Add debugging for route extraction
2 years ago
Garrett Mills c7557cf5b6 Allow overriding content-type of plaintext response factory
2 years ago
Garrett Mills 771fed8002 Make parameter middleware handler call collection.toArray NON-recursively
2 years ago
Garrett Mills 7914a8f12e Fix incorrect flow-through if-else case
2 years ago
Garrett Mills 5fa4f614e2 Pass request to ParameterMiddleware make call
2 years ago
Garrett Mills ee21811771 Add ability to pass arguments to parameter providing middleware
2 years ago
Garrett Mills 8b9f393405 Add support for parameter middleware classes
2 years ago
Garrett Mills f6a7cac05c Improve ORM templates; improve StaticClass typedef; bump version
2 years ago
Garrett Mills 25265b5560 Only load .env if the file exists; bump version
2 years ago
Garrett Mills a779ec1d09 Update content-length calculation to use buffer
2 years ago
Garrett Mills bea48602f5 Fix content-length header set
2 years ago
Garrett Mills 445f16d973 Prevent duplicate bus shutdowns on container destroy; bump version
2 years ago
Garrett Mills 351a2e14b8 Prevent request Bus instances from creating new IORedis connections
2 years ago
Garrett Mills b42e91533a Fix missed return in container.makeNew
2 years ago
Garrett Mills 78cb26fcb2 Add request container lifecycle handling
2 years ago
Garrett Mills 514a578260 Make HTTPServer ignore responses that cannot be sent
2 years ago
Garrett Mills 3d7d583367 Add Request logging
2 years ago
Garrett Mills 6f66126d38 Improve Response verbose/debug logging
2 years ago
Garrett Mills 10b3e1ecc3 Temporarily remove timeout logic from HTTPServer
2 years ago
Garrett Mills 795adac68b Add better detection for write-after-destroy errors on the response
2 years ago
Garrett Mills ca348b2ff6 Add Trace logging level; bump version
2 years ago
Garrett Mills 508d92f759 Add better debugging for Bus connections
2 years ago
Garrett Mills a590d78155 Reduce # of duplicate Bus.up() calls; bump version
2 years ago
Garrett Mills dbe48ea8a5 Add debugging option to make bus warnings throw Error; bump version
2 years ago
Garrett Mills 467721f775 Include request parameters in input(...) sources; bump version
2 years ago
Garrett Mills 153f8f7685 Fix Controller request injection and bump version
2 years ago
Garrett Mills ba87ea32c3 Add make helper and bump version
2 years ago
Garrett Mills 737d06f6f0 Fix preflight middleware ordering and bump version
2 years ago
Garrett Mills 6ee3e2a729 Fix preflight middleware ordering and bump version
2 years ago
Garrett Mills 1288e51de0 Disable broadcasting migration events and bump version
2 years ago
Garrett Mills 1fde692a65 Bump version
2 years ago
Garrett Mills cdecb7e628 Fix hanging IORedis connections; add extollo.wontstop debugging helper
2 years ago
Garrett Mills 8f08b94f74 Error response enhancements, CoreID auth client backend
2 years ago
Garrett Mills a039b1ff25 Add Safe value API and start OAuth2Server
2 years ago
Garrett Mills 70d67c2730 Add model serializer and coreid login provider
2 years ago
Garrett Mills 0774deea91 bump version
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills 16e5fa00aa Implement queue work and listen commands
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills e098a5edb7 Version bump
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills 6d1cf18680 Refactor event bus and queue system; detect cycles in DI realization and make
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills 506fb55c74 Start auth provider system
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills cfd555723b Add whereDefined and mapCall methods to Collection class
2 years ago
Garrett Mills 32050cb2ce Fix route CLI commands
2 years ago
Garrett Mills dc16dfdb81 Make new routing system the default
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 8cf19792a6 Start routing and pipeline rewrite
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 9b8333295f Version bump
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 5ffb91329e Start new validation system and zodified types with excc
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills b105a61ca2 Add RequestClass to override AppClass
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 9204a02450 Modify GlobalRegistry to use async local storage to support multiple "global" containers
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
2 years ago
Garrett Mills 463076d182 Introduce async local storage for request access, more view globals, and improved welcome view
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills b5eb407b55 Prevent duplicate auto-discovery for nested packages
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 0ed096c782 Add dependency on @extollo/ui and enable recursive discovery
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills 5175d64e36 Rework authentication system
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills bd7d6a2dbd Start tests
2 years ago
Garrett Mills d251f8bc15 Util: fix Pipe conditionals and add type-safe hasOwnProperty helper
2 years ago
Garrett Mills bf4a675faa DI: add logic for static class overrides
2 years ago
Garrett Mills 6fc901b3ec bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2 years ago
Garrett Mills 50e0cf3090 Fix prototype access issue with model scopes property
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills d245d15ad6 Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
2 years ago
Garrett Mills 265837b5cd Fix stupid typescript error...
continuous-integration/drone/push Build is passing Details
2 years ago
Garrett Mills fe0b4d6d8f bump version
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
2 years ago
Garrett Mills ce1d22ff44 Make the linter happy
continuous-integration/drone/push Build is failing Details
2 years ago
Garrett Mills b7bfb3e153 Model: fix eager-loaded relation loading from static query
2 years ago
Garrett Mills e57819d318 Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills 0a9dd30909 Implement scopes on models and support interacting with them via ModelBuilder
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills d92c8b5409 Start implementation of model relations
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 589cb7d579 Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills 3680ad1914 Fix back-fill on insert for Model.save
3 years ago
Garrett Mills 96e13d85fc Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills 5a9283ad85 orm(PostgreSQLDialect): double-quote column names in INSERT field lists
3 years ago
Garrett Mills b1ea489ccb Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills c3f2779650 Add generic to APIResponse
3 years ago
Garrett Mills 248b24e612 Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills b4a9057e2b CLI invocation output better debugging infor
3 years ago
Garrett Mills c078d695a8 Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills 55ffadc742 Export CLI decorators
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 56574d43ce Bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills e16f02ce12 Readd migrations
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills c34fad3502 Fix path in drone docs deploy
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills 156006053b Fix path in drone static deploy
continuous-integration/drone/push Build was killed Details
continuous-integration/drone Build is failing Details
3 years ago
Garrett Mills 22cf6aa953 bump version
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone Build is failing Details
3 years ago
Garrett Mills b35eb8d6a1 Fix error throw
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 9ee4c42e43 Error type fixes
continuous-integration/drone/push Build is failing Details
3 years ago
Garrett Mills 8d1dcc87fb Bump version
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/tag Build is failing Details
3 years ago
Garrett Mills 3efbfecf9d OAuth2 stuff
continuous-integration/drone/push Build is failing Details
3 years ago
Garrett Mills a1d04d652e Implement basic login & registration forms
continuous-integration/drone/push Build is failing Details
3 years ago
Garrett Mills 5940b6e2b3 Fix circular dependencies in migrator
continuous-integration/drone/push Build is failing Details
3 years ago
Garrett Mills 074a3187eb
Add support for jobs & queueables, migrations
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is failing Details
3 years ago
Garrett Mills 26e0444e40
version 0.5.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills fcce28081b
AsyncPipe; table schemata; migrations; File logging
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills e86cf420df
Named routes & basic login framework
3 years ago
Garrett Mills e33d8dee8f
Add support for registering vendor asset routes
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 39d97d6e14
version 0.4.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills f496046461
File-based response support & static server
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills b3b5b169e8
Add mechanism for NPM package auto-discovery
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 5d960e6186
chore: make Rehydratable use Awaitable; add docblock
3 years ago
Garrett Mills cf6d14abca
- Start support for auto-generated routes using UniversalPath
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills faa8a31102
Route - prevent pre/post middleware from being applied twice
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 7506d6567d
Support registering namespaced view directories; add lib() universal path
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills a69c81ed35
chore(version): 0.3.1
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
3 years ago
Garrett Mills 36b451c32b
Expose auth repos in context; create routes commands
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 9796a7277e
Begin abstracting global container into injector
3 years ago
Garrett Mills f00233d49a
Add middleware and logic for bootstrapping the session auth
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 91abcdf8ef
Start auth framework
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills c264d45927
Add query executed event; forward model events to global event bus
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 61731c4ebd
Add basic concepts for event bus, and implement in request and model
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills dab3d006c8
Containers - add ability to purge/release factories; override factories in scoped
3 years ago
Garrett Mills cd9bec7c5e
Remove old doc build trigger from CI
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills 0b86d796e8
version 0.3.0
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details
3 years ago
Garrett Mills 1d5056b753
Setup eslint and enforce rules
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 82e7a1f299
Add docs build pipeline to drone config
continuous-integration/drone/push Build is passing Details
continuous-integration/drone Build is passing Details
3 years ago
Garrett Mills 4849016784
Move docs in-repo
continuous-integration/drone/push Build is passing Details
3 years ago
Garrett Mills 0dde436b4c
version 0.2.1
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details
3 years ago
Garrett Mills 4d39637f30
Fix more import issues from monorepo merge
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is failing Details
3 years ago
Garrett Mills 9be9c44a32
Import other modules into monorepo
continuous-integration/drone/push Build is failing Details
3 years ago
Garrett Mills 26d54033af
Abstract out DataContainer into interface
3 years ago
Garrett Mills 574ddbe9cb
make HTTP server unit more configurable
continuous-integration/drone/push Build is passing Details
3 years ago

@ -1,151 +1,87 @@
---
kind: pipeline
name: default
type: docker
type: kubernetes
name: docs
metadata:
labels:
pod-security.kubernetes.io/audit: privileged
services:
- name: docker daemon
image: docker:dind
privileged: true
environment:
DOCKER_TLS_CERTDIR: ""
when:
event: tag
steps:
- 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."
- name: typedoc build
image: node:18
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: pull_request
event: tag
status: success
- name: remove lockfile
image: glmdev/node-pnpm:latest
- name: k8s rollout
image: bitnami/kubectl
commands:
- rm -rf pnpm-lock.yaml
- cd docs/deploy && kubectl apply -f .
- kubectl rollout restart -n extollo deployment/docs
when:
event:
exclude: tag
event: tag
status: success
---
kind: pipeline
type: kubernetes
name: npm
- name: build module
image: glmdev/node-pnpm:latest
steps:
- name: node.js build
image: node:18
commands:
- "npm add --global pnpm"
- pnpm i
- pnpm build
- mkdir artifacts
- tar czf artifacts/extollo-lib.tar.gz lib
- name: create Gitea release
- name: gitea release
image: plugins/gitea-release
settings:
api_key:
from_secret: gitea_token
from_secret: GITEA_TOKEN
base_url: https://code.garrettmills.dev
checksum: md5
title: ${DRONE_TAG}
files: "artifacts/*"
when:
event: tag
status: success
- name: prepare NPM release
image: glmdev/node-pnpm:latest
commands:
- rm -rf artifacts
when:
event: tag
status: success
- name: create NPM release
- name: npm release
image: plugins/npm
settings:
username: extollo_bot
password:
from_secret: npm_password
from_secret: NPM_PASSWORD
email: extollo@garrettmills.dev
when:
event: tag
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

@ -0,0 +1,3 @@
node_modules
lib
dist

@ -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
# 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

@ -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>

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

@ -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$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="module" module-name="extollo" />
</component>
</module>

@ -2,6 +2,7 @@
<project version="4">
<component name="ProjectModuleManager">
<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" />
</modules>
</component>

@ -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

@ -0,0 +1 @@
www*

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

@ -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.

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

@ -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

@ -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

@ -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

@ -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,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))

@ -0,0 +1,6 @@
[
{
"pattern": "^",
"replace": "https://code.garrettmills.dev/extollo/lib/src/branch/master/"
}
]

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

@ -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.

File diff suppressed because it is too large Load Diff

@ -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;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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>

@ -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}}

@ -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}}&nbsp;
{{/ifCond}}
{{model.name}}
{{#if model.typeParameters}}
&lt;
{{#each model.typeParameters}}
{{#if @index}},&nbsp;{{/if}}
{{name}}
{{/each}}
&gt;
{{/if}}
{{/compact}}</h1>
</div>
</div>
</header>

@ -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"
}
}
}
}

@ -1,6 +1,6 @@
{
"name": "@extollo/lib",
"version": "0.1.3",
"version": "0.14.14",
"description": "The framework library that lifts up your code.",
"main": "lib/index.js",
"types": "lib/index.d.ts",
@ -8,25 +8,58 @@
"lib": "lib"
},
"dependencies": {
"@extollo/di": "git+https://code.garrettmills.dev/extollo/di",
"@extollo/util": "git+https://code.garrettmills.dev/extollo/util",
"@types/busboy": "^0.2.3",
"@atao60/fse-cli": "^0.1.7",
"@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/node": "^14.14.37",
"@types/pug": "^2.0.4",
"@types/node": "^14.18.51",
"@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",
"dotenv": "^8.2.0",
"negotiator": "^0.6.2",
"dotenv": "^8.6.0",
"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",
"ts-node": "^9.1.1",
"typescript": "^4.2.3"
"reflect-metadata": "^0.1.13",
"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": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"test": "env TS_NODE_COMPILER_OPTIONS='{\"module\": \"commonjs\" }' mocha -r ts-node/register 'tests/**/*.ts'",
"build": "pnpm run lint && rimraf lib && tsc && fse copy --all --dereference --preserveTimestamps --keepExisting=false --quiet --errorOnExist=false src/resources lib/resources",
"app": "tsc && node lib/index.js",
"prepare": "pnpm run build"
"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": [
"lib/**/*"
@ -38,5 +71,30 @@
"url": "https://code.garrettmills.dev/extollo/lib"
},
"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
}
}
}

@ -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"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,5 @@
import {ErrorWithContext} from '../util'
export class AuthenticatableAlreadyExistsError extends ErrorWithContext {
}

@ -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]
}
}
}

@ -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)
}
}

@ -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
}

@ -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)
}
}

@ -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
}
}

@ -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
}
}

@ -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
}
}

@ -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()
}
}

@ -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'
}

@ -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'
}

@ -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'
}

@ -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'

@ -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)
}
}
}
}

@ -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)
}
}
}
}

@ -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),
)
}

@ -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()
}
}

@ -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()
}
}

@ -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)
}
}

@ -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(),
})

@ -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()
}
}

@ -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),
})

@ -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()
}
}
}

@ -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)
}
}

@ -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
}
}

@ -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)
}
}

@ -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())
}
}

@ -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,
})
}
}

@ -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
}
}

@ -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
}
}

@ -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
}
}

@ -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
}
}

@ -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)
}
}

@ -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
}
}

@ -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
}
}

@ -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
}
}

@ -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>
}

@ -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>
}

@ -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())
}
}

@ -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()
})
})
}
}

@ -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>
}

@ -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)
}
}
}

@ -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.')
}
}
}

@ -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)
}
}

@ -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)
}
}

@ -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())
})
}
}

@ -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}`)
}
}

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

Loading…
Cancel
Save