@getstrata/core 0.2.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 +40 -0
- package/dist/index.js +2187 -0
- package/index.ts +1 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2187 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/bootstrap/contracts.ts
|
|
3
|
+
class ServiceContainer {
|
|
4
|
+
services = new Map;
|
|
5
|
+
singletonFactories = new Map;
|
|
6
|
+
bindings = new Map;
|
|
7
|
+
set(key, value) {
|
|
8
|
+
this.singletonFactories.delete(key);
|
|
9
|
+
this.bindings.delete(key);
|
|
10
|
+
this.services.set(key, value);
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
singleton(key, factory) {
|
|
14
|
+
this.bindings.delete(key);
|
|
15
|
+
this.services.delete(key);
|
|
16
|
+
this.singletonFactories.set(key, factory);
|
|
17
|
+
}
|
|
18
|
+
bind(key, factory) {
|
|
19
|
+
this.singletonFactories.delete(key);
|
|
20
|
+
this.services.delete(key);
|
|
21
|
+
this.bindings.set(key, factory);
|
|
22
|
+
}
|
|
23
|
+
get(key) {
|
|
24
|
+
if (this.services.has(key)) {
|
|
25
|
+
return this.services.get(key);
|
|
26
|
+
}
|
|
27
|
+
const singletonFactory = this.singletonFactories.get(key);
|
|
28
|
+
if (singletonFactory) {
|
|
29
|
+
const value = singletonFactory(this);
|
|
30
|
+
this.services.set(key, value);
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
const binding = this.bindings.get(key);
|
|
34
|
+
if (binding) {
|
|
35
|
+
return binding(this);
|
|
36
|
+
}
|
|
37
|
+
throw new Error(`Service "${key}" is not registered.`);
|
|
38
|
+
}
|
|
39
|
+
resolve(key) {
|
|
40
|
+
return this.get(key);
|
|
41
|
+
}
|
|
42
|
+
has(key) {
|
|
43
|
+
return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class ConfigStore {
|
|
48
|
+
values = new Map;
|
|
49
|
+
set(key, value) {
|
|
50
|
+
this.values.set(key, value);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
get(key) {
|
|
54
|
+
return this.values.get(key);
|
|
55
|
+
}
|
|
56
|
+
require(key) {
|
|
57
|
+
if (!this.values.has(key)) {
|
|
58
|
+
throw new Error(`Config key "${key}" is not defined.`);
|
|
59
|
+
}
|
|
60
|
+
return this.values.get(key);
|
|
61
|
+
}
|
|
62
|
+
has(key) {
|
|
63
|
+
return this.values.has(key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function getRequiredDependency(dependencies, key) {
|
|
67
|
+
const dependency = dependencies[key];
|
|
68
|
+
if (dependency === undefined) {
|
|
69
|
+
throw new Error(`Required dependency "${key}" is not registered.`);
|
|
70
|
+
}
|
|
71
|
+
return dependency;
|
|
72
|
+
}
|
|
73
|
+
function resolveService(dependencies, token) {
|
|
74
|
+
return dependencies.container.resolve(token);
|
|
75
|
+
}
|
|
76
|
+
// ../../src/core/auth/authContext.ts
|
|
77
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
78
|
+
var authContext = new AsyncLocalStorage;
|
|
79
|
+
function runWithAuthUser(user, callback) {
|
|
80
|
+
return authContext.run(user, callback);
|
|
81
|
+
}
|
|
82
|
+
function currentAuthUser() {
|
|
83
|
+
return authContext.getStore() ?? null;
|
|
84
|
+
}
|
|
85
|
+
// ../../src/core/errors/http.ts
|
|
86
|
+
class HttpError extends Error {
|
|
87
|
+
status;
|
|
88
|
+
details;
|
|
89
|
+
constructor(status, message, details) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = new.target.name;
|
|
92
|
+
this.status = status;
|
|
93
|
+
this.details = details;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
class BadRequestError extends HttpError {
|
|
98
|
+
constructor(message = "Bad Request", details) {
|
|
99
|
+
super(400, message, details);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
class NotFoundError extends HttpError {
|
|
104
|
+
constructor(message = "Not Found", details) {
|
|
105
|
+
super(404, message, details);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
class ConflictError extends HttpError {
|
|
110
|
+
constructor(message = "Conflict", details) {
|
|
111
|
+
super(409, message, details);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
class UnprocessableEntityError extends HttpError {
|
|
116
|
+
constructor(message = "Unprocessable Entity", details) {
|
|
117
|
+
super(422, message, details);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class ValidationError extends HttpError {
|
|
122
|
+
constructor(message = "Validation failed", details) {
|
|
123
|
+
super(422, message, details);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
class ForbiddenError extends HttpError {
|
|
128
|
+
constructor(message = "Forbidden", details) {
|
|
129
|
+
super(403, message, details);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class UnauthorizedError extends HttpError {
|
|
134
|
+
constructor(message = "Unauthorized", details) {
|
|
135
|
+
super(401, message, details);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
class PreconditionFailedError extends HttpError {
|
|
139
|
+
constructor(message = "Precondition Failed", details) {
|
|
140
|
+
super(412, message, details);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ../../src/core/auth/policy.ts
|
|
145
|
+
class Policy {
|
|
146
|
+
constructor() {}
|
|
147
|
+
view(_user, _resource) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
create(_user) {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
update(_user, _resource) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
delete(_user, _resource) {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
class PolicyGate {
|
|
162
|
+
constructor() {}
|
|
163
|
+
policies = new Map;
|
|
164
|
+
register(resource, policy) {
|
|
165
|
+
this.policies.set(resource, policy);
|
|
166
|
+
}
|
|
167
|
+
allows(resource, action, user, model) {
|
|
168
|
+
const policy = this.policies.get(resource);
|
|
169
|
+
if (!policy) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
const handler = policy[action];
|
|
173
|
+
if (typeof handler !== "function") {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const resolvedUser = user === undefined ? currentAuthUser() : user;
|
|
177
|
+
return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
|
|
178
|
+
}
|
|
179
|
+
authorize(resource, action, user, model) {
|
|
180
|
+
if (!this.allows(resource, action, user, model)) {
|
|
181
|
+
throw new ForbiddenError;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ../../src/core/cache/taggedCache.ts
|
|
186
|
+
class TaggedCache {
|
|
187
|
+
store;
|
|
188
|
+
tags;
|
|
189
|
+
constructor(store, tags) {
|
|
190
|
+
this.store = store;
|
|
191
|
+
this.tags = tags;
|
|
192
|
+
}
|
|
193
|
+
async remember(key, callback, ttlMs) {
|
|
194
|
+
const value = await this.store.getOrSet(key, callback, ttlMs);
|
|
195
|
+
await this.store.attachTags(key, this.tags);
|
|
196
|
+
return value;
|
|
197
|
+
}
|
|
198
|
+
async flush() {
|
|
199
|
+
return this.store.flushTags(this.tags);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
var taggedCache_default = TaggedCache;
|
|
203
|
+
|
|
204
|
+
// ../../src/core/cache/repository.ts
|
|
205
|
+
class CacheRepository {
|
|
206
|
+
store;
|
|
207
|
+
constructor(store) {
|
|
208
|
+
this.store = store;
|
|
209
|
+
}
|
|
210
|
+
async get(key) {
|
|
211
|
+
return this.store.get(key);
|
|
212
|
+
}
|
|
213
|
+
async remember(key, callback, ttlMs) {
|
|
214
|
+
return this.store.getOrSet(key, callback, ttlMs);
|
|
215
|
+
}
|
|
216
|
+
async forget(key) {
|
|
217
|
+
return this.store.invalidate(key);
|
|
218
|
+
}
|
|
219
|
+
async flush() {
|
|
220
|
+
await this.store.clear();
|
|
221
|
+
}
|
|
222
|
+
tags(...names) {
|
|
223
|
+
return new taggedCache_default(this.store, names);
|
|
224
|
+
}
|
|
225
|
+
async getOrSet(key, loader, ttlMs) {
|
|
226
|
+
return this.remember(key, loader, ttlMs);
|
|
227
|
+
}
|
|
228
|
+
async invalidate(key) {
|
|
229
|
+
return this.forget(key);
|
|
230
|
+
}
|
|
231
|
+
async invalidateByPrefix(prefix) {
|
|
232
|
+
return this.store.invalidateByPrefix(prefix);
|
|
233
|
+
}
|
|
234
|
+
async clear() {
|
|
235
|
+
await this.flush();
|
|
236
|
+
}
|
|
237
|
+
async size() {
|
|
238
|
+
return this.store.size();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
var repository_default = CacheRepository;
|
|
242
|
+
// ../../src/core/cache/tags.ts
|
|
243
|
+
var CACHE_TAGS = {
|
|
244
|
+
organizations: "organizations",
|
|
245
|
+
projects: "projects",
|
|
246
|
+
tasks: "tasks",
|
|
247
|
+
comments: "comments",
|
|
248
|
+
attachments: "attachments",
|
|
249
|
+
reports: "reports"
|
|
250
|
+
};
|
|
251
|
+
// ../../src/config/database.ts
|
|
252
|
+
function readInteger(name, fallback) {
|
|
253
|
+
const parsed = Number.parseInt(process.env[name] ?? String(fallback), 10);
|
|
254
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
255
|
+
}
|
|
256
|
+
var databaseConfig = {
|
|
257
|
+
url: process.env.DATABASE_URL ?? "",
|
|
258
|
+
poolMax: readInteger("DB_POOL_MAX", 10),
|
|
259
|
+
idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
|
|
260
|
+
maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
|
|
261
|
+
connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
// ../../src/core/database/connectionContext.ts
|
|
265
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
266
|
+
var activeConnection = new AsyncLocalStorage2;
|
|
267
|
+
function getActiveDatabaseConnection(fallback) {
|
|
268
|
+
return activeConnection.getStore() ?? fallback;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// ../../src/db/connection/createConnection.ts
|
|
272
|
+
var {SQL } = globalThis.Bun;
|
|
273
|
+
function createDatabaseConnection(config) {
|
|
274
|
+
if (!config.url) {
|
|
275
|
+
throw new Error("DATABASE_URL is not configured. Set DATABASE_URL before starting the app or running integration tests.");
|
|
276
|
+
}
|
|
277
|
+
return new SQL({
|
|
278
|
+
url: config.url,
|
|
279
|
+
max: config.poolMax,
|
|
280
|
+
idleTimeout: config.idleTimeoutSeconds,
|
|
281
|
+
maxLifetime: config.maxLifetimeSeconds,
|
|
282
|
+
connectionTimeout: config.connectionTimeoutSeconds
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ../../src/db/connection/index.ts
|
|
287
|
+
var connectionHolder = {
|
|
288
|
+
connection: createDatabaseConnection(databaseConfig)
|
|
289
|
+
};
|
|
290
|
+
function getDatabase() {
|
|
291
|
+
return connectionHolder.connection;
|
|
292
|
+
}
|
|
293
|
+
var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
294
|
+
function resolveDatabase() {
|
|
295
|
+
return getActiveDatabaseConnection(getDatabase());
|
|
296
|
+
}
|
|
297
|
+
function resolveDatabaseForProperty(property) {
|
|
298
|
+
if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
|
|
299
|
+
return getDatabase();
|
|
300
|
+
}
|
|
301
|
+
return resolveDatabase();
|
|
302
|
+
}
|
|
303
|
+
var db = new Proxy(function database() {}, {
|
|
304
|
+
apply(_target, _thisArg, args) {
|
|
305
|
+
return resolveDatabase()(...args);
|
|
306
|
+
},
|
|
307
|
+
get(_target, property) {
|
|
308
|
+
const connection = resolveDatabaseForProperty(property);
|
|
309
|
+
const value = connection[property];
|
|
310
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
var connection_default = db;
|
|
314
|
+
|
|
315
|
+
// ../../src/core/events/eventBus.ts
|
|
316
|
+
class EventBus {
|
|
317
|
+
constructor() {}
|
|
318
|
+
listeners = new Map;
|
|
319
|
+
listen(event, listener) {
|
|
320
|
+
const handlers = this.listeners.get(event) ?? new Set;
|
|
321
|
+
handlers.add(listener);
|
|
322
|
+
this.listeners.set(event, handlers);
|
|
323
|
+
return () => {
|
|
324
|
+
handlers.delete(listener);
|
|
325
|
+
if (handlers.size === 0) {
|
|
326
|
+
this.listeners.delete(event);
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
async dispatch(event, payload) {
|
|
331
|
+
const handlers = this.listeners.get(event);
|
|
332
|
+
if (!handlers || handlers.size === 0) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
for (const handler of handlers) {
|
|
336
|
+
await handler(payload);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
var eventBus = new EventBus;
|
|
341
|
+
|
|
342
|
+
// ../../src/core/events/index.ts
|
|
343
|
+
function modelEventName(tableName, action) {
|
|
344
|
+
return `${tableName}.${action}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ../../src/core/pagination/index.ts
|
|
348
|
+
function buildPaginationMeta(input) {
|
|
349
|
+
const lastPage = Math.max(1, Math.ceil(input.total / input.perPage));
|
|
350
|
+
return {
|
|
351
|
+
page: input.page,
|
|
352
|
+
per_page: input.perPage,
|
|
353
|
+
total: input.total,
|
|
354
|
+
last_page: lastPage
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ../../src/core/database/errors.ts
|
|
359
|
+
function isPostgresError(error) {
|
|
360
|
+
return typeof error === "object" && error !== null && (("errno" in error) || ("code" in error));
|
|
361
|
+
}
|
|
362
|
+
function getPostgresSqlState(error) {
|
|
363
|
+
if (typeof error.errno === "string" && /^\d{5}$/.test(error.errno)) {
|
|
364
|
+
return error.errno;
|
|
365
|
+
}
|
|
366
|
+
if (typeof error.errno === "number") {
|
|
367
|
+
return String(error.errno).padStart(5, "0");
|
|
368
|
+
}
|
|
369
|
+
if (typeof error.code === "string" && /^\d{5}$/.test(error.code)) {
|
|
370
|
+
return error.code;
|
|
371
|
+
}
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
function mapDatabaseError(error) {
|
|
375
|
+
if (error instanceof HttpError) {
|
|
376
|
+
return error;
|
|
377
|
+
}
|
|
378
|
+
if (!isPostgresError(error)) {
|
|
379
|
+
const message = error instanceof Error ? error.message : "Database operation failed.";
|
|
380
|
+
return new BadRequestError(message);
|
|
381
|
+
}
|
|
382
|
+
const sqlState = getPostgresSqlState(error);
|
|
383
|
+
switch (sqlState) {
|
|
384
|
+
case "23505":
|
|
385
|
+
return new ConflictError(error.detail ?? "A record with these values already exists.", {
|
|
386
|
+
constraint: error.constraint
|
|
387
|
+
});
|
|
388
|
+
case "23503":
|
|
389
|
+
return new UnprocessableEntityError(error.detail ?? "Referenced record does not exist.", {
|
|
390
|
+
constraint: error.constraint
|
|
391
|
+
});
|
|
392
|
+
case "23502":
|
|
393
|
+
return new BadRequestError(error.detail ?? "Required field is missing.", {
|
|
394
|
+
constraint: error.constraint
|
|
395
|
+
});
|
|
396
|
+
case "23514":
|
|
397
|
+
return new BadRequestError(error.detail ?? "Value violates a database constraint.", {
|
|
398
|
+
constraint: error.constraint
|
|
399
|
+
});
|
|
400
|
+
default:
|
|
401
|
+
return new BadRequestError(error.message ?? "Database operation failed.", {
|
|
402
|
+
code: error.code,
|
|
403
|
+
sqlState
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
async function withDatabaseErrorHandling(operation) {
|
|
408
|
+
try {
|
|
409
|
+
return await operation();
|
|
410
|
+
} catch (error) {
|
|
411
|
+
throw mapDatabaseError(error);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ../../src/core/database/query.ts
|
|
416
|
+
function quoteIdentifier(identifier) {
|
|
417
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
418
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
419
|
+
}
|
|
420
|
+
return `"${identifier}"`;
|
|
421
|
+
}
|
|
422
|
+
function qualifyColumn(tableName, column) {
|
|
423
|
+
return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
|
|
424
|
+
}
|
|
425
|
+
function normalizeDirection(direction = "ASC") {
|
|
426
|
+
return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
|
427
|
+
}
|
|
428
|
+
function isQueryOperator(value) {
|
|
429
|
+
return value !== null && !Array.isArray(value) && !(value instanceof Date) && typeof value === "object";
|
|
430
|
+
}
|
|
431
|
+
function pushParam(values, value) {
|
|
432
|
+
values.push(value);
|
|
433
|
+
return `$${values.length}`;
|
|
434
|
+
}
|
|
435
|
+
function buildInClause(column, values, params) {
|
|
436
|
+
if (values.length === 0) {
|
|
437
|
+
return "1 = 0";
|
|
438
|
+
}
|
|
439
|
+
const placeholders = values.map((value) => pushParam(params, value)).join(", ");
|
|
440
|
+
return `${column} IN (${placeholders})`;
|
|
441
|
+
}
|
|
442
|
+
function buildOperatorClauses(column, operator, params) {
|
|
443
|
+
const clauses = [];
|
|
444
|
+
if (operator.isNull === true) {
|
|
445
|
+
clauses.push(`${column} IS NULL`);
|
|
446
|
+
}
|
|
447
|
+
if (operator.isNull === false) {
|
|
448
|
+
clauses.push(`${column} IS NOT NULL`);
|
|
449
|
+
}
|
|
450
|
+
if (operator.eq !== undefined) {
|
|
451
|
+
if (operator.eq === null) {
|
|
452
|
+
clauses.push(`${column} IS NULL`);
|
|
453
|
+
} else {
|
|
454
|
+
clauses.push(`${column} = ${pushParam(params, operator.eq)}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
if (operator.in !== undefined) {
|
|
458
|
+
clauses.push(buildInClause(column, operator.in, params));
|
|
459
|
+
}
|
|
460
|
+
if (operator.gt !== undefined) {
|
|
461
|
+
clauses.push(`${column} > ${pushParam(params, operator.gt)}`);
|
|
462
|
+
}
|
|
463
|
+
if (operator.gte !== undefined) {
|
|
464
|
+
clauses.push(`${column} >= ${pushParam(params, operator.gte)}`);
|
|
465
|
+
}
|
|
466
|
+
if (operator.lt !== undefined) {
|
|
467
|
+
clauses.push(`${column} < ${pushParam(params, operator.lt)}`);
|
|
468
|
+
}
|
|
469
|
+
if (operator.lte !== undefined) {
|
|
470
|
+
clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
|
|
471
|
+
}
|
|
472
|
+
return clauses;
|
|
473
|
+
}
|
|
474
|
+
function buildWhereClause(tableName, where = {}) {
|
|
475
|
+
const clauses = [];
|
|
476
|
+
const params = [];
|
|
477
|
+
for (const [columnName, filterValue] of Object.entries(where)) {
|
|
478
|
+
if (filterValue === undefined) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
const column = qualifyColumn(tableName, columnName);
|
|
482
|
+
if (Array.isArray(filterValue)) {
|
|
483
|
+
clauses.push(buildInClause(column, filterValue, params));
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (isQueryOperator(filterValue)) {
|
|
487
|
+
clauses.push(...buildOperatorClauses(column, filterValue, params));
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (filterValue === null) {
|
|
491
|
+
clauses.push(`${column} IS NULL`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
clauses.push(`${column} = ${pushParam(params, filterValue)}`);
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
clause: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "",
|
|
498
|
+
params
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function resolveSoftDeleteColumn(table) {
|
|
502
|
+
if (!table.softDeletes) {
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
if (table.softDeletes === true) {
|
|
506
|
+
return "deleted_at";
|
|
507
|
+
}
|
|
508
|
+
return table.softDeletes.column ?? "deleted_at";
|
|
509
|
+
}
|
|
510
|
+
function appendSoftDeleteScope(table, options, clauses) {
|
|
511
|
+
const column = resolveSoftDeleteColumn(table);
|
|
512
|
+
if (!column) {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const qualifiedColumn = qualifyColumn(table.name, column);
|
|
516
|
+
if (options.onlyTrashed) {
|
|
517
|
+
clauses.push(`${qualifiedColumn} IS NOT NULL`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (!options.withTrashed) {
|
|
521
|
+
clauses.push(`${qualifiedColumn} IS NULL`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function buildQueryWhereClause(table, options = {}) {
|
|
525
|
+
const { clause, params } = buildWhereClause(table.name, options.where ?? {});
|
|
526
|
+
const clauses = clause.length > 0 ? clause.replace(/^ WHERE /, "").split(" AND ") : [];
|
|
527
|
+
appendSoftDeleteScope(table, options, clauses);
|
|
528
|
+
return {
|
|
529
|
+
clause: clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "",
|
|
530
|
+
params
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function normalizeOrderBy(orderBy) {
|
|
534
|
+
if (!orderBy) {
|
|
535
|
+
return [];
|
|
536
|
+
}
|
|
537
|
+
return Array.isArray(orderBy) ? orderBy : [orderBy];
|
|
538
|
+
}
|
|
539
|
+
function buildOrderByClause(tableName, orderBy) {
|
|
540
|
+
const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
|
|
541
|
+
return `${qualifyColumn(tableName, column)} ${normalizeDirection(direction)}`;
|
|
542
|
+
});
|
|
543
|
+
return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
|
|
544
|
+
}
|
|
545
|
+
function buildLimitClause(limit) {
|
|
546
|
+
if (limit === undefined) {
|
|
547
|
+
return "";
|
|
548
|
+
}
|
|
549
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
550
|
+
throw new Error("Query limit must be a positive integer.");
|
|
551
|
+
}
|
|
552
|
+
return ` LIMIT ${limit}`;
|
|
553
|
+
}
|
|
554
|
+
function buildOffsetClause(offset) {
|
|
555
|
+
if (offset === undefined) {
|
|
556
|
+
return "";
|
|
557
|
+
}
|
|
558
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
559
|
+
throw new Error("Query offset must be a non-negative integer.");
|
|
560
|
+
}
|
|
561
|
+
return ` OFFSET ${offset}`;
|
|
562
|
+
}
|
|
563
|
+
function buildReturningColumns(table) {
|
|
564
|
+
return table.columns.map((column) => qualifyColumn(table.name, column)).join(", ");
|
|
565
|
+
}
|
|
566
|
+
function getDefinedColumnEntries(table, values, options = {}) {
|
|
567
|
+
const record = values;
|
|
568
|
+
const excluded = new Set(options.exclude ?? []);
|
|
569
|
+
return table.columns.flatMap((column) => {
|
|
570
|
+
if (excluded.has(column) || !Object.hasOwn(record, column)) {
|
|
571
|
+
return [];
|
|
572
|
+
}
|
|
573
|
+
const value = record[column];
|
|
574
|
+
if (value === undefined) {
|
|
575
|
+
return [];
|
|
576
|
+
}
|
|
577
|
+
return [[column, value]];
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
function buildSelectQuery(table, options = {}) {
|
|
581
|
+
const columns = buildReturningColumns(table);
|
|
582
|
+
const { clause, params } = buildQueryWhereClause(table, options);
|
|
583
|
+
const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
|
|
584
|
+
const limit = buildLimitClause(options.limit);
|
|
585
|
+
const offset = buildOffsetClause(options.offset);
|
|
586
|
+
return {
|
|
587
|
+
text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${clause}${orderBy}${limit}${offset}`,
|
|
588
|
+
params
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
function buildCountQuery(table, where = {}, options = {}) {
|
|
592
|
+
const { clause, params } = buildQueryWhereClause(table, {
|
|
593
|
+
where,
|
|
594
|
+
...options
|
|
595
|
+
});
|
|
596
|
+
return {
|
|
597
|
+
text: `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}${clause}`,
|
|
598
|
+
params
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function buildProjectionQuery(table, expression, alias, options = {}) {
|
|
602
|
+
const { clause, params } = buildQueryWhereClause(table, options);
|
|
603
|
+
const orderBy = buildOrderByClause(table.name, options.orderBy);
|
|
604
|
+
const limit = buildLimitClause(options.limit);
|
|
605
|
+
return {
|
|
606
|
+
text: `SELECT ${expression} AS ${quoteIdentifier(alias)} FROM ${quoteIdentifier(table.name)}${clause}${orderBy}${limit}`,
|
|
607
|
+
params
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
function buildGroupedCountQuery(table, column, where = {}, options = {}) {
|
|
611
|
+
const qualifiedColumn = qualifyColumn(table.name, column);
|
|
612
|
+
const { clause, params } = buildQueryWhereClause(table, {
|
|
613
|
+
where,
|
|
614
|
+
...options
|
|
615
|
+
});
|
|
616
|
+
return {
|
|
617
|
+
text: `SELECT ${qualifiedColumn} AS ${quoteIdentifier("value")}, COUNT(*) AS ${quoteIdentifier("count")} FROM ${quoteIdentifier(table.name)}${clause} GROUP BY ${qualifiedColumn} ORDER BY ${qualifiedColumn} ASC`,
|
|
618
|
+
params
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function buildInsertQuery(table, values) {
|
|
622
|
+
const entries = getDefinedColumnEntries(table, values);
|
|
623
|
+
if (entries.length === 0) {
|
|
624
|
+
throw new Error(`Cannot insert into ${table.name} without any column values.`);
|
|
625
|
+
}
|
|
626
|
+
const params = [];
|
|
627
|
+
const columns = entries.map(([column]) => quoteIdentifier(column)).join(", ");
|
|
628
|
+
const placeholders = entries.map(([, value]) => pushParam(params, value)).join(", ");
|
|
629
|
+
const returningColumns = buildReturningColumns(table);
|
|
630
|
+
return {
|
|
631
|
+
text: `INSERT INTO ${quoteIdentifier(table.name)} (${columns}) VALUES (${placeholders}) RETURNING ${returningColumns}`,
|
|
632
|
+
params
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function buildUpdateQuery(table, id, changes) {
|
|
636
|
+
const entries = getDefinedColumnEntries(table, changes, {
|
|
637
|
+
exclude: [table.primaryKey]
|
|
638
|
+
});
|
|
639
|
+
if (entries.length === 0) {
|
|
640
|
+
throw new Error(`Cannot update ${table.name} without any changed column values.`);
|
|
641
|
+
}
|
|
642
|
+
const params = [];
|
|
643
|
+
const setClause = entries.map(([column, value]) => `${quoteIdentifier(column)} = ${pushParam(params, value)}`).join(", ");
|
|
644
|
+
const primaryKeyPlaceholder = pushParam(params, id);
|
|
645
|
+
const returningColumns = buildReturningColumns(table);
|
|
646
|
+
const scopeClauses = [];
|
|
647
|
+
appendSoftDeleteScope(table, {}, scopeClauses);
|
|
648
|
+
const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
|
|
649
|
+
return {
|
|
650
|
+
text: `UPDATE ${quoteIdentifier(table.name)} SET ${setClause} WHERE ${quoteIdentifier(table.primaryKey)} = ${primaryKeyPlaceholder}${scopeSuffix} RETURNING ${returningColumns}`,
|
|
651
|
+
params
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function buildSoftDeleteByIdQuery(table, id, deletedAt) {
|
|
655
|
+
const deletedAtColumn = resolveSoftDeleteColumn(table);
|
|
656
|
+
if (!deletedAtColumn) {
|
|
657
|
+
throw new Error(`Table ${table.name} does not support soft deletes.`);
|
|
658
|
+
}
|
|
659
|
+
const returningColumns = buildReturningColumns(table);
|
|
660
|
+
const scopeClauses = [];
|
|
661
|
+
appendSoftDeleteScope(table, {}, scopeClauses);
|
|
662
|
+
const scopeSuffix = scopeClauses.length > 0 ? ` AND ${scopeClauses.join(" AND ")}` : "";
|
|
663
|
+
return {
|
|
664
|
+
text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = $1 WHERE ${quoteIdentifier(table.primaryKey)} = $2${scopeSuffix} RETURNING ${returningColumns}`,
|
|
665
|
+
params: [deletedAt, id]
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function buildRestoreByIdQuery(table, id) {
|
|
669
|
+
const deletedAtColumn = resolveSoftDeleteColumn(table);
|
|
670
|
+
if (!deletedAtColumn) {
|
|
671
|
+
throw new Error(`Table ${table.name} does not support soft deletes.`);
|
|
672
|
+
}
|
|
673
|
+
const returningColumns = buildReturningColumns(table);
|
|
674
|
+
return {
|
|
675
|
+
text: `UPDATE ${quoteIdentifier(table.name)} SET ${quoteIdentifier(deletedAtColumn)} = $1 WHERE ${quoteIdentifier(table.primaryKey)} = $2 AND ${qualifyColumn(table.name, deletedAtColumn)} IS NOT NULL RETURNING ${returningColumns}`,
|
|
676
|
+
params: [null, id]
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
function buildDeleteByIdQuery(table, id) {
|
|
680
|
+
return {
|
|
681
|
+
text: `DELETE FROM ${quoteIdentifier(table.name)} WHERE ${quoteIdentifier(table.primaryKey)} = $1 RETURNING ${quoteIdentifier(table.primaryKey)} AS ${quoteIdentifier("deleted_id")}`,
|
|
682
|
+
params: [id]
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ../../src/core/database/relationships.ts
|
|
687
|
+
function indexHasManyRelation(parents, children, relation) {
|
|
688
|
+
const groups = new Map;
|
|
689
|
+
for (const parent of parents) {
|
|
690
|
+
groups.set(parent[relation.localKey], []);
|
|
691
|
+
}
|
|
692
|
+
for (const child of children) {
|
|
693
|
+
const key = child[relation.foreignKey];
|
|
694
|
+
const group = groups.get(key);
|
|
695
|
+
if (!group) {
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
group.push(child);
|
|
699
|
+
}
|
|
700
|
+
return groups;
|
|
701
|
+
}
|
|
702
|
+
function indexBelongsToRelation(children, parents, relation) {
|
|
703
|
+
const parentsById = new Map;
|
|
704
|
+
for (const parent of parents) {
|
|
705
|
+
parentsById.set(parent[relation.ownerKey], parent);
|
|
706
|
+
}
|
|
707
|
+
const result = new Map;
|
|
708
|
+
for (const child of children) {
|
|
709
|
+
const foreignKey = child[relation.foreignKey];
|
|
710
|
+
const parent = parentsById.get(foreignKey);
|
|
711
|
+
if (parent) {
|
|
712
|
+
result.set(foreignKey, parent);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
return result;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ../../src/core/database/baseRepository.ts
|
|
719
|
+
class BaseRepository {
|
|
720
|
+
table;
|
|
721
|
+
connection;
|
|
722
|
+
constructor(table, connection = connection_default) {
|
|
723
|
+
this.table = table;
|
|
724
|
+
this.connection = connection;
|
|
725
|
+
}
|
|
726
|
+
async findAll(options = {}) {
|
|
727
|
+
return await withDatabaseErrorHandling(async () => {
|
|
728
|
+
const { text, params } = buildSelectQuery(this.table, options);
|
|
729
|
+
return await this.connection.unsafe(text, params);
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
async paginate(options) {
|
|
733
|
+
const where = options.where ?? {};
|
|
734
|
+
const total = await this.countWhere(where, {
|
|
735
|
+
withTrashed: options.withTrashed,
|
|
736
|
+
onlyTrashed: options.onlyTrashed
|
|
737
|
+
});
|
|
738
|
+
const offset = (options.page - 1) * options.perPage;
|
|
739
|
+
const { page, perPage, ...queryOptions } = options;
|
|
740
|
+
const data = await this.findAll({
|
|
741
|
+
...queryOptions,
|
|
742
|
+
where,
|
|
743
|
+
limit: perPage,
|
|
744
|
+
offset
|
|
745
|
+
});
|
|
746
|
+
return {
|
|
747
|
+
data,
|
|
748
|
+
meta: buildPaginationMeta({ page, perPage, total })
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
async findById(id) {
|
|
752
|
+
return await this.firstOrNull({
|
|
753
|
+
[this.table.primaryKey]: id
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
async findByIdOrThrow(id, errorFactory) {
|
|
757
|
+
const record = await this.findById(id);
|
|
758
|
+
if (record) {
|
|
759
|
+
return record;
|
|
760
|
+
}
|
|
761
|
+
throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
|
|
762
|
+
}
|
|
763
|
+
async findByIds(ids) {
|
|
764
|
+
const uniqueIds = [...new Set(ids)];
|
|
765
|
+
if (uniqueIds.length === 0) {
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
return await this.findWhere({
|
|
769
|
+
[this.table.primaryKey]: uniqueIds
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
async firstOrNull(where, options = {}) {
|
|
773
|
+
const [record] = await this.findAll({ ...options, where, limit: 1 });
|
|
774
|
+
return record ?? null;
|
|
775
|
+
}
|
|
776
|
+
async create(values) {
|
|
777
|
+
return await withDatabaseErrorHandling(async () => {
|
|
778
|
+
const { text, params } = buildInsertQuery(this.table, values);
|
|
779
|
+
const [record] = await this.connection.unsafe(text, params);
|
|
780
|
+
if (!record) {
|
|
781
|
+
throw new Error(`Insert into ${this.table.name} did not return a record.`);
|
|
782
|
+
}
|
|
783
|
+
const entity = record;
|
|
784
|
+
await eventBus.dispatch(modelEventName(this.table.name, "created"), entity);
|
|
785
|
+
return entity;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
async updateById(id, changes) {
|
|
789
|
+
return await withDatabaseErrorHandling(async () => {
|
|
790
|
+
const { text, params } = buildUpdateQuery(this.table, id, changes);
|
|
791
|
+
const [record] = await this.connection.unsafe(text, params);
|
|
792
|
+
const entity = record ?? null;
|
|
793
|
+
if (entity) {
|
|
794
|
+
await eventBus.dispatch(modelEventName(this.table.name, "updated"), entity);
|
|
795
|
+
}
|
|
796
|
+
return entity;
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
async updateByIdOrThrow(id, changes, errorFactory) {
|
|
800
|
+
const record = await this.updateById(id, changes);
|
|
801
|
+
if (record) {
|
|
802
|
+
return record;
|
|
803
|
+
}
|
|
804
|
+
throw errorFactory?.(id) ?? new Error(`${this.table.name} ${String(id)} was not found.`);
|
|
805
|
+
}
|
|
806
|
+
async deleteById(id) {
|
|
807
|
+
if (resolveSoftDeleteColumn(this.table)) {
|
|
808
|
+
return await this.softDeleteById(id);
|
|
809
|
+
}
|
|
810
|
+
return await this.forceDeleteById(id);
|
|
811
|
+
}
|
|
812
|
+
async softDeleteById(id) {
|
|
813
|
+
return await withDatabaseErrorHandling(async () => {
|
|
814
|
+
const { text, params } = buildSoftDeleteByIdQuery(this.table, id, new Date);
|
|
815
|
+
const [record] = await this.connection.unsafe(text, params);
|
|
816
|
+
if (!record) {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
await eventBus.dispatch(modelEventName(this.table.name, "deleted"), record);
|
|
820
|
+
return true;
|
|
821
|
+
});
|
|
822
|
+
}
|
|
823
|
+
async forceDeleteById(id) {
|
|
824
|
+
return await withDatabaseErrorHandling(async () => {
|
|
825
|
+
const { text, params } = buildDeleteByIdQuery(this.table, id);
|
|
826
|
+
const [row] = await this.connection.unsafe(text, params);
|
|
827
|
+
if (!row) {
|
|
828
|
+
return false;
|
|
829
|
+
}
|
|
830
|
+
await eventBus.dispatch(modelEventName(this.table.name, "force-deleted"), {
|
|
831
|
+
id
|
|
832
|
+
});
|
|
833
|
+
return true;
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
async restoreById(id) {
|
|
837
|
+
return await withDatabaseErrorHandling(async () => {
|
|
838
|
+
const { text, params } = buildRestoreByIdQuery(this.table, id);
|
|
839
|
+
const [record] = await this.connection.unsafe(text, params);
|
|
840
|
+
if (!record) {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
const entity = record;
|
|
844
|
+
await eventBus.dispatch(modelEventName(this.table.name, "restored"), entity);
|
|
845
|
+
return entity;
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
withConnection(connection) {
|
|
849
|
+
const clone = Object.create(Object.getPrototypeOf(this));
|
|
850
|
+
Object.assign(clone, this);
|
|
851
|
+
clone.connection = connection;
|
|
852
|
+
return clone;
|
|
853
|
+
}
|
|
854
|
+
async findWhere(where, options = {}) {
|
|
855
|
+
return await this.findAll({ ...options, where });
|
|
856
|
+
}
|
|
857
|
+
async countWhere(where = {}, options = {}) {
|
|
858
|
+
const { text, params } = buildCountQuery(this.table, where, options);
|
|
859
|
+
const [row] = await this.connection.unsafe(text, params);
|
|
860
|
+
return Number(row?.count ?? 0);
|
|
861
|
+
}
|
|
862
|
+
async averageColumn(column, where = {}) {
|
|
863
|
+
const qualifiedColumn = qualifyColumn(this.table.name, column);
|
|
864
|
+
return await this.averageExpression(`AVG(${qualifiedColumn})`, "value", where);
|
|
865
|
+
}
|
|
866
|
+
async averageExpression(expression, alias, where = {}) {
|
|
867
|
+
const { text, params } = buildProjectionQuery(this.table, expression, alias, { where });
|
|
868
|
+
const [row] = await this.connection.unsafe(text, params);
|
|
869
|
+
return Math.round(Number(row?.[alias] ?? 0));
|
|
870
|
+
}
|
|
871
|
+
async pluckNumberValues(expression, alias, options = {}) {
|
|
872
|
+
const { text, params } = buildProjectionQuery(this.table, expression, alias, options);
|
|
873
|
+
const rows = await this.connection.unsafe(text, params);
|
|
874
|
+
return rows.flatMap((row) => {
|
|
875
|
+
const value = row[alias];
|
|
876
|
+
return value === null || value === undefined ? [] : [Number(value)];
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
async countGroupedBy(column, where = {}) {
|
|
880
|
+
const { text, params } = buildGroupedCountQuery(this.table, column, where);
|
|
881
|
+
const rows = await this.connection.unsafe(text, params);
|
|
882
|
+
return rows.map(({ value, count }) => ({
|
|
883
|
+
value,
|
|
884
|
+
count: Number(count)
|
|
885
|
+
}));
|
|
886
|
+
}
|
|
887
|
+
async findByHasManyRelation(relation, parentId, options = {}) {
|
|
888
|
+
return await this.findWhere({
|
|
889
|
+
[relation.foreignKey]: parentId
|
|
890
|
+
}, options);
|
|
891
|
+
}
|
|
892
|
+
async loadHasManyForParents(parents, relation, options = {}) {
|
|
893
|
+
if (parents.length === 0) {
|
|
894
|
+
return indexHasManyRelation(parents, [], relation);
|
|
895
|
+
}
|
|
896
|
+
const parentIds = [...new Set(parents.map((parent) => parent[relation.localKey]))];
|
|
897
|
+
const children = await this.findWhere({
|
|
898
|
+
[relation.foreignKey]: parentIds
|
|
899
|
+
}, options);
|
|
900
|
+
return indexHasManyRelation(parents, children, relation);
|
|
901
|
+
}
|
|
902
|
+
async loadBelongsToForParents(children, relation, parentRepository, options = {}) {
|
|
903
|
+
if (children.length === 0) {
|
|
904
|
+
return new Map;
|
|
905
|
+
}
|
|
906
|
+
const ownerIds = [...new Set(children.map((child) => child[relation.foreignKey]))];
|
|
907
|
+
const parents = await parentRepository.withConnection(this.connection).findWhere({
|
|
908
|
+
[relation.ownerKey]: ownerIds
|
|
909
|
+
}, options);
|
|
910
|
+
return indexBelongsToRelation(children, parents, relation);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
var baseRepository_default = BaseRepository;
|
|
914
|
+
// ../../src/core/database/table.ts
|
|
915
|
+
function defineTable(definition) {
|
|
916
|
+
return definition;
|
|
917
|
+
}
|
|
918
|
+
// ../../src/core/logging/logger.ts
|
|
919
|
+
class Logger {
|
|
920
|
+
channel;
|
|
921
|
+
constructor(channel = "app") {
|
|
922
|
+
this.channel = channel;
|
|
923
|
+
}
|
|
924
|
+
write(level, message, context = {}) {
|
|
925
|
+
const entry = {
|
|
926
|
+
level,
|
|
927
|
+
channel: this.channel,
|
|
928
|
+
message,
|
|
929
|
+
timestamp: new Date().toISOString(),
|
|
930
|
+
...context
|
|
931
|
+
};
|
|
932
|
+
const line = JSON.stringify(entry);
|
|
933
|
+
if (level === "error") {
|
|
934
|
+
console.error(line);
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
console.log(line);
|
|
938
|
+
}
|
|
939
|
+
debug(message, context) {
|
|
940
|
+
this.write("debug", message, context);
|
|
941
|
+
}
|
|
942
|
+
info(message, context) {
|
|
943
|
+
this.write("info", message, context);
|
|
944
|
+
}
|
|
945
|
+
warn(message, context) {
|
|
946
|
+
this.write("warn", message, context);
|
|
947
|
+
}
|
|
948
|
+
error(message, context) {
|
|
949
|
+
this.write("error", message, context);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
var appLogger = new Logger("app");
|
|
953
|
+
|
|
954
|
+
// ../../src/bootstrap/config.ts
|
|
955
|
+
var CORE_QUEUE_TOKEN = "core.queue";
|
|
956
|
+
var CORE_POLICY_GATE_TOKEN = "core.policyGate";
|
|
957
|
+
var CORE_AUTH_TOKEN = "core.auth";
|
|
958
|
+
|
|
959
|
+
// ../../src/bootstrap/applicationRegistry.ts
|
|
960
|
+
var activeContext;
|
|
961
|
+
function requireActiveApplicationContext() {
|
|
962
|
+
if (!activeContext) {
|
|
963
|
+
throw new Error("The application context has not been bootstrapped.");
|
|
964
|
+
}
|
|
965
|
+
return activeContext;
|
|
966
|
+
}
|
|
967
|
+
function resolveApplicationCache() {
|
|
968
|
+
return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
|
|
969
|
+
}
|
|
970
|
+
function resolveApplicationQueue() {
|
|
971
|
+
return requireActiveApplicationContext().container.resolve(CORE_QUEUE_TOKEN);
|
|
972
|
+
}
|
|
973
|
+
function resolveApplicationAuth() {
|
|
974
|
+
return requireActiveApplicationContext().container.resolve(CORE_AUTH_TOKEN);
|
|
975
|
+
}
|
|
976
|
+
function resolveApplicationPolicyGate() {
|
|
977
|
+
return requireActiveApplicationContext().container.resolve(CORE_POLICY_GATE_TOKEN);
|
|
978
|
+
}
|
|
979
|
+
function resolveApplicationConfig() {
|
|
980
|
+
return requireActiveApplicationContext().config;
|
|
981
|
+
}
|
|
982
|
+
function resolveApplicationLogger() {
|
|
983
|
+
return appLogger;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// ../../src/core/mail/mailer.ts
|
|
987
|
+
function resolveSmtpConfig() {
|
|
988
|
+
const host = process.env.MAIL_HOST?.trim();
|
|
989
|
+
if (!host) {
|
|
990
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
|
|
991
|
+
}
|
|
992
|
+
const from = process.env.MAIL_FROM?.trim();
|
|
993
|
+
if (!from) {
|
|
994
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
995
|
+
}
|
|
996
|
+
const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
|
|
997
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
998
|
+
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
999
|
+
}
|
|
1000
|
+
return {
|
|
1001
|
+
host,
|
|
1002
|
+
port,
|
|
1003
|
+
from,
|
|
1004
|
+
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
1005
|
+
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
1006
|
+
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function encodeBase64(value) {
|
|
1010
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
1011
|
+
}
|
|
1012
|
+
function parseSmtpResponses(buffer) {
|
|
1013
|
+
const responses = [];
|
|
1014
|
+
let remainder = buffer;
|
|
1015
|
+
while (remainder.includes(`\r
|
|
1016
|
+
`)) {
|
|
1017
|
+
const index = remainder.indexOf(`\r
|
|
1018
|
+
`);
|
|
1019
|
+
const line = remainder.slice(0, index);
|
|
1020
|
+
remainder = remainder.slice(index + 2);
|
|
1021
|
+
if (line.length >= 4 && line[3] === "-") {
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
responses.push(line);
|
|
1025
|
+
}
|
|
1026
|
+
return { responses, remainder };
|
|
1027
|
+
}
|
|
1028
|
+
async function waitForSmtpResponse(readResponse, expectedCodes) {
|
|
1029
|
+
const response = await readResponse();
|
|
1030
|
+
const code = response.slice(0, 3);
|
|
1031
|
+
if (!expectedCodes.includes(code)) {
|
|
1032
|
+
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
1033
|
+
}
|
|
1034
|
+
return response;
|
|
1035
|
+
}
|
|
1036
|
+
async function openSmtpConnection(config) {
|
|
1037
|
+
let buffer = "";
|
|
1038
|
+
const waiters = [];
|
|
1039
|
+
const readResponse = () => new Promise((resolve, reject) => {
|
|
1040
|
+
const parsed = parseSmtpResponses(buffer);
|
|
1041
|
+
if (parsed.responses.length > 0) {
|
|
1042
|
+
buffer = parsed.remainder;
|
|
1043
|
+
resolve(parsed.responses.shift());
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
waiters.push({ resolve, reject });
|
|
1047
|
+
});
|
|
1048
|
+
const socket = await Bun.connect({
|
|
1049
|
+
hostname: config.host,
|
|
1050
|
+
port: config.port,
|
|
1051
|
+
socket: {
|
|
1052
|
+
open() {},
|
|
1053
|
+
data(_socket, chunk) {
|
|
1054
|
+
buffer += Buffer.from(chunk).toString("utf8");
|
|
1055
|
+
const parsed = parseSmtpResponses(buffer);
|
|
1056
|
+
buffer = parsed.remainder;
|
|
1057
|
+
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
1058
|
+
const response = parsed.responses.shift();
|
|
1059
|
+
waiters.shift()?.resolve(response);
|
|
1060
|
+
}
|
|
1061
|
+
},
|
|
1062
|
+
error(_socket, error) {
|
|
1063
|
+
const pending = waiters.splice(0);
|
|
1064
|
+
for (const waiter of pending) {
|
|
1065
|
+
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
});
|
|
1070
|
+
return { socket, readResponse };
|
|
1071
|
+
}
|
|
1072
|
+
async function defaultSmtpTransport(config, message) {
|
|
1073
|
+
const { socket, readResponse } = await openSmtpConnection(config);
|
|
1074
|
+
try {
|
|
1075
|
+
await waitForSmtpResponse(readResponse, ["220"]);
|
|
1076
|
+
await socket.write(`EHLO workhub.local\r
|
|
1077
|
+
`);
|
|
1078
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
1079
|
+
if (config.username && config.password) {
|
|
1080
|
+
await socket.write(`AUTH LOGIN\r
|
|
1081
|
+
`);
|
|
1082
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
1083
|
+
await socket.write(`${encodeBase64(config.username)}\r
|
|
1084
|
+
`);
|
|
1085
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
1086
|
+
await socket.write(`${encodeBase64(config.password)}\r
|
|
1087
|
+
`);
|
|
1088
|
+
await waitForSmtpResponse(readResponse, ["235"]);
|
|
1089
|
+
}
|
|
1090
|
+
await socket.write(`MAIL FROM:<${config.from}>\r
|
|
1091
|
+
`);
|
|
1092
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
1093
|
+
await socket.write(`RCPT TO:<${message.to}>\r
|
|
1094
|
+
`);
|
|
1095
|
+
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
1096
|
+
await socket.write(`DATA\r
|
|
1097
|
+
`);
|
|
1098
|
+
await waitForSmtpResponse(readResponse, ["354"]);
|
|
1099
|
+
const payload = [
|
|
1100
|
+
`From: ${config.from}`,
|
|
1101
|
+
`To: ${message.to}`,
|
|
1102
|
+
`Subject: ${message.subject}`,
|
|
1103
|
+
"MIME-Version: 1.0",
|
|
1104
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
1105
|
+
"",
|
|
1106
|
+
message.body,
|
|
1107
|
+
".",
|
|
1108
|
+
""
|
|
1109
|
+
].join(`\r
|
|
1110
|
+
`);
|
|
1111
|
+
await socket.write(payload);
|
|
1112
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
1113
|
+
await socket.write(`QUIT\r
|
|
1114
|
+
`);
|
|
1115
|
+
await waitForSmtpResponse(readResponse, ["221"]);
|
|
1116
|
+
} finally {
|
|
1117
|
+
socket.end();
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
class LogMailDriver {
|
|
1122
|
+
async send(message) {
|
|
1123
|
+
console.log(JSON.stringify({
|
|
1124
|
+
level: "info",
|
|
1125
|
+
channel: "mail",
|
|
1126
|
+
to: message.to,
|
|
1127
|
+
subject: message.subject,
|
|
1128
|
+
body: message.body
|
|
1129
|
+
}));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
class SmtpMailDriver {
|
|
1134
|
+
config;
|
|
1135
|
+
transport;
|
|
1136
|
+
constructor(config, transport = defaultSmtpTransport) {
|
|
1137
|
+
this.config = config;
|
|
1138
|
+
this.transport = transport;
|
|
1139
|
+
}
|
|
1140
|
+
send(message) {
|
|
1141
|
+
return this.transport(this.config, message);
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
class Mailer {
|
|
1146
|
+
driver;
|
|
1147
|
+
constructor(driver) {
|
|
1148
|
+
this.driver = driver;
|
|
1149
|
+
}
|
|
1150
|
+
send(message) {
|
|
1151
|
+
return this.driver.send(message);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
function createMailDriver() {
|
|
1155
|
+
const driver = process.env.MAIL_DRIVER ?? "log";
|
|
1156
|
+
if (driver === "smtp") {
|
|
1157
|
+
return new SmtpMailDriver(resolveSmtpConfig());
|
|
1158
|
+
}
|
|
1159
|
+
return new LogMailDriver;
|
|
1160
|
+
}
|
|
1161
|
+
var appMailer = new Mailer(createMailDriver());
|
|
1162
|
+
function mailer() {
|
|
1163
|
+
return appMailer;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
// ../../src/core/storage/storage.ts
|
|
1167
|
+
var {S3Client } = globalThis.Bun;
|
|
1168
|
+
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
1169
|
+
import { dirname, join } from "path";
|
|
1170
|
+
|
|
1171
|
+
class LocalStorageDriver {
|
|
1172
|
+
rootDirectory;
|
|
1173
|
+
constructor(rootDirectory) {
|
|
1174
|
+
this.rootDirectory = rootDirectory;
|
|
1175
|
+
}
|
|
1176
|
+
resolvePath(path) {
|
|
1177
|
+
return join(this.rootDirectory, path.replace(/^\/+/, ""));
|
|
1178
|
+
}
|
|
1179
|
+
async put(path, contents) {
|
|
1180
|
+
const absolutePath = this.resolvePath(path);
|
|
1181
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
1182
|
+
await writeFile(absolutePath, contents);
|
|
1183
|
+
return path;
|
|
1184
|
+
}
|
|
1185
|
+
async get(path) {
|
|
1186
|
+
try {
|
|
1187
|
+
return await readFile(this.resolvePath(path));
|
|
1188
|
+
} catch {
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async delete(path) {
|
|
1193
|
+
try {
|
|
1194
|
+
await unlink(this.resolvePath(path));
|
|
1195
|
+
return true;
|
|
1196
|
+
} catch {
|
|
1197
|
+
return false;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
class S3StorageDriver {
|
|
1203
|
+
client;
|
|
1204
|
+
constructor(client) {
|
|
1205
|
+
this.client = client;
|
|
1206
|
+
}
|
|
1207
|
+
async put(path, contents) {
|
|
1208
|
+
await this.client.write(path.replace(/^\/+/, ""), contents);
|
|
1209
|
+
return path;
|
|
1210
|
+
}
|
|
1211
|
+
async get(path) {
|
|
1212
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
1213
|
+
const file = this.client.file(normalizedPath);
|
|
1214
|
+
if (!await file.exists()) {
|
|
1215
|
+
return null;
|
|
1216
|
+
}
|
|
1217
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
1218
|
+
}
|
|
1219
|
+
async delete(path) {
|
|
1220
|
+
try {
|
|
1221
|
+
await this.client.unlink(path.replace(/^\/+/, ""));
|
|
1222
|
+
return true;
|
|
1223
|
+
} catch {
|
|
1224
|
+
return false;
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
class StorageManager {
|
|
1230
|
+
driver;
|
|
1231
|
+
constructor(driver) {
|
|
1232
|
+
this.driver = driver;
|
|
1233
|
+
}
|
|
1234
|
+
put(path, contents) {
|
|
1235
|
+
return this.driver.put(path, contents);
|
|
1236
|
+
}
|
|
1237
|
+
get(path) {
|
|
1238
|
+
return this.driver.get(path);
|
|
1239
|
+
}
|
|
1240
|
+
delete(path) {
|
|
1241
|
+
return this.driver.delete(path);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function resolveS3Config() {
|
|
1245
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
|
|
1246
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
|
1247
|
+
const bucket = process.env.AWS_BUCKET?.trim();
|
|
1248
|
+
if (!accessKeyId || !secretAccessKey || !bucket) {
|
|
1249
|
+
throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
|
|
1250
|
+
}
|
|
1251
|
+
return {
|
|
1252
|
+
accessKeyId,
|
|
1253
|
+
secretAccessKey,
|
|
1254
|
+
bucket,
|
|
1255
|
+
...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
|
|
1256
|
+
...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
function createS3Client(config = resolveS3Config()) {
|
|
1260
|
+
return new S3Client({
|
|
1261
|
+
accessKeyId: config.accessKeyId,
|
|
1262
|
+
secretAccessKey: config.secretAccessKey,
|
|
1263
|
+
bucket: config.bucket,
|
|
1264
|
+
...config.region ? { region: config.region } : {},
|
|
1265
|
+
...config.endpoint ? { endpoint: config.endpoint } : {}
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
function createStorageDriver() {
|
|
1269
|
+
const driver = process.env.STORAGE_DRIVER ?? "local";
|
|
1270
|
+
if (driver === "s3") {
|
|
1271
|
+
return new S3StorageDriver(createS3Client());
|
|
1272
|
+
}
|
|
1273
|
+
return new LocalStorageDriver(process.env.STORAGE_PATH ?? "storage");
|
|
1274
|
+
}
|
|
1275
|
+
var defaultStorage = new StorageManager(createStorageDriver());
|
|
1276
|
+
function storage() {
|
|
1277
|
+
return defaultStorage;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// ../../src/core/facades/index.ts
|
|
1281
|
+
function cache() {
|
|
1282
|
+
return resolveApplicationCache();
|
|
1283
|
+
}
|
|
1284
|
+
function auth() {
|
|
1285
|
+
return resolveApplicationAuth();
|
|
1286
|
+
}
|
|
1287
|
+
function policyGate() {
|
|
1288
|
+
return resolveApplicationPolicyGate();
|
|
1289
|
+
}
|
|
1290
|
+
function queue() {
|
|
1291
|
+
return resolveApplicationQueue();
|
|
1292
|
+
}
|
|
1293
|
+
function events() {
|
|
1294
|
+
return eventBus;
|
|
1295
|
+
}
|
|
1296
|
+
function config(key) {
|
|
1297
|
+
return resolveApplicationConfig().get(key);
|
|
1298
|
+
}
|
|
1299
|
+
function log() {
|
|
1300
|
+
return resolveApplicationLogger();
|
|
1301
|
+
}
|
|
1302
|
+
function mail() {
|
|
1303
|
+
return mailer();
|
|
1304
|
+
}
|
|
1305
|
+
function storageFacade() {
|
|
1306
|
+
return storage();
|
|
1307
|
+
}
|
|
1308
|
+
// ../../src/config/frontend.ts
|
|
1309
|
+
function readFrontendMode() {
|
|
1310
|
+
const mode = (process.env.FRONTEND_MODE ?? "api").trim();
|
|
1311
|
+
if (mode === "server-htmx") {
|
|
1312
|
+
return "server-htmx";
|
|
1313
|
+
}
|
|
1314
|
+
if (mode === "spa-react") {
|
|
1315
|
+
return "spa-react";
|
|
1316
|
+
}
|
|
1317
|
+
return "api";
|
|
1318
|
+
}
|
|
1319
|
+
function isViewsEnabled() {
|
|
1320
|
+
return readFrontendMode() === "server-htmx";
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ../../src/core/view/etaViewEngine.ts
|
|
1324
|
+
import { join as join2 } from "path";
|
|
1325
|
+
import { Eta } from "eta";
|
|
1326
|
+
var DEFAULT_VIEWS_DIRECTORY = join2(process.cwd(), "resources/views");
|
|
1327
|
+
// ../../src/core/view/htmlResponse.ts
|
|
1328
|
+
function htmlResponse(html, init = {}) {
|
|
1329
|
+
return new Response(html, {
|
|
1330
|
+
status: init.status ?? 200,
|
|
1331
|
+
statusText: init.statusText,
|
|
1332
|
+
headers: {
|
|
1333
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
1334
|
+
}
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
// ../../src/config/features.ts
|
|
1338
|
+
function readFeatureFlags() {
|
|
1339
|
+
return {
|
|
1340
|
+
webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
|
|
1341
|
+
fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
|
|
1342
|
+
auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
|
|
1343
|
+
oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
|
|
1344
|
+
samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
|
|
1345
|
+
scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
|
|
1346
|
+
billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
|
|
1347
|
+
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
|
|
1348
|
+
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
|
|
1349
|
+
emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
|
|
1350
|
+
mfa: (process.env.FEATURE_MFA ?? "false") === "true"
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
var featureFlags = readFeatureFlags();
|
|
1354
|
+
function isFeatureEnabled(feature) {
|
|
1355
|
+
return readFeatureFlags()[feature];
|
|
1356
|
+
}
|
|
1357
|
+
// ../../src/modules/user/apiTokenTable.ts
|
|
1358
|
+
var apiTokenTable = defineTable({
|
|
1359
|
+
name: "api_token",
|
|
1360
|
+
primaryKey: "id",
|
|
1361
|
+
columns: [
|
|
1362
|
+
"id",
|
|
1363
|
+
"user_id",
|
|
1364
|
+
"name",
|
|
1365
|
+
"token_hash",
|
|
1366
|
+
"abilities",
|
|
1367
|
+
"last_used_at",
|
|
1368
|
+
"expires_at",
|
|
1369
|
+
"created_at"
|
|
1370
|
+
],
|
|
1371
|
+
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
// ../../src/core/auth/password.ts
|
|
1375
|
+
async function verifyPassword(password, passwordHash) {
|
|
1376
|
+
return await Bun.password.verify(password, passwordHash);
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// ../../src/core/crypto/fieldEncryption.ts
|
|
1380
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
1381
|
+
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
1382
|
+
var IV_LENGTH = 12;
|
|
1383
|
+
var TAG_LENGTH = 16;
|
|
1384
|
+
function resolveEncryptionKey() {
|
|
1385
|
+
const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
|
|
1386
|
+
if (!raw) {
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
if (/^[0-9a-f]{64}$/i.test(raw)) {
|
|
1390
|
+
return Buffer.from(raw, "hex");
|
|
1391
|
+
}
|
|
1392
|
+
const decoded = Buffer.from(raw, "base64");
|
|
1393
|
+
if (decoded.length === 32) {
|
|
1394
|
+
return decoded;
|
|
1395
|
+
}
|
|
1396
|
+
throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
|
|
1397
|
+
}
|
|
1398
|
+
function isFieldEncryptionEnabled() {
|
|
1399
|
+
const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
|
|
1400
|
+
if (featureFlag === "false") {
|
|
1401
|
+
return false;
|
|
1402
|
+
}
|
|
1403
|
+
if (featureFlag === "true") {
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
return (process.env.APP_ENV ?? "local") === "production";
|
|
1407
|
+
}
|
|
1408
|
+
function decryptField(value, key) {
|
|
1409
|
+
if (!value.startsWith(ENCRYPTION_PREFIX)) {
|
|
1410
|
+
return value;
|
|
1411
|
+
}
|
|
1412
|
+
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
1413
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
1414
|
+
const tag = payload.subarray(payload.length - TAG_LENGTH);
|
|
1415
|
+
const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
|
|
1416
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
1417
|
+
decipher.setAuthTag(tag);
|
|
1418
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// ../../src/core/crypto/mfaSecret.ts
|
|
1422
|
+
function revealMfaSecret(stored) {
|
|
1423
|
+
if (!stored) {
|
|
1424
|
+
return null;
|
|
1425
|
+
}
|
|
1426
|
+
const key = resolveEncryptionKey();
|
|
1427
|
+
if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
|
|
1428
|
+
return stored;
|
|
1429
|
+
}
|
|
1430
|
+
return decryptField(stored, key);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
1434
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
1435
|
+
var requestMetaContext = new AsyncLocalStorage3;
|
|
1436
|
+
function currentRequestMeta() {
|
|
1437
|
+
return requestMetaContext.getStore() ?? {
|
|
1438
|
+
ipAddress: null,
|
|
1439
|
+
userAgent: null
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// ../../src/core/security/securityEvents.ts
|
|
1444
|
+
function logSecurityEvent(event, details = {}) {
|
|
1445
|
+
const meta = currentRequestMeta();
|
|
1446
|
+
const user = currentAuthUser();
|
|
1447
|
+
console.log(JSON.stringify({
|
|
1448
|
+
level: "security",
|
|
1449
|
+
event,
|
|
1450
|
+
timestamp: new Date().toISOString(),
|
|
1451
|
+
ip_address: meta.ipAddress ?? null,
|
|
1452
|
+
user_agent: meta.userAgent ?? null,
|
|
1453
|
+
user_id: user?.id ?? null,
|
|
1454
|
+
...details
|
|
1455
|
+
}));
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ../../src/core/security/tokenExpiry.ts
|
|
1459
|
+
function resolveDefaultTokenExpiryDays() {
|
|
1460
|
+
const raw = process.env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim();
|
|
1461
|
+
if (!raw) {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
const parsed = Number.parseInt(raw, 10);
|
|
1465
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
return parsed;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// ../../src/core/security/totp.ts
|
|
1472
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
1473
|
+
function decodeBase32(input) {
|
|
1474
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1475
|
+
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
1476
|
+
let bits = "";
|
|
1477
|
+
for (const char of normalized) {
|
|
1478
|
+
const value = alphabet.indexOf(char);
|
|
1479
|
+
if (value === -1) {
|
|
1480
|
+
throw new Error("Invalid base32 character in MFA secret.");
|
|
1481
|
+
}
|
|
1482
|
+
bits += value.toString(2).padStart(5, "0");
|
|
1483
|
+
}
|
|
1484
|
+
const bytes = [];
|
|
1485
|
+
for (let index = 0;index + 8 <= bits.length; index += 8) {
|
|
1486
|
+
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
|
1487
|
+
}
|
|
1488
|
+
return Buffer.from(bytes);
|
|
1489
|
+
}
|
|
1490
|
+
function generateTotp(secret, counter, digits = 6) {
|
|
1491
|
+
const key = decodeBase32(secret);
|
|
1492
|
+
const buffer = Buffer.alloc(8);
|
|
1493
|
+
buffer.writeBigUInt64BE(BigInt(counter));
|
|
1494
|
+
const digest = createHmac2("sha1", key).update(buffer).digest();
|
|
1495
|
+
const lastByte = digest[digest.length - 1] ?? 0;
|
|
1496
|
+
const offset = lastByte & 15;
|
|
1497
|
+
const b0 = digest[offset] ?? 0;
|
|
1498
|
+
const b1 = digest[offset + 1] ?? 0;
|
|
1499
|
+
const b2 = digest[offset + 2] ?? 0;
|
|
1500
|
+
const b3 = digest[offset + 3] ?? 0;
|
|
1501
|
+
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
1502
|
+
return String(code % 10 ** digits).padStart(digits, "0");
|
|
1503
|
+
}
|
|
1504
|
+
function verifyTotp(secret, token, window = 1) {
|
|
1505
|
+
const normalized = token.trim();
|
|
1506
|
+
if (!/^\d{6}$/u.test(normalized)) {
|
|
1507
|
+
return false;
|
|
1508
|
+
}
|
|
1509
|
+
const timestep = Math.floor(Date.now() / 30000);
|
|
1510
|
+
for (let offset = -window;offset <= window; offset += 1) {
|
|
1511
|
+
if (generateTotp(secret, timestep + offset) === normalized) {
|
|
1512
|
+
return true;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return false;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// ../../src/core/tenant/tenantContext.ts
|
|
1519
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
1520
|
+
var tenantContext = new AsyncLocalStorage4;
|
|
1521
|
+
function currentTenant() {
|
|
1522
|
+
return tenantContext.getStore() ?? null;
|
|
1523
|
+
}
|
|
1524
|
+
function currentTenantId() {
|
|
1525
|
+
return currentTenant()?.id ?? 1;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// ../../src/domain/abilities.ts
|
|
1529
|
+
var MEMBER_ABILITIES = [
|
|
1530
|
+
"organizations:read",
|
|
1531
|
+
"projects:read",
|
|
1532
|
+
"projects:create",
|
|
1533
|
+
"tasks:read",
|
|
1534
|
+
"tasks:create",
|
|
1535
|
+
"comments:read",
|
|
1536
|
+
"comments:create",
|
|
1537
|
+
"attachments:read",
|
|
1538
|
+
"attachments:create",
|
|
1539
|
+
"auth:tokens:read",
|
|
1540
|
+
"auth:tokens:write"
|
|
1541
|
+
];
|
|
1542
|
+
var ADMIN_ABILITIES = [
|
|
1543
|
+
...MEMBER_ABILITIES,
|
|
1544
|
+
"organizations:create",
|
|
1545
|
+
"organizations:update",
|
|
1546
|
+
"organizations:delete",
|
|
1547
|
+
"projects:update",
|
|
1548
|
+
"projects:delete",
|
|
1549
|
+
"tasks:update",
|
|
1550
|
+
"tasks:delete",
|
|
1551
|
+
"comments:update",
|
|
1552
|
+
"comments:delete",
|
|
1553
|
+
"attachments:delete",
|
|
1554
|
+
"webhooks:read",
|
|
1555
|
+
"webhooks:write",
|
|
1556
|
+
"audit:read"
|
|
1557
|
+
];
|
|
1558
|
+
var PLATFORM_ADMIN_ABILITIES = ["*"];
|
|
1559
|
+
function resolveAbilitiesForRole(role) {
|
|
1560
|
+
if (role === "admin") {
|
|
1561
|
+
return [...PLATFORM_ADMIN_ABILITIES];
|
|
1562
|
+
}
|
|
1563
|
+
return [...MEMBER_ABILITIES];
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
// ../../src/modules/user/authService.ts
|
|
1567
|
+
class AuthService {
|
|
1568
|
+
users;
|
|
1569
|
+
tokens;
|
|
1570
|
+
oauthIdentities;
|
|
1571
|
+
oauthProviders = new Map;
|
|
1572
|
+
constructor(users, tokens, oauthIdentities) {
|
|
1573
|
+
this.users = users;
|
|
1574
|
+
this.tokens = tokens;
|
|
1575
|
+
this.oauthIdentities = oauthIdentities;
|
|
1576
|
+
}
|
|
1577
|
+
registerOAuthProvider(provider) {
|
|
1578
|
+
this.oauthProviders.set(provider.name, provider);
|
|
1579
|
+
}
|
|
1580
|
+
getOAuthProvider(name) {
|
|
1581
|
+
return this.oauthProviders.get(name);
|
|
1582
|
+
}
|
|
1583
|
+
async loginWithPassword(email, password, options = {}) {
|
|
1584
|
+
const user = await this.users.findByEmail(email);
|
|
1585
|
+
if (!user?.password_hash) {
|
|
1586
|
+
logSecurityEvent("auth_login_failed", { reason: "unknown_user", email });
|
|
1587
|
+
throw new UnauthorizedError("Invalid credentials.");
|
|
1588
|
+
}
|
|
1589
|
+
const valid = await verifyPassword(password, user.password_hash);
|
|
1590
|
+
if (!valid) {
|
|
1591
|
+
logSecurityEvent("auth_login_failed", { reason: "invalid_password", user_id: user.id });
|
|
1592
|
+
throw new UnauthorizedError("Invalid credentials.");
|
|
1593
|
+
}
|
|
1594
|
+
if (isFeatureEnabled("emailVerification") && !user.email_verified_at) {
|
|
1595
|
+
logSecurityEvent("auth_login_blocked", { reason: "email_unverified", user_id: user.id });
|
|
1596
|
+
throw new UnauthorizedError("Email address is not verified.");
|
|
1597
|
+
}
|
|
1598
|
+
if (isFeatureEnabled("mfa") && user.mfa_enabled) {
|
|
1599
|
+
const mfaSecret = revealMfaSecret(user.mfa_secret);
|
|
1600
|
+
if (!mfaSecret || !options.mfaCode || !verifyTotp(mfaSecret, options.mfaCode)) {
|
|
1601
|
+
logSecurityEvent("auth_login_failed", { reason: "invalid_mfa", user_id: user.id });
|
|
1602
|
+
throw new UnauthorizedError("Invalid MFA code.");
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
logSecurityEvent("auth_login_success", { user_id: user.id, method: "password" });
|
|
1606
|
+
return await this.tokens.createToken(user.id, {
|
|
1607
|
+
name: "password-login",
|
|
1608
|
+
abilities: resolveAbilitiesForRole(user.role),
|
|
1609
|
+
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
1610
|
+
});
|
|
1611
|
+
}
|
|
1612
|
+
async loginWithOAuth(providerName, code) {
|
|
1613
|
+
const provider = this.oauthProviders.get(providerName);
|
|
1614
|
+
if (!provider) {
|
|
1615
|
+
throw new UnauthorizedError("Unsupported OAuth provider.");
|
|
1616
|
+
}
|
|
1617
|
+
const profile = await provider.exchangeCode(code);
|
|
1618
|
+
const user = await this.findOrCreateOAuthUser(providerName, profile);
|
|
1619
|
+
logSecurityEvent("auth_login_success", { user_id: user.id, method: `oauth:${providerName}` });
|
|
1620
|
+
return await this.tokens.createToken(user.id, {
|
|
1621
|
+
name: `${providerName}-oauth`,
|
|
1622
|
+
abilities: resolveAbilitiesForRole(user.role),
|
|
1623
|
+
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
buildOAuthAuthorizationUrl(providerName, state) {
|
|
1627
|
+
const provider = this.oauthProviders.get(providerName);
|
|
1628
|
+
if (!provider) {
|
|
1629
|
+
throw new UnauthorizedError("Unsupported OAuth provider.");
|
|
1630
|
+
}
|
|
1631
|
+
return provider.getAuthorizationUrl(state);
|
|
1632
|
+
}
|
|
1633
|
+
async findOrCreateOAuthUser(providerName, profile) {
|
|
1634
|
+
const existingIdentity = await this.oauthIdentities.findByProviderUser(providerName, profile.providerUserId);
|
|
1635
|
+
if (existingIdentity) {
|
|
1636
|
+
return await this.users.findByIdOrThrow(existingIdentity.user_id);
|
|
1637
|
+
}
|
|
1638
|
+
const existingUser = await this.users.findByEmail(profile.email);
|
|
1639
|
+
const user = existingUser ?? await this.users.create({
|
|
1640
|
+
name: profile.name,
|
|
1641
|
+
email: profile.email,
|
|
1642
|
+
role: "member",
|
|
1643
|
+
tenant_id: currentTenantId(),
|
|
1644
|
+
email_verified_at: new Date,
|
|
1645
|
+
created_at: new Date,
|
|
1646
|
+
updated_at: new Date
|
|
1647
|
+
});
|
|
1648
|
+
await this.oauthIdentities.create({
|
|
1649
|
+
user_id: user.id,
|
|
1650
|
+
provider: providerName,
|
|
1651
|
+
provider_user_id: profile.providerUserId,
|
|
1652
|
+
email: profile.email,
|
|
1653
|
+
created_at: new Date
|
|
1654
|
+
});
|
|
1655
|
+
return user;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
// ../../src/modules/user/notificationTable.ts
|
|
1660
|
+
var notificationTable = defineTable({
|
|
1661
|
+
name: "notification",
|
|
1662
|
+
primaryKey: "id",
|
|
1663
|
+
columns: ["id", "user_id", "tenant_id", "type", "title", "body", "data", "read_at", "created_at"],
|
|
1664
|
+
defaultOrderBy: { column: "created_at", direction: "DESC" }
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
// ../../src/modules/user/oauthIdentityRepository.ts
|
|
1668
|
+
var oauthIdentityTable = defineTable({
|
|
1669
|
+
name: "oauth_identity",
|
|
1670
|
+
primaryKey: "id",
|
|
1671
|
+
columns: ["id", "user_id", "provider", "provider_user_id", "email", "created_at"]
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
// ../../src/modules/user/table.ts
|
|
1675
|
+
var userTable = defineTable({
|
|
1676
|
+
name: "users",
|
|
1677
|
+
primaryKey: "id",
|
|
1678
|
+
columns: [
|
|
1679
|
+
"id",
|
|
1680
|
+
"name",
|
|
1681
|
+
"email",
|
|
1682
|
+
"email_lookup",
|
|
1683
|
+
"role",
|
|
1684
|
+
"tenant_id",
|
|
1685
|
+
"password_hash",
|
|
1686
|
+
"email_verified_at",
|
|
1687
|
+
"mfa_secret",
|
|
1688
|
+
"mfa_enabled",
|
|
1689
|
+
"created_at",
|
|
1690
|
+
"updated_at"
|
|
1691
|
+
],
|
|
1692
|
+
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
1693
|
+
});
|
|
1694
|
+
|
|
1695
|
+
// ../../src/core/http/csrfToken.ts
|
|
1696
|
+
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
1697
|
+
|
|
1698
|
+
// ../../src/core/http/flashSession.ts
|
|
1699
|
+
var FLASH_TTL_MS = 60 * 1000;
|
|
1700
|
+
// ../../src/core/http/contentNegotiation.ts
|
|
1701
|
+
function requestPrefersJson(request) {
|
|
1702
|
+
if (!request) {
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
1705
|
+
if (request.headers.get("HX-Request") === "true") {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
const accept = request.headers.get("accept")?.toLowerCase() ?? "";
|
|
1709
|
+
if (accept.includes("text/html")) {
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
if (accept.includes("application/json")) {
|
|
1713
|
+
return true;
|
|
1714
|
+
}
|
|
1715
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1716
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
1717
|
+
return false;
|
|
1718
|
+
}
|
|
1719
|
+
const pathname = new URL(request.url).pathname;
|
|
1720
|
+
return pathname.startsWith("/api/");
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// ../../src/core/http/webErrorResponse.ts
|
|
1724
|
+
function normalizeFieldErrors(details) {
|
|
1725
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
1726
|
+
return {};
|
|
1727
|
+
}
|
|
1728
|
+
const errors = {};
|
|
1729
|
+
for (const [field, messages] of Object.entries(details)) {
|
|
1730
|
+
if (Array.isArray(messages)) {
|
|
1731
|
+
errors[field] = messages.map(String);
|
|
1732
|
+
continue;
|
|
1733
|
+
}
|
|
1734
|
+
if (typeof messages === "string") {
|
|
1735
|
+
errors[field] = [messages];
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
return errors;
|
|
1739
|
+
}
|
|
1740
|
+
function webErrorResponse(error, request) {
|
|
1741
|
+
if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
|
|
1742
|
+
return null;
|
|
1743
|
+
}
|
|
1744
|
+
const mappedError = error instanceof HttpError ? error : mapDatabaseError(error);
|
|
1745
|
+
if (mappedError instanceof UnauthorizedError) {
|
|
1746
|
+
const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
|
|
1747
|
+
return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
|
|
1748
|
+
}
|
|
1749
|
+
if (mappedError instanceof ValidationError) {
|
|
1750
|
+
const errors = normalizeFieldErrors(mappedError.details);
|
|
1751
|
+
const fieldSummary = Object.entries(errors).flatMap(([field, messages]) => messages.map((message) => `${field}: ${message}`)).join(`
|
|
1752
|
+
`);
|
|
1753
|
+
return htmlResponse(`<section class="page-header"><h1>Validation failed</h1><pre>${fieldSummary || mappedError.message}</pre><p><a href="javascript:history.back()">Go back</a></p></section>`, { status: mappedError.status });
|
|
1754
|
+
}
|
|
1755
|
+
return htmlResponse(`<section class="page-header"><h1>${mappedError.message}</h1></section>`, {
|
|
1756
|
+
status: mappedError.status
|
|
1757
|
+
});
|
|
1758
|
+
}
|
|
1759
|
+
// ../../src/core/http/etag.ts
|
|
1760
|
+
import { createHash } from "crypto";
|
|
1761
|
+
function isEtagEnabled() {
|
|
1762
|
+
return (process.env.FEATURE_ETAG ?? "true") !== "false";
|
|
1763
|
+
}
|
|
1764
|
+
function formatWeakEtag(digest) {
|
|
1765
|
+
return `W/"${digest}"`;
|
|
1766
|
+
}
|
|
1767
|
+
function etagFromResource(resource) {
|
|
1768
|
+
const version = resource.updated_at ?? resource.created_at ?? "";
|
|
1769
|
+
const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
|
|
1770
|
+
const digest = createHash("sha256").update(`${String(resource.id ?? "0")}:${versionText}`).digest("hex").slice(0, 32);
|
|
1771
|
+
return formatWeakEtag(digest);
|
|
1772
|
+
}
|
|
1773
|
+
function normalizeEtag(value) {
|
|
1774
|
+
return value.trim();
|
|
1775
|
+
}
|
|
1776
|
+
function etagValuesMatch(left, right) {
|
|
1777
|
+
return normalizeEtag(left) === normalizeEtag(right);
|
|
1778
|
+
}
|
|
1779
|
+
function parseEtagList(header) {
|
|
1780
|
+
if (!header) {
|
|
1781
|
+
return [];
|
|
1782
|
+
}
|
|
1783
|
+
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
1784
|
+
}
|
|
1785
|
+
function ifNoneMatchSatisfied(request, etag) {
|
|
1786
|
+
const header = request.headers.get("if-none-match");
|
|
1787
|
+
if (!header) {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
if (header.trim() === "*") {
|
|
1791
|
+
return true;
|
|
1792
|
+
}
|
|
1793
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
1794
|
+
}
|
|
1795
|
+
function ifMatchSatisfied(request, etag) {
|
|
1796
|
+
const header = request.headers.get("if-match");
|
|
1797
|
+
if (!header) {
|
|
1798
|
+
return false;
|
|
1799
|
+
}
|
|
1800
|
+
if (header.trim() === "*") {
|
|
1801
|
+
return true;
|
|
1802
|
+
}
|
|
1803
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
1804
|
+
}
|
|
1805
|
+
function assertIfMatch(request, etag, options = {}) {
|
|
1806
|
+
const header = request.headers.get("if-match");
|
|
1807
|
+
if (!header) {
|
|
1808
|
+
if (options.required) {
|
|
1809
|
+
throw new PreconditionFailedError("If-Match header is required.");
|
|
1810
|
+
}
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
if (!ifMatchSatisfied(request, etag)) {
|
|
1814
|
+
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
function applyEtagHeaders(headers, etag) {
|
|
1818
|
+
const next = new Headers(headers);
|
|
1819
|
+
next.set("ETag", etag);
|
|
1820
|
+
next.set("Cache-Control", "private, must-revalidate");
|
|
1821
|
+
next.append("Vary", "Authorization");
|
|
1822
|
+
next.append("Vary", "X-Tenant-Id");
|
|
1823
|
+
return next;
|
|
1824
|
+
}
|
|
1825
|
+
function notModifiedResponse(etag) {
|
|
1826
|
+
return new Response(null, {
|
|
1827
|
+
status: 304,
|
|
1828
|
+
headers: applyEtagHeaders(new Headers, etag)
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
function applyConditionalGet(request, response, etag) {
|
|
1832
|
+
if (!isEtagEnabled()) {
|
|
1833
|
+
return response;
|
|
1834
|
+
}
|
|
1835
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
1836
|
+
return notModifiedResponse(etag);
|
|
1837
|
+
}
|
|
1838
|
+
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
1839
|
+
return new Response(response.body, {
|
|
1840
|
+
status: response.status,
|
|
1841
|
+
statusText: response.statusText,
|
|
1842
|
+
headers
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
// ../../src/core/http/validation.ts
|
|
1846
|
+
function getQueryParams(request) {
|
|
1847
|
+
if (!request) {
|
|
1848
|
+
return new URLSearchParams;
|
|
1849
|
+
}
|
|
1850
|
+
return new URL(request.url).searchParams;
|
|
1851
|
+
}
|
|
1852
|
+
async function parseJsonBody(request, validator) {
|
|
1853
|
+
let payload;
|
|
1854
|
+
try {
|
|
1855
|
+
payload = await request.json();
|
|
1856
|
+
} catch {
|
|
1857
|
+
throw new BadRequestError("Request body must be valid JSON.");
|
|
1858
|
+
}
|
|
1859
|
+
return validator(payload);
|
|
1860
|
+
}
|
|
1861
|
+
function parsePositiveIntParam(value, name = "id") {
|
|
1862
|
+
const parsed = Number.parseInt(value, 10);
|
|
1863
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1864
|
+
throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
|
|
1865
|
+
}
|
|
1866
|
+
return parsed;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// ../../src/core/http/formRequest.ts
|
|
1870
|
+
class FormRequest {
|
|
1871
|
+
authorize(_request) {
|
|
1872
|
+
return true;
|
|
1873
|
+
}
|
|
1874
|
+
async validate(request) {
|
|
1875
|
+
if (!await this.authorize(request)) {
|
|
1876
|
+
throw new ForbiddenError;
|
|
1877
|
+
}
|
|
1878
|
+
return await parseJsonBody(request, (payload) => this.parse(payload));
|
|
1879
|
+
}
|
|
1880
|
+
}
|
|
1881
|
+
// ../../src/core/http/pagination.ts
|
|
1882
|
+
var DEFAULT_PER_PAGE = 15;
|
|
1883
|
+
var MAX_PER_PAGE = 100;
|
|
1884
|
+
function parseRequiredPositiveIntQueryParam(params, name) {
|
|
1885
|
+
const value = params.get(name);
|
|
1886
|
+
if (value === null || value.trim() === "") {
|
|
1887
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
|
|
1888
|
+
}
|
|
1889
|
+
const parsed = Number.parseInt(value, 10);
|
|
1890
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
1891
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
|
|
1892
|
+
}
|
|
1893
|
+
return parsed;
|
|
1894
|
+
}
|
|
1895
|
+
function parsePaginationQuery(request) {
|
|
1896
|
+
const params = getQueryParams(request);
|
|
1897
|
+
const pageParam = params.get("page");
|
|
1898
|
+
const perPageParam = params.get("per_page");
|
|
1899
|
+
const page = pageParam === null || pageParam.trim() === "" ? 1 : parseRequiredPositiveIntQueryParam(params, "page");
|
|
1900
|
+
if (perPageParam === null || perPageParam.trim() === "") {
|
|
1901
|
+
return { page, perPage: DEFAULT_PER_PAGE };
|
|
1902
|
+
}
|
|
1903
|
+
const perPage = parseRequiredPositiveIntQueryParam(params, "per_page");
|
|
1904
|
+
if (perPage > MAX_PER_PAGE) {
|
|
1905
|
+
throw new BadRequestError(`Invalid query parameter "per_page". Maximum allowed value is ${MAX_PER_PAGE}.`);
|
|
1906
|
+
}
|
|
1907
|
+
return { page, perPage };
|
|
1908
|
+
}
|
|
1909
|
+
function paginatedResponse(data, meta, init = {}) {
|
|
1910
|
+
return Response.json({ data, meta }, init);
|
|
1911
|
+
}
|
|
1912
|
+
// ../../src/core/http/securedRouteModelBinding.ts
|
|
1913
|
+
function isMutatingPolicyAction(action) {
|
|
1914
|
+
return action === "update" || action === "delete";
|
|
1915
|
+
}
|
|
1916
|
+
function securedBindRouteModel(param, resolver, authorization, handler) {
|
|
1917
|
+
return async (request) => {
|
|
1918
|
+
const id = parsePositiveIntParam(String(request.params[param]), String(param));
|
|
1919
|
+
const model = await resolver(id, request);
|
|
1920
|
+
const gate = resolveApplicationPolicyGate();
|
|
1921
|
+
const auth2 = resolveApplicationAuth();
|
|
1922
|
+
gate.authorize(authorization.resource, authorization.action, await auth2.resolve(request), model);
|
|
1923
|
+
if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
|
|
1924
|
+
assertIfMatch(request, etagFromResource(model), {
|
|
1925
|
+
required: authorization.requireIfMatch ?? true
|
|
1926
|
+
});
|
|
1927
|
+
}
|
|
1928
|
+
const response = await handler(request, model);
|
|
1929
|
+
if (isEtagEnabled() && authorization.action === "view") {
|
|
1930
|
+
return applyConditionalGet(request, response, etagFromResource(model));
|
|
1931
|
+
}
|
|
1932
|
+
return response;
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
// ../../src/config/uploads.ts
|
|
1936
|
+
var DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
|
|
1937
|
+
var ALLOWED_UPLOAD_MIME_TYPES = new Set([
|
|
1938
|
+
"application/pdf",
|
|
1939
|
+
"application/json",
|
|
1940
|
+
"application/zip",
|
|
1941
|
+
"application/x-zip-compressed",
|
|
1942
|
+
"image/jpeg",
|
|
1943
|
+
"image/png",
|
|
1944
|
+
"image/gif",
|
|
1945
|
+
"image/webp",
|
|
1946
|
+
"text/plain",
|
|
1947
|
+
"text/csv"
|
|
1948
|
+
]);
|
|
1949
|
+
|
|
1950
|
+
// ../../src/core/http/index.ts
|
|
1951
|
+
function jsonResponse(data, init = {}) {
|
|
1952
|
+
return Response.json(data, {
|
|
1953
|
+
status: init.status ?? 200,
|
|
1954
|
+
headers: init.headers
|
|
1955
|
+
});
|
|
1956
|
+
}
|
|
1957
|
+
function createdResponse(data, init = {}) {
|
|
1958
|
+
return jsonResponse(data, { ...init, status: init.status ?? 201 });
|
|
1959
|
+
}
|
|
1960
|
+
function noContentResponse() {
|
|
1961
|
+
return new Response(null, { status: 204 });
|
|
1962
|
+
}
|
|
1963
|
+
function errorResponse(error) {
|
|
1964
|
+
const mappedError = error instanceof HttpError ? error : mapDatabaseError(error);
|
|
1965
|
+
return Response.json({
|
|
1966
|
+
error: mappedError.message,
|
|
1967
|
+
...mappedError.details === undefined ? {} : { details: mappedError.details }
|
|
1968
|
+
}, { status: mappedError.status });
|
|
1969
|
+
}
|
|
1970
|
+
function withErrorHandling(handler) {
|
|
1971
|
+
return async (...args) => {
|
|
1972
|
+
try {
|
|
1973
|
+
return await handler(...args);
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
const request = args.find((arg) => arg instanceof Request);
|
|
1976
|
+
const webResponse = webErrorResponse(error, request);
|
|
1977
|
+
if (webResponse) {
|
|
1978
|
+
return webResponse;
|
|
1979
|
+
}
|
|
1980
|
+
return errorResponse(error);
|
|
1981
|
+
}
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
// ../../src/core/metrics/prometheus.ts
|
|
1985
|
+
class PrometheusRegistry {
|
|
1986
|
+
httpRequestsTotal = new Map;
|
|
1987
|
+
httpRequestDurationMs = new Map;
|
|
1988
|
+
incrementHttpRequest(labels) {
|
|
1989
|
+
const key = this.metricKey(labels);
|
|
1990
|
+
this.httpRequestsTotal.set(key, (this.httpRequestsTotal.get(key) ?? 0) + 1);
|
|
1991
|
+
}
|
|
1992
|
+
observeHttpDuration(labels, durationMs) {
|
|
1993
|
+
const key = this.metricKey(labels);
|
|
1994
|
+
const samples = this.httpRequestDurationMs.get(key) ?? [];
|
|
1995
|
+
samples.push(durationMs);
|
|
1996
|
+
this.httpRequestDurationMs.set(key, samples);
|
|
1997
|
+
}
|
|
1998
|
+
renderMetrics() {
|
|
1999
|
+
const lines = [
|
|
2000
|
+
"# HELP http_requests_total Total HTTP requests processed.",
|
|
2001
|
+
"# TYPE http_requests_total counter"
|
|
2002
|
+
];
|
|
2003
|
+
for (const [key, value] of this.httpRequestsTotal) {
|
|
2004
|
+
lines.push(`http_requests_total{${key}} ${value}`);
|
|
2005
|
+
}
|
|
2006
|
+
lines.push("# HELP http_request_duration_ms_sum Sum of HTTP request durations in milliseconds.", "# TYPE http_request_duration_ms_sum counter");
|
|
2007
|
+
for (const [key, samples] of this.httpRequestDurationMs) {
|
|
2008
|
+
const sum = samples.reduce((total, sample) => total + sample, 0);
|
|
2009
|
+
lines.push(`http_request_duration_ms_sum{${key}} ${sum}`);
|
|
2010
|
+
}
|
|
2011
|
+
return `${lines.join(`
|
|
2012
|
+
`)}
|
|
2013
|
+
`;
|
|
2014
|
+
}
|
|
2015
|
+
resetForTests() {
|
|
2016
|
+
this.httpRequestsTotal.clear();
|
|
2017
|
+
this.httpRequestDurationMs.clear();
|
|
2018
|
+
}
|
|
2019
|
+
getHttpRequestSummary() {
|
|
2020
|
+
const byStatus = {};
|
|
2021
|
+
const pathCounts = new Map;
|
|
2022
|
+
let totalRequests = 0;
|
|
2023
|
+
for (const [key, count] of this.httpRequestsTotal) {
|
|
2024
|
+
totalRequests += count;
|
|
2025
|
+
const method = key.match(/method="([^"]+)"/)?.[1] ?? "GET";
|
|
2026
|
+
const path = key.match(/path="([^"]+)"/)?.[1] ?? "/";
|
|
2027
|
+
const status = key.match(/status="([^"]+)"/)?.[1] ?? "200";
|
|
2028
|
+
byStatus[status] = (byStatus[status] ?? 0) + count;
|
|
2029
|
+
const pathKey = `${method} ${path}`;
|
|
2030
|
+
const existing = pathCounts.get(pathKey);
|
|
2031
|
+
if (existing) {
|
|
2032
|
+
existing.count += count;
|
|
2033
|
+
} else {
|
|
2034
|
+
pathCounts.set(pathKey, { method, path, count });
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
const topPaths = Array.from(pathCounts.values()).sort((left, right) => right.count - left.count).slice(0, 10);
|
|
2038
|
+
return {
|
|
2039
|
+
totalRequests,
|
|
2040
|
+
byStatus,
|
|
2041
|
+
topPaths
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
metricKey(labels) {
|
|
2045
|
+
return `method="${labels.method}",path="${labels.path}",status="${labels.status}"`;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
var prometheusRegistry = new PrometheusRegistry;
|
|
2049
|
+
|
|
2050
|
+
// ../../src/core/http/metricsMiddleware.ts
|
|
2051
|
+
function normalizeMetricPath(pathname) {
|
|
2052
|
+
return pathname.replace(/\/\d+/g, "/:id").replace(/\/[0-9a-f-]{36}/gi, "/:id");
|
|
2053
|
+
}
|
|
2054
|
+
function createMetricsMiddleware() {
|
|
2055
|
+
return async (request, next) => {
|
|
2056
|
+
const startedAt = performance.now();
|
|
2057
|
+
const response = await next();
|
|
2058
|
+
const durationMs = performance.now() - startedAt;
|
|
2059
|
+
const path = normalizeMetricPath(new URL(request.url).pathname);
|
|
2060
|
+
const labels = {
|
|
2061
|
+
method: request.method,
|
|
2062
|
+
path,
|
|
2063
|
+
status: String(response.status)
|
|
2064
|
+
};
|
|
2065
|
+
prometheusRegistry.incrementHttpRequest(labels);
|
|
2066
|
+
prometheusRegistry.observeHttpDuration(labels, durationMs);
|
|
2067
|
+
return response;
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
// ../../src/core/http/parseFormBody.ts
|
|
2071
|
+
async function parseFormBody(request) {
|
|
2072
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
2073
|
+
if (!contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data")) {
|
|
2074
|
+
throw new BadRequestError("Expected a form submission.");
|
|
2075
|
+
}
|
|
2076
|
+
return formDataToRecord(await request.formData());
|
|
2077
|
+
}
|
|
2078
|
+
function formDataToRecord(formData) {
|
|
2079
|
+
const values = {};
|
|
2080
|
+
for (const [key, value] of formData.entries()) {
|
|
2081
|
+
if (typeof value === "string") {
|
|
2082
|
+
values[key] = value;
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
return values;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// ../../src/core/http/webFormRequest.ts
|
|
2089
|
+
class WebFormRequest {
|
|
2090
|
+
authorize(_request) {
|
|
2091
|
+
return true;
|
|
2092
|
+
}
|
|
2093
|
+
async validate(request) {
|
|
2094
|
+
if (!await this.authorize(request)) {
|
|
2095
|
+
throw new ForbiddenError;
|
|
2096
|
+
}
|
|
2097
|
+
const payload = requestPrefersJson(request) ? await parseJsonBody(request, (body) => body) : await parseFormBody(request);
|
|
2098
|
+
try {
|
|
2099
|
+
return this.parse(payload);
|
|
2100
|
+
} catch (error) {
|
|
2101
|
+
if (error instanceof ValidationError) {
|
|
2102
|
+
throw error;
|
|
2103
|
+
}
|
|
2104
|
+
throw error;
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
// ../../src/core/queue/index.ts
|
|
2109
|
+
class Job {
|
|
2110
|
+
maxAttempts;
|
|
2111
|
+
backoffMs;
|
|
2112
|
+
priority;
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
class SyncQueue {
|
|
2116
|
+
async dispatch(job, payload) {
|
|
2117
|
+
await job.handle(payload);
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
class AsyncQueue {
|
|
2122
|
+
async dispatch(job, payload) {
|
|
2123
|
+
setTimeout(() => {
|
|
2124
|
+
job.handle(payload).catch((error) => {
|
|
2125
|
+
console.error("[AsyncQueue] Job failed:", error);
|
|
2126
|
+
});
|
|
2127
|
+
}, 0);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
function createQueue(driver) {
|
|
2131
|
+
return driver === "async" ? new AsyncQueue : new SyncQueue;
|
|
2132
|
+
}
|
|
2133
|
+
export {
|
|
2134
|
+
withErrorHandling,
|
|
2135
|
+
storageFacade as storage,
|
|
2136
|
+
securedBindRouteModel,
|
|
2137
|
+
runWithAuthUser,
|
|
2138
|
+
resolveService,
|
|
2139
|
+
queue,
|
|
2140
|
+
prometheusRegistry,
|
|
2141
|
+
policyGate,
|
|
2142
|
+
parsePaginationQuery,
|
|
2143
|
+
paginatedResponse,
|
|
2144
|
+
normalizeMetricPath,
|
|
2145
|
+
noContentResponse,
|
|
2146
|
+
mailer,
|
|
2147
|
+
mail,
|
|
2148
|
+
log,
|
|
2149
|
+
jsonResponse,
|
|
2150
|
+
isEtagEnabled,
|
|
2151
|
+
events,
|
|
2152
|
+
etagFromResource,
|
|
2153
|
+
defineTable,
|
|
2154
|
+
currentAuthUser,
|
|
2155
|
+
createdResponse,
|
|
2156
|
+
createQueue,
|
|
2157
|
+
createMetricsMiddleware,
|
|
2158
|
+
config,
|
|
2159
|
+
cache,
|
|
2160
|
+
auth,
|
|
2161
|
+
assertIfMatch,
|
|
2162
|
+
WebFormRequest,
|
|
2163
|
+
ValidationError,
|
|
2164
|
+
UnprocessableEntityError,
|
|
2165
|
+
UnauthorizedError,
|
|
2166
|
+
SyncQueue,
|
|
2167
|
+
StorageManager,
|
|
2168
|
+
ServiceContainer,
|
|
2169
|
+
PrometheusRegistry,
|
|
2170
|
+
PreconditionFailedError,
|
|
2171
|
+
Policy,
|
|
2172
|
+
NotFoundError,
|
|
2173
|
+
Mailer,
|
|
2174
|
+
LogMailDriver,
|
|
2175
|
+
LocalStorageDriver,
|
|
2176
|
+
Job,
|
|
2177
|
+
FormRequest,
|
|
2178
|
+
ForbiddenError,
|
|
2179
|
+
EventBus,
|
|
2180
|
+
ConflictError,
|
|
2181
|
+
ConfigStore,
|
|
2182
|
+
repository_default as CacheRepository,
|
|
2183
|
+
CACHE_TAGS,
|
|
2184
|
+
baseRepository_default as BaseRepository,
|
|
2185
|
+
BadRequestError,
|
|
2186
|
+
AsyncQueue
|
|
2187
|
+
};
|