@strav/kernel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +59 -0
- package/src/cache/cache_manager.ts +60 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/index.ts +4 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/index.ts +2 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +241 -0
- package/src/core/container.ts +113 -0
- package/src/core/index.ts +4 -0
- package/src/core/inject.ts +39 -0
- package/src/core/service_provider.ts +44 -0
- package/src/encryption/encryption_manager.ts +215 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +71 -0
- package/src/exceptions/exception_handler.ts +140 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +132 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/helpers/compose.ts +104 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/index.ts +6 -0
- package/src/helpers/strings.ts +67 -0
- package/src/helpers/ulid.ts +28 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +76 -0
- package/src/i18n/i18n_manager.ts +157 -0
- package/src/i18n/index.ts +3 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/index.ts +11 -0
- package/src/logger/index.ts +5 -0
- package/src/logger/logger.ts +113 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/providers/cache_provider.ts +16 -0
- package/src/providers/config_provider.ts +26 -0
- package/src/providers/encryption_provider.ts +16 -0
- package/src/providers/i18n_provider.ts +17 -0
- package/src/providers/index.ts +8 -0
- package/src/providers/logger_provider.ts +16 -0
- package/src/providers/storage_provider.ts +16 -0
- package/src/storage/index.ts +32 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/ostra_client.ts +432 -0
- package/src/storage/ostra_driver.ts +58 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +70 -0
- package/src/storage/types.ts +49 -0
- package/src/storage/upload.ts +91 -0
- package/tsconfig.json +5 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import Container from './container.ts'
|
|
2
|
+
import type ServiceProvider from './service_provider.ts'
|
|
3
|
+
import Emitter from '../events/emitter.ts'
|
|
4
|
+
|
|
5
|
+
const SHUTDOWN_TIMEOUT = 30_000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Application container with service-provider lifecycle management.
|
|
9
|
+
*
|
|
10
|
+
* Extends {@link Container} so all existing DI methods (`singleton`, `resolve`,
|
|
11
|
+
* `register`, `make`, `has`) continue to work unchanged. Adds provider
|
|
12
|
+
* orchestration: topological dependency sort, ordered boot, and reverse-order
|
|
13
|
+
* graceful shutdown on SIGINT / SIGTERM.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* import { app } from '@stravigor/kernel/core'
|
|
17
|
+
* import { ConfigProvider, DatabaseProvider, AuthProvider } from '@stravigor/kernel/providers'
|
|
18
|
+
*
|
|
19
|
+
* app
|
|
20
|
+
* .useProviders([
|
|
21
|
+
* new ConfigProvider(),
|
|
22
|
+
* new DatabaseProvider(),
|
|
23
|
+
* new AuthProvider({ resolver: (id) => User.find(id) })
|
|
24
|
+
* ])
|
|
25
|
+
* .onBooted(async () => {
|
|
26
|
+
* console.log('Application is ready!')
|
|
27
|
+
* })
|
|
28
|
+
*
|
|
29
|
+
* await app.start()
|
|
30
|
+
*/
|
|
31
|
+
export default class Application extends Container {
|
|
32
|
+
private _providers: ServiceProvider[] = []
|
|
33
|
+
private _bootedProviders: ServiceProvider[] = []
|
|
34
|
+
private _booted = false
|
|
35
|
+
private _shuttingDown = false
|
|
36
|
+
private _signalHandlers: (() => void)[] = []
|
|
37
|
+
|
|
38
|
+
/** Add a service provider. Must be called before {@link start}. */
|
|
39
|
+
use(provider: ServiceProvider): this {
|
|
40
|
+
if (this._booted) {
|
|
41
|
+
throw new Error(`Cannot add provider "${provider.name}" after the application has started.`)
|
|
42
|
+
}
|
|
43
|
+
this._providers.push(provider)
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Add multiple service providers at once. Must be called before {@link start}. */
|
|
48
|
+
useProviders(providers: ServiceProvider[]): this {
|
|
49
|
+
if (this._booted) {
|
|
50
|
+
throw new Error('Cannot add providers after the application has started.')
|
|
51
|
+
}
|
|
52
|
+
this._providers.push(...providers)
|
|
53
|
+
return this
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Register a callback to run after the application has booted. */
|
|
57
|
+
onBooted(callback: () => void | Promise<void>): this {
|
|
58
|
+
Emitter.once('app:booted', callback)
|
|
59
|
+
return this
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Boot the application.
|
|
64
|
+
*
|
|
65
|
+
* 1. Emit `app:starting`
|
|
66
|
+
* 2. Topologically sort providers by their declared dependencies
|
|
67
|
+
* 3. Call `register()` on every provider (synchronous, binds factories)
|
|
68
|
+
* 4. Call `boot()` on every provider in dependency order (async init)
|
|
69
|
+
* 5. Install SIGINT / SIGTERM handlers for graceful shutdown
|
|
70
|
+
* 6. Emit `app:booted`
|
|
71
|
+
*/
|
|
72
|
+
async start(): Promise<void> {
|
|
73
|
+
if (this._booted) return
|
|
74
|
+
|
|
75
|
+
await Emitter.emit('app:starting')
|
|
76
|
+
|
|
77
|
+
// Sort providers so dependencies are booted first
|
|
78
|
+
const sorted = this.topologicalSort(this._providers)
|
|
79
|
+
|
|
80
|
+
// Phase 1: register all (synchronous)
|
|
81
|
+
for (const provider of sorted) {
|
|
82
|
+
provider.register(this)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Phase 2: boot in order (async)
|
|
86
|
+
for (const provider of sorted) {
|
|
87
|
+
try {
|
|
88
|
+
await provider.boot(this)
|
|
89
|
+
this._bootedProviders.push(provider)
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// Rollback: shutdown already-booted providers in reverse
|
|
92
|
+
await this.shutdownProviders()
|
|
93
|
+
throw error
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
this._booted = true
|
|
98
|
+
this.installSignalHandlers()
|
|
99
|
+
|
|
100
|
+
await Emitter.emit('app:booted')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Gracefully shut down the application.
|
|
105
|
+
*
|
|
106
|
+
* Calls `shutdown()` on every booted provider in reverse boot order,
|
|
107
|
+
* then exits the process. A 30-second timeout forces exit if providers
|
|
108
|
+
* don't finish in time.
|
|
109
|
+
*/
|
|
110
|
+
async shutdown(): Promise<void> {
|
|
111
|
+
if (this._shuttingDown) return
|
|
112
|
+
this._shuttingDown = true
|
|
113
|
+
|
|
114
|
+
await Emitter.emit('app:shutdown')
|
|
115
|
+
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
console.error('Shutdown timed out, forcing exit.')
|
|
118
|
+
process.exit(1)
|
|
119
|
+
}, SHUTDOWN_TIMEOUT)
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
await this.shutdownProviders()
|
|
123
|
+
} finally {
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
this.removeSignalHandlers()
|
|
126
|
+
this._booted = false
|
|
127
|
+
this._shuttingDown = false
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await Emitter.emit('app:terminated')
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether the application has finished booting. */
|
|
134
|
+
get isBooted(): boolean {
|
|
135
|
+
return this._booted
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Whether the application is currently shutting down. */
|
|
139
|
+
get isShuttingDown(): boolean {
|
|
140
|
+
return this._shuttingDown
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Internal
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
/** Shutdown booted providers in reverse order. */
|
|
148
|
+
private async shutdownProviders(): Promise<void> {
|
|
149
|
+
const reversed = [...this._bootedProviders].reverse()
|
|
150
|
+
for (const provider of reversed) {
|
|
151
|
+
try {
|
|
152
|
+
await provider.shutdown(this)
|
|
153
|
+
} catch (error) {
|
|
154
|
+
console.error(`Error shutting down provider "${provider.name}":`, error)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
this._bootedProviders = []
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Install SIGINT/SIGTERM handlers for graceful shutdown. */
|
|
161
|
+
private installSignalHandlers(): void {
|
|
162
|
+
const handler = () => {
|
|
163
|
+
this.shutdown().then(() => process.exit(0))
|
|
164
|
+
}
|
|
165
|
+
this._signalHandlers.push(handler)
|
|
166
|
+
process.on('SIGINT', handler)
|
|
167
|
+
process.on('SIGTERM', handler)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Remove signal handlers. */
|
|
171
|
+
private removeSignalHandlers(): void {
|
|
172
|
+
for (const handler of this._signalHandlers) {
|
|
173
|
+
process.off('SIGINT', handler)
|
|
174
|
+
process.off('SIGTERM', handler)
|
|
175
|
+
}
|
|
176
|
+
this._signalHandlers = []
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Topologically sort providers using Kahn's algorithm.
|
|
181
|
+
*
|
|
182
|
+
* Throws if a provider declares a dependency on an unknown provider name,
|
|
183
|
+
* or if a circular dependency is detected.
|
|
184
|
+
*/
|
|
185
|
+
private topologicalSort(providers: ServiceProvider[]): ServiceProvider[] {
|
|
186
|
+
const byName = new Map<string, ServiceProvider>()
|
|
187
|
+
for (const p of providers) {
|
|
188
|
+
if (byName.has(p.name)) {
|
|
189
|
+
throw new Error(`Duplicate provider name: "${p.name}"`)
|
|
190
|
+
}
|
|
191
|
+
byName.set(p.name, p)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Build adjacency list and in-degree map
|
|
195
|
+
const inDegree = new Map<string, number>()
|
|
196
|
+
const dependents = new Map<string, string[]>() // dep → providers that depend on it
|
|
197
|
+
|
|
198
|
+
for (const p of providers) {
|
|
199
|
+
inDegree.set(p.name, 0)
|
|
200
|
+
dependents.set(p.name, [])
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const p of providers) {
|
|
204
|
+
for (const dep of p.dependencies) {
|
|
205
|
+
if (!byName.has(dep)) {
|
|
206
|
+
throw new Error(`Provider "${p.name}" depends on "${dep}", which is not registered.`)
|
|
207
|
+
}
|
|
208
|
+
inDegree.set(p.name, (inDegree.get(p.name) ?? 0) + 1)
|
|
209
|
+
dependents.get(dep)!.push(p.name)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Kahn's algorithm
|
|
214
|
+
const queue: string[] = []
|
|
215
|
+
for (const [name, degree] of inDegree) {
|
|
216
|
+
if (degree === 0) queue.push(name)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sorted: ServiceProvider[] = []
|
|
220
|
+
while (queue.length > 0) {
|
|
221
|
+
const name = queue.shift()!
|
|
222
|
+
sorted.push(byName.get(name)!)
|
|
223
|
+
|
|
224
|
+
for (const dependent of dependents.get(name)!) {
|
|
225
|
+
const newDegree = inDegree.get(dependent)! - 1
|
|
226
|
+
inDegree.set(dependent, newDegree)
|
|
227
|
+
if (newDegree === 0) queue.push(dependent)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (sorted.length !== providers.length) {
|
|
232
|
+
const remaining = providers.filter(p => !sorted.includes(p)).map(p => p.name)
|
|
233
|
+
throw new Error(`Circular dependency detected among providers: ${remaining.join(', ')}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return sorted
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Global application container singleton. */
|
|
241
|
+
export const app = new Application()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
import { INJECTABLE } from './inject.ts'
|
|
3
|
+
|
|
4
|
+
/** Constructor type for injectable classes. */
|
|
5
|
+
type Constructor<T = any> = new (...args: any[]) => T
|
|
6
|
+
|
|
7
|
+
/** A factory function that receives the container and returns a service instance. */
|
|
8
|
+
type Factory<T = any> = (container: Container) => T
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A lightweight dependency injection container.
|
|
12
|
+
*
|
|
13
|
+
* Services are registered as factory functions or `@inject`-decorated classes
|
|
14
|
+
* and resolved by string name or class constructor.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* const app = new Container()
|
|
18
|
+
* .singleton(Database)
|
|
19
|
+
* .singleton(UserService) // @inject decorated
|
|
20
|
+
* .singleton('logger', () => new Logger())
|
|
21
|
+
*
|
|
22
|
+
* const svc = app.resolve(UserService) // Database auto-injected
|
|
23
|
+
*/
|
|
24
|
+
export default class Container {
|
|
25
|
+
private factories = new Map<any, { factory: Factory; singleton: boolean }>()
|
|
26
|
+
private instances = new Map<any, any>()
|
|
27
|
+
|
|
28
|
+
/** Create a factory from a class constructor, resolving `design:paramtypes` metadata. */
|
|
29
|
+
private classToFactory<T>(Cls: Constructor<T>): Factory<T> {
|
|
30
|
+
const paramTypes: Constructor[] = Reflect.getMetadata('design:paramtypes', Cls) ?? []
|
|
31
|
+
return (c: Container) => new Cls(...paramTypes.map(dep => c.resolve(dep)))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Wrap an `@inject`-decorated class in a factory, or return a plain factory as-is. */
|
|
35
|
+
private toFactory<T>(factoryOrClass: Factory<T> | Constructor<T>): Factory<T> {
|
|
36
|
+
if (INJECTABLE in factoryOrClass) {
|
|
37
|
+
return this.classToFactory(factoryOrClass as Constructor<T>)
|
|
38
|
+
}
|
|
39
|
+
return factoryOrClass as Factory<T>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Register an `@inject` class that creates a new instance on every {@link resolve} call. */
|
|
43
|
+
register<T>(ctor: Constructor<T>): this
|
|
44
|
+
/** Register a factory under a class constructor key. Creates a new instance on every {@link resolve} call. */
|
|
45
|
+
register<T>(ctor: Constructor<T>, factory: Factory<T>): this
|
|
46
|
+
/** Register a factory or `@inject` class under a string name. Creates a new instance on every {@link resolve} call. */
|
|
47
|
+
register<T>(name: string, factory: Factory<T> | Constructor<T>): this
|
|
48
|
+
register<T>(nameOrCtor: string | Constructor<T>, factory?: Factory<T> | Constructor<T>): this {
|
|
49
|
+
if (typeof nameOrCtor === 'function') {
|
|
50
|
+
const resolved = factory ? this.toFactory(factory) : this.classToFactory(nameOrCtor)
|
|
51
|
+
this.factories.set(nameOrCtor, { factory: resolved, singleton: false })
|
|
52
|
+
} else {
|
|
53
|
+
this.factories.set(nameOrCtor, { factory: this.toFactory(factory!), singleton: false })
|
|
54
|
+
}
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Register an `@inject` class as a singleton by its constructor. */
|
|
59
|
+
singleton<T>(ctor: Constructor<T>): this
|
|
60
|
+
/** Register a factory under a class constructor key (resolved by constructor). */
|
|
61
|
+
singleton<T>(ctor: Constructor<T>, factory: Factory<T>): this
|
|
62
|
+
/** Register a factory or `@inject` class as a singleton under a string name. */
|
|
63
|
+
singleton<T>(name: string, factory: Factory<T> | Constructor<T>): this
|
|
64
|
+
singleton<T>(nameOrCtor: string | Constructor<T>, factory?: Factory<T> | Constructor<T>): this {
|
|
65
|
+
if (typeof nameOrCtor === 'function') {
|
|
66
|
+
const resolved = factory ? this.toFactory(factory) : this.classToFactory(nameOrCtor)
|
|
67
|
+
this.factories.set(nameOrCtor, { factory: resolved, singleton: true })
|
|
68
|
+
} else {
|
|
69
|
+
this.factories.set(nameOrCtor, { factory: this.toFactory(factory!), singleton: true })
|
|
70
|
+
}
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve a service by its class constructor. */
|
|
75
|
+
resolve<T>(ctor: Constructor<T>): T
|
|
76
|
+
/** Resolve a service by its string name. */
|
|
77
|
+
resolve<T>(name: string): T
|
|
78
|
+
resolve<T>(key: string | Constructor<T>): T {
|
|
79
|
+
const entry = this.factories.get(key)
|
|
80
|
+
if (!entry) {
|
|
81
|
+
const label = typeof key === 'string' ? `"${key}"` : key.name
|
|
82
|
+
throw new Error(`Service ${label} is not registered`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (entry.singleton) {
|
|
86
|
+
if (!this.instances.has(key)) {
|
|
87
|
+
this.instances.set(key, entry.factory(this))
|
|
88
|
+
}
|
|
89
|
+
return this.instances.get(key)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return entry.factory(this)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Check whether a service has been registered under the given name or constructor. */
|
|
96
|
+
has(key: string | Constructor): boolean {
|
|
97
|
+
return this.factories.has(key)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Instantiate a class with automatic dependency injection.
|
|
102
|
+
*
|
|
103
|
+
* Unlike {@link resolve}, this does not require prior registration.
|
|
104
|
+
* Constructor dependencies are resolved recursively: registered services
|
|
105
|
+
* are pulled from the container, unregistered `@inject` classes are
|
|
106
|
+
* instantiated via `make()` as well.
|
|
107
|
+
*/
|
|
108
|
+
make<T>(ctor: Constructor<T>): T {
|
|
109
|
+
const paramTypes: Constructor[] = Reflect.getMetadata('design:paramtypes', ctor) ?? []
|
|
110
|
+
const deps = paramTypes.map(dep => (this.has(dep) ? this.resolve(dep) : this.make(dep)))
|
|
111
|
+
return new ctor(...deps)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import 'reflect-metadata'
|
|
2
|
+
|
|
3
|
+
/** Symbol used to mark a class as injectable. */
|
|
4
|
+
export const INJECTABLE = Symbol('inject:injectable')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Class decorator that marks a class as injectable.
|
|
8
|
+
*
|
|
9
|
+
* With `emitDecoratorMetadata` enabled, TypeScript automatically emits
|
|
10
|
+
* constructor parameter type metadata (`design:paramtypes`). The container
|
|
11
|
+
* reads this metadata to auto-resolve dependencies by class reference.
|
|
12
|
+
*
|
|
13
|
+
* Works as both `@inject` and `@inject()`.
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* @inject
|
|
17
|
+
* class UserService {
|
|
18
|
+
* constructor(protected db: Database, protected logger: Logger) {}
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* container.singleton(Database)
|
|
22
|
+
* container.singleton(Logger)
|
|
23
|
+
* container.singleton(UserService)
|
|
24
|
+
* container.resolve(UserService) // db and logger auto-injected by type
|
|
25
|
+
*/
|
|
26
|
+
export function inject<T extends Function>(target: T): void
|
|
27
|
+
export function inject(): <T extends Function>(target: T) => void
|
|
28
|
+
export function inject(target?: any) {
|
|
29
|
+
const mark = (cls: any) => {
|
|
30
|
+
Object.defineProperty(cls, INJECTABLE, { value: true, enumerable: false })
|
|
31
|
+
}
|
|
32
|
+
if (typeof target === 'function') {
|
|
33
|
+
mark(target)
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
return (cls: any) => {
|
|
37
|
+
mark(cls)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type Application from './application.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Base class for service providers.
|
|
5
|
+
*
|
|
6
|
+
* A service provider encapsulates the full lifecycle of a framework service:
|
|
7
|
+
* registration (binding into the container), booting (async initialization),
|
|
8
|
+
* and shutdown (cleanup). The {@link Application} orchestrates providers
|
|
9
|
+
* in dependency order via topological sort.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* class AuthProvider extends ServiceProvider {
|
|
13
|
+
* readonly name = 'auth'
|
|
14
|
+
* readonly dependencies = ['database']
|
|
15
|
+
*
|
|
16
|
+
* register(app: Application): void {
|
|
17
|
+
* app.singleton(Auth)
|
|
18
|
+
* }
|
|
19
|
+
*
|
|
20
|
+
* async boot(app: Application): Promise<void> {
|
|
21
|
+
* app.resolve(Auth)
|
|
22
|
+
* Auth.useResolver((id) => User.find(id))
|
|
23
|
+
* await Auth.ensureTables()
|
|
24
|
+
* }
|
|
25
|
+
*
|
|
26
|
+
* async shutdown(app: Application): Promise<void> {}
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
export default abstract class ServiceProvider {
|
|
30
|
+
/** Unique name used for dependency resolution between providers. */
|
|
31
|
+
abstract readonly name: string
|
|
32
|
+
|
|
33
|
+
/** Names of other providers that must be registered and booted first. */
|
|
34
|
+
readonly dependencies: string[] = []
|
|
35
|
+
|
|
36
|
+
/** Bind services into the container. Synchronous. Called before boot(). */
|
|
37
|
+
register(app: Application): void {}
|
|
38
|
+
|
|
39
|
+
/** Initialize services after ALL providers are registered. Can be async. */
|
|
40
|
+
boot(app: Application): void | Promise<void> {}
|
|
41
|
+
|
|
42
|
+
/** Clean up resources during shutdown. Called in reverse boot order. */
|
|
43
|
+
shutdown(app: Application): void | Promise<void> {}
|
|
44
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import {
|
|
2
|
+
hkdfSync,
|
|
3
|
+
createCipheriv,
|
|
4
|
+
createDecipheriv,
|
|
5
|
+
createHmac,
|
|
6
|
+
timingSafeEqual,
|
|
7
|
+
} from 'node:crypto'
|
|
8
|
+
import { inject } from '../core/inject.ts'
|
|
9
|
+
import Configuration from '../config/configuration.ts'
|
|
10
|
+
import type { EncryptionConfig } from './types.ts'
|
|
11
|
+
import { ConfigurationError, EncryptionError } from '../exceptions/errors.ts'
|
|
12
|
+
|
|
13
|
+
const IV_LENGTH = 12
|
|
14
|
+
const TAG_LENGTH = 16
|
|
15
|
+
const KEY_LENGTH = 32
|
|
16
|
+
const ALGORITHM = 'aes-256-gcm'
|
|
17
|
+
const HKDF_SALT = 'strav-encryption-salt'
|
|
18
|
+
|
|
19
|
+
function deriveKey(raw: string, info: string): Buffer {
|
|
20
|
+
return Buffer.from(hkdfSync('sha256', raw, HKDF_SALT, info, KEY_LENGTH))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function encryptWithKey(plaintext: string, key: Buffer): string {
|
|
24
|
+
const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(IV_LENGTH)))
|
|
25
|
+
const cipher = createCipheriv(ALGORITHM, key, iv)
|
|
26
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
27
|
+
const tag = cipher.getAuthTag()
|
|
28
|
+
// iv (12) + ciphertext (variable) + tag (16)
|
|
29
|
+
const payload = Buffer.concat([iv, encrypted, tag])
|
|
30
|
+
return payload.toString('base64url')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function decryptWithKey(payload: string, key: Buffer): string {
|
|
34
|
+
const buf = Buffer.from(payload, 'base64url')
|
|
35
|
+
if (buf.length < IV_LENGTH + TAG_LENGTH) {
|
|
36
|
+
throw new EncryptionError('Invalid encrypted payload: too short.')
|
|
37
|
+
}
|
|
38
|
+
const iv = buf.subarray(0, IV_LENGTH)
|
|
39
|
+
const tag = buf.subarray(buf.length - TAG_LENGTH)
|
|
40
|
+
const ciphertext = buf.subarray(IV_LENGTH, buf.length - TAG_LENGTH)
|
|
41
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv)
|
|
42
|
+
decipher.setAuthTag(tag)
|
|
43
|
+
return decipher.update(ciphertext) + decipher.final('utf8')
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Central encryption configuration hub.
|
|
48
|
+
*
|
|
49
|
+
* Resolved once via the DI container — reads the encryption config
|
|
50
|
+
* and derives cryptographic keys from the application key.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* app.singleton(EncryptionManager)
|
|
54
|
+
* app.resolve(EncryptionManager)
|
|
55
|
+
*
|
|
56
|
+
* // Swap keys at runtime (e.g., for testing)
|
|
57
|
+
* EncryptionManager.useKey('test-key-here')
|
|
58
|
+
*/
|
|
59
|
+
@inject
|
|
60
|
+
export default class EncryptionManager {
|
|
61
|
+
private static _config: EncryptionConfig
|
|
62
|
+
private static _encryptionKey: Buffer
|
|
63
|
+
private static _hmacKey: Buffer
|
|
64
|
+
private static _previousEncryptionKeys: Buffer[]
|
|
65
|
+
private static _previousHmacKeys: Buffer[]
|
|
66
|
+
|
|
67
|
+
constructor(config: Configuration) {
|
|
68
|
+
EncryptionManager._config = {
|
|
69
|
+
key: '',
|
|
70
|
+
previousKeys: [],
|
|
71
|
+
...(config.get('encryption', {}) as object),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const raw = EncryptionManager._config.key
|
|
75
|
+
if (!raw) {
|
|
76
|
+
throw new ConfigurationError(
|
|
77
|
+
'Encryption key is not set. Set APP_KEY in your .env file or configure encryption.key.'
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
EncryptionManager._encryptionKey = deriveKey(raw, 'aes-256-gcm')
|
|
82
|
+
EncryptionManager._hmacKey = deriveKey(raw, 'hmac-sha256')
|
|
83
|
+
|
|
84
|
+
EncryptionManager._previousEncryptionKeys = EncryptionManager._config.previousKeys.map(k =>
|
|
85
|
+
deriveKey(k, 'aes-256-gcm')
|
|
86
|
+
)
|
|
87
|
+
EncryptionManager._previousHmacKeys = EncryptionManager._config.previousKeys.map(k =>
|
|
88
|
+
deriveKey(k, 'hmac-sha256')
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
static get config(): EncryptionConfig {
|
|
93
|
+
return EncryptionManager._config
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Swap the application key at runtime (e.g., for testing). */
|
|
97
|
+
static useKey(key: string): void {
|
|
98
|
+
EncryptionManager._encryptionKey = deriveKey(key, 'aes-256-gcm')
|
|
99
|
+
EncryptionManager._hmacKey = deriveKey(key, 'hmac-sha256')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Symmetric Encryption (AES-256-GCM)
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** Encrypt a plaintext string. Returns a base64url-encoded payload. */
|
|
107
|
+
static encrypt(plaintext: string): string {
|
|
108
|
+
return encryptWithKey(plaintext, EncryptionManager._encryptionKey)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Decrypt a payload. Tries the current key first, then previous keys for rotation.
|
|
113
|
+
* Throws if none of the keys can decrypt the payload.
|
|
114
|
+
*/
|
|
115
|
+
static decrypt(payload: string): string {
|
|
116
|
+
try {
|
|
117
|
+
return decryptWithKey(payload, EncryptionManager._encryptionKey)
|
|
118
|
+
} catch {
|
|
119
|
+
// Try previous keys for rotation
|
|
120
|
+
for (const key of EncryptionManager._previousEncryptionKeys) {
|
|
121
|
+
try {
|
|
122
|
+
return decryptWithKey(payload, key)
|
|
123
|
+
} catch {
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new EncryptionError('Decryption failed: invalid payload or key.')
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Encrypt and JSON-serialize an object. */
|
|
132
|
+
static seal(data: unknown): string {
|
|
133
|
+
return EncryptionManager.encrypt(JSON.stringify(data))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Decrypt and JSON-deserialize an object. */
|
|
137
|
+
static unseal<T = unknown>(payload: string): T {
|
|
138
|
+
return JSON.parse(EncryptionManager.decrypt(payload)) as T
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// HMAC Signing
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
/** Create an HMAC-SHA256 signature. Returns a hex string. */
|
|
146
|
+
static sign(data: string): string {
|
|
147
|
+
return createHmac('sha256', EncryptionManager._hmacKey).update(data).digest('hex')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Verify an HMAC-SHA256 signature using timing-safe comparison.
|
|
152
|
+
* Tries the current key first, then previous keys for rotation.
|
|
153
|
+
*/
|
|
154
|
+
static verifySignature(data: string, signature: string): boolean {
|
|
155
|
+
const expected = Buffer.from(EncryptionManager.sign(data), 'hex')
|
|
156
|
+
const actual = Buffer.from(signature, 'hex')
|
|
157
|
+
if (expected.length !== actual.length) {
|
|
158
|
+
// Try previous keys
|
|
159
|
+
for (const key of EncryptionManager._previousHmacKeys) {
|
|
160
|
+
const prev = Buffer.from(createHmac('sha256', key).update(data).digest('hex'), 'hex')
|
|
161
|
+
if (prev.length === actual.length && timingSafeEqual(prev, actual)) return true
|
|
162
|
+
}
|
|
163
|
+
return false
|
|
164
|
+
}
|
|
165
|
+
if (timingSafeEqual(expected, actual)) return true
|
|
166
|
+
// Try previous keys
|
|
167
|
+
for (const key of EncryptionManager._previousHmacKeys) {
|
|
168
|
+
const prev = Buffer.from(createHmac('sha256', key).update(data).digest('hex'), 'hex')
|
|
169
|
+
if (prev.length === actual.length && timingSafeEqual(prev, actual)) return true
|
|
170
|
+
}
|
|
171
|
+
return false
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Password Hashing (Bun.password — argon2id)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/** Hash a password using argon2id. Returns an encoded hash string. */
|
|
179
|
+
static hash(password: string): Promise<string> {
|
|
180
|
+
return Bun.password.hash(password, 'argon2id')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Verify a password against a hash. Works with argon2id and bcrypt hashes. */
|
|
184
|
+
static verify(password: string, hash: string): Promise<boolean> {
|
|
185
|
+
return Bun.password.verify(password, hash)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// One-way Hashing
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/** SHA-256 hash. Returns a hex string. */
|
|
193
|
+
static sha256(data: string): string {
|
|
194
|
+
return new Bun.CryptoHasher('sha256').update(data).digest('hex')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** SHA-512 hash. Returns a hex string. */
|
|
198
|
+
static sha512(data: string): string {
|
|
199
|
+
return new Bun.CryptoHasher('sha512').update(data).digest('hex')
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Random Generation
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Generate a random hex string (2 hex chars per byte). */
|
|
207
|
+
static random(bytes: number = 32): string {
|
|
208
|
+
return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Generate raw random bytes. */
|
|
212
|
+
static randomBytes(bytes: number = 32): Uint8Array {
|
|
213
|
+
return crypto.getRandomValues(new Uint8Array(bytes))
|
|
214
|
+
}
|
|
215
|
+
}
|