@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.
Files changed (64) hide show
  1. package/package.json +59 -0
  2. package/src/cache/cache_manager.ts +60 -0
  3. package/src/cache/cache_store.ts +31 -0
  4. package/src/cache/helpers.ts +74 -0
  5. package/src/cache/index.ts +4 -0
  6. package/src/cache/memory_store.ts +63 -0
  7. package/src/config/configuration.ts +105 -0
  8. package/src/config/index.ts +2 -0
  9. package/src/config/loaders/base_loader.ts +69 -0
  10. package/src/config/loaders/env_loader.ts +112 -0
  11. package/src/config/loaders/typescript_loader.ts +56 -0
  12. package/src/config/types.ts +8 -0
  13. package/src/core/application.ts +241 -0
  14. package/src/core/container.ts +113 -0
  15. package/src/core/index.ts +4 -0
  16. package/src/core/inject.ts +39 -0
  17. package/src/core/service_provider.ts +44 -0
  18. package/src/encryption/encryption_manager.ts +215 -0
  19. package/src/encryption/helpers.ts +158 -0
  20. package/src/encryption/index.ts +3 -0
  21. package/src/encryption/types.ts +6 -0
  22. package/src/events/emitter.ts +101 -0
  23. package/src/events/index.ts +2 -0
  24. package/src/exceptions/errors.ts +71 -0
  25. package/src/exceptions/exception_handler.ts +140 -0
  26. package/src/exceptions/helpers.ts +25 -0
  27. package/src/exceptions/http_exception.ts +132 -0
  28. package/src/exceptions/index.ts +23 -0
  29. package/src/exceptions/strav_error.ts +11 -0
  30. package/src/helpers/compose.ts +104 -0
  31. package/src/helpers/crypto.ts +4 -0
  32. package/src/helpers/env.ts +50 -0
  33. package/src/helpers/index.ts +6 -0
  34. package/src/helpers/strings.ts +67 -0
  35. package/src/helpers/ulid.ts +28 -0
  36. package/src/i18n/defaults/en/validation.json +20 -0
  37. package/src/i18n/helpers.ts +76 -0
  38. package/src/i18n/i18n_manager.ts +157 -0
  39. package/src/i18n/index.ts +3 -0
  40. package/src/i18n/translator.ts +96 -0
  41. package/src/i18n/types.ts +17 -0
  42. package/src/index.ts +11 -0
  43. package/src/logger/index.ts +5 -0
  44. package/src/logger/logger.ts +113 -0
  45. package/src/logger/sinks/console_sink.ts +24 -0
  46. package/src/logger/sinks/file_sink.ts +24 -0
  47. package/src/logger/sinks/sink.ts +36 -0
  48. package/src/providers/cache_provider.ts +16 -0
  49. package/src/providers/config_provider.ts +26 -0
  50. package/src/providers/encryption_provider.ts +16 -0
  51. package/src/providers/i18n_provider.ts +17 -0
  52. package/src/providers/index.ts +8 -0
  53. package/src/providers/logger_provider.ts +16 -0
  54. package/src/providers/storage_provider.ts +16 -0
  55. package/src/storage/index.ts +32 -0
  56. package/src/storage/local_driver.ts +46 -0
  57. package/src/storage/ostra_client.ts +432 -0
  58. package/src/storage/ostra_driver.ts +58 -0
  59. package/src/storage/s3_driver.ts +51 -0
  60. package/src/storage/storage.ts +43 -0
  61. package/src/storage/storage_manager.ts +70 -0
  62. package/src/storage/types.ts +49 -0
  63. package/src/storage/upload.ts +91 -0
  64. 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,4 @@
1
+ export { default as Application, app } from './application.ts'
2
+ export { default as Container } from './container.ts'
3
+ export { default as ServiceProvider } from './service_provider.ts'
4
+ export { inject, INJECTABLE } from './inject.ts'
@@ -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
+ }