@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.
- package/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -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 +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -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 +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -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/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- 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
|
+
}
|