Compare commits

...

189 Commits

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

- Make Response body specify explicit types and support
  writing Readable streams to the body

- Create a static file server that supports directory listing
2021-07-07 20:13:23 -05:00
b3b5b169e8
Add mechanism for NPM package auto-discovery
All checks were successful
continuous-integration/drone/push Build is passing
2021-07-02 21:45:15 -05:00
5d960e6186
chore: make Rehydratable use Awaitable; add docblock 2021-07-02 21:44:34 -05:00
cf6d14abca
- Start support for auto-generated routes using UniversalPath
All checks were successful
continuous-integration/drone/push Build is passing
- Start support for custom view engine props & functions
- Start login template and namespace
2021-06-29 01:44:07 -05:00
faa8a31102
Route - prevent pre/post middleware from being applied twice
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-29 00:34:05 -05:00
7506d6567d
Support registering namespaced view directories; add lib() universal path
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-24 00:14:04 -05:00
a69c81ed35
chore(version): 0.3.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2021-06-17 19:35:50 -05:00
36b451c32b
Expose auth repos in context; create routes commands
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-17 19:35:31 -05:00
9796a7277e
Begin abstracting global container into injector 2021-06-17 19:34:32 -05:00
f00233d49a
Add middleware and logic for bootstrapping the session auth
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 13:24:12 -05:00
91abcdf8ef
Start auth framework
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 12:02:36 -05:00
c264d45927
Add query executed event; forward model events to global event bus
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-05 08:36:35 -05:00
61731c4ebd
Add basic concepts for event bus, and implement in request and model
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-04 01:03:31 -05:00
dab3d006c8
Containers - add ability to purge/release factories; override factories in scoped 2021-06-04 01:03:10 -05:00
cd9bec7c5e
Remove old doc build trigger from CI
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-06-02 22:45:49 -05:00
0b86d796e8
version 0.3.0
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2021-06-02 22:41:26 -05:00
1d5056b753
Setup eslint and enforce rules
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-02 22:36:25 -05:00
82e7a1f299
Add docs build pipeline to drone config
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone Build is passing
2021-06-01 22:21:29 -05:00
4849016784
Move docs in-repo
All checks were successful
continuous-integration/drone/push Build is passing
2021-06-01 21:32:24 -05:00
0dde436b4c
version 0.2.1
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2021-06-01 21:11:37 -05:00
4d39637f30
Fix more import issues from monorepo merge
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2021-06-01 21:09:47 -05:00
9be9c44a32
Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing
2021-06-01 20:59:40 -05:00
26d54033af
Abstract out DataContainer into interface 2021-05-22 10:44:52 -05:00
574ddbe9cb
make HTTP server unit more configurable
All checks were successful
continuous-integration/drone/push Build is passing
2021-04-12 11:43:06 -05:00
384 changed files with 33803 additions and 2209 deletions

View File

@ -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."
when:
event: pull_request
- name: remove lockfile
image: glmdev/node-pnpm:latest
- name: typedoc build
image: node:18
commands:
- rm -rf pnpm-lock.yaml
when:
event:
exclude: tag
- "node -v"
- "npm add --global pnpm"
- "pnpm --version"
- pnpm i
- pnpm run docs:build
- name: build module
image: glmdev/node-pnpm:latest
- name: container build
image: docker:latest
privileged: true
commands:
- "while ! docker stats --no-stream; do sleep 1; done"
- docker image build docs -t $DOCKER_REGISTRY/extollo/docs:latest
- docker push $DOCKER_REGISTRY/extollo/docs:latest
environment:
DOCKER_HOST: tcp://localhost:2375
DOCKER_REGISTRY:
from_secret: DOCKER_REGISTRY
when:
event: tag
status: success
- name: k8s rollout
image: bitnami/kubectl
commands:
- cd docs/deploy && kubectl apply -f .
- kubectl rollout restart -n extollo deployment/docs
when:
event: tag
status: success
---
kind: pipeline
type: kubernetes
name: npm
steps:
- name: node.js build
image: node:18
commands:
- "npm add --global pnpm"
- pnpm i
- pnpm 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

3
.eslintignore Normal file
View File

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

113
.eslintrc.json Normal file
View File

@ -0,0 +1,113 @@
{
"env": {
"browser": true,
"es2021": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"plugins": [
"@typescript-eslint"
],
"rules": {
"indent": [
"error",
4
],
"linebreak-style": [
"error",
"unix"
],
"quotes": [
"error",
"single",
{
"allowTemplateLiterals": true
}
],
"semi": [
"error",
"never"
],
"no-console": "error",
"curly": "error",
"eqeqeq": "error",
"guard-for-in": "error",
"no-alert": "error",
"no-caller": "error",
"no-constructor-return": "error",
"no-eval": "error",
"no-implicit-coercion": "error",
"no-implied-eval": "error",
"no-invalid-this": "error",
"no-return-await": "error",
"no-throw-literal": "error",
"no-useless-call": "error",
"radix": "error",
"yoda": "error",
"@typescript-eslint/no-shadow": "error",
"brace-style": "error",
"camelcase": "error",
"comma-dangle": [
"error",
"always-multiline"
],
"comma-spacing": [
"error",
{
"before": false,
"after": true
}
],
"comma-style": [
"error",
"last"
],
"computed-property-spacing": [
"error",
"never"
],
"eol-last": "error",
"func-call-spacing": [
"error",
"never"
],
"keyword-spacing": [
"error",
{
"before": true,
"after": true
}
],
"lines-between-class-members": "error",
"max-params": [
"error",
4
],
"new-parens": [
"error",
"always"
],
"newline-per-chained-call": "error",
"no-trailing-spaces": "error",
"no-underscore-dangle": "error",
"no-unneeded-ternary": "error",
"no-whitespace-before-property": "error",
"object-property-newline": "error",
"prefer-exponentiation-operator": "error",
"prefer-object-spread": "error",
"spaced-comment": [
"error",
"always"
],
"prefer-const": "error",
"@typescript-eslint/no-explicit-any": "off"
}
}

2
.gitignore vendored
View File

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

View File

@ -0,0 +1,55 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JSCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="USE_SEMICOLON_AFTER_STATEMENT" value="false" />
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="USE_DOUBLE_QUOTES" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="WhenMultiline" />
<option name="OBJECT_LITERAL_WRAP" value="2" />
</TypeScriptCodeStyleSettings>
<editorconfig>
<option name="ENABLED" value="false" />
</editorconfig>
<codeStyleSettings language="JavaScript">
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
<codeStyleSettings language="PHP">
<indentOptions>
<option name="USE_TAB_CHARACTER" value="true" />
<option name="SMART_TABS" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Shell Script">
<indentOptions>
<option name="INDENT_SIZE" value="4" />
<option name="TAB_SIZE" value="4" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="INDENT_CASE_FROM_SWITCH" value="false" />
<option name="ALIGN_MULTILINE_PARAMETERS" value="false" />
<option name="ALIGN_MULTILINE_FOR" value="false" />
<option name="METHOD_CALL_CHAIN_WRAP" value="2" />
<option name="IF_BRACE_FORCE" value="3" />
<option name="DOWHILE_BRACE_FORCE" value="3" />
<option name="WHILE_BRACE_FORCE" value="3" />
<option name="FOR_BRACE_FORCE" value="3" />
</codeStyleSettings>
</code_scheme>
</component>

View File

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

19
.idea/dataSources.xml Normal file
View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="mongo@localhost" uuid="b05ce3f5-fadc-47d6-8621-e232ed1ad2f3">
<driver-ref>mongo</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.dbschema.MongoJdbcDriver</jdbc-driver>
<jdbc-url>mongodb://localhost:27017/extollo_1</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
<data-source source="LOCAL" name="extollo_1@db03.platform.local" uuid="c8dc268d-b69d-497a-9e6d-b5c6e5275835">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://db03.platform.local:5432/extollo_1</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

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

View File

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

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

1
docs/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
www*

4
docs/Dockerfile Normal file
View File

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

31
docs/HOME.md Normal file
View File

@ -0,0 +1,31 @@
<center>
<br>
<img alt="The Extollo logo" src="https://static.garrettmills.dev/sites/extollo/docs/assets/logo/svg/Extollo-Icon-and-Text-LIGHT-Final.svg" height="150">
<br><br>
<b>extollo</b> - (v. <em>latin</em>) - to lift up, to elevate
<br><br>
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
</center>
<hr>
Built on principles of modularity, strict-typing, inversion-of-control, and developer ergonomics, Extollo enables developers to build maintainable, scalable, and expressive applications.
Node.js provides an excellent platform for quickly getting an application up and running, but this loose minimalism can lead to larger, more unweildy code-bases as your application grows. Extollo fixes this by providing an opinionated, robust framework and first-party modules that provide, among other things:
- Type-based dependency injection
- Strongly-typed ORM with an expressive query-builder and models
- Customizable session & caching interfaces
- Modular, pre-compiled, nest-able routes
- First-party, extensible command line tools
- Unit-based application structure
## Getting Started
Writing an application with Extollo is very straightforward if you are familiar with Node.js/TypeScript, or similar frameworks like Laravel.
Check out the [Getting Started](pages/Getting-Started.html) page site for more information.
## License & Philosophy
The Extollo project is, and will always be, free & libre software. The framework itself is open-source available [here](https://code.garrettmills.dev/Extollo), and is licensed under the terms of the MIT license. See the LICENSE file for more information.
## Contributing
Have an improvement or fix to Extollo? Contributors are always welcome. See the CONTRIBUTING.md file for next steps.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

@ -0,0 +1,6 @@
## Requirements
- Node.js v14 or later
- [PNPM](https://pnpm.js.org/) (not NPM/Yarn)
- Postgres credentials (if you want to use [@extollo/orm](../modules/orm_src.html))

6
docs/sourcefile-map.json Normal file
View File

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

BIN
docs/static/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

19
docs/static/humans.txt vendored Normal file
View File

@ -0,0 +1,19 @@
/* PROJECT */
Site Name: The Extollo Framework
Site URL: https://extollo.garrettmills.dev/
Created: 2021/03/24
Standards: HTML5, CSS3
Software: TypeDoc
/* AUTHOR */
Name: Garrett Mills
Location: Lawrence, Kansas
Site: https://garrettmills.dev/
Blog: https://garrettmills.dev/blog/
Contact: https://garrettmills.dev/#contact
/* THANKS */
To Piper Mills for the excellent font, color, and logo design.

2727
docs/theme/assets/css/main.css vendored Normal file

File diff suppressed because it is too large Load Diff

64
docs/theme/assets/css/pages.css vendored Normal file
View File

@ -0,0 +1,64 @@
h2 code {
font-size: 1em;
}
h3 code {
font-size: 1em;
}
.tsd-navigation.primary ul {
border-bottom: none;
}
.tsd-navigation.primary li {
border-top: none;
}
.tsd-navigation li.label.pp-nav.pp-group:first-child span {
padding-top: 0;
}
.tsd-navigation li.label.pp-nav.pp-group {
font-weight: 700;
border-bottom: 1px solid #eee;
}
.tsd-navigation li.label.pp-nav.pp-group span {
color: #222;
}
.tsd-navigation li.pp-nav.pp-page.current {
background-color: #f8f8f8;
border-left: 2px solid #222;
}
.tsd-navigation li.pp-nav.pp-page.current a {
color: #222;
}
.tsd-navigation li.pp-nav.pp-page.pp-parent.pp-active {
border-left: 2px solid #eee;
}
.tsd-navigation li.pp-nav.pp-page.pp-child {
border-left: 2px solid #eee;
padding-left: 15px;
}
.tsd-navigation li.pp-nav.pp-page.pp-child.current {
border-left: 2px solid #222;
}
.tsd-kind-page .tsd-kind-icon:before {
display: inline-block;
vertical-align: middle;
height: 16px;
width: 16px;
content: "";
background-image: url("../images/page-icon.svg");
background-size: 16px 16px;
}
#tsd-search .results span.parent {
color: #b3b2b2 !important;
}

BIN
docs/theme/assets/font/Extatica-Bold.otf vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
docs/theme/assets/images/icons.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
docs/theme/assets/images/icons@2x.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#AA43FF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-file-text"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>

After

Width:  |  Height:  |  Size: 468 B

BIN
docs/theme/assets/images/widgets.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

BIN
docs/theme/assets/images/widgets@2x.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

1
docs/theme/assets/js/main.js vendored Normal file

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

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 336.67 432"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="39.28 359.8 202.72 4.89 336.67 303.76 280.63 303.76 194.94 129.42 98.43 359.8 39.28 359.8"/><polygon class="cls-2" points="335.04 310.28 308.57 366.31 252.54 366.31 279 310.28 335.04 310.28"/><polygon class="cls-3" points="246.02 363.06 272.49 307.02 194.94 145.7 165.56 215.83 246.02 363.06"/><polygon class="cls-3" points="194.58 0 140.1 0 0 298.88 31.13 354.92 194.58 0"/><ellipse class="cls-4" cx="170.63" cy="420.6" rx="122.95" ry="11.4"/></svg>

After

Width:  |  Height:  |  Size: 691 B

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 646.49 291.04"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.46 242.4 136.57 3.29 226.81 204.64 189.06 204.64 131.33 87.19 66.31 242.4 26.46 242.4"/><polygon class="cls-2" points="225.72 209.03 207.89 246.79 170.13 246.79 187.96 209.03 225.72 209.03"/><polygon class="cls-3" points="165.75 244.59 183.57 206.84 131.33 98.16 111.54 145.41 165.75 244.59"/><polygon class="cls-3" points="131.09 0 94.38 0 0 201.35 20.97 239.11 131.09 0"/><ellipse class="cls-4" cx="114.95" cy="283.36" rx="82.83" ry="7.68"/><path class="cls-2" d="M290.79,131.24H344v14.32h-35.6v27.38h32.06V187.4H308.37v28.79H344v14.33H290.79Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M353.19,220.59l15.46-27-13.9-24.11V159.6h13.33l14.75,27.66,14.75-27.66h13.33v9.93L397,193.64l15.46,27v9.93H399.14L382.83,200l-16.31,30.49H353.19Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M426.93,172.94h-9.5V159.6h9.5V144h19.15L438,159.6h20v13.34H443.39V211.8a5.11,5.11,0,0,0,5.39,5.39H458v13.33H445.66q-8.37,0-13.55-5.18t-5.18-13.54Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M475,167.05q8.65-8.73,23-8.72,14.17,0,22.83,8.72T529.48,190v10.21q0,14.18-8.66,22.9T498,231.79q-14.33,0-23-8.72t-8.65-22.9V190Q466.36,175.77,475,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.83,14.83,0,0,0,513,202.44V187.69q0-6.81-4.11-10.93T498,172.65a15,15,0,0,0-11,4.11q-4.19,4.13-4.19,10.93v14.75A14.69,14.69,0,0,0,487,213.29Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M541.53,127.69H558V230.52H541.53Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M573,127.69h16.45V230.52H573Z" transform="translate(-18 -34.48)"/><path class="cls-2" d="M610,167.05q8.64-8.73,23-8.72t22.84,8.72q8.65,8.72,8.65,22.91v10.21q0,14.18-8.65,22.9T633,231.79q-14.32,0-23-8.72t-8.65-22.9V190Q601.38,175.77,610,167.05Zm12,46.24a14.85,14.85,0,0,0,11,4.18q6.81,0,10.92-4.18A14.8,14.8,0,0,0,648,202.44V187.69q0-6.81-4.12-10.93T633,172.65a15,15,0,0,0-11,4.11q-4.18,4.13-4.18,10.93v14.75A14.68,14.68,0,0,0,622,213.29Z" transform="translate(-18 -34.48)"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 648 290.82"><defs><style>.cls-1{fill:#49686a;}.cls-2{fill:#f2c373;}.cls-3{fill:#2e5252;}.cls-4{fill:#ffe293;}</style></defs><polygon class="cls-1" points="26.44 242.22 136.47 3.29 226.64 204.49 188.92 204.49 131.23 87.12 66.26 242.22 26.44 242.22"/><polygon class="cls-2" points="225.54 208.88 207.73 246.6 170 246.6 187.82 208.88 225.54 208.88"/><polygon class="cls-3" points="165.62 244.41 183.43 206.68 131.23 98.09 111.45 145.3 165.62 244.41"/><polygon class="cls-3" points="130.99 0 94.31 0 0 201.2 20.96 238.93 130.99 0"/><ellipse class="cls-4" cx="114.87" cy="283.14" rx="82.77" ry="7.67"/><path class="cls-3" d="M292.58,131.27h53.14v14.32H310.15v27.35h32V187.4h-32v28.77h35.57v14.31H292.58Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M354.93,220.56l15.45-26.93-13.89-24.09v-9.92h13.32l14.74,27.64,14.74-27.64h13.32v9.92l-13.89,24.09,15.45,26.93v9.92H400.85L384.55,200l-16.3,30.47H354.93Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M428.62,172.94h-9.49V159.62h9.49V144h19.14l-8.08,15.59h20v13.32h-14.6v38.83a5.11,5.11,0,0,0,5.39,5.39h9.21v13.32H447.33q-8.35,0-13.53-5.17t-5.18-13.54Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M476.67,167.06q8.64-8.72,22.95-8.72t22.82,8.72q8.65,8.72,8.65,22.89v10.2q0,14.17-8.65,22.89t-22.82,8.72q-14.31,0-22.95-8.72T468,200.15V190Q468,175.78,476.67,167.06Zm12,46.2a14.84,14.84,0,0,0,11,4.18q6.81,0,10.92-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.92-4.11a15,15,0,0,0-11,4.11q-4.18,4.11-4.18,10.91v14.74A14.66,14.66,0,0,0,488.64,213.26Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M543.13,127.73h16.44V230.48H543.13Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M574.59,127.73H591V230.48H574.59Z" transform="translate(-18 -34.59)"/><path class="cls-3" d="M611.58,167.06q8.64-8.72,23-8.72t22.81,8.72Q666,175.78,666,190v10.2q0,14.17-8.65,22.89t-22.81,8.72q-14.32,0-23-8.72t-8.65-22.89V190Q602.93,175.78,611.58,167.06Zm12,46.2a14.85,14.85,0,0,0,11,4.18q6.8,0,10.91-4.18a14.81,14.81,0,0,0,4.11-10.84V187.68q0-6.8-4.11-10.91t-10.91-4.11a15,15,0,0,0-11,4.11q-4.17,4.11-4.18,10.91v14.74A14.7,14.7,0,0,0,623.55,213.26Z" transform="translate(-18 -34.59)"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#49686a;opacity:0.75;}.cls-2{fill:#f2c373;}</style></defs><ellipse class="cls-1" cx="214.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M5,9H66.44V25.53H25.29V57.17H62.34V73.89H25.29v33.29H66.44v16.56H5Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M77.09,112.26,95,81.11,78.9,53.23V41.76H94.31l17,32,17-32h15.41V53.23L127.75,81.11l17.88,31.15v11.48H130.21L111.36,88.49,92.5,123.74H77.09Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M162.34,57.17h-11V41.76h11v-18h22.14l-9.35,18h23.12V57.17H181.36v44.92a5.9,5.9,0,0,0,6.23,6.23h10.66v15.42H184q-9.68,0-15.66-6t-6-15.66Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M217.92,50.36q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.4,10.08q-16.56,0-26.56-10.08t-10-26.48V76.84Q207.92,60.44,217.92,50.36Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.62t-12.63-4.76q-7.86,0-12.7,4.76t-4.84,12.62v17A16.93,16.93,0,0,0,231.78,103.81Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M294.81,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M331.21,4.87h19V123.74h-19Z" transform="translate(-4.96 -4.87)"/><path class="cls-2" d="M374,50.36q10-10.08,26.56-10.08,16.4,0,26.39,10.08t10,26.48V88.65q0,16.39-10,26.48t-26.39,10.08q-16.56,0-26.56-10.08T364,88.65V76.84Q364,60.44,374,50.36Zm13.85,53.45q4.84,4.84,12.71,4.84t12.62-4.84q4.76-4.83,4.76-12.54v-17q0-7.87-4.76-12.62t-12.62-4.76q-7.87,0-12.71,4.76T383,74.22v17A17,17,0,0,0,387.85,103.81Z" transform="translate(-4.96 -4.87)"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 432 162.99"><defs><style>.cls-1{fill:#ffe293;}.cls-2{fill:#2e5252;}</style></defs><ellipse class="cls-1" cx="213.64" cy="153.99" rx="180" ry="9"/><path class="cls-2" d="M3.6,9H65.08V25.56H23.93V57.21H61V73.93h-37v33.28H65.08v16.56H3.6Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M75.73,112.3,93.61,81.14,77.54,53.27V41.79H93l17,32,17-32h15.42V53.27L126.4,81.14l17.87,31.16v11.47H128.86L110,88.52,91.15,123.77H75.73Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M161,57.21H150V41.79h11v-18h22.13l-9.34,18h23.11V57.21H180v44.92a5.9,5.9,0,0,0,6.23,6.23h10.65v15.41H182.63q-9.67,0-15.66-6t-6-15.66Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M216.57,50.4q10-10.08,26.56-10.08,16.39,0,26.39,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.39,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q206.56,60.49,216.57,50.4Zm13.85,53.45q4.83,4.84,12.71,4.84t12.62-4.84q4.75-4.83,4.75-12.54v-17q0-7.87-4.75-12.63t-12.62-4.75q-7.87,0-12.71,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,230.42,103.85Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M293.45,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M329.85,4.9h19V123.77h-19Z" transform="translate(-3.6 -4.9)"/><path class="cls-2" d="M372.64,50.4q10-10.08,26.56-10.08,16.4,0,26.4,10.08t10,26.48V88.69q0,16.4-10,26.47t-26.4,10.09q-16.56,0-26.56-10.09t-10-26.47V76.88Q362.64,60.49,372.64,50.4Zm13.86,53.45q4.83,4.84,12.7,4.84t12.63-4.84q4.76-4.83,4.75-12.54v-17q0-7.87-4.75-12.63T399.2,56.88q-7.87,0-12.7,4.75t-4.84,12.63v17A16.93,16.93,0,0,0,386.5,103.85Z" transform="translate(-3.6 -4.9)"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

51
docs/theme/layouts/default.hbs vendored Normal file
View File

@ -0,0 +1,51 @@
<!doctype html>
<html class="default no-js">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>{{#ifCond model.name '==' project.name}}{{project.name}}{{else}}{{model.name}} | {{project.name}}{{/ifCond}}</title>
<meta name="description" content="Documentation for {{project.name}}">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{relativeURL "assets/css/main.css"}}">
<link rel="author" href="{{relativeURL "humans.txt"}}">
<script async src="{{relativeURL "assets/js/search.js"}}" id="search-script"></script>
</head>
<body>
{{> header}}
<div class="container container-main">
<div class="row">
<div class="col-8 col-content">
{{{contents}}}
</div>
<div class="col-4 col-menu menu-sticky-wrap menu-highlight">
<nav class="tsd-navigation primary">
<ul>
{{#each navigation.children}}
{{> navigation}}
{{/each}}
</ul>
</nav>
<nav class="tsd-navigation secondary menu-sticky">
<ul class="before-current">
{{#each toc.children}}
{{> toc.root}}
{{/each}}
</ul>
</nav>
</div>
</div>
</div>
{{> footer}}
<div class="overlay"></div>
<script src="{{relativeURL "assets/js/main.js"}}"></script>
{{> analytics}}
</body>
</html>

35
docs/theme/partials/footer.hbs vendored Normal file
View File

@ -0,0 +1,35 @@
<footer>
<div class="container">
<h2>Legend</h2>
<div class="tsd-legend-group">
{{#each legend}}
<ul class="tsd-legend">
{{#each .}}
<li class="{{#compact}}{{#each classes}} {{.}}{{/each}}{{/compact}}"><span class="tsd-kind-icon">{{name}}</span></li>
{{/each}}
</ul>
{{/each}}
</div>
</div>
</footer>
{{#unless settings.hideGenerator}}
<div class="tsd-generator extollo-end-cap">
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-and-Text-DARK-Final.svg"}}" style="max-height: 100px" class="svg-filter-white" alt="Extollo Logo">
<p><b>extollo</b> (v. <em>latin</em>) - to lift up, to elevate</p>
<p>
Extollo is a <a href="https://www.gnu.org/philosophy/floss-and-foss.en.html" target="_blank">free & libre</a> application framework in TypeScript.
</p>
<p class="list-of-links">
<ul>
<li><a href="{{relativeURL "/"}}">Home</a></li>
<li><a href="{{relativeURL "pages/Documentation/Getting-Started.html"}}">Getting Started</a></li>
<li><a href="{{relativeURL "pages/Documentation/About-Extollo.html"}}">About Extollo</a></li>
<li><a href="https://code.garrettmills.dev/Extollo" target="_blank">Source Code</a></li>
<li><a href="https://code.garrettmills.dev/extollo/extollo/src/branch/master/CONTRIBUTING.md" target="_blank">Contributing</a></li>
<li><a href="https://code.garrettmills.dev/Extollo/docs" target="_blank">Build These Docs</a></li>
</ul>
</p>
</div>
{{/unless}}

71
docs/theme/partials/header.hbs vendored Normal file
View File

@ -0,0 +1,71 @@
<header>
<div class="tsd-page-toolbar">
<div class="container">
<div class="table-wrap">
<div class="table-cell" id="tsd-search" data-index="{{relativeURL "assets/js/search.json"}}" data-base="{{relativeURL "./"}}">
<div class="field">
<label for="tsd-search-field" class="tsd-widget search no-caption">Search</label>
<input id="tsd-search-field" type="text" />
</div>
<ul class="results">
<li class="state loading">Preparing search index...</li>
<li class="state failure">The search index is not available</li>
</ul>
<img src="{{relativeURL "assets/logo/svg/Extollo-Icon-NO-TEXT-light-and-dark-Final.svg"}}" alt="Extollo Icon" class="token-logo" style="max-height: 30px; margin-bottom: -10px; padding-right: 10px;">
<a href="{{relativeURL "index.html"}}" class="title">{{project.name}}</a>
</div>
<div class="table-cell" id="tsd-widgets">
<div id="tsd-filter">
<a href="#" class="tsd-widget options no-caption" data-toggle="options">Options</a>
<div class="tsd-filter-group">
<div class="tsd-select" id="tsd-filter-visibility">
<span class="tsd-select-label">All</span>
<ul class="tsd-select-list">
<li data-value="public">Public</li>
<li data-value="protected">Public/Protected</li>
<li data-value="private" class="selected">All</li>
</ul>
</div>
<input type="checkbox" id="tsd-filter-inherited" checked />
<label class="tsd-widget" for="tsd-filter-inherited">Inherited</label>
{{#unless settings.excludeExternals}}
<input type="checkbox" id="tsd-filter-externals" checked />
<label class="tsd-widget" for="tsd-filter-externals">Externals</label>
{{/unless}}
</div>
</div>
<a href="#" class="tsd-widget menu no-caption" data-toggle="menu">Menu</a>
</div>
</div>
</div>
</div>
<div class="tsd-page-title">
<div class="container">
{{#if model.parent}} {{! Don't show breadcrumbs on main project page, it is the root page. !}}
<ul class="tsd-breadcrumb">
{{#with model}}{{> breadcrumb}}{{/with}}
</ul>
{{/if}}
<h1>{{#compact}}
{{#ifCond model.kindString "!==" "Project" }}
{{model.kindString}}&nbsp;
{{/ifCond}}
{{model.name}}
{{#if model.typeParameters}}
&lt;
{{#each model.typeParameters}}
{{#if @index}},&nbsp;{{/if}}
{{name}}
{{/each}}
&gt;
{{/if}}
{{/compact}}</h1>
</div>
</div>
</header>

View File

@ -0,0 +1,3 @@
<div class="tsd-panel tsd-typography">
{{#markdown}}{{{model.pagesPlugin.item.contents}}}{{/markdown}}
</div>

444
package-lock.json generated
View File

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

View File

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

17
pagesconfig.json Normal file
View File

@ -0,0 +1,17 @@
{
"groups": [
{
"title": "Documentation",
"pages": [
{
"title": "Getting Started",
"source": "./docs/pages/Getting-Started.md"
},
{
"title": "About Extollo",
"source": "./docs/pages/About-Extollo.md"
}
]
}
]
}

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -0,0 +1,11 @@
import {HTTPError} from '../http/HTTPError'
import {HTTPStatus} from '../util'
/**
* Error thrown when a user attempts an action that they are not authorized to perform.
*/
export class NotAuthorizedError extends HTTPError {
constructor(message = 'Not Authorized') {
super(HTTPStatus.FORBIDDEN, message)
}
}

51
src/auth/config.ts Normal file
View File

@ -0,0 +1,51 @@
import {Instantiable, isInstantiable} from '../di'
import {AuthenticatableRepository} from './types'
import {hasOwnProperty} from '../util'
import {LoginProvider, LoginProviderConfig} from './provider/LoginProvider'
import {Middleware} from '../http/routing/Middleware'
export interface AuthenticationConfig {
storage: Instantiable<AuthenticatableRepository>,
middleware?: Instantiable<Middleware>,
providers?: {
[key: string]: {
driver: Instantiable<LoginProvider<LoginProviderConfig>>,
config: LoginProviderConfig,
},
},
}
export function isAuthenticationConfig(what: unknown): what is AuthenticationConfig {
if ( typeof what !== 'object' || !what ) {
return false
}
if ( !hasOwnProperty(what, 'storage') || !hasOwnProperty(what, 'providers') ) {
return false
}
if ( !isInstantiable(what.storage) || !(what.storage.prototype instanceof AuthenticatableRepository) ) {
return false
}
if ( typeof what.providers !== 'object' ) {
return false
}
for ( const key in what.providers ) {
if ( !hasOwnProperty(what.providers, key) ) {
continue
}
const source = what.providers[key]
if ( typeof source !== 'object' || source === null || !hasOwnProperty(source, 'driver') ) {
return false
}
if ( !isInstantiable(source.driver) || !(source.driver.prototype instanceof LoginProvider) ) {
return false
}
}
return true
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event raised when a user is re-authenticated to a security context
*/
export class UserAuthenticationResumedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserAuthenticationResumedEvent'
}

View File

@ -0,0 +1,8 @@
import {AuthenticationEvent} from './AuthenticationEvent'
/**
* Event fired when a user is unauthenticated.
*/
export class UserFlushedEvent extends AuthenticationEvent {
public readonly eventName = '@extollo/lib:UserFlushedEvent'
}

49
src/auth/index.ts Normal file
View File

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

View File

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

View File

@ -0,0 +1,30 @@
import {Middleware} from '../../http/routing/Middleware'
import {Inject, Injectable} from '../../di'
import {SecurityContext} from '../context/SecurityContext'
import {ResponseObject} from '../../http/routing/Route'
import {error} from '../../http/response/ErrorResponseFactory'
import {NotAuthorizedError} from '../NotAuthorizedError'
import {HTTPStatus} from '../../util'
import {Routing} from '../../service/Routing'
import {redirect} from '../../http/response/RedirectResponseFactory'
// TODO handle JSON and non-web
@Injectable()
export class GuestRequiredMiddleware extends Middleware {
@Inject()
protected readonly security!: SecurityContext
@Inject()
protected readonly routing!: Routing
async apply(): Promise<ResponseObject> {
if ( this.security.hasUser() ) {
if ( this.routing.hasNamedRoute('@auth.redirectFromGuest') ) {
return redirect(this.routing.getNamedPath('@auth.redirectFromGuest').toRemote)
} else {
return error(new NotAuthorizedError(), HTTPStatus.FORBIDDEN)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

43
src/auth/types.ts Normal file
View File

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

View File

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

489
src/cli/Directive.ts Normal file
View File

@ -0,0 +1,489 @@
import {Injectable, Inject} from '../di'
import {infer, ErrorWithContext} from '../util'
import {CLIOption} from './directive/options/CLIOption'
import {PositionalOption} from './directive/options/PositionalOption'
import {FlagOption} from './directive/options/FlagOption'
import {AppClass} from '../lifecycle/AppClass'
import {Logging} from '../service/Logging'
/**
* Type alias for a definition of a command-line option.
*
* This can be either an instance of CLIOption or a string describing an option.
*
* @example
* Some examples of positional/flag options defined by strings:
* `'{file name} | canonical name of the resource to create'`
*
* `'--push -p {value} | the value to be pushed'`
*
* `'--force -f | do a force push'`
*/
export type OptionDefinition = CLIOption<any> | string
/**
* An error thrown when an invalid option was detected.
*/
export class OptionValidationError extends ErrorWithContext {}
/**
* A base class representing a sub-command in the command-line utility.
*/
@Injectable()
export abstract class Directive extends AppClass {
@Inject()
protected readonly logging!: Logging
/** Parsed option values. */
private optionValues: any
/**
* Get the keyword or array of keywords that will specify this directive.
*
* @example
* If this returns `['up', 'start']`, the directive can be run by either of:
*
* ```shell
* ./ex up
* ./ex start
* ```
*/
public abstract getKeywords(): string | string[]
/**
* Get the usage description of this directive. Should be brief (1 sentence).
*/
public abstract getDescription(): string
/**
* Optionally, specify a longer usage text that is shown on the directive's `--help` page.
*/
public getHelpText(): string {
return ''
}
/**
* Get an array of options defined for this command.
*/
public getOptions(): OptionDefinition[] {
return []
}
/**
* Called when the directive is run from the command line.
*
* The raw arguments are provided as `argv`, but you are encouraged to use
* `getOptions()` and `option()` helpers to access the parsed options instead.
*
* @param argv
*/
public abstract handle(argv: string[]): void | Promise<void>
/**
* Sets the parsed option values.
* @param optionValues
* @private
*/
private setOptionValues(optionValues: any) {
this.optionValues = optionValues
}
/**
* Get the value of a parsed option. If none exists, return `defaultValue`.
* @param name
* @param defaultValue
*/
public option(name: string, defaultValue?: unknown): any {
if ( name in this.optionValues ) {
return this.optionValues[name]
}
return defaultValue
}
/**
* Invoke this directive with the specified arguments.
*
* If usage was requested (see `didRequestUsage()`), it prints the extended usage info.
*
* Otherwise, it parses the options from `argv` and calls `handle()`.
*
* @param argv
*/
async invoke(argv: string[]): Promise<void> {
const options = this.getResolvedOptions()
if ( this.didRequestUsage(argv) ) {
const positionalArguments: PositionalOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof PositionalOption ) {
positionalArguments.push(opt)
}
})
const flagArguments: FlagOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof FlagOption ) {
flagArguments.push(opt)
}
})
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
this.nativeOutput([
'',
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
'',
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
].join('\n'))
if ( positionalArguments.length ) {
this.nativeOutput([
'',
`POSITIONAL ARGUMENTS:`,
...(positionalArguments.map(arg => {
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
})),
].join('\n'))
}
if ( flagArguments.length ) {
this.nativeOutput([
'',
`FLAGS:`,
...(flagArguments.map(arg => {
return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}`
})),
].join('\n'))
}
const help = this.getHelpText()
if ( help ) {
this.nativeOutput('\n' + help)
}
this.nativeOutput('\n')
} else {
try {
const optionValues = this.parseOptions(options, argv)
this.setOptionValues(optionValues)
await this.handle(argv)
} catch (e: unknown) {
if ( e instanceof Error ) {
this.nativeOutput(e.message)
this.error(e)
}
if ( e instanceof OptionValidationError ) {
// expecting, value, requirements
if ( e.context.expecting ) {
this.nativeOutput(` - Expecting: ${e.context.expecting}`)
}
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
for ( const req of e.context.requirements ) {
this.nativeOutput(` - ${req}`)
}
}
if ( e.context.value ) {
this.nativeOutput(` - ${e.context.value}`)
}
}
this.nativeOutput('\nUse --help for more info.')
}
}
}
/**
* Resolve the array of option definitions to CLIOption instances.
* Of note, this resolves the string-form definitions to actual CLIOption instances.
*/
public getResolvedOptions(): CLIOption<any>[] {
return this.getOptions().map(option => {
if ( typeof option === 'string' ) {
return this.instantiateOptionFromString(option)
} else {
return option
}
})
}
/**
* Get the main keyword displayed for this directive.
* @example
* If `getKeywords()` returns `['up', 'start']`, this will return `'up'`.
*/
public getMainKeyword(): string {
const kws = this.getKeywords()
if ( Array.isArray(kws) ) {
return kws[0]
}
return kws
}
/**
* Returns true if the given keyword should invoke this directive.
* @param name
*/
public matchesKeyword(name: string): boolean {
let kws = this.getKeywords()
if ( !Array.isArray(kws) ) {
kws = [kws]
}
return kws.includes(name)
}
/**
* Print the given output to the log as success text.
* @param output
*/
success(output: unknown): void {
this.logging.success(output, true)
}
/**
* Print the given output to the log as error text.
* @param output
*/
error(output: unknown): void {
this.logging.error(output, true)
}
/**
* Print the given output to the log as warning text.
* @param output
*/
warn(output: unknown): void {
this.logging.warn(output, true)
}
/**
* Print the given output to the log as info text.
* @param output
*/
info(output: unknown): void {
this.logging.info(output, true)
}
/**
* Print the given output to the log as debugging text.
* @param output
*/
debug(output: unknown): void {
this.logging.debug(output, true)
}
/**
* Print the given output to the log as verbose text.
* @param output
*/
verbose(output: unknown): void {
this.logging.verbose(output, true)
}
/**
* Get the flag option that signals help. Usually, this is named 'help'
* and supports the flags '--help' and '-?'.
*/
getHelpOption(): FlagOption<any> {
return new FlagOption('--help', '-?', 'usage information about this directive')
}
/**
* Process the raw CLI arguments using an array of option class instances to build
* a mapping of option names to provided values.
*/
parseOptions(options: CLIOption<any>[], args: string[]): {[key: string]: any} {
let positionalArguments: PositionalOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof PositionalOption ) {
positionalArguments.push(opt)
}
})
const flagArguments: FlagOption<any>[] = []
options.forEach(opt => {
if ( opt instanceof FlagOption ) {
flagArguments.push(opt)
}
})
const optionValue: any = {}
flagArguments.push(this.getHelpOption())
let expectingFlagArgument = false
let positionalFlagName = ''
for ( const value of args ) {
if ( value.startsWith('--') ) {
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, {
expecting: positionalFlagName,
})
} else {
const flagArgument = flagArguments.filter(x => x.longFlag === value)
if ( flagArgument.length < 1 ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
value,
})
} else {
if ( flagArgument[0].argumentDescription ) {
positionalFlagName = flagArgument[0].getArgumentName()
expectingFlagArgument = true
} else {
optionValue[flagArgument[0].getArgumentName()] = true
}
}
}
} else if ( value.startsWith('-') ) {
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
expecting: positionalFlagName,
})
} else {
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
if ( flagArgument.length < 1 ) {
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
value,
})
} else {
if ( flagArgument[0].argumentDescription ) {
positionalFlagName = flagArgument[0].getArgumentName()
expectingFlagArgument = true
} else {
optionValue[flagArgument[0].getArgumentName()] = true
}
}
}
} else if ( expectingFlagArgument ) {
const inferredValue = infer(value)
const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0]
if ( !optionInstance.validate(inferredValue) ) {
throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, {
requirements: optionInstance.getRequirementDisplays(),
})
}
optionValue[positionalFlagName] = inferredValue
expectingFlagArgument = false
} else {
if ( positionalArguments.length < 1 ) {
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
value,
})
} else {
const inferredValue = infer(value)
if ( !positionalArguments[0].validate(inferredValue) ) {
throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, {
requirements: positionalArguments[0].getRequirementDisplays(),
})
}
optionValue[positionalArguments[0].getArgumentName()] = infer(value)
positionalArguments = positionalArguments.slice(1)
}
}
}
if ( expectingFlagArgument ) {
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
expecting: positionalFlagName,
})
}
if ( positionalArguments.length > 0 ) {
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
expecting: positionalArguments[0].getArgumentName(),
})
}
return optionValue
}
/**
* Create an instance of CLIOption based on a string definition of a particular format.
*
* e.g. '{file name} | canonical name of the resource to create'
* e.g. '--push -p {value} | the value to be pushed'
* e.g. '--force -f | do a force push'
*
* @param string
*/
protected instantiateOptionFromString(string: string): CLIOption<any> {
if ( string.startsWith('{') ) {
// The string is a positional argument
const stringParts = string.split('|').map(x => x.trim())
const name = stringParts[0].replace(/\{|\}/g, '')
return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name))
} else {
// The string is a flag argument
const stringParts = string.split('|').map(x => x.trim())
// Parse the flag parts first
const hasArgument = stringParts[0].indexOf('{') >= 0
const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim()
const flagParts = flagString.split(' ')
let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined
if ( !longFlag && flagParts.length > 1 ) {
if ( flagParts[1].startsWith('--') ) {
longFlag = flagParts[1]
}
}
let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined
if ( !shortFlag && flagParts.length > 1 ) {
if ( flagParts[1].length === 2 ) {
shortFlag = flagParts[1]
}
}
const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined
const description = stringParts.length > 1 ? stringParts[1] : undefined
return new FlagOption(longFlag, shortFlag, description, argumentDescription)
}
}
/**
* Determines if, at any point in the arguments, the help option's short or long flag appears.
* @returns {boolean} - true if the help flag appeared
*/
didRequestUsage(argv: string[]): boolean {
const helpOption = this.getHelpOption()
for ( const arg of argv ) {
if ( arg.trim() === helpOption.longFlag || arg.trim() === helpOption.shortFlag ) {
return true
}
}
return false
}
protected nativeOutput(...outputs: any[]): void {
console.log(...outputs) // eslint-disable-line no-console
}
/**
* Get a promise that resolves after SIGINT is received, executing a
* callback beforehand.
* @param callback
* @protected
*/
protected async untilInterrupt(callback?: () => unknown): Promise<void> {
return new Promise<void>(res => {
process.on('SIGINT', async () => {
if ( callback ) {
await callback()
}
res()
})
})
}
}

64
src/cli/Template.ts Normal file
View File

@ -0,0 +1,64 @@
import {UniversalPath} from '../util'
/**
* Interface defining a template that can be generated using the TemplateDirective.
*/
export interface Template {
/**
* The name of the template as it will be specified from the command line.
*
* @example
* If this is `'mytemplate'`, then the template will be created with:
*
* ```shell
* ./ex new mytemplate some:path
* ```
*/
name: string,
/**
* The suffix of the file generated by this template.
* @example `.mytemplate.ts`
* @example `.controller.ts`
*/
fileSuffix: string,
/**
* Brief description of the template displayed on the --help page for the TemplateDirective.
* Should be brief (1 sentence).
*/
description: string,
/**
* Array of path-strings that are resolved relative to the base `app` directory.
* @example `['http', 'controllers']`
* @example `['units']`
*/
baseAppPath: string[],
/**
* Render the given template to a string which will be written to the file.
* Note: this method should NOT write the contents to `targetFilePath`.
*
* @example
* If the user enters:
*
* ```shell
* ./ex new mytemplate path:to:NewInstance
* ```
*
* Then, the following params are:
* ```typescript
* {
* name: 'NewInstance',
* fullCanonicalPath: 'path:to:NewInstance',
* targetFilePath: UniversalPath { }
* }
* ```
*
* @param name - the singular name of the resource
* @param fullCanonicalName - the full canonical name of the resource
* @param targetFilePath - the UniversalPath where the file will be written
*/
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
}

23
src/cli/decorators.ts Normal file
View File

@ -0,0 +1,23 @@
import {ContainerBlueprint, Instantiable, isInstantiableOf} from '../di'
import {CommandLine} from './service'
import {Directive} from './Directive'
import {logIfDebugging} from '../util'
/**
* Register a class as a command-line Directive.
* The class must extend Directive.
* @constructor
*/
export const CLIDirective = (): ClassDecorator => {
return (target) => {
if ( isInstantiableOf(target, Directive) ) {
logIfDebugging('extollo.cli.decorators', 'Registering CLIDirective blueprint:', target)
ContainerBlueprint.getContainerBlueprint()
.onResolve<CommandLine>(CommandLine, (cli: CommandLine) => {
cli.registerDirective(target as Instantiable<Directive>)
})
} else {
logIfDebugging('extollo.cli.decorators', 'Skipping CLIDirective blueprint:', target)
}
}
}

View File

@ -0,0 +1,72 @@
import {Directive, OptionDefinition} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
import {HTTPMethod} from '../../http/lifecycle/Request'
@Injectable()
export class RouteDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'Get information about a specific route'
}
getKeywords(): string | string[] {
return ['route']
}
getOptions(): OptionDefinition[] {
return [
'{route} | the path of the route',
'--method -m {value} | the HTTP method of the route',
]
}
async handle(): Promise<void> {
const method: string | undefined = this.option('method')
?.toLowerCase()
?.trim()
const route: string = this.option('route')
.toLowerCase()
.trim()
const matched = this.routing.getCompiled()
.filter(match => {
if ( !method ) {
return match.getRoute().trim() === route
}
return (
(match.getRoute().trim() === route && match.getMethods().includes(method as HTTPMethod))
|| match.match(method as HTTPMethod, route)
)
})
.some(match => {
const displays = match.getDisplays()
.map<[string, string]>(ware => [ware.stage, ware.display])
if ( displays.isEmpty() ) {
return
}
const maxLen = displays.max(x => x[1].length)
const table = new Table({
head: ['Stage', 'Handler'],
colWidths: [10, maxLen + 2],
})
displays.each(x => table.push(x))
this.info(`\nRoute: ${match}\n\n${table}`)
return true
})
if ( !matched ) {
this.error('No matching routes found.')
}
}
}

View File

@ -0,0 +1,41 @@
import {Directive} from '../Directive'
import {Inject, Injectable} from '../../di'
import {Routing} from '../../service/Routing'
import Table = require('cli-table')
@Injectable()
export class RoutesDirective extends Directive {
@Inject()
protected readonly routing!: Routing
getDescription(): string {
return 'List routes registered in the application'
}
getKeywords(): string | string[] {
return ['routes']
}
async handle(): Promise<void> {
const compiled = this.routing.getCompiled()
const maxRouteLength = compiled.strings().max('length')
const maxHandlerLength = compiled.mapCall('getHandlerDisplay')
.whereDefined()
.max('length')
const maxNameLength = compiled.mapCall('getAlias')
.whereDefined()
.max('length')
const rows = compiled.map(route => [String(route), route.getHandlerDisplay(), route.getAlias() || ''])
const table = new Table({
head: ['Route', 'Handler', 'Name'],
colWidths: [maxRouteLength + 2, maxHandlerLength + 2, maxNameLength + 2],
})
table.push(...rows.toArray())
this.info('\n' + table)
}
}

View File

@ -0,0 +1,31 @@
import {Directive} from '../Directive'
import {CommandLineApplication} from '../service'
import {Injectable} from '../../di'
import {ErrorWithContext} from '../../util'
import {Unit} from '../../lifecycle/Unit'
/**
* A directive that starts the framework's final target normally.
* In most cases, this runs the HTTP server, which would have been replaced
* by the CommandLineApplication unit.
*/
@Injectable()
export class RunDirective extends Directive {
getDescription(): string {
return 'run the application normally'
}
getKeywords(): string | string[] {
return ['run', 'up']
}
async handle(): Promise<void> {
if ( !CommandLineApplication.getReplacement() ) {
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
}
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
await this.app().startUnit(unit)
await this.app().stopUnit(unit)
}
}

View File

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

View File

@ -0,0 +1,90 @@
import {Directive, OptionDefinition} from '../Directive'
import {PositionalOption} from './options/PositionalOption'
import {CommandLine} from '../service'
import {Inject, Injectable} from '../../di'
import {ErrorWithContext} from '../../util'
/**
* Create a new file based on a template registered with the CommandLine service.
*/
@Injectable()
export class TemplateDirective extends Directive {
@Inject()
protected readonly cli!: CommandLine
getKeywords(): string | string[] {
return ['new', 'make']
}
getDescription(): string {
return 'create a new file from a registered template'
}
getOptions(): OptionDefinition[] {
const registeredTemplates = this.cli.getTemplates()
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
template.whitelist(...registeredTemplates.pluck('name').all())
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
return [template, destination]
}
getHelpText(): string {
const registeredTemplates = this.cli.getTemplates()
return [
'Modules in Extollo register templates that can be used to quickly create common file types.',
'',
'For example, you can create a new model from @extollo/orm using the "model" template:',
'',
'./ex new model auth:Group',
'',
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
'',
'AVAILABLE TEMPLATES:',
'',
...(registeredTemplates.map(template => {
return ` - ${template.name}: ${template.description}`
}).all()),
].join('\n')
}
async handle(): Promise<void> {
const templateName: string = this.option('template_name')
const destinationName: string = this.option('file_name')
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
this.error(`The destination should be a canonical name, not a file path.`)
this.error(`Reference sub-directories using the : character instead.`)
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
process.exitCode = 1
return
}
const template = this.cli.getTemplate(templateName)
if ( !template ) {
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
templateName,
destinationName,
})
}
const name = destinationName.split(':').reverse()[0]
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
if ( await path.exists() ) {
this.error(`File already exists: ${path}`)
process.exitCode = 1
return
}
// Make sure the parent direction exists
await path.concat('..').mkdir()
const contents = await template.render(name, destinationName, path.clone())
await path.write(contents)
this.success(`Created new ${template.name} in ${path}`)
}
}

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