@stravigor/core 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 (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. package/tsconfig.json +4 -0
@@ -0,0 +1,474 @@
1
+ import { DateTime } from 'luxon'
2
+ import { toSnakeCase } from '../helpers/strings.ts'
3
+ import type BaseModel from '../orm/base_model.ts'
4
+ import { ModelNotFoundError } from '../exceptions/errors.ts'
5
+
6
+ type ModelStatic<T extends BaseModel> = (new (...args: any[]) => T) & typeof BaseModel
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Pagination
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export interface PaginationMeta {
13
+ page: number
14
+ perPage: number
15
+ total: number
16
+ lastPage: number
17
+ from: number
18
+ to: number
19
+ }
20
+
21
+ export interface PaginationResult<T> {
22
+ data: T[]
23
+ meta: PaginationMeta
24
+ }
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Internal types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ type WhereClause =
31
+ | { kind: 'comparison'; column: string; operator: string; value: unknown }
32
+ | { kind: 'in' | 'not_in'; column: string; values: unknown[] }
33
+ | { kind: 'null' | 'not_null'; column: string }
34
+ | { kind: 'between'; column: string; low: unknown; high: unknown }
35
+ | { kind: 'raw'; sql: string; params: unknown[] }
36
+
37
+ interface JoinClause {
38
+ type: 'LEFT JOIN' | 'INNER JOIN' | 'RIGHT JOIN'
39
+ table: string
40
+ alias: string
41
+ leftCol: string
42
+ operator: string
43
+ rightCol: string
44
+ }
45
+
46
+ interface OrderByClause {
47
+ column: string
48
+ direction: 'ASC' | 'DESC'
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // JoinBuilder
53
+ // ---------------------------------------------------------------------------
54
+
55
+ class JoinBuilder<T extends BaseModel> {
56
+ constructor(
57
+ private parent: QueryBuilder<T>,
58
+ private model: typeof BaseModel,
59
+ private joinType: 'LEFT JOIN' | 'INNER JOIN' | 'RIGHT JOIN'
60
+ ) {}
61
+
62
+ on(leftColumn: string, operator: string, rightColumn: string): QueryBuilder<T> {
63
+ return this.parent._addJoin({
64
+ type: this.joinType,
65
+ table: this.model.tableName,
66
+ alias: this.model.name,
67
+ leftCol: leftColumn,
68
+ operator,
69
+ rightCol: rightColumn,
70
+ })
71
+ }
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // QueryBuilder
76
+ // ---------------------------------------------------------------------------
77
+
78
+ export default class QueryBuilder<T extends BaseModel> {
79
+ private modelClass: ModelStatic<T>
80
+ private primaryTable: string
81
+ private models: Map<string, typeof BaseModel> = new Map()
82
+
83
+ private wheres: WhereClause[] = []
84
+ private joins: JoinClause[] = []
85
+ private orderBys: OrderByClause[] = []
86
+ private groupBys: string[] = []
87
+ private selectColumns: string[] = []
88
+ private limitValue: number | null = null
89
+ private offsetValue: number | null = null
90
+ private isDistinct: boolean = false
91
+ private includeTrashed: boolean = false
92
+ private isOnlyTrashed: boolean = false
93
+
94
+ constructor(modelClass: ModelStatic<T>) {
95
+ this.modelClass = modelClass
96
+ this.primaryTable = modelClass.tableName
97
+ this.models.set(modelClass.name, modelClass)
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // WHERE
102
+ // ---------------------------------------------------------------------------
103
+
104
+ where(column: string, operatorOrValue: unknown, value?: unknown): this {
105
+ if (value === undefined) {
106
+ this.wheres.push({ kind: 'comparison', column, operator: '=', value: operatorOrValue })
107
+ } else {
108
+ this.wheres.push({ kind: 'comparison', column, operator: operatorOrValue as string, value })
109
+ }
110
+ return this
111
+ }
112
+
113
+ whereIn(column: string, values: unknown[]): this {
114
+ this.wheres.push({ kind: 'in', column, values })
115
+ return this
116
+ }
117
+
118
+ whereNotIn(column: string, values: unknown[]): this {
119
+ this.wheres.push({ kind: 'not_in', column, values })
120
+ return this
121
+ }
122
+
123
+ whereNull(column: string): this {
124
+ this.wheres.push({ kind: 'null', column })
125
+ return this
126
+ }
127
+
128
+ whereNotNull(column: string): this {
129
+ this.wheres.push({ kind: 'not_null', column })
130
+ return this
131
+ }
132
+
133
+ whereBetween(column: string, low: unknown, high: unknown): this {
134
+ this.wheres.push({ kind: 'between', column, low, high })
135
+ return this
136
+ }
137
+
138
+ whereRaw(sql: string, params: unknown[] = []): this {
139
+ this.wheres.push({ kind: 'raw', sql, params })
140
+ return this
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // JOIN
145
+ // ---------------------------------------------------------------------------
146
+
147
+ leftJoin(model: typeof BaseModel): JoinBuilder<T> {
148
+ this.models.set(model.name, model)
149
+ return new JoinBuilder(this, model, 'LEFT JOIN')
150
+ }
151
+
152
+ innerJoin(model: typeof BaseModel): JoinBuilder<T> {
153
+ this.models.set(model.name, model)
154
+ return new JoinBuilder(this, model, 'INNER JOIN')
155
+ }
156
+
157
+ rightJoin(model: typeof BaseModel): JoinBuilder<T> {
158
+ this.models.set(model.name, model)
159
+ return new JoinBuilder(this, model, 'RIGHT JOIN')
160
+ }
161
+
162
+ /** @internal Called by JoinBuilder to register a completed join. */
163
+ _addJoin(join: JoinClause): this {
164
+ this.joins.push(join)
165
+ return this
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // SELECT, ORDER, GROUP, LIMIT, OFFSET
170
+ // ---------------------------------------------------------------------------
171
+
172
+ select(...columns: string[]): this {
173
+ this.selectColumns.push(...columns)
174
+ return this
175
+ }
176
+
177
+ orderBy(column: string, direction: 'asc' | 'desc' | 'ASC' | 'DESC' = 'ASC'): this {
178
+ this.orderBys.push({ column, direction: direction.toUpperCase() as 'ASC' | 'DESC' })
179
+ return this
180
+ }
181
+
182
+ groupBy(...columns: string[]): this {
183
+ this.groupBys.push(...columns)
184
+ return this
185
+ }
186
+
187
+ limit(n: number): this {
188
+ this.limitValue = n
189
+ return this
190
+ }
191
+
192
+ offset(n: number): this {
193
+ this.offsetValue = n
194
+ return this
195
+ }
196
+
197
+ distinct(): this {
198
+ this.isDistinct = true
199
+ return this
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Soft deletes
204
+ // ---------------------------------------------------------------------------
205
+
206
+ withTrashed(): this {
207
+ this.includeTrashed = true
208
+ return this
209
+ }
210
+
211
+ onlyTrashed(): this {
212
+ this.isOnlyTrashed = true
213
+ return this
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // Terminal methods
218
+ // ---------------------------------------------------------------------------
219
+
220
+ async all(): Promise<T[]> {
221
+ const { sql, params } = this.build('select')
222
+ const db = this.modelClass.db
223
+ const rows = await db.sql.unsafe(sql, params)
224
+ const Model = this.modelClass as any
225
+ return rows.map((row: Record<string, unknown>) => Model.hydrate(row) as T)
226
+ }
227
+
228
+ async first(): Promise<T | null> {
229
+ const savedLimit = this.limitValue
230
+ this.limitValue = 1
231
+ const results = await this.all()
232
+ this.limitValue = savedLimit
233
+ return results[0] ?? null
234
+ }
235
+
236
+ async firstOrFail(): Promise<T> {
237
+ const result = await this.first()
238
+ if (!result) {
239
+ throw new ModelNotFoundError(this.modelClass.name)
240
+ }
241
+ return result
242
+ }
243
+
244
+ async count(): Promise<number> {
245
+ const { sql, params } = this.build('count')
246
+ const db = this.modelClass.db
247
+ const rows = await db.sql.unsafe(sql, params)
248
+ return Number(rows[0]?.count ?? 0)
249
+ }
250
+
251
+ async exists(): Promise<boolean> {
252
+ return (await this.count()) > 0
253
+ }
254
+
255
+ async paginate(page: number = 1, perPage: number = 15): Promise<PaginationResult<T>> {
256
+ const currentPage = Math.max(1, Math.floor(page))
257
+
258
+ const total = await this.count()
259
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
260
+
261
+ const savedLimit = this.limitValue
262
+ const savedOffset = this.offsetValue
263
+ this.limitValue = perPage
264
+ this.offsetValue = (currentPage - 1) * perPage
265
+ const data = await this.all()
266
+ this.limitValue = savedLimit
267
+ this.offsetValue = savedOffset
268
+
269
+ const from = total > 0 ? (currentPage - 1) * perPage + 1 : 0
270
+ const to = Math.min(currentPage * perPage, total)
271
+
272
+ return {
273
+ data,
274
+ meta: { page: currentPage, perPage, total, lastPage, from, to },
275
+ }
276
+ }
277
+
278
+ /** Return the generated SQL and params without executing. */
279
+ toSQL(): { sql: string; params: unknown[] } {
280
+ return this.build('select')
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // SQL building
285
+ // ---------------------------------------------------------------------------
286
+
287
+ private build(mode: 'select' | 'count'): { sql: string; params: unknown[] } {
288
+ const parts: string[] = []
289
+ const params: unknown[] = []
290
+ let paramIdx = 1
291
+
292
+ // SELECT
293
+ if (mode === 'count') {
294
+ parts.push('SELECT COUNT(*) AS "count"')
295
+ } else if (this.selectColumns.length > 0) {
296
+ const cols = this.selectColumns.map(c => this.resolveSelectColumn(c))
297
+ parts.push(`SELECT ${this.isDistinct ? 'DISTINCT ' : ''}${cols.join(', ')}`)
298
+ } else {
299
+ parts.push(`SELECT ${this.isDistinct ? 'DISTINCT ' : ''}"${this.primaryTable}".*`)
300
+ }
301
+
302
+ // FROM
303
+ parts.push(`FROM "${this.primaryTable}"`)
304
+
305
+ // JOINs
306
+ for (const join of this.joins) {
307
+ const left = this.resolveColumn(join.leftCol)
308
+ const right = this.resolveColumn(join.rightCol)
309
+ parts.push(`${join.type} "${join.table}" ON ${left} ${join.operator} ${right}`)
310
+ }
311
+
312
+ // WHERE
313
+ const whereParts: string[] = []
314
+
315
+ // Soft delete handling
316
+ if (this.modelClass.softDeletes && !this.includeTrashed) {
317
+ if (this.isOnlyTrashed) {
318
+ whereParts.push(`"${this.primaryTable}"."deleted_at" IS NOT NULL`)
319
+ } else {
320
+ whereParts.push(`"${this.primaryTable}"."deleted_at" IS NULL`)
321
+ }
322
+ }
323
+
324
+ for (const w of this.wheres) {
325
+ switch (w.kind) {
326
+ case 'comparison': {
327
+ const col = this.resolveColumn(w.column)
328
+ params.push(this.dehydrateValue(w.value))
329
+ whereParts.push(`${col} ${w.operator} $${paramIdx++}`)
330
+ break
331
+ }
332
+ case 'in': {
333
+ const col = this.resolveColumn(w.column)
334
+ const placeholders = w.values.map(v => {
335
+ params.push(this.dehydrateValue(v))
336
+ return `$${paramIdx++}`
337
+ })
338
+ whereParts.push(`${col} IN (${placeholders.join(', ')})`)
339
+ break
340
+ }
341
+ case 'not_in': {
342
+ const col = this.resolveColumn(w.column)
343
+ const placeholders = w.values.map(v => {
344
+ params.push(this.dehydrateValue(v))
345
+ return `$${paramIdx++}`
346
+ })
347
+ whereParts.push(`${col} NOT IN (${placeholders.join(', ')})`)
348
+ break
349
+ }
350
+ case 'null': {
351
+ const col = this.resolveColumn(w.column)
352
+ whereParts.push(`${col} IS NULL`)
353
+ break
354
+ }
355
+ case 'not_null': {
356
+ const col = this.resolveColumn(w.column)
357
+ whereParts.push(`${col} IS NOT NULL`)
358
+ break
359
+ }
360
+ case 'between': {
361
+ const col = this.resolveColumn(w.column)
362
+ params.push(this.dehydrateValue(w.low))
363
+ params.push(this.dehydrateValue(w.high))
364
+ whereParts.push(`${col} BETWEEN $${paramIdx++} AND $${paramIdx++}`)
365
+ break
366
+ }
367
+ case 'raw': {
368
+ let rawSql = w.sql
369
+ for (const p of w.params) {
370
+ rawSql = rawSql.replace(`$${w.params.indexOf(p) + 1}`, `$${paramIdx++}`)
371
+ params.push(this.dehydrateValue(p))
372
+ }
373
+ whereParts.push(rawSql)
374
+ break
375
+ }
376
+ }
377
+ }
378
+
379
+ if (whereParts.length > 0) {
380
+ parts.push(`WHERE ${whereParts.join(' AND ')}`)
381
+ }
382
+
383
+ // GROUP BY
384
+ if (this.groupBys.length > 0) {
385
+ const cols = this.groupBys.map(c => this.resolveColumn(c))
386
+ parts.push(`GROUP BY ${cols.join(', ')}`)
387
+ }
388
+
389
+ // count mode skips ORDER BY, LIMIT, OFFSET
390
+ if (mode === 'select') {
391
+ // ORDER BY
392
+ if (this.orderBys.length > 0) {
393
+ const clauses = this.orderBys.map(o => `${this.resolveColumn(o.column)} ${o.direction}`)
394
+ parts.push(`ORDER BY ${clauses.join(', ')}`)
395
+ }
396
+
397
+ // LIMIT
398
+ if (this.limitValue !== null) {
399
+ parts.push(`LIMIT ${this.limitValue}`)
400
+ }
401
+
402
+ // OFFSET
403
+ if (this.offsetValue !== null) {
404
+ parts.push(`OFFSET ${this.offsetValue}`)
405
+ }
406
+ }
407
+
408
+ return { sql: parts.join(' '), params }
409
+ }
410
+
411
+ // ---------------------------------------------------------------------------
412
+ // Column resolution
413
+ // ---------------------------------------------------------------------------
414
+
415
+ /**
416
+ * Resolve a user column reference to a fully qualified SQL identifier.
417
+ *
418
+ * - `'email'` → `"user"."email"`
419
+ * - `'User.email'` → `"user"."email"`
420
+ * - `'Project.userId'` → `"project"."user_id"`
421
+ */
422
+ private resolveColumn(ref: string): string {
423
+ const dot = ref.indexOf('.')
424
+ if (dot === -1) {
425
+ return `"${this.primaryTable}"."${toSnakeCase(ref)}"`
426
+ }
427
+
428
+ const modelName = ref.substring(0, dot)
429
+ const propName = ref.substring(dot + 1)
430
+ const tableName = this.resolveModelTable(modelName)
431
+ return `"${tableName}"."${toSnakeCase(propName)}"`
432
+ }
433
+
434
+ /**
435
+ * Resolve a select column. Passes through raw expressions containing
436
+ * special characters (parentheses, asterisk, AS keyword).
437
+ */
438
+ private resolveSelectColumn(col: string): string {
439
+ if (/[(*)]/.test(col) || /\bAS\b/i.test(col)) {
440
+ return col
441
+ }
442
+ return this.resolveColumn(col)
443
+ }
444
+
445
+ /** Resolve a PascalCase model name to its table name. */
446
+ private resolveModelTable(modelName: string): string {
447
+ const model = this.models.get(modelName)
448
+ if (model) return model.tableName
449
+ return toSnakeCase(modelName)
450
+ }
451
+
452
+ // ---------------------------------------------------------------------------
453
+ // Value helpers
454
+ // ---------------------------------------------------------------------------
455
+
456
+ private dehydrateValue(value: unknown): unknown {
457
+ if (value instanceof DateTime) return value.toJSDate()
458
+ return value
459
+ }
460
+ }
461
+
462
+ // ---------------------------------------------------------------------------
463
+ // Entry point
464
+ // ---------------------------------------------------------------------------
465
+
466
+ /**
467
+ * Create a new QueryBuilder for the given model class.
468
+ *
469
+ * @example
470
+ * const users = await query(User).where('email', 'test@example.com').all()
471
+ */
472
+ export function query<T extends BaseModel>(model: ModelStatic<T>): QueryBuilder<T> {
473
+ return new QueryBuilder<T>(model)
474
+ }
@@ -0,0 +1,209 @@
1
+ import { hkdfSync, createCipheriv, createDecipheriv, createHmac, timingSafeEqual } from 'node:crypto'
2
+ import { inject } from '../core/inject.ts'
3
+ import Configuration from '../config/configuration.ts'
4
+ import type { EncryptionConfig } from './types.ts'
5
+ import { ConfigurationError, EncryptionError } from '../exceptions/errors.ts'
6
+
7
+ const IV_LENGTH = 12
8
+ const TAG_LENGTH = 16
9
+ const KEY_LENGTH = 32
10
+ const ALGORITHM = 'aes-256-gcm'
11
+ const HKDF_SALT = 'strav-encryption-salt'
12
+
13
+ function deriveKey(raw: string, info: string): Buffer {
14
+ return Buffer.from(hkdfSync('sha256', raw, HKDF_SALT, info, KEY_LENGTH))
15
+ }
16
+
17
+ function encryptWithKey(plaintext: string, key: Buffer): string {
18
+ const iv = Buffer.from(crypto.getRandomValues(new Uint8Array(IV_LENGTH)))
19
+ const cipher = createCipheriv(ALGORITHM, key, iv)
20
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
21
+ const tag = cipher.getAuthTag()
22
+ // iv (12) + ciphertext (variable) + tag (16)
23
+ const payload = Buffer.concat([iv, encrypted, tag])
24
+ return payload.toString('base64url')
25
+ }
26
+
27
+ function decryptWithKey(payload: string, key: Buffer): string {
28
+ const buf = Buffer.from(payload, 'base64url')
29
+ if (buf.length < IV_LENGTH + TAG_LENGTH) {
30
+ throw new EncryptionError('Invalid encrypted payload: too short.')
31
+ }
32
+ const iv = buf.subarray(0, IV_LENGTH)
33
+ const tag = buf.subarray(buf.length - TAG_LENGTH)
34
+ const ciphertext = buf.subarray(IV_LENGTH, buf.length - TAG_LENGTH)
35
+ const decipher = createDecipheriv(ALGORITHM, key, iv)
36
+ decipher.setAuthTag(tag)
37
+ return decipher.update(ciphertext) + decipher.final('utf8')
38
+ }
39
+
40
+ /**
41
+ * Central encryption configuration hub.
42
+ *
43
+ * Resolved once via the DI container — reads the encryption config
44
+ * and derives cryptographic keys from the application key.
45
+ *
46
+ * @example
47
+ * app.singleton(EncryptionManager)
48
+ * app.resolve(EncryptionManager)
49
+ *
50
+ * // Swap keys at runtime (e.g., for testing)
51
+ * EncryptionManager.useKey('test-key-here')
52
+ */
53
+ @inject
54
+ export default class EncryptionManager {
55
+ private static _config: EncryptionConfig
56
+ private static _encryptionKey: Buffer
57
+ private static _hmacKey: Buffer
58
+ private static _previousEncryptionKeys: Buffer[]
59
+ private static _previousHmacKeys: Buffer[]
60
+
61
+ constructor(config: Configuration) {
62
+ EncryptionManager._config = {
63
+ key: '',
64
+ previousKeys: [],
65
+ ...(config.get('encryption', {}) as object),
66
+ }
67
+
68
+ const raw = EncryptionManager._config.key
69
+ if (!raw) {
70
+ throw new ConfigurationError(
71
+ 'Encryption key is not set. Set APP_KEY in your .env file or configure encryption.key.'
72
+ )
73
+ }
74
+
75
+ EncryptionManager._encryptionKey = deriveKey(raw, 'aes-256-gcm')
76
+ EncryptionManager._hmacKey = deriveKey(raw, 'hmac-sha256')
77
+
78
+ EncryptionManager._previousEncryptionKeys = EncryptionManager._config.previousKeys.map(k =>
79
+ deriveKey(k, 'aes-256-gcm')
80
+ )
81
+ EncryptionManager._previousHmacKeys = EncryptionManager._config.previousKeys.map(k =>
82
+ deriveKey(k, 'hmac-sha256')
83
+ )
84
+ }
85
+
86
+ static get config(): EncryptionConfig {
87
+ return EncryptionManager._config
88
+ }
89
+
90
+ /** Swap the application key at runtime (e.g., for testing). */
91
+ static useKey(key: string): void {
92
+ EncryptionManager._encryptionKey = deriveKey(key, 'aes-256-gcm')
93
+ EncryptionManager._hmacKey = deriveKey(key, 'hmac-sha256')
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Symmetric Encryption (AES-256-GCM)
98
+ // ---------------------------------------------------------------------------
99
+
100
+ /** Encrypt a plaintext string. Returns a base64url-encoded payload. */
101
+ static encrypt(plaintext: string): string {
102
+ return encryptWithKey(plaintext, EncryptionManager._encryptionKey)
103
+ }
104
+
105
+ /**
106
+ * Decrypt a payload. Tries the current key first, then previous keys for rotation.
107
+ * Throws if none of the keys can decrypt the payload.
108
+ */
109
+ static decrypt(payload: string): string {
110
+ try {
111
+ return decryptWithKey(payload, EncryptionManager._encryptionKey)
112
+ } catch {
113
+ // Try previous keys for rotation
114
+ for (const key of EncryptionManager._previousEncryptionKeys) {
115
+ try {
116
+ return decryptWithKey(payload, key)
117
+ } catch {
118
+ continue
119
+ }
120
+ }
121
+ throw new EncryptionError('Decryption failed: invalid payload or key.')
122
+ }
123
+ }
124
+
125
+ /** Encrypt and JSON-serialize an object. */
126
+ static seal(data: unknown): string {
127
+ return EncryptionManager.encrypt(JSON.stringify(data))
128
+ }
129
+
130
+ /** Decrypt and JSON-deserialize an object. */
131
+ static unseal<T = unknown>(payload: string): T {
132
+ return JSON.parse(EncryptionManager.decrypt(payload)) as T
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // HMAC Signing
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /** Create an HMAC-SHA256 signature. Returns a hex string. */
140
+ static sign(data: string): string {
141
+ return createHmac('sha256', EncryptionManager._hmacKey).update(data).digest('hex')
142
+ }
143
+
144
+ /**
145
+ * Verify an HMAC-SHA256 signature using timing-safe comparison.
146
+ * Tries the current key first, then previous keys for rotation.
147
+ */
148
+ static verifySignature(data: string, signature: string): boolean {
149
+ const expected = Buffer.from(EncryptionManager.sign(data), 'hex')
150
+ const actual = Buffer.from(signature, 'hex')
151
+ if (expected.length !== actual.length) {
152
+ // Try previous keys
153
+ for (const key of EncryptionManager._previousHmacKeys) {
154
+ const prev = Buffer.from(createHmac('sha256', key).update(data).digest('hex'), 'hex')
155
+ if (prev.length === actual.length && timingSafeEqual(prev, actual)) return true
156
+ }
157
+ return false
158
+ }
159
+ if (timingSafeEqual(expected, actual)) return true
160
+ // Try previous keys
161
+ for (const key of EncryptionManager._previousHmacKeys) {
162
+ const prev = Buffer.from(createHmac('sha256', key).update(data).digest('hex'), 'hex')
163
+ if (prev.length === actual.length && timingSafeEqual(prev, actual)) return true
164
+ }
165
+ return false
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Password Hashing (Bun.password — argon2id)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /** Hash a password using argon2id. Returns an encoded hash string. */
173
+ static hash(password: string): Promise<string> {
174
+ return Bun.password.hash(password, 'argon2id')
175
+ }
176
+
177
+ /** Verify a password against a hash. Works with argon2id and bcrypt hashes. */
178
+ static verify(password: string, hash: string): Promise<boolean> {
179
+ return Bun.password.verify(password, hash)
180
+ }
181
+
182
+ // ---------------------------------------------------------------------------
183
+ // One-way Hashing
184
+ // ---------------------------------------------------------------------------
185
+
186
+ /** SHA-256 hash. Returns a hex string. */
187
+ static sha256(data: string): string {
188
+ return new Bun.CryptoHasher('sha256').update(data).digest('hex')
189
+ }
190
+
191
+ /** SHA-512 hash. Returns a hex string. */
192
+ static sha512(data: string): string {
193
+ return new Bun.CryptoHasher('sha512').update(data).digest('hex')
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Random Generation
198
+ // ---------------------------------------------------------------------------
199
+
200
+ /** Generate a random hex string (2 hex chars per byte). */
201
+ static random(bytes: number = 32): string {
202
+ return Buffer.from(crypto.getRandomValues(new Uint8Array(bytes))).toString('hex')
203
+ }
204
+
205
+ /** Generate raw random bytes. */
206
+ static randomBytes(bytes: number = 32): Uint8Array {
207
+ return crypto.getRandomValues(new Uint8Array(bytes))
208
+ }
209
+ }