@getstrata/core 0.3.9 → 0.5.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/dist/bootstrap/httpKernel.d.ts +2 -0
- package/dist/config/queue.d.ts +8 -0
- package/dist/config/rateLimit.d.ts +2 -1
- package/dist/core/database/baseRepository.d.ts +16 -4
- package/dist/core/database/boundConnection.d.ts +5 -0
- package/dist/core/database/index.d.ts +10 -4
- package/dist/core/database/migrations/advisoryLock.d.ts +6 -0
- package/dist/core/database/migrations/runner.d.ts +8 -2
- package/dist/core/database/migrations/types.d.ts +1 -0
- package/dist/core/database/model.d.ts +46 -17
- package/dist/core/database/query.d.ts +26 -14
- package/dist/core/database/relationships.d.ts +32 -2
- package/dist/core/database/repositoryConnection.d.ts +4 -0
- package/dist/core/database/repositoryQuery.d.ts +16 -1
- package/dist/core/database/schema/blueprint.d.ts +47 -0
- package/dist/core/database/schema/columnDefinition.d.ts +36 -0
- package/dist/core/database/schema/driver.d.ts +8 -0
- package/dist/core/database/schema/errors.d.ts +4 -0
- package/dist/core/database/schema/grammars/compileStatements.d.ts +8 -0
- package/dist/core/database/schema/grammars/createGrammar.d.ts +4 -0
- package/dist/core/database/schema/grammars/grammar.d.ts +10 -0
- package/dist/core/database/schema/grammars/index.d.ts +7 -0
- package/dist/core/database/schema/grammars/mysqlGrammar.d.ts +2 -0
- package/dist/core/database/schema/grammars/postgresGrammar.d.ts +2 -0
- package/dist/core/database/schema/grammars/sqliteGrammar.d.ts +2 -0
- package/dist/core/database/schema/index.d.ts +12 -0
- package/dist/core/database/schema/schema.d.ts +21 -0
- package/dist/core/database/seeders/runner.d.ts +6 -0
- package/dist/core/database/seeders/types.d.ts +8 -0
- package/dist/core/database/types.d.ts +39 -2
- package/dist/core/database/whereBuilder.d.ts +17 -0
- package/dist/core/http/index.d.ts +1 -1
- package/dist/core/http/securedRouteModelBinding.d.ts +2 -1
- package/dist/core/lifecycle/gracefulShutdown.d.ts +6 -0
- package/dist/core/pagination/index.d.ts +11 -1
- package/dist/core/queue/failedJobRepository.d.ts +6 -0
- package/dist/core/queue/failedJobService.d.ts +15 -0
- package/dist/core/queue/failedJobTable.d.ts +3 -0
- package/dist/core/queue/jobRegistry.d.ts +14 -0
- package/dist/core/queue/jobRunner.d.ts +9 -0
- package/dist/core/queue/publicQueue.d.ts +15 -0
- package/dist/core/queue/redisQueue.d.ts +29 -0
- package/dist/core/queue/resilientQueue.d.ts +9 -0
- package/dist/core/queue/types.d.ts +8 -0
- package/dist/core/scheduler/schedule.d.ts +15 -0
- package/dist/entries/auth/accessControl.js +113 -0
- package/dist/entries/auth/authContext.js +15 -0
- package/dist/entries/auth/guard.js +2870 -0
- package/dist/entries/auth/membershipContext.js +276 -0
- package/dist/entries/auth/membershipScope.js +390 -0
- package/dist/entries/auth/membershipService.js +477 -0
- package/dist/entries/auth/oauth/oidcProvider.js +49 -0
- package/dist/entries/auth/oauth/providers.js +71 -0
- package/dist/entries/auth/oauth/samlProvider.js +26 -0
- package/dist/entries/auth/oauth/types.js +1 -0
- package/dist/entries/auth/password.js +15 -0
- package/dist/entries/auth/policy.js +134 -0
- package/dist/entries/auth/sessionCookie.js +75 -0
- package/dist/entries/auth/tokenHash.js +17 -0
- package/dist/entries/cache/tags.js +13 -0
- package/dist/entries/crypto/fieldEncryption.js +93 -0
- package/dist/entries/crypto/mfaSecret.js +105 -0
- package/dist/entries/database/factory.js +17 -0
- package/dist/entries/database/seeders.js +30 -0
- package/dist/entries/database/types.js +1 -0
- package/dist/entries/database.js +2218 -0
- package/dist/entries/errors/http.js +78 -0
- package/dist/entries/http/contentNegotiation.js +26 -0
- package/dist/entries/http/csrfToken.js +147 -0
- package/dist/entries/http/etag.js +170 -0
- package/dist/entries/http/flashSession.js +106 -0
- package/dist/entries/http/middleware.js +68 -0
- package/dist/entries/http/parseFormBody.js +88 -0
- package/dist/entries/http/requestMetaContext.js +18 -0
- package/dist/entries/http/resources.js +19 -0
- package/dist/entries/http/webErrorResponse.js +3143 -0
- package/dist/entries/http/webFormRequest.js +293 -0
- package/dist/entries/http.js +3924 -0
- package/dist/entries/jobs/dispatchWebhookJob.js +342 -0
- package/dist/entries/lifecycle/gracefulShutdown.js +50 -0
- package/dist/entries/metrics/prometheus.js +70 -0
- package/dist/entries/pagination.js +14 -0
- package/dist/entries/queue/createAppQueue.js +2848 -0
- package/dist/entries/queue/failedJobService.js +38 -0
- package/dist/entries/queue/jobRegistry.js +32 -0
- package/dist/entries/queue/jobRunner.js +75 -0
- package/dist/entries/queue/publicQueue.js +2476 -0
- package/dist/entries/queue/queueMetrics.js +2898 -0
- package/dist/entries/queue/types.js +1 -0
- package/dist/entries/security/oauthState.js +75 -0
- package/dist/entries/security/publicReads.js +33 -0
- package/dist/entries/security/safeUrl.js +143 -0
- package/dist/entries/security/securityEvents.js +41 -0
- package/dist/entries/security/stripeWebhook.js +115 -0
- package/dist/entries/security/tokenExpiry.js +16 -0
- package/dist/entries/security/totp.js +51 -0
- package/dist/entries/storage/storage.js +123 -0
- package/dist/entries/tenant/tenantContext.js +30 -0
- package/dist/entries/tenant/tenantMiddleware.js +312 -0
- package/dist/entries/tracing/traceContext.js +15 -0
- package/dist/entries/validation/rules.js +232 -0
- package/dist/entries/view.js +3072 -0
- package/dist/framework/public-api.d.ts +65 -38
- package/dist/index.js +3213 -281
- package/dist/modules/user/apiTokenRepository.d.ts +1 -1
- package/dist/modules/user/apiTokenTable.d.ts +1 -1
- package/dist/modules/user/authService.d.ts +1 -1
- package/dist/modules/user/notificationRepository.d.ts +1 -1
- package/dist/modules/user/notificationService.d.ts +1 -1
- package/dist/modules/user/notificationTable.d.ts +1 -1
- package/dist/modules/user/oauthIdentityRepository.d.ts +2 -2
- package/dist/modules/user/provider.d.ts +1 -1
- package/dist/modules/user/repository.d.ts +1 -1
- package/dist/modules/user/table.d.ts +1 -1
- package/dist/modules/user/tokenService.d.ts +1 -1
- package/package.json +289 -3
package/dist/index.js
CHANGED
|
@@ -135,6 +135,13 @@ class UnauthorizedError extends HttpError {
|
|
|
135
135
|
super(401, message, details);
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
class PayloadTooLargeError extends HttpError {
|
|
140
|
+
constructor(message = "Payload Too Large", details) {
|
|
141
|
+
super(413, message, details);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
138
145
|
class PreconditionFailedError extends HttpError {
|
|
139
146
|
constructor(message = "Precondition Failed", details) {
|
|
140
147
|
super(412, message, details);
|
|
@@ -157,6 +164,15 @@ class Policy {
|
|
|
157
164
|
return false;
|
|
158
165
|
}
|
|
159
166
|
}
|
|
167
|
+
var BLOCKED_POLICY_ACTIONS = new Set([
|
|
168
|
+
"constructor",
|
|
169
|
+
"toString",
|
|
170
|
+
"valueOf",
|
|
171
|
+
"hasOwnProperty",
|
|
172
|
+
"isPrototypeOf",
|
|
173
|
+
"propertyIsEnumerable",
|
|
174
|
+
"__proto__"
|
|
175
|
+
]);
|
|
160
176
|
|
|
161
177
|
class PolicyGate {
|
|
162
178
|
constructor() {}
|
|
@@ -169,6 +185,9 @@ class PolicyGate {
|
|
|
169
185
|
if (!policy) {
|
|
170
186
|
return false;
|
|
171
187
|
}
|
|
188
|
+
if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
172
191
|
const handler = policy[action];
|
|
173
192
|
if (typeof handler !== "function") {
|
|
174
193
|
return false;
|
|
@@ -248,76 +267,6 @@ var CACHE_TAGS = {
|
|
|
248
267
|
attachments: "attachments",
|
|
249
268
|
reports: "reports"
|
|
250
269
|
};
|
|
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: null
|
|
289
|
-
};
|
|
290
|
-
function getDatabase() {
|
|
291
|
-
if (!connectionHolder.connection) {
|
|
292
|
-
connectionHolder.connection = createDatabaseConnection(databaseConfig);
|
|
293
|
-
}
|
|
294
|
-
return connectionHolder.connection;
|
|
295
|
-
}
|
|
296
|
-
function resetDatabaseConnectionForTests(connection) {
|
|
297
|
-
connectionHolder.connection = connection;
|
|
298
|
-
}
|
|
299
|
-
var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
300
|
-
function resolveDatabase() {
|
|
301
|
-
return getActiveDatabaseConnection(getDatabase());
|
|
302
|
-
}
|
|
303
|
-
function resolveDatabaseForProperty(property) {
|
|
304
|
-
if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
|
|
305
|
-
return getDatabase();
|
|
306
|
-
}
|
|
307
|
-
return resolveDatabase();
|
|
308
|
-
}
|
|
309
|
-
var db = new Proxy(function database() {}, {
|
|
310
|
-
apply(_target, _thisArg, args) {
|
|
311
|
-
return resolveDatabase()(...args);
|
|
312
|
-
},
|
|
313
|
-
get(_target, property) {
|
|
314
|
-
const connection = resolveDatabaseForProperty(property);
|
|
315
|
-
const value = connection[property];
|
|
316
|
-
return typeof value === "function" ? value.bind(connection) : value;
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
var connection_default = db;
|
|
320
|
-
|
|
321
270
|
// ../../src/core/events/eventBus.ts
|
|
322
271
|
class EventBus {
|
|
323
272
|
constructor() {}
|
|
@@ -428,6 +377,23 @@ function quoteIdentifier(identifier) {
|
|
|
428
377
|
function qualifyColumn(tableName, column) {
|
|
429
378
|
return `${quoteIdentifier(tableName)}.${quoteIdentifier(column)}`;
|
|
430
379
|
}
|
|
380
|
+
function resolveQualifiedColumn(defaultTable, columnName) {
|
|
381
|
+
if (columnName.includes(".")) {
|
|
382
|
+
const [table, column] = columnName.split(".", 2);
|
|
383
|
+
if (!table || !column) {
|
|
384
|
+
throw new Error(`Invalid qualified column: ${columnName}`);
|
|
385
|
+
}
|
|
386
|
+
return qualifyColumn(table, column);
|
|
387
|
+
}
|
|
388
|
+
return qualifyColumn(defaultTable, columnName);
|
|
389
|
+
}
|
|
390
|
+
function parseQualifiedColumn(reference) {
|
|
391
|
+
const [table, column] = reference.split(".", 2);
|
|
392
|
+
if (!table || !column) {
|
|
393
|
+
throw new Error(`Join columns must be qualified as table.column: ${reference}`);
|
|
394
|
+
}
|
|
395
|
+
return { table, column };
|
|
396
|
+
}
|
|
431
397
|
function normalizeDirection(direction = "ASC") {
|
|
432
398
|
return direction.toUpperCase() === "DESC" ? "DESC" : "ASC";
|
|
433
399
|
}
|
|
@@ -475,16 +441,21 @@ function buildOperatorClauses(column, operator, params) {
|
|
|
475
441
|
if (operator.lte !== undefined) {
|
|
476
442
|
clauses.push(`${column} <= ${pushParam(params, operator.lte)}`);
|
|
477
443
|
}
|
|
444
|
+
if (operator.ilike !== undefined) {
|
|
445
|
+
clauses.push(`${column} ILIKE ${pushParam(params, `%${operator.ilike}%`)}`);
|
|
446
|
+
}
|
|
447
|
+
if (operator.tsMatch !== undefined) {
|
|
448
|
+
clauses.push(`${column} @@ plainto_tsquery('english', ${pushParam(params, operator.tsMatch)})`);
|
|
449
|
+
}
|
|
478
450
|
return clauses;
|
|
479
451
|
}
|
|
480
|
-
function
|
|
452
|
+
function appendWhereParts(tableName, where, params) {
|
|
481
453
|
const clauses = [];
|
|
482
|
-
const params = [];
|
|
483
454
|
for (const [columnName, filterValue] of Object.entries(where)) {
|
|
484
455
|
if (filterValue === undefined) {
|
|
485
456
|
continue;
|
|
486
457
|
}
|
|
487
|
-
const column =
|
|
458
|
+
const column = resolveQualifiedColumn(tableName, columnName);
|
|
488
459
|
if (Array.isArray(filterValue)) {
|
|
489
460
|
clauses.push(buildInClause(column, filterValue, params));
|
|
490
461
|
continue;
|
|
@@ -499,8 +470,54 @@ function buildWhereClause(tableName, where = {}) {
|
|
|
499
470
|
}
|
|
500
471
|
clauses.push(`${column} = ${pushParam(params, filterValue)}`);
|
|
501
472
|
}
|
|
473
|
+
return clauses.join(" AND ");
|
|
474
|
+
}
|
|
475
|
+
function buildWhereClause(tableName, where = {}) {
|
|
476
|
+
const params = [];
|
|
477
|
+
const body = appendWhereParts(tableName, where, params);
|
|
478
|
+
return {
|
|
479
|
+
clause: body.length > 0 ? ` WHERE ${body}` : "",
|
|
480
|
+
params
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function buildWhereNodeClause(tableName, node, params) {
|
|
484
|
+
if ("where" in node) {
|
|
485
|
+
return appendWhereParts(tableName, node.where, params);
|
|
486
|
+
}
|
|
487
|
+
const grouped = buildWhereGroupClause(tableName, node.group, params);
|
|
488
|
+
if (!grouped) {
|
|
489
|
+
return "";
|
|
490
|
+
}
|
|
491
|
+
return grouped.includes(" OR ") ? `(${grouped})` : grouped;
|
|
492
|
+
}
|
|
493
|
+
function buildWhereGroupClause(tableName, nodes, params) {
|
|
494
|
+
let result = "";
|
|
495
|
+
for (const node of nodes) {
|
|
496
|
+
const part = buildWhereNodeClause(tableName, node, params);
|
|
497
|
+
if (!part) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
if (!result) {
|
|
501
|
+
result = part;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
result = node.kind === "or" ? `${result} OR ${part}` : `${result} AND ${part}`;
|
|
505
|
+
}
|
|
506
|
+
if (!result) {
|
|
507
|
+
return "";
|
|
508
|
+
}
|
|
509
|
+
return result;
|
|
510
|
+
}
|
|
511
|
+
function buildAdvancedWhereClause(tableName, where = {}, whereNodes = []) {
|
|
512
|
+
const params = [];
|
|
513
|
+
const nodes = [];
|
|
514
|
+
if (Object.keys(where).length > 0) {
|
|
515
|
+
nodes.push({ kind: "and", where });
|
|
516
|
+
}
|
|
517
|
+
nodes.push(...whereNodes);
|
|
518
|
+
const combined = buildWhereGroupClause(tableName, nodes, params);
|
|
502
519
|
return {
|
|
503
|
-
clause:
|
|
520
|
+
clause: combined ? ` WHERE ${combined}` : "",
|
|
504
521
|
params
|
|
505
522
|
};
|
|
506
523
|
}
|
|
@@ -527,12 +544,17 @@ function appendSoftDeleteScope(table, options, clauses) {
|
|
|
527
544
|
clauses.push(`${qualifiedColumn} IS NULL`);
|
|
528
545
|
}
|
|
529
546
|
}
|
|
530
|
-
function buildQueryWhereClause(table, options = {}) {
|
|
531
|
-
const { clause, params } =
|
|
532
|
-
const
|
|
533
|
-
appendSoftDeleteScope(table, options,
|
|
547
|
+
function buildQueryWhereClause(table, options = {}, whereNodes = []) {
|
|
548
|
+
const { clause, params } = buildAdvancedWhereClause(table.name, options.where ?? {}, whereNodes);
|
|
549
|
+
const softDeleteClauses = [];
|
|
550
|
+
appendSoftDeleteScope(table, options, softDeleteClauses);
|
|
551
|
+
if (softDeleteClauses.length === 0) {
|
|
552
|
+
return { clause, params };
|
|
553
|
+
}
|
|
554
|
+
const base = clause.replace(/^ WHERE /, "");
|
|
555
|
+
const scope = softDeleteClauses.join(" AND ");
|
|
534
556
|
return {
|
|
535
|
-
clause:
|
|
557
|
+
clause: base ? ` WHERE (${base}) AND ${scope}` : ` WHERE ${scope}`,
|
|
536
558
|
params
|
|
537
559
|
};
|
|
538
560
|
}
|
|
@@ -556,10 +578,32 @@ function normalizeOrderBy(orderBy) {
|
|
|
556
578
|
}
|
|
557
579
|
function buildOrderByClause(tableName, orderBy) {
|
|
558
580
|
const parts = normalizeOrderBy(orderBy).map(({ column, direction }) => {
|
|
559
|
-
return `${
|
|
581
|
+
return `${resolveQualifiedColumn(tableName, column)} ${normalizeDirection(direction)}`;
|
|
560
582
|
});
|
|
561
583
|
return parts.length > 0 ? ` ORDER BY ${parts.join(", ")}` : "";
|
|
562
584
|
}
|
|
585
|
+
function buildGroupByClause(tableName, groupBy) {
|
|
586
|
+
if (!groupBy) {
|
|
587
|
+
return "";
|
|
588
|
+
}
|
|
589
|
+
const columns = (Array.isArray(groupBy) ? groupBy : [groupBy]).map((column) => String(column));
|
|
590
|
+
const parts = columns.map((column) => resolveQualifiedColumn(tableName, column));
|
|
591
|
+
return parts.length > 0 ? ` GROUP BY ${parts.join(", ")}` : "";
|
|
592
|
+
}
|
|
593
|
+
function buildHavingClause(tableName, having, params) {
|
|
594
|
+
if (!having) {
|
|
595
|
+
return "";
|
|
596
|
+
}
|
|
597
|
+
const body = appendWhereParts(tableName, having, params);
|
|
598
|
+
return body.length > 0 ? ` HAVING ${body}` : "";
|
|
599
|
+
}
|
|
600
|
+
function buildJoinClause(joins = []) {
|
|
601
|
+
return joins.map((join) => {
|
|
602
|
+
const joinType = join.type === "left" ? "LEFT JOIN" : "INNER JOIN";
|
|
603
|
+
const onClause = join.on.map(({ left, right }) => `${qualifyColumn(left.table, left.column)} = ${qualifyColumn(right.table, right.column)}`).join(" AND ");
|
|
604
|
+
return ` ${joinType} ${quoteIdentifier(join.table)} ON ${onClause}`;
|
|
605
|
+
}).join("");
|
|
606
|
+
}
|
|
563
607
|
function buildLimitClause(limit) {
|
|
564
608
|
if (limit === undefined) {
|
|
565
609
|
return "";
|
|
@@ -581,6 +625,23 @@ function buildOffsetClause(offset) {
|
|
|
581
625
|
function buildReturningColumns(table) {
|
|
582
626
|
return table.columns.map((column) => qualifyColumn(table.name, column)).join(", ");
|
|
583
627
|
}
|
|
628
|
+
function buildSelectList(table, select, params = []) {
|
|
629
|
+
if (!select || select.length === 0) {
|
|
630
|
+
return buildReturningColumns(table);
|
|
631
|
+
}
|
|
632
|
+
return select.map((item) => {
|
|
633
|
+
if (item.kind === "column") {
|
|
634
|
+
const column2 = qualifyColumn(item.table, item.column);
|
|
635
|
+
return item.as ? `${column2} AS ${quoteIdentifier(item.as)}` : column2;
|
|
636
|
+
}
|
|
637
|
+
if (item.kind === "literalText") {
|
|
638
|
+
return `${pushParam(params, item.value)}::text AS ${quoteIdentifier(item.as)}`;
|
|
639
|
+
}
|
|
640
|
+
const column = qualifyColumn(item.table, item.column);
|
|
641
|
+
const placeholder = pushParam(params, item.query);
|
|
642
|
+
return `ts_rank(${column}, plainto_tsquery('english', ${placeholder})) AS ${quoteIdentifier(item.as)}`;
|
|
643
|
+
}).join(", ");
|
|
644
|
+
}
|
|
584
645
|
function getDefinedColumnEntries(table, values, options = {}) {
|
|
585
646
|
const record = values;
|
|
586
647
|
const excluded = new Set(options.exclude ?? []);
|
|
@@ -595,36 +656,57 @@ function getDefinedColumnEntries(table, values, options = {}) {
|
|
|
595
656
|
return [[column, value]];
|
|
596
657
|
});
|
|
597
658
|
}
|
|
598
|
-
function buildSelectQuery(table, options = {}) {
|
|
599
|
-
const
|
|
600
|
-
const
|
|
659
|
+
function buildSelectQuery(table, options = {}, whereNodes = []) {
|
|
660
|
+
const params = [];
|
|
661
|
+
const columns = buildSelectList(table, options.select, params);
|
|
662
|
+
const { clause, params: whereParams } = buildQueryWhereClause(table, options, whereNodes);
|
|
663
|
+
params.push(...whereParams);
|
|
664
|
+
const joins = buildJoinClause(options.joins);
|
|
665
|
+
const groupBy = buildGroupByClause(table.name, options.groupBy);
|
|
666
|
+
const havingClause = buildHavingClause(table.name, options.having, params);
|
|
601
667
|
const orderBy = buildOrderByClause(table.name, options.orderBy ?? table.defaultOrderBy);
|
|
602
668
|
const limit = buildLimitClause(options.limit);
|
|
603
669
|
const offset = buildOffsetClause(options.offset);
|
|
604
670
|
return {
|
|
605
|
-
text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${clause}${orderBy}${limit}${offset}`,
|
|
671
|
+
text: `SELECT ${columns} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${havingClause}${orderBy}${limit}${offset}`,
|
|
606
672
|
params
|
|
607
673
|
};
|
|
608
674
|
}
|
|
609
|
-
function buildCountQuery(table, where = {}, options = {}) {
|
|
610
|
-
const
|
|
675
|
+
function buildCountQuery(table, where = {}, options = {}, whereNodes = []) {
|
|
676
|
+
const params = [];
|
|
677
|
+
const { clause, params: whereParams } = buildQueryWhereClause(table, {
|
|
611
678
|
where,
|
|
612
|
-
|
|
613
|
-
|
|
679
|
+
withTrashed: options.withTrashed,
|
|
680
|
+
onlyTrashed: options.onlyTrashed
|
|
681
|
+
}, whereNodes);
|
|
682
|
+
params.push(...whereParams);
|
|
683
|
+
const joins = buildJoinClause(options.joins);
|
|
684
|
+
const groupBy = buildGroupByClause(table.name, options.groupBy);
|
|
614
685
|
return {
|
|
615
|
-
text: `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}${clause}`,
|
|
686
|
+
text: `SELECT COUNT(*) AS count FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}`,
|
|
616
687
|
params
|
|
617
688
|
};
|
|
618
689
|
}
|
|
619
|
-
function buildProjectionQuery(table, expression, alias, options = {}) {
|
|
620
|
-
|
|
690
|
+
function buildProjectionQuery(table, expression, alias, options = {}, whereNodes = []) {
|
|
691
|
+
assertSafeProjectionExpression(expression);
|
|
692
|
+
const params = [];
|
|
693
|
+
const { clause, params: whereParams } = buildQueryWhereClause(table, options, whereNodes);
|
|
694
|
+
params.push(...whereParams);
|
|
695
|
+
const joins = buildJoinClause(options.joins);
|
|
696
|
+
const groupBy = buildGroupByClause(table.name, options.groupBy);
|
|
621
697
|
const orderBy = buildOrderByClause(table.name, options.orderBy);
|
|
622
698
|
const limit = buildLimitClause(options.limit);
|
|
623
699
|
return {
|
|
624
|
-
text: `SELECT ${expression} AS ${quoteIdentifier(alias)} FROM ${quoteIdentifier(table.name)}${clause}${orderBy}${limit}`,
|
|
700
|
+
text: `SELECT ${expression} AS ${quoteIdentifier(alias)} FROM ${quoteIdentifier(table.name)}${joins}${clause}${groupBy}${orderBy}${limit}`,
|
|
625
701
|
params
|
|
626
702
|
};
|
|
627
703
|
}
|
|
704
|
+
var SAFE_PROJECTION_EXPRESSION = /^(COUNT\(\*\)|(?:"[\w_]+"(?:\."[\w_]+")?)|(?:AVG|SUM|MIN|MAX)\((?:"[\w_]+"(?:\."[\w_]+")?)\))$/;
|
|
705
|
+
function assertSafeProjectionExpression(expression) {
|
|
706
|
+
if (!SAFE_PROJECTION_EXPRESSION.test(expression.trim())) {
|
|
707
|
+
throw new Error(`Unsafe projection expression: ${expression}`);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
628
710
|
function buildGroupedCountQuery(table, column, where = {}, options = {}) {
|
|
629
711
|
const qualifiedColumn = qualifyColumn(table.name, column);
|
|
630
712
|
const { clause, params } = buildQueryWhereClause(table, {
|
|
@@ -708,12 +790,24 @@ function hasMany(definition) {
|
|
|
708
790
|
...definition
|
|
709
791
|
};
|
|
710
792
|
}
|
|
793
|
+
function hasOne(definition) {
|
|
794
|
+
return {
|
|
795
|
+
type: "hasOne",
|
|
796
|
+
...definition
|
|
797
|
+
};
|
|
798
|
+
}
|
|
711
799
|
function belongsTo(definition) {
|
|
712
800
|
return {
|
|
713
801
|
type: "belongsTo",
|
|
714
802
|
...definition
|
|
715
803
|
};
|
|
716
804
|
}
|
|
805
|
+
function belongsToMany(definition) {
|
|
806
|
+
return {
|
|
807
|
+
type: "belongsToMany",
|
|
808
|
+
...definition
|
|
809
|
+
};
|
|
810
|
+
}
|
|
717
811
|
function indexHasManyRelation(parents, children, relation) {
|
|
718
812
|
const groups = new Map;
|
|
719
813
|
for (const parent of parents) {
|
|
@@ -729,6 +823,15 @@ function indexHasManyRelation(parents, children, relation) {
|
|
|
729
823
|
}
|
|
730
824
|
return groups;
|
|
731
825
|
}
|
|
826
|
+
function indexHasOneRelation(parents, children, relation) {
|
|
827
|
+
const grouped = indexHasManyRelation(parents, children, relation);
|
|
828
|
+
const result = new Map;
|
|
829
|
+
for (const parent of parents) {
|
|
830
|
+
const matches = grouped.get(parent[relation.localKey]) ?? [];
|
|
831
|
+
result.set(parent[relation.localKey], matches[0]);
|
|
832
|
+
}
|
|
833
|
+
return result;
|
|
834
|
+
}
|
|
732
835
|
function indexBelongsToRelation(children, parents, relation) {
|
|
733
836
|
const parentsById = new Map;
|
|
734
837
|
for (const parent of parents) {
|
|
@@ -744,6 +847,149 @@ function indexBelongsToRelation(children, parents, relation) {
|
|
|
744
847
|
}
|
|
745
848
|
return result;
|
|
746
849
|
}
|
|
850
|
+
function indexBelongsToManyRelation(parents, pivotRows, relatedRows, relation) {
|
|
851
|
+
const relatedById = new Map;
|
|
852
|
+
for (const related of relatedRows) {
|
|
853
|
+
relatedById.set(related[relation.relatedKey], related);
|
|
854
|
+
}
|
|
855
|
+
const groups = new Map;
|
|
856
|
+
for (const parent of parents) {
|
|
857
|
+
groups.set(parent[relation.parentKey], []);
|
|
858
|
+
}
|
|
859
|
+
for (const pivot of pivotRows) {
|
|
860
|
+
const parentId = pivot[relation.foreignPivotKey];
|
|
861
|
+
const relatedId = pivot[relation.relatedPivotKey];
|
|
862
|
+
const group = groups.get(parentId);
|
|
863
|
+
const related = relatedById.get(relatedId);
|
|
864
|
+
if (!group || !related) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
group.push(related);
|
|
868
|
+
}
|
|
869
|
+
return groups;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ../../src/config/database.ts
|
|
873
|
+
function readInteger(name, fallback) {
|
|
874
|
+
const parsed = Number.parseInt(process.env[name] ?? String(fallback), 10);
|
|
875
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
876
|
+
}
|
|
877
|
+
var databaseConfig = {
|
|
878
|
+
url: process.env.DATABASE_URL ?? "",
|
|
879
|
+
poolMax: readInteger("DB_POOL_MAX", 10),
|
|
880
|
+
idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
|
|
881
|
+
maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
|
|
882
|
+
connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
|
|
883
|
+
};
|
|
884
|
+
|
|
885
|
+
// ../../src/core/database/connectionContext.ts
|
|
886
|
+
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
887
|
+
var activeConnection = new AsyncLocalStorage2;
|
|
888
|
+
function runWithDatabaseConnection(connection, callback) {
|
|
889
|
+
return activeConnection.run(connection, callback);
|
|
890
|
+
}
|
|
891
|
+
function getActiveDatabaseConnection(fallback) {
|
|
892
|
+
return activeConnection.getStore() ?? fallback;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ../../src/db/connection/createConnection.ts
|
|
896
|
+
var {SQL } = globalThis.Bun;
|
|
897
|
+
function createDatabaseConnection(config) {
|
|
898
|
+
if (!config.url) {
|
|
899
|
+
throw new Error("DATABASE_URL is not configured. Set DATABASE_URL before starting the app or running integration tests.");
|
|
900
|
+
}
|
|
901
|
+
return new SQL({
|
|
902
|
+
url: config.url,
|
|
903
|
+
max: config.poolMax,
|
|
904
|
+
idleTimeout: config.idleTimeoutSeconds,
|
|
905
|
+
maxLifetime: config.maxLifetimeSeconds,
|
|
906
|
+
connectionTimeout: config.connectionTimeoutSeconds
|
|
907
|
+
});
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// ../../src/db/connection/index.ts
|
|
911
|
+
var connectionHolder = {
|
|
912
|
+
connection: null
|
|
913
|
+
};
|
|
914
|
+
function getDatabase() {
|
|
915
|
+
if (!connectionHolder.connection) {
|
|
916
|
+
connectionHolder.connection = createDatabaseConnection(databaseConfig);
|
|
917
|
+
}
|
|
918
|
+
return connectionHolder.connection;
|
|
919
|
+
}
|
|
920
|
+
var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
921
|
+
function resolveDatabase() {
|
|
922
|
+
return getActiveDatabaseConnection(getDatabase());
|
|
923
|
+
}
|
|
924
|
+
function resolveDatabaseForProperty(property) {
|
|
925
|
+
if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
|
|
926
|
+
return getDatabase();
|
|
927
|
+
}
|
|
928
|
+
return resolveDatabase();
|
|
929
|
+
}
|
|
930
|
+
var db = new Proxy(function database() {}, {
|
|
931
|
+
apply(_target, _thisArg, args) {
|
|
932
|
+
return resolveDatabase()(...args);
|
|
933
|
+
},
|
|
934
|
+
get(_target, property) {
|
|
935
|
+
const connection = resolveDatabaseForProperty(property);
|
|
936
|
+
const value = connection[property];
|
|
937
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
var connection_default = db;
|
|
941
|
+
|
|
942
|
+
// ../../src/core/database/boundConnection.ts
|
|
943
|
+
var boundConnectionHolder = {
|
|
944
|
+
connection: null
|
|
945
|
+
};
|
|
946
|
+
function bindDatabaseConnection(connection) {
|
|
947
|
+
boundConnectionHolder.connection = connection;
|
|
948
|
+
}
|
|
949
|
+
function getBoundDatabaseConnection() {
|
|
950
|
+
return boundConnectionHolder.connection;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// ../../src/core/database/repositoryConnection.ts
|
|
954
|
+
function resolveRepositoryConnection() {
|
|
955
|
+
return getBoundDatabaseConnection() ?? connection_default;
|
|
956
|
+
}
|
|
957
|
+
var repositoryConnection = new Proxy({}, {
|
|
958
|
+
get(_target, property) {
|
|
959
|
+
const connection = resolveRepositoryConnection();
|
|
960
|
+
const value = connection[property];
|
|
961
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
962
|
+
}
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
// ../../src/core/database/whereBuilder.ts
|
|
966
|
+
class WhereBuilder {
|
|
967
|
+
nodes = [];
|
|
968
|
+
where(where) {
|
|
969
|
+
this.nodes.push({ kind: "and", where });
|
|
970
|
+
return this;
|
|
971
|
+
}
|
|
972
|
+
orWhere(where) {
|
|
973
|
+
this.nodes.push({ kind: "or", where });
|
|
974
|
+
return this;
|
|
975
|
+
}
|
|
976
|
+
whereGroup(fn) {
|
|
977
|
+
const nested = new WhereBuilder;
|
|
978
|
+
fn(nested);
|
|
979
|
+
if (nested.nodes.length > 0) {
|
|
980
|
+
this.nodes.push({ kind: "and", group: nested.nodes });
|
|
981
|
+
}
|
|
982
|
+
return this;
|
|
983
|
+
}
|
|
984
|
+
orWhereGroup(fn) {
|
|
985
|
+
const nested = new WhereBuilder;
|
|
986
|
+
fn(nested);
|
|
987
|
+
if (nested.nodes.length > 0) {
|
|
988
|
+
this.nodes.push({ kind: "or", group: nested.nodes });
|
|
989
|
+
}
|
|
990
|
+
return this;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
747
993
|
|
|
748
994
|
// ../../src/core/database/repositoryQuery.ts
|
|
749
995
|
class RepositoryQuery {
|
|
@@ -751,13 +997,32 @@ class RepositoryQuery {
|
|
|
751
997
|
whereClause;
|
|
752
998
|
queryOptions;
|
|
753
999
|
eagerLoads = [];
|
|
1000
|
+
whereNodes = [];
|
|
754
1001
|
constructor(repository, whereClause = {}, queryOptions = {}) {
|
|
755
1002
|
this.repository = repository;
|
|
756
1003
|
this.whereClause = whereClause;
|
|
757
1004
|
this.queryOptions = queryOptions;
|
|
758
1005
|
}
|
|
759
|
-
where(
|
|
760
|
-
|
|
1006
|
+
where(input) {
|
|
1007
|
+
if (typeof input === "function") {
|
|
1008
|
+
const builder = new WhereBuilder;
|
|
1009
|
+
input(builder);
|
|
1010
|
+
this.whereNodes.push(...builder.nodes);
|
|
1011
|
+
return this;
|
|
1012
|
+
}
|
|
1013
|
+
this.whereClause = { ...this.whereClause, ...input };
|
|
1014
|
+
return this;
|
|
1015
|
+
}
|
|
1016
|
+
orWhere(input) {
|
|
1017
|
+
if (typeof input === "function") {
|
|
1018
|
+
const builder = new WhereBuilder;
|
|
1019
|
+
input(builder);
|
|
1020
|
+
if (builder.nodes.length > 0) {
|
|
1021
|
+
this.whereNodes.push({ kind: "or", group: builder.nodes });
|
|
1022
|
+
}
|
|
1023
|
+
return this;
|
|
1024
|
+
}
|
|
1025
|
+
this.whereNodes.push({ kind: "or", where: input });
|
|
761
1026
|
return this;
|
|
762
1027
|
}
|
|
763
1028
|
orderBy(orderBy) {
|
|
@@ -768,6 +1033,24 @@ class RepositoryQuery {
|
|
|
768
1033
|
this.queryOptions = { ...this.queryOptions, limit };
|
|
769
1034
|
return this;
|
|
770
1035
|
}
|
|
1036
|
+
offset(offset) {
|
|
1037
|
+
this.queryOptions = { ...this.queryOptions, offset };
|
|
1038
|
+
return this;
|
|
1039
|
+
}
|
|
1040
|
+
join(left, right) {
|
|
1041
|
+
return this.addJoin("inner", left, right);
|
|
1042
|
+
}
|
|
1043
|
+
leftJoin(left, right) {
|
|
1044
|
+
return this.addJoin("left", left, right);
|
|
1045
|
+
}
|
|
1046
|
+
groupBy(groupBy) {
|
|
1047
|
+
this.queryOptions = { ...this.queryOptions, groupBy };
|
|
1048
|
+
return this;
|
|
1049
|
+
}
|
|
1050
|
+
having(having) {
|
|
1051
|
+
this.queryOptions = { ...this.queryOptions, having };
|
|
1052
|
+
return this;
|
|
1053
|
+
}
|
|
771
1054
|
withHasMany(as, relation, childRepository, options = {}) {
|
|
772
1055
|
this.eagerLoads.push({
|
|
773
1056
|
kind: "hasMany",
|
|
@@ -789,16 +1072,50 @@ class RepositoryQuery {
|
|
|
789
1072
|
return this;
|
|
790
1073
|
}
|
|
791
1074
|
async get() {
|
|
792
|
-
const rows = await this.repository.findAll(
|
|
793
|
-
...this.queryOptions,
|
|
794
|
-
where: this.whereClause
|
|
795
|
-
});
|
|
1075
|
+
const rows = await this.repository.findAll(this.buildOptions());
|
|
796
1076
|
return await this.attach(rows);
|
|
797
1077
|
}
|
|
798
1078
|
async first() {
|
|
799
1079
|
const rows = await this.get();
|
|
800
1080
|
return rows[0] ?? null;
|
|
801
1081
|
}
|
|
1082
|
+
async paginate(options) {
|
|
1083
|
+
return await this.repository.paginate({
|
|
1084
|
+
...this.buildOptions(),
|
|
1085
|
+
page: options.page,
|
|
1086
|
+
perPage: options.perPage
|
|
1087
|
+
});
|
|
1088
|
+
}
|
|
1089
|
+
buildOptions() {
|
|
1090
|
+
return {
|
|
1091
|
+
...this.queryOptions,
|
|
1092
|
+
where: this.whereClause,
|
|
1093
|
+
whereNodes: this.whereNodes
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
addJoin(type, left, right) {
|
|
1097
|
+
const leftRef = parseQualifiedColumn(left);
|
|
1098
|
+
const rightRef = parseQualifiedColumn(right);
|
|
1099
|
+
const table = type === "inner" ? rightRef.table : rightRef.table;
|
|
1100
|
+
const joins = this.queryOptions.joins ?? [];
|
|
1101
|
+
const existing = joins.find((join) => join.table === table && join.type === type);
|
|
1102
|
+
if (existing) {
|
|
1103
|
+
existing.on.push({ left: leftRef, right: rightRef });
|
|
1104
|
+
return this;
|
|
1105
|
+
}
|
|
1106
|
+
this.queryOptions = {
|
|
1107
|
+
...this.queryOptions,
|
|
1108
|
+
joins: [
|
|
1109
|
+
...joins,
|
|
1110
|
+
{
|
|
1111
|
+
type,
|
|
1112
|
+
table,
|
|
1113
|
+
on: [{ left: leftRef, right: rightRef }]
|
|
1114
|
+
}
|
|
1115
|
+
]
|
|
1116
|
+
};
|
|
1117
|
+
return this;
|
|
1118
|
+
}
|
|
802
1119
|
async attach(rows) {
|
|
803
1120
|
if (rows.length === 0 || this.eagerLoads.length === 0) {
|
|
804
1121
|
return rows.map((row) => ({ ...row }));
|
|
@@ -829,27 +1146,30 @@ class RepositoryQuery {
|
|
|
829
1146
|
class BaseRepository {
|
|
830
1147
|
table;
|
|
831
1148
|
connection;
|
|
832
|
-
constructor(table, connection =
|
|
1149
|
+
constructor(table, connection = repositoryConnection) {
|
|
833
1150
|
this.table = table;
|
|
834
1151
|
this.connection = connection;
|
|
835
1152
|
}
|
|
836
1153
|
async findAll(options = {}) {
|
|
837
1154
|
return await withDatabaseErrorHandling(async () => {
|
|
838
|
-
const {
|
|
1155
|
+
const { whereNodes, ...queryOptions } = options;
|
|
1156
|
+
const { text, params } = buildSelectQuery(this.table, queryOptions, whereNodes ?? []);
|
|
839
1157
|
return await this.connection.unsafe(text, params);
|
|
840
1158
|
});
|
|
841
1159
|
}
|
|
842
1160
|
async paginate(options) {
|
|
843
|
-
const
|
|
1161
|
+
const { whereNodes, where = {}, page, perPage, ...queryOptions } = options;
|
|
844
1162
|
const total = await this.countWhere(where, {
|
|
845
1163
|
withTrashed: options.withTrashed,
|
|
846
|
-
onlyTrashed: options.onlyTrashed
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1164
|
+
onlyTrashed: options.onlyTrashed,
|
|
1165
|
+
joins: options.joins,
|
|
1166
|
+
groupBy: options.groupBy
|
|
1167
|
+
}, whereNodes);
|
|
1168
|
+
const offset = (page - 1) * perPage;
|
|
850
1169
|
const data = await this.findAll({
|
|
851
1170
|
...queryOptions,
|
|
852
1171
|
where,
|
|
1172
|
+
whereNodes,
|
|
853
1173
|
limit: perPage,
|
|
854
1174
|
offset
|
|
855
1175
|
});
|
|
@@ -858,6 +1178,65 @@ class BaseRepository {
|
|
|
858
1178
|
meta: buildPaginationMeta({ page, perPage, total })
|
|
859
1179
|
};
|
|
860
1180
|
}
|
|
1181
|
+
async chunk(count, callback, options = {}) {
|
|
1182
|
+
if (!Number.isInteger(count) || count <= 0) {
|
|
1183
|
+
throw new Error("Chunk size must be a positive integer.");
|
|
1184
|
+
}
|
|
1185
|
+
let offset = 0;
|
|
1186
|
+
while (true) {
|
|
1187
|
+
const rows = await this.findAll({
|
|
1188
|
+
...options,
|
|
1189
|
+
limit: count,
|
|
1190
|
+
offset
|
|
1191
|
+
});
|
|
1192
|
+
if (rows.length === 0) {
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const shouldContinue = await callback(rows);
|
|
1196
|
+
if (shouldContinue === false || rows.length < count) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
offset += count;
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
async cursorPaginate(options) {
|
|
1203
|
+
const {
|
|
1204
|
+
perPage,
|
|
1205
|
+
cursor,
|
|
1206
|
+
cursorColumn = this.table.primaryKey,
|
|
1207
|
+
direction = "asc",
|
|
1208
|
+
where = {},
|
|
1209
|
+
whereNodes,
|
|
1210
|
+
...queryOptions
|
|
1211
|
+
} = options;
|
|
1212
|
+
if (!Number.isInteger(perPage) || perPage <= 0) {
|
|
1213
|
+
throw new Error("Cursor page size must be a positive integer.");
|
|
1214
|
+
}
|
|
1215
|
+
const cursorWhere = { ...where };
|
|
1216
|
+
if (cursor !== undefined) {
|
|
1217
|
+
cursorWhere[cursorColumn] = direction === "asc" ? { gt: cursor } : { lt: cursor };
|
|
1218
|
+
}
|
|
1219
|
+
const rows = await this.findAll({
|
|
1220
|
+
...queryOptions,
|
|
1221
|
+
where: cursorWhere,
|
|
1222
|
+
whereNodes,
|
|
1223
|
+
orderBy: { [cursorColumn]: direction },
|
|
1224
|
+
limit: perPage + 1
|
|
1225
|
+
});
|
|
1226
|
+
const hasMore = rows.length > perPage;
|
|
1227
|
+
const data = hasMore ? rows.slice(0, perPage) : rows;
|
|
1228
|
+
const nextCursor = hasMore ? data[data.length - 1]?.[cursorColumn] ?? null : null;
|
|
1229
|
+
const prevCursor = cursor ?? null;
|
|
1230
|
+
return {
|
|
1231
|
+
data,
|
|
1232
|
+
meta: {
|
|
1233
|
+
per_page: perPage,
|
|
1234
|
+
next_cursor: nextCursor,
|
|
1235
|
+
prev_cursor: prevCursor,
|
|
1236
|
+
has_more: hasMore
|
|
1237
|
+
}
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
861
1240
|
async findById(id) {
|
|
862
1241
|
return await this.firstOrNull({
|
|
863
1242
|
[this.table.primaryKey]: id
|
|
@@ -964,14 +1343,17 @@ class BaseRepository {
|
|
|
964
1343
|
getConnection() {
|
|
965
1344
|
return this.connection;
|
|
966
1345
|
}
|
|
1346
|
+
getTable() {
|
|
1347
|
+
return this.table;
|
|
1348
|
+
}
|
|
967
1349
|
query(where = {}) {
|
|
968
1350
|
return new RepositoryQuery(this, where);
|
|
969
1351
|
}
|
|
970
1352
|
async findWhere(where, options = {}) {
|
|
971
1353
|
return await this.findAll({ ...options, where });
|
|
972
1354
|
}
|
|
973
|
-
async countWhere(where = {}, options = {}) {
|
|
974
|
-
const { text, params } = buildCountQuery(this.table, where, options);
|
|
1355
|
+
async countWhere(where = {}, options = {}, whereNodes = []) {
|
|
1356
|
+
const { text, params } = buildCountQuery(this.table, where, options, whereNodes);
|
|
975
1357
|
const [row] = await this.connection.unsafe(text, params);
|
|
976
1358
|
return Number(row?.count ?? 0);
|
|
977
1359
|
}
|
|
@@ -1028,8 +1410,18 @@ class BaseRepository {
|
|
|
1028
1410
|
}
|
|
1029
1411
|
var baseRepository_default = BaseRepository;
|
|
1030
1412
|
// ../../src/core/database/bindConnection.ts
|
|
1031
|
-
function
|
|
1032
|
-
|
|
1413
|
+
function bindDatabaseConnection2(connection) {
|
|
1414
|
+
bindDatabaseConnection(connection);
|
|
1415
|
+
}
|
|
1416
|
+
// ../../src/core/database/migrations/advisoryLock.ts
|
|
1417
|
+
var MIGRATION_LOCK_KEY = 42424242;
|
|
1418
|
+
async function withMigrationLock(db2, callback, lockKey = MIGRATION_LOCK_KEY) {
|
|
1419
|
+
await db2.unsafe("SELECT pg_advisory_lock($1)", [lockKey]);
|
|
1420
|
+
try {
|
|
1421
|
+
return await callback();
|
|
1422
|
+
} finally {
|
|
1423
|
+
await db2.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
|
|
1424
|
+
}
|
|
1033
1425
|
}
|
|
1034
1426
|
// ../../src/core/database/migrations/runner.ts
|
|
1035
1427
|
import { readdir } from "fs/promises";
|
|
@@ -1055,7 +1447,7 @@ async function getAppliedMigrations(db2) {
|
|
|
1055
1447
|
}
|
|
1056
1448
|
async function loadMigrationsFromDirectory(directory) {
|
|
1057
1449
|
const entries = await readdir(directory);
|
|
1058
|
-
const migrationFiles = entries.filter((entry) => entry.endsWith(".ts") && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
1450
|
+
const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
1059
1451
|
const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
|
|
1060
1452
|
const moduleUrl = pathToFileURL(join(directory, fileName)).href;
|
|
1061
1453
|
const module = await import(moduleUrl);
|
|
@@ -1072,7 +1464,7 @@ async function getMigrationStatus(db2, migrations) {
|
|
|
1072
1464
|
batch: appliedByName.get(name) ?? null
|
|
1073
1465
|
}));
|
|
1074
1466
|
}
|
|
1075
|
-
async function
|
|
1467
|
+
async function runPendingMigrations(db2, migrations, options = {}) {
|
|
1076
1468
|
const applied = await getAppliedMigrations(db2);
|
|
1077
1469
|
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
1078
1470
|
const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
|
|
@@ -1080,10 +1472,20 @@ async function migrateDatabase(db2, migrations, options = {}) {
|
|
|
1080
1472
|
for (const migration of pendingMigrations) {
|
|
1081
1473
|
options.onMigration?.(migration.name);
|
|
1082
1474
|
await migration.up(db2);
|
|
1083
|
-
await db2.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name, batch) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING`, [migration.name, nextBatch]);
|
|
1475
|
+
const inserted = await db2.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name, batch) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING name`, [migration.name, nextBatch]);
|
|
1476
|
+
if (inserted.length === 0) {
|
|
1477
|
+
throw new Error(`Migration ${migration.name} was applied but not recorded.`);
|
|
1478
|
+
}
|
|
1084
1479
|
}
|
|
1085
1480
|
return pendingMigrations.length;
|
|
1086
1481
|
}
|
|
1482
|
+
async function migrateDatabase(db2, migrations, options = {}) {
|
|
1483
|
+
const { advisoryLock = false, onMigration } = options;
|
|
1484
|
+
if (advisoryLock) {
|
|
1485
|
+
return withMigrationLock(db2, () => runPendingMigrations(db2, migrations, { onMigration }));
|
|
1486
|
+
}
|
|
1487
|
+
return runPendingMigrations(db2, migrations, { onMigration });
|
|
1488
|
+
}
|
|
1087
1489
|
async function rollbackDatabase(db2, migrations, options = {}) {
|
|
1088
1490
|
const applied = await getAppliedMigrations(db2);
|
|
1089
1491
|
if (applied.length === 0) {
|
|
@@ -1104,20 +1506,29 @@ async function rollbackDatabase(db2, migrations, options = {}) {
|
|
|
1104
1506
|
return rolledBack;
|
|
1105
1507
|
}
|
|
1106
1508
|
async function freshDatabase(db2, migrations, options = {}) {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1509
|
+
const runFresh = async () => {
|
|
1510
|
+
const applied = await getAppliedMigrations(db2);
|
|
1511
|
+
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
1512
|
+
const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
|
|
1513
|
+
for (const migration of [...appliedMigrations].reverse()) {
|
|
1514
|
+
options.onMigration?.(migration.name);
|
|
1515
|
+
await migration.down(db2);
|
|
1516
|
+
}
|
|
1517
|
+
if (appliedMigrations.length > 0) {
|
|
1518
|
+
await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
|
|
1519
|
+
}
|
|
1520
|
+
await runPendingMigrations(db2, migrations, options);
|
|
1521
|
+
};
|
|
1522
|
+
if (options.advisoryLock) {
|
|
1523
|
+
await withMigrationLock(db2, runFresh);
|
|
1524
|
+
return;
|
|
1116
1525
|
}
|
|
1117
|
-
await
|
|
1526
|
+
await runFresh();
|
|
1118
1527
|
}
|
|
1119
1528
|
// ../../src/core/database/model.ts
|
|
1120
1529
|
var modelRepositories = new WeakMap;
|
|
1530
|
+
var modelGlobalScopes = new WeakMap;
|
|
1531
|
+
var modelBooted = new WeakSet;
|
|
1121
1532
|
function resolveModelRepository(model) {
|
|
1122
1533
|
const repository = modelRepositories.get(model);
|
|
1123
1534
|
if (!repository) {
|
|
@@ -1125,13 +1536,121 @@ function resolveModelRepository(model) {
|
|
|
1125
1536
|
}
|
|
1126
1537
|
return repository;
|
|
1127
1538
|
}
|
|
1539
|
+
function modelStatics(model) {
|
|
1540
|
+
return model;
|
|
1541
|
+
}
|
|
1542
|
+
function ensureBooted(model) {
|
|
1543
|
+
if (modelBooted.has(model)) {
|
|
1544
|
+
return;
|
|
1545
|
+
}
|
|
1546
|
+
modelBooted.add(model);
|
|
1547
|
+
const boot = model.boot;
|
|
1548
|
+
if (typeof boot === "function") {
|
|
1549
|
+
boot.call(model);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function getGlobalScopes(model) {
|
|
1553
|
+
return modelGlobalScopes.get(model) ?? [];
|
|
1554
|
+
}
|
|
1555
|
+
function hydrateValue(value, cast) {
|
|
1556
|
+
if (value === null || value === undefined) {
|
|
1557
|
+
return value;
|
|
1558
|
+
}
|
|
1559
|
+
switch (cast) {
|
|
1560
|
+
case "date":
|
|
1561
|
+
case "datetime":
|
|
1562
|
+
return value instanceof Date ? value : new Date(String(value));
|
|
1563
|
+
case "json":
|
|
1564
|
+
return typeof value === "string" ? JSON.parse(value) : value;
|
|
1565
|
+
case "bool":
|
|
1566
|
+
case "boolean":
|
|
1567
|
+
return value === true || value === 1 || value === "1" || value === "true";
|
|
1568
|
+
default:
|
|
1569
|
+
return value;
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
function dehydrateValue(value, cast) {
|
|
1573
|
+
if (value === null || value === undefined) {
|
|
1574
|
+
return value;
|
|
1575
|
+
}
|
|
1576
|
+
switch (cast) {
|
|
1577
|
+
case "date":
|
|
1578
|
+
case "datetime":
|
|
1579
|
+
return value instanceof Date ? value : new Date(String(value));
|
|
1580
|
+
case "json":
|
|
1581
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
1582
|
+
case "bool":
|
|
1583
|
+
case "boolean":
|
|
1584
|
+
return Boolean(value);
|
|
1585
|
+
default:
|
|
1586
|
+
return value;
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
function filterMassAssignable(fillable, guarded, input) {
|
|
1590
|
+
const resolvedGuarded = guarded ?? true;
|
|
1591
|
+
if (fillable && fillable.length > 0) {
|
|
1592
|
+
const allowed = new Set(fillable);
|
|
1593
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => allowed.has(key)));
|
|
1594
|
+
}
|
|
1595
|
+
if (resolvedGuarded === true || resolvedGuarded.includes("*")) {
|
|
1596
|
+
return {};
|
|
1597
|
+
}
|
|
1598
|
+
const blocked = new Set(resolvedGuarded);
|
|
1599
|
+
return Object.fromEntries(Object.entries(input).filter(([key]) => !blocked.has(key)));
|
|
1600
|
+
}
|
|
1601
|
+
function applyCasts(values, casts, direction) {
|
|
1602
|
+
if (Object.keys(casts).length === 0) {
|
|
1603
|
+
return values;
|
|
1604
|
+
}
|
|
1605
|
+
const result = { ...values };
|
|
1606
|
+
const castFn = direction === "hydrate" ? hydrateValue : dehydrateValue;
|
|
1607
|
+
for (const [key, cast] of Object.entries(casts)) {
|
|
1608
|
+
if (key in result && cast) {
|
|
1609
|
+
result[key] = castFn(result[key], cast);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
return result;
|
|
1613
|
+
}
|
|
1614
|
+
function applyTimestampsOnCreate(columns, values, enabled) {
|
|
1615
|
+
if (!enabled) {
|
|
1616
|
+
return values;
|
|
1617
|
+
}
|
|
1618
|
+
const now = new Date;
|
|
1619
|
+
const result = { ...values };
|
|
1620
|
+
if (columns.includes("created_at")) {
|
|
1621
|
+
result.created_at = now;
|
|
1622
|
+
}
|
|
1623
|
+
if (columns.includes("updated_at")) {
|
|
1624
|
+
result.updated_at = now;
|
|
1625
|
+
}
|
|
1626
|
+
return result;
|
|
1627
|
+
}
|
|
1628
|
+
function applyTimestampsOnUpdate(columns, values, enabled) {
|
|
1629
|
+
if (!enabled) {
|
|
1630
|
+
return values;
|
|
1631
|
+
}
|
|
1632
|
+
const result = { ...values };
|
|
1633
|
+
if (columns.includes("updated_at")) {
|
|
1634
|
+
result.updated_at = new Date;
|
|
1635
|
+
}
|
|
1636
|
+
return result;
|
|
1637
|
+
}
|
|
1128
1638
|
|
|
1129
1639
|
class Model {
|
|
1130
1640
|
attributes;
|
|
1131
1641
|
repository;
|
|
1132
|
-
|
|
1642
|
+
static $fillable;
|
|
1643
|
+
static $guarded;
|
|
1644
|
+
static $casts = {};
|
|
1645
|
+
static $timestamps = true;
|
|
1646
|
+
_exists;
|
|
1647
|
+
constructor(attributes, repository, exists = true) {
|
|
1133
1648
|
this.attributes = attributes;
|
|
1134
1649
|
this.repository = repository;
|
|
1650
|
+
this._exists = exists;
|
|
1651
|
+
}
|
|
1652
|
+
get $exists() {
|
|
1653
|
+
return this._exists;
|
|
1135
1654
|
}
|
|
1136
1655
|
get(key) {
|
|
1137
1656
|
return this.attributes[key];
|
|
@@ -1145,43 +1664,169 @@ class Model {
|
|
|
1145
1664
|
primaryKey() {
|
|
1146
1665
|
throw new Error(`${this.constructor.name}.primaryKey() is not implemented.`);
|
|
1147
1666
|
}
|
|
1667
|
+
static primaryKeyField() {
|
|
1668
|
+
return resolveModelRepository(Model).getTable().primaryKey;
|
|
1669
|
+
}
|
|
1670
|
+
static hydrateAttributes(attributes) {
|
|
1671
|
+
const casts = Model.$casts ?? {};
|
|
1672
|
+
return applyCasts(attributes, casts, "hydrate");
|
|
1673
|
+
}
|
|
1674
|
+
static dehydrateAttributes(attributes) {
|
|
1675
|
+
const casts = Model.$casts ?? {};
|
|
1676
|
+
return applyCasts(attributes, casts, "dehydrate");
|
|
1677
|
+
}
|
|
1678
|
+
static fromRecord(record, repository, exists = true) {
|
|
1679
|
+
const hydrated = Model.hydrateAttributes(record);
|
|
1680
|
+
return new Model(hydrated, repository, exists);
|
|
1681
|
+
}
|
|
1682
|
+
static boot() {}
|
|
1683
|
+
static addGlobalScope(_name, scope) {
|
|
1684
|
+
ensureBooted(Model);
|
|
1685
|
+
const existing = modelGlobalScopes.get(Model) ?? [];
|
|
1686
|
+
modelGlobalScopes.set(Model, [
|
|
1687
|
+
...existing,
|
|
1688
|
+
scope
|
|
1689
|
+
]);
|
|
1690
|
+
}
|
|
1148
1691
|
static repository() {
|
|
1149
1692
|
return resolveModelRepository(this);
|
|
1150
1693
|
}
|
|
1151
1694
|
static query() {
|
|
1152
|
-
|
|
1695
|
+
ensureBooted(this);
|
|
1696
|
+
const repository = resolveModelRepository(Model);
|
|
1697
|
+
let query = repository.query();
|
|
1698
|
+
for (const scope of getGlobalScopes(Model)) {
|
|
1699
|
+
query = scope(query);
|
|
1700
|
+
}
|
|
1701
|
+
return query;
|
|
1702
|
+
}
|
|
1703
|
+
static async create(attributes) {
|
|
1704
|
+
const statics = modelStatics(this);
|
|
1705
|
+
ensureBooted(Model);
|
|
1706
|
+
const repository = resolveModelRepository(Model);
|
|
1707
|
+
const table = repository.getTable();
|
|
1708
|
+
const timestamps = statics.$timestamps ?? true;
|
|
1709
|
+
const assignable = filterMassAssignable(statics.$fillable, statics.$guarded, attributes);
|
|
1710
|
+
const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
|
|
1711
|
+
const payload = statics.dehydrateAttributes(withTimestamps);
|
|
1712
|
+
const record = await repository.create(payload);
|
|
1713
|
+
return statics.fromRecord(record, repository, true);
|
|
1153
1714
|
}
|
|
1154
1715
|
static async find(id) {
|
|
1155
|
-
const
|
|
1156
|
-
const
|
|
1157
|
-
|
|
1716
|
+
const statics = modelStatics(this);
|
|
1717
|
+
const repository = resolveModelRepository(Model);
|
|
1718
|
+
const primaryKey = statics.primaryKeyField();
|
|
1719
|
+
const record = await Model.query.call(Model).where({ [primaryKey]: id }).first();
|
|
1720
|
+
return record ? statics.fromRecord(record, repository, true) : null;
|
|
1158
1721
|
}
|
|
1159
1722
|
static async findOrFail(id, errorFactory) {
|
|
1160
|
-
const
|
|
1161
|
-
|
|
1162
|
-
|
|
1723
|
+
const model = await Model.find.call(Model, id);
|
|
1724
|
+
if (model) {
|
|
1725
|
+
return model;
|
|
1726
|
+
}
|
|
1727
|
+
throw errorFactory?.(id) ?? new NotFoundError(`${Model.name} ${String(id)} not found.`);
|
|
1163
1728
|
}
|
|
1164
1729
|
static async all(options = {}) {
|
|
1165
|
-
const
|
|
1166
|
-
const
|
|
1167
|
-
|
|
1730
|
+
const statics = modelStatics(this);
|
|
1731
|
+
const repository = resolveModelRepository(Model);
|
|
1732
|
+
let query = Model.query.call(Model);
|
|
1733
|
+
if (options.orderBy) {
|
|
1734
|
+
query = query.orderBy(options.orderBy);
|
|
1735
|
+
}
|
|
1736
|
+
if (options.limit !== undefined) {
|
|
1737
|
+
query = query.limit(options.limit);
|
|
1738
|
+
}
|
|
1739
|
+
const rows = await query.get();
|
|
1740
|
+
return rows.map((row) => statics.fromRecord(row, repository, true));
|
|
1168
1741
|
}
|
|
1169
1742
|
static async firstWhere(where, options = {}) {
|
|
1170
|
-
const
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1743
|
+
const statics = modelStatics(this);
|
|
1744
|
+
const repository = resolveModelRepository(Model);
|
|
1745
|
+
let query = Model.query.call(Model).where(where);
|
|
1746
|
+
if (options.orderBy) {
|
|
1747
|
+
query = query.orderBy(options.orderBy);
|
|
1748
|
+
}
|
|
1749
|
+
const record = await query.first();
|
|
1750
|
+
return record ? statics.fromRecord(record, repository, true) : null;
|
|
1751
|
+
}
|
|
1752
|
+
async save() {
|
|
1753
|
+
const ModelClass = modelStatics(this.constructor);
|
|
1754
|
+
const timestamps = ModelClass.$timestamps ?? true;
|
|
1755
|
+
const casts = ModelClass.$casts ?? {};
|
|
1756
|
+
const table = this.repository.getTable();
|
|
1757
|
+
if (this.$exists) {
|
|
1758
|
+
const changes = applyTimestampsOnUpdate(table.columns, applyCasts(this.attributes, casts, "dehydrate"), timestamps);
|
|
1759
|
+
const record2 = await this.repository.updateByIdOrThrow(this.id, changes);
|
|
1760
|
+
this.attributes = ModelClass.hydrateAttributes(record2);
|
|
1761
|
+
return this;
|
|
1762
|
+
}
|
|
1763
|
+
const assignable = filterMassAssignable(ModelClass.$fillable, ModelClass.$guarded, this.attributes);
|
|
1764
|
+
const withTimestamps = applyTimestampsOnCreate(table.columns, assignable, timestamps);
|
|
1765
|
+
const payload = ModelClass.dehydrateAttributes(withTimestamps);
|
|
1766
|
+
const record = await this.repository.create(payload);
|
|
1767
|
+
this.attributes = ModelClass.hydrateAttributes(record);
|
|
1768
|
+
this._exists = true;
|
|
1769
|
+
return this;
|
|
1770
|
+
}
|
|
1771
|
+
async update(changes) {
|
|
1772
|
+
const ModelClass = modelStatics(this.constructor);
|
|
1773
|
+
const assignable = filterMassAssignable(ModelClass.$fillable, ModelClass.$guarded, changes);
|
|
1774
|
+
Object.assign(this.attributes, assignable);
|
|
1775
|
+
return await this.save();
|
|
1776
|
+
}
|
|
1777
|
+
async delete() {
|
|
1778
|
+
if (resolveSoftDeleteColumn(this.repository.getTable())) {
|
|
1779
|
+
return await this.repository.deleteById(this.id);
|
|
1780
|
+
}
|
|
1781
|
+
return await this.repository.forceDeleteById(this.id);
|
|
1782
|
+
}
|
|
1783
|
+
async forceDelete() {
|
|
1784
|
+
return await this.repository.forceDeleteById(this.id);
|
|
1785
|
+
}
|
|
1786
|
+
async restore() {
|
|
1787
|
+
const ModelClass = modelStatics(this.constructor);
|
|
1788
|
+
const record = await this.repository.restoreById(this.id);
|
|
1789
|
+
if (!record) {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
this.attributes = ModelClass.hydrateAttributes(record);
|
|
1793
|
+
return this;
|
|
1174
1794
|
}
|
|
1175
1795
|
async loadHasMany(as, relation, childRepository, options = {}) {
|
|
1176
1796
|
const grouped = await childRepository.withConnection(this.repository.getConnection()).loadHasManyForParents([this.attributes], relation, options);
|
|
1177
1797
|
const loaded = grouped.get(this.attributes[relation.localKey]) ?? [];
|
|
1178
1798
|
return Object.assign(this, { [as]: loaded });
|
|
1179
1799
|
}
|
|
1800
|
+
async loadHasOne(as, relation, childRepository, options = {}) {
|
|
1801
|
+
const loaded = await this.loadHasMany(as, relation, childRepository, { ...options, limit: 1 });
|
|
1802
|
+
const value = loaded[as]?.[0];
|
|
1803
|
+
return Object.assign(this, { [as]: value });
|
|
1804
|
+
}
|
|
1180
1805
|
async loadBelongsTo(as, relation, parentRepository, options = {}) {
|
|
1181
1806
|
const grouped = await this.repository.loadBelongsToForParents([this.attributes], relation, parentRepository, options);
|
|
1182
1807
|
const loaded = grouped.get(this.attributes[relation.foreignKey]);
|
|
1183
1808
|
return Object.assign(this, { [as]: loaded });
|
|
1184
1809
|
}
|
|
1810
|
+
async loadBelongsToMany(as, relation, relatedRepository, options = {}) {
|
|
1811
|
+
const connection = this.repository.getConnection();
|
|
1812
|
+
const parentId = this.attributes[relation.parentKey];
|
|
1813
|
+
const pivotRows = await connection.unsafe(`SELECT * FROM ${relation.pivotTable} WHERE ${String(relation.foreignPivotKey)} = $1`, [parentId]);
|
|
1814
|
+
if (pivotRows.length === 0) {
|
|
1815
|
+
return Object.assign(this, { [as]: [] });
|
|
1816
|
+
}
|
|
1817
|
+
const relatedIds = [
|
|
1818
|
+
...new Set(pivotRows.map((row) => row[relation.relatedPivotKey]))
|
|
1819
|
+
];
|
|
1820
|
+
const relatedRows = await relatedRepository.withConnection(connection).findAll({
|
|
1821
|
+
...options,
|
|
1822
|
+
where: {
|
|
1823
|
+
[relation.relatedKey]: relatedIds
|
|
1824
|
+
}
|
|
1825
|
+
});
|
|
1826
|
+
const grouped = indexBelongsToManyRelation([this.attributes], pivotRows, relatedRows, relation);
|
|
1827
|
+
const loaded = grouped.get(parentId) ?? [];
|
|
1828
|
+
return Object.assign(this, { [as]: loaded });
|
|
1829
|
+
}
|
|
1185
1830
|
mergeAttributes(patch) {
|
|
1186
1831
|
Object.assign(this.attributes, patch);
|
|
1187
1832
|
return this;
|
|
@@ -1189,12 +1834,676 @@ class Model {
|
|
|
1189
1834
|
}
|
|
1190
1835
|
function registerModelRepository(model, repository) {
|
|
1191
1836
|
modelRepositories.set(model, repository);
|
|
1837
|
+
ensureBooted(model);
|
|
1192
1838
|
return model;
|
|
1193
1839
|
}
|
|
1840
|
+
// ../../src/core/database/schema/columnDefinition.ts
|
|
1841
|
+
class ColumnDefinition {
|
|
1842
|
+
name;
|
|
1843
|
+
kind;
|
|
1844
|
+
length;
|
|
1845
|
+
isNullable = false;
|
|
1846
|
+
isPrimary = false;
|
|
1847
|
+
isUnique = false;
|
|
1848
|
+
autoIncrement = false;
|
|
1849
|
+
defaultValue;
|
|
1850
|
+
checkExpression;
|
|
1851
|
+
foreignKey;
|
|
1852
|
+
constructor(name, kind) {
|
|
1853
|
+
this.name = name;
|
|
1854
|
+
this.kind = kind;
|
|
1855
|
+
}
|
|
1856
|
+
nullable() {
|
|
1857
|
+
this.isNullable = true;
|
|
1858
|
+
return this;
|
|
1859
|
+
}
|
|
1860
|
+
notNullable() {
|
|
1861
|
+
this.isNullable = false;
|
|
1862
|
+
return this;
|
|
1863
|
+
}
|
|
1864
|
+
default(value) {
|
|
1865
|
+
if (typeof value === "boolean") {
|
|
1866
|
+
this.defaultValue = value ? "TRUE" : "FALSE";
|
|
1867
|
+
return this;
|
|
1868
|
+
}
|
|
1869
|
+
if (typeof value === "number") {
|
|
1870
|
+
this.defaultValue = String(value);
|
|
1871
|
+
return this;
|
|
1872
|
+
}
|
|
1873
|
+
this.defaultValue = `'${value.replace(/'/g, "''")}'`;
|
|
1874
|
+
return this;
|
|
1875
|
+
}
|
|
1876
|
+
defaultRaw(expression) {
|
|
1877
|
+
this.defaultValue = expression;
|
|
1878
|
+
return this;
|
|
1879
|
+
}
|
|
1880
|
+
unique() {
|
|
1881
|
+
this.isUnique = true;
|
|
1882
|
+
return this;
|
|
1883
|
+
}
|
|
1884
|
+
primary() {
|
|
1885
|
+
this.isPrimary = true;
|
|
1886
|
+
return this;
|
|
1887
|
+
}
|
|
1888
|
+
check(expression) {
|
|
1889
|
+
this.checkExpression = expression;
|
|
1890
|
+
return this;
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
class ForeignIdColumnDefinition extends ColumnDefinition {
|
|
1895
|
+
constructor(name) {
|
|
1896
|
+
super(name, "foreignId");
|
|
1897
|
+
this.notNullable();
|
|
1898
|
+
}
|
|
1899
|
+
references(table, column = "id") {
|
|
1900
|
+
this.foreignKey = {
|
|
1901
|
+
referencesTable: table,
|
|
1902
|
+
referencesColumn: column
|
|
1903
|
+
};
|
|
1904
|
+
return this;
|
|
1905
|
+
}
|
|
1906
|
+
constrained(table) {
|
|
1907
|
+
const referencesTable = table ?? inferReferencedTable(this.name);
|
|
1908
|
+
return this.references(referencesTable);
|
|
1909
|
+
}
|
|
1910
|
+
cascadeOnDelete() {
|
|
1911
|
+
if (!this.foreignKey) {
|
|
1912
|
+
throw new Error(`Foreign key is not defined for column ${this.name}`);
|
|
1913
|
+
}
|
|
1914
|
+
this.foreignKey.onDelete = "cascade";
|
|
1915
|
+
return this;
|
|
1916
|
+
}
|
|
1917
|
+
nullOnDelete() {
|
|
1918
|
+
if (!this.foreignKey) {
|
|
1919
|
+
throw new Error(`Foreign key is not defined for column ${this.name}`);
|
|
1920
|
+
}
|
|
1921
|
+
this.foreignKey.onDelete = "set null";
|
|
1922
|
+
return this;
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
function inferReferencedTable(columnName) {
|
|
1926
|
+
if (!columnName.endsWith("_id")) {
|
|
1927
|
+
throw new Error(`Cannot infer referenced table from column ${columnName}`);
|
|
1928
|
+
}
|
|
1929
|
+
return columnName.slice(0, -3);
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
// ../../src/core/database/schema/blueprint.ts
|
|
1933
|
+
class Blueprint {
|
|
1934
|
+
table;
|
|
1935
|
+
action;
|
|
1936
|
+
columns = [];
|
|
1937
|
+
indexes = [];
|
|
1938
|
+
droppedColumns = [];
|
|
1939
|
+
droppedIndexes = [];
|
|
1940
|
+
constructor(table, action) {
|
|
1941
|
+
this.table = table;
|
|
1942
|
+
this.action = action;
|
|
1943
|
+
}
|
|
1944
|
+
id(name = "id") {
|
|
1945
|
+
const column = new ColumnDefinition(name, "id");
|
|
1946
|
+
column.primary();
|
|
1947
|
+
column.autoIncrement = true;
|
|
1948
|
+
this.columns.push(column);
|
|
1949
|
+
return column;
|
|
1950
|
+
}
|
|
1951
|
+
string(name, length) {
|
|
1952
|
+
const column = new ColumnDefinition(name, "string");
|
|
1953
|
+
column.length = length;
|
|
1954
|
+
column.notNullable();
|
|
1955
|
+
this.columns.push(column);
|
|
1956
|
+
return column;
|
|
1957
|
+
}
|
|
1958
|
+
text(name) {
|
|
1959
|
+
const column = new ColumnDefinition(name, "text");
|
|
1960
|
+
column.notNullable();
|
|
1961
|
+
this.columns.push(column);
|
|
1962
|
+
return column;
|
|
1963
|
+
}
|
|
1964
|
+
boolean(name) {
|
|
1965
|
+
const column = new ColumnDefinition(name, "boolean");
|
|
1966
|
+
column.notNullable();
|
|
1967
|
+
this.columns.push(column);
|
|
1968
|
+
return column;
|
|
1969
|
+
}
|
|
1970
|
+
integer(name) {
|
|
1971
|
+
const column = new ColumnDefinition(name, "integer");
|
|
1972
|
+
column.notNullable();
|
|
1973
|
+
this.columns.push(column);
|
|
1974
|
+
return column;
|
|
1975
|
+
}
|
|
1976
|
+
bigInteger(name) {
|
|
1977
|
+
const column = new ColumnDefinition(name, "bigInteger");
|
|
1978
|
+
column.notNullable();
|
|
1979
|
+
this.columns.push(column);
|
|
1980
|
+
return column;
|
|
1981
|
+
}
|
|
1982
|
+
timestamp(name) {
|
|
1983
|
+
const column = new ColumnDefinition(name, "timestamp");
|
|
1984
|
+
column.notNullable();
|
|
1985
|
+
this.columns.push(column);
|
|
1986
|
+
return column;
|
|
1987
|
+
}
|
|
1988
|
+
json(name) {
|
|
1989
|
+
const column = new ColumnDefinition(name, "json");
|
|
1990
|
+
column.notNullable();
|
|
1991
|
+
this.columns.push(column);
|
|
1992
|
+
return column;
|
|
1993
|
+
}
|
|
1994
|
+
jsonb(name) {
|
|
1995
|
+
const column = new ColumnDefinition(name, "jsonb");
|
|
1996
|
+
column.notNullable();
|
|
1997
|
+
this.columns.push(column);
|
|
1998
|
+
return column;
|
|
1999
|
+
}
|
|
2000
|
+
foreignId(name) {
|
|
2001
|
+
const column = new ForeignIdColumnDefinition(name);
|
|
2002
|
+
this.columns.push(column);
|
|
2003
|
+
return column;
|
|
2004
|
+
}
|
|
2005
|
+
timestamps() {
|
|
2006
|
+
this.timestamp("created_at").defaultRaw("NOW()");
|
|
2007
|
+
this.timestamp("updated_at").defaultRaw("NOW()");
|
|
2008
|
+
}
|
|
2009
|
+
softDeletes() {
|
|
2010
|
+
this.timestamp("deleted_at").nullable();
|
|
2011
|
+
}
|
|
2012
|
+
dropColumn(name) {
|
|
2013
|
+
this.droppedColumns.push(name);
|
|
2014
|
+
}
|
|
2015
|
+
dropSoftDeletes() {
|
|
2016
|
+
this.dropColumn("deleted_at");
|
|
2017
|
+
this.dropIndex(`idx_${this.table}_deleted_at`);
|
|
2018
|
+
}
|
|
2019
|
+
dropIndex(name) {
|
|
2020
|
+
this.droppedIndexes.push(name);
|
|
2021
|
+
}
|
|
2022
|
+
unique(columns, name) {
|
|
2023
|
+
this.indexes.push({
|
|
2024
|
+
name,
|
|
2025
|
+
columns: Array.isArray(columns) ? columns : [columns],
|
|
2026
|
+
kind: "unique"
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
index(columns, options = {}) {
|
|
2030
|
+
this.indexes.push({
|
|
2031
|
+
name: options.name,
|
|
2032
|
+
columns: Array.isArray(columns) ? columns : [columns],
|
|
2033
|
+
kind: "index",
|
|
2034
|
+
order: options.order
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
partialIndex(columns, where, nameOrOptions) {
|
|
2038
|
+
const options = typeof nameOrOptions === "string" ? { name: nameOrOptions } : nameOrOptions ?? {};
|
|
2039
|
+
this.indexes.push({
|
|
2040
|
+
name: options.name,
|
|
2041
|
+
columns: Array.isArray(columns) ? columns : [columns],
|
|
2042
|
+
kind: options.unique ? "uniquePartial" : "partial",
|
|
2043
|
+
where
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
fullText(columns, name) {
|
|
2047
|
+
this.indexes.push({
|
|
2048
|
+
name,
|
|
2049
|
+
columns: Array.isArray(columns) ? columns : [columns],
|
|
2050
|
+
kind: "fullText"
|
|
2051
|
+
});
|
|
2052
|
+
}
|
|
2053
|
+
ginIndex(column, name) {
|
|
2054
|
+
this.indexes.push({
|
|
2055
|
+
name,
|
|
2056
|
+
columns: [column],
|
|
2057
|
+
kind: "gin"
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
}
|
|
2061
|
+
// ../../src/core/database/schema/driver.ts
|
|
2062
|
+
function normalizeConnectionName(connection) {
|
|
2063
|
+
const normalized = connection.trim().toLowerCase();
|
|
2064
|
+
if (normalized === "pgsql" || normalized === "postgres" || normalized === "postgresql") {
|
|
2065
|
+
return "pgsql";
|
|
2066
|
+
}
|
|
2067
|
+
if (normalized === "mysql" || normalized === "mariadb") {
|
|
2068
|
+
return "mysql";
|
|
2069
|
+
}
|
|
2070
|
+
if (normalized === "sqlite") {
|
|
2071
|
+
return "sqlite";
|
|
2072
|
+
}
|
|
2073
|
+
throw new Error(`Unsupported DB_CONNECTION: ${connection}`);
|
|
2074
|
+
}
|
|
2075
|
+
function resolveDriverFromUrl(url) {
|
|
2076
|
+
const normalized = url.trim().toLowerCase();
|
|
2077
|
+
if (normalized.startsWith("postgres://") || normalized.startsWith("postgresql://") || normalized.startsWith("postgres:")) {
|
|
2078
|
+
return "pgsql";
|
|
2079
|
+
}
|
|
2080
|
+
if (normalized.startsWith("mysql://") || normalized.startsWith("mysql:")) {
|
|
2081
|
+
return "mysql";
|
|
2082
|
+
}
|
|
2083
|
+
if (normalized.startsWith("sqlite:")) {
|
|
2084
|
+
return "sqlite";
|
|
2085
|
+
}
|
|
2086
|
+
return null;
|
|
2087
|
+
}
|
|
2088
|
+
function resolveDatabaseDriver(options = {}) {
|
|
2089
|
+
const connection = options.connection ?? process.env.DB_CONNECTION;
|
|
2090
|
+
if (connection) {
|
|
2091
|
+
return normalizeConnectionName(connection);
|
|
2092
|
+
}
|
|
2093
|
+
const url = options.url ?? process.env.DATABASE_URL ?? "";
|
|
2094
|
+
const fromUrl = resolveDriverFromUrl(url);
|
|
2095
|
+
if (fromUrl) {
|
|
2096
|
+
return fromUrl;
|
|
2097
|
+
}
|
|
2098
|
+
return "pgsql";
|
|
2099
|
+
}
|
|
2100
|
+
// ../../src/core/database/schema/errors.ts
|
|
2101
|
+
class UnsupportedSchemaFeatureError extends Error {
|
|
2102
|
+
constructor(feature, driver) {
|
|
2103
|
+
super(`${feature} is not supported for the ${driver} driver`);
|
|
2104
|
+
this.name = "UnsupportedSchemaFeatureError";
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
// ../../src/core/database/schema/grammars/grammar.ts
|
|
2108
|
+
function compileColumnType(driver, column) {
|
|
2109
|
+
switch (column.kind) {
|
|
2110
|
+
case "id":
|
|
2111
|
+
return compileIdType(driver);
|
|
2112
|
+
case "string":
|
|
2113
|
+
return compileStringType(driver, column.length);
|
|
2114
|
+
case "text":
|
|
2115
|
+
return compileTextType(driver);
|
|
2116
|
+
case "boolean":
|
|
2117
|
+
return compileBooleanType(driver);
|
|
2118
|
+
case "integer":
|
|
2119
|
+
case "foreignId":
|
|
2120
|
+
return compileIntegerType(driver);
|
|
2121
|
+
case "bigInteger":
|
|
2122
|
+
return compileBigIntegerType(driver);
|
|
2123
|
+
case "timestamp":
|
|
2124
|
+
return compileTimestampType(driver);
|
|
2125
|
+
case "json":
|
|
2126
|
+
return compileJsonType(driver);
|
|
2127
|
+
case "jsonb":
|
|
2128
|
+
return compileJsonbType(driver);
|
|
2129
|
+
default:
|
|
2130
|
+
throw new Error(`Unsupported column kind: ${column.kind}`);
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
function compileIdType(driver) {
|
|
2134
|
+
switch (driver) {
|
|
2135
|
+
case "pgsql":
|
|
2136
|
+
return "SERIAL";
|
|
2137
|
+
case "mysql":
|
|
2138
|
+
return "BIGINT UNSIGNED";
|
|
2139
|
+
case "sqlite":
|
|
2140
|
+
return "INTEGER";
|
|
2141
|
+
}
|
|
2142
|
+
}
|
|
2143
|
+
function compileStringType(driver, length) {
|
|
2144
|
+
switch (driver) {
|
|
2145
|
+
case "pgsql":
|
|
2146
|
+
return "TEXT";
|
|
2147
|
+
case "mysql":
|
|
2148
|
+
return length ? `VARCHAR(${length})` : "VARCHAR(255)";
|
|
2149
|
+
case "sqlite":
|
|
2150
|
+
return "TEXT";
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
function compileTextType(driver) {
|
|
2154
|
+
switch (driver) {
|
|
2155
|
+
case "pgsql":
|
|
2156
|
+
case "sqlite":
|
|
2157
|
+
return "TEXT";
|
|
2158
|
+
case "mysql":
|
|
2159
|
+
return "TEXT";
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function compileBooleanType(driver) {
|
|
2163
|
+
switch (driver) {
|
|
2164
|
+
case "pgsql":
|
|
2165
|
+
return "BOOLEAN";
|
|
2166
|
+
case "mysql":
|
|
2167
|
+
return "BOOLEAN";
|
|
2168
|
+
case "sqlite":
|
|
2169
|
+
return "INTEGER";
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
function compileIntegerType(driver) {
|
|
2173
|
+
switch (driver) {
|
|
2174
|
+
case "pgsql":
|
|
2175
|
+
return "INTEGER";
|
|
2176
|
+
case "mysql":
|
|
2177
|
+
return "INT";
|
|
2178
|
+
case "sqlite":
|
|
2179
|
+
return "INTEGER";
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
function compileBigIntegerType(driver) {
|
|
2183
|
+
switch (driver) {
|
|
2184
|
+
case "pgsql":
|
|
2185
|
+
return "BIGINT";
|
|
2186
|
+
case "mysql":
|
|
2187
|
+
return "BIGINT";
|
|
2188
|
+
case "sqlite":
|
|
2189
|
+
return "INTEGER";
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
function compileTimestampType(driver) {
|
|
2193
|
+
switch (driver) {
|
|
2194
|
+
case "pgsql":
|
|
2195
|
+
return "TIMESTAMPTZ";
|
|
2196
|
+
case "mysql":
|
|
2197
|
+
return "TIMESTAMP";
|
|
2198
|
+
case "sqlite":
|
|
2199
|
+
return "TEXT";
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
function compileJsonType(driver) {
|
|
2203
|
+
switch (driver) {
|
|
2204
|
+
case "pgsql":
|
|
2205
|
+
return "JSONB";
|
|
2206
|
+
case "mysql":
|
|
2207
|
+
return "JSON";
|
|
2208
|
+
case "sqlite":
|
|
2209
|
+
return "TEXT";
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
function compileJsonbType(driver) {
|
|
2213
|
+
switch (driver) {
|
|
2214
|
+
case "pgsql":
|
|
2215
|
+
return "JSONB";
|
|
2216
|
+
case "mysql":
|
|
2217
|
+
return "JSON";
|
|
2218
|
+
case "sqlite":
|
|
2219
|
+
return "TEXT";
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
// ../../src/core/database/schema/grammars/compileStatements.ts
|
|
2224
|
+
function compileCreateTable(driver, blueprint) {
|
|
2225
|
+
const table = quoteIdentifier(blueprint.table);
|
|
2226
|
+
const parts = blueprint.columns.map((column) => compileColumn(driver, column, "create"));
|
|
2227
|
+
for (const index of blueprint.indexes) {
|
|
2228
|
+
if (index.kind === "unique" && index.columns.length > 1) {
|
|
2229
|
+
const columns = index.columns.map((column) => quoteIdentifier(column)).join(", ");
|
|
2230
|
+
parts.push(`UNIQUE (${columns})`);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
const statements = [`CREATE TABLE IF NOT EXISTS ${table} (
|
|
2234
|
+
${parts.join(`,
|
|
2235
|
+
`)}
|
|
2236
|
+
)`];
|
|
2237
|
+
for (const index of blueprint.indexes) {
|
|
2238
|
+
if (index.kind === "unique" && index.columns.length === 1) {
|
|
2239
|
+
continue;
|
|
2240
|
+
}
|
|
2241
|
+
if (index.kind === "index") {
|
|
2242
|
+
statements.push(compileIndex(driver, blueprint.table, index));
|
|
2243
|
+
} else if (index.kind === "partial" || index.kind === "uniquePartial" || index.kind === "gin" || index.kind === "fullText") {
|
|
2244
|
+
statements.push(...compileSpecialIndex(driver, blueprint.table, index));
|
|
2245
|
+
}
|
|
2246
|
+
}
|
|
2247
|
+
return statements;
|
|
2248
|
+
}
|
|
2249
|
+
function compileAlterTable(driver, blueprint) {
|
|
2250
|
+
const statements = [];
|
|
2251
|
+
const table = quoteIdentifier(blueprint.table);
|
|
2252
|
+
for (const column of blueprint.columns) {
|
|
2253
|
+
const addPrefix = driver === "pgsql" ? "ADD COLUMN IF NOT EXISTS" : "ADD COLUMN";
|
|
2254
|
+
statements.push(`ALTER TABLE ${table}
|
|
2255
|
+
${addPrefix} ${compileColumn(driver, column, "alter")}`);
|
|
2256
|
+
}
|
|
2257
|
+
for (const columnName of blueprint.droppedColumns) {
|
|
2258
|
+
const dropPrefix = driver === "pgsql" ? "DROP COLUMN IF EXISTS" : "DROP COLUMN";
|
|
2259
|
+
statements.push(`ALTER TABLE ${table} ${dropPrefix} ${quoteIdentifier(columnName)}`);
|
|
2260
|
+
}
|
|
2261
|
+
for (const indexName of blueprint.droppedIndexes) {
|
|
2262
|
+
statements.push(`DROP INDEX IF EXISTS ${quoteIdentifier(indexName)}`);
|
|
2263
|
+
}
|
|
2264
|
+
for (const index of blueprint.indexes) {
|
|
2265
|
+
if (index.kind === "index" || index.kind === "unique") {
|
|
2266
|
+
statements.push(compileIndex(driver, blueprint.table, index));
|
|
2267
|
+
} else {
|
|
2268
|
+
statements.push(...compileSpecialIndex(driver, blueprint.table, index));
|
|
2269
|
+
}
|
|
2270
|
+
}
|
|
2271
|
+
return statements;
|
|
2272
|
+
}
|
|
2273
|
+
function compileDropTable(driver, tableName) {
|
|
2274
|
+
const cascade = driver === "pgsql" ? " CASCADE" : "";
|
|
2275
|
+
return [`DROP TABLE IF EXISTS ${quoteIdentifier(tableName)}${cascade}`];
|
|
2276
|
+
}
|
|
2277
|
+
function compileColumn(driver, column, mode) {
|
|
2278
|
+
const parts = [quoteIdentifier(column.name), compileColumnType(driver, column)];
|
|
2279
|
+
if (column.autoIncrement && driver === "mysql") {
|
|
2280
|
+
parts[1] = `${parts[1]} AUTO_INCREMENT`;
|
|
2281
|
+
}
|
|
2282
|
+
if (column.isPrimary && mode === "create") {
|
|
2283
|
+
if (driver === "sqlite") {
|
|
2284
|
+
parts.push("PRIMARY KEY AUTOINCREMENT");
|
|
2285
|
+
} else {
|
|
2286
|
+
parts.push("PRIMARY KEY");
|
|
2287
|
+
}
|
|
2288
|
+
} else if (!column.isNullable) {
|
|
2289
|
+
parts.push("NOT NULL");
|
|
2290
|
+
} else if (column.isNullable) {
|
|
2291
|
+
parts.push("NULL");
|
|
2292
|
+
}
|
|
2293
|
+
if (column.defaultValue !== undefined) {
|
|
2294
|
+
parts.push(`DEFAULT ${column.defaultValue}`);
|
|
2295
|
+
}
|
|
2296
|
+
if (column.isUnique) {
|
|
2297
|
+
parts.push("UNIQUE");
|
|
2298
|
+
}
|
|
2299
|
+
if (column.checkExpression) {
|
|
2300
|
+
parts.push(`CHECK (${column.checkExpression})`);
|
|
2301
|
+
}
|
|
2302
|
+
if (column.foreignKey) {
|
|
2303
|
+
const { referencesTable, referencesColumn, onDelete } = column.foreignKey;
|
|
2304
|
+
const reference = `${quoteIdentifier(referencesTable)}(${quoteIdentifier(referencesColumn)})`;
|
|
2305
|
+
let clause = `REFERENCES ${reference}`;
|
|
2306
|
+
if (onDelete === "cascade") {
|
|
2307
|
+
clause += " ON DELETE CASCADE";
|
|
2308
|
+
} else if (onDelete === "set null") {
|
|
2309
|
+
clause += " ON DELETE SET NULL";
|
|
2310
|
+
}
|
|
2311
|
+
parts.push(clause);
|
|
2312
|
+
}
|
|
2313
|
+
return parts.join(" ");
|
|
2314
|
+
}
|
|
2315
|
+
function compileIndex(_driver, tableName, index) {
|
|
2316
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns, index.kind === "unique" ? "unique" : "index");
|
|
2317
|
+
const columns = index.columns.map((column) => {
|
|
2318
|
+
const quoted = quoteIdentifier(column);
|
|
2319
|
+
if (index.order === "desc") {
|
|
2320
|
+
return `${quoted} DESC`;
|
|
2321
|
+
}
|
|
2322
|
+
return quoted;
|
|
2323
|
+
}).join(", ");
|
|
2324
|
+
const unique = index.kind === "unique" ? "UNIQUE " : "";
|
|
2325
|
+
return `CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)}(${columns})`;
|
|
2326
|
+
}
|
|
2327
|
+
function compileSpecialIndex(driver, tableName, index) {
|
|
2328
|
+
const indexName = index.name ?? defaultIndexName(tableName, index.columns, index.kind);
|
|
2329
|
+
const columns = index.columns.map((column) => quoteIdentifier(column)).join(", ");
|
|
2330
|
+
switch (index.kind) {
|
|
2331
|
+
case "partial":
|
|
2332
|
+
case "uniquePartial": {
|
|
2333
|
+
if (driver !== "pgsql") {
|
|
2334
|
+
throw new UnsupportedSchemaFeatureError("partialIndex()", driver);
|
|
2335
|
+
}
|
|
2336
|
+
const unique = index.kind === "uniquePartial" ? "UNIQUE " : "";
|
|
2337
|
+
return [
|
|
2338
|
+
`CREATE ${unique}INDEX IF NOT EXISTS ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)}(${columns}) WHERE ${index.where}`
|
|
2339
|
+
];
|
|
2340
|
+
}
|
|
2341
|
+
case "gin": {
|
|
2342
|
+
if (driver !== "pgsql") {
|
|
2343
|
+
throw new UnsupportedSchemaFeatureError("ginIndex()", driver);
|
|
2344
|
+
}
|
|
2345
|
+
return [
|
|
2346
|
+
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)} USING GIN(${columns})`
|
|
2347
|
+
];
|
|
2348
|
+
}
|
|
2349
|
+
case "fullText": {
|
|
2350
|
+
if (driver === "mysql") {
|
|
2351
|
+
return [
|
|
2352
|
+
`CREATE FULLTEXT INDEX ${quoteIdentifier(indexName)} ON ${quoteIdentifier(tableName)}(${columns})`
|
|
2353
|
+
];
|
|
2354
|
+
}
|
|
2355
|
+
if (driver === "pgsql") {
|
|
2356
|
+
throw new UnsupportedSchemaFeatureError("fullText(); use ginIndex() with a tsvector column on PostgreSQL", driver);
|
|
2357
|
+
}
|
|
2358
|
+
throw new UnsupportedSchemaFeatureError("fullText()", driver);
|
|
2359
|
+
}
|
|
2360
|
+
default:
|
|
2361
|
+
return [];
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
function defaultIndexName(tableName, columns, kind) {
|
|
2365
|
+
return `idx_${tableName}_${columns.join("_")}_${kind}`;
|
|
2366
|
+
}
|
|
2367
|
+
function compileBlueprint(driver, blueprint) {
|
|
2368
|
+
switch (blueprint.action) {
|
|
2369
|
+
case "create":
|
|
2370
|
+
return compileCreateTable(driver, blueprint);
|
|
2371
|
+
case "alter":
|
|
2372
|
+
return compileAlterTable(driver, blueprint);
|
|
2373
|
+
case "drop":
|
|
2374
|
+
return compileDropTable(driver, blueprint.table);
|
|
2375
|
+
default:
|
|
2376
|
+
throw new Error(`Unsupported blueprint action: ${blueprint.action}`);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
// ../../src/core/database/schema/grammars/createGrammar.ts
|
|
2380
|
+
function createGrammar(driver) {
|
|
2381
|
+
return {
|
|
2382
|
+
driver,
|
|
2383
|
+
compile(blueprint) {
|
|
2384
|
+
return compileBlueprint(driver, blueprint);
|
|
2385
|
+
}
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
// ../../src/core/database/schema/grammars/mysqlGrammar.ts
|
|
2390
|
+
var MySqlGrammar = createGrammar("mysql");
|
|
2391
|
+
|
|
2392
|
+
// ../../src/core/database/schema/grammars/postgresGrammar.ts
|
|
2393
|
+
var PostgresGrammar = createGrammar("pgsql");
|
|
2394
|
+
|
|
2395
|
+
// ../../src/core/database/schema/grammars/sqliteGrammar.ts
|
|
2396
|
+
var SqliteGrammar = createGrammar("sqlite");
|
|
2397
|
+
|
|
2398
|
+
// ../../src/core/database/schema/grammars/index.ts
|
|
2399
|
+
function grammarForDriver(driver) {
|
|
2400
|
+
switch (driver) {
|
|
2401
|
+
case "pgsql":
|
|
2402
|
+
return PostgresGrammar;
|
|
2403
|
+
case "mysql":
|
|
2404
|
+
return MySqlGrammar;
|
|
2405
|
+
case "sqlite":
|
|
2406
|
+
return SqliteGrammar;
|
|
2407
|
+
default:
|
|
2408
|
+
throw new Error(`Unsupported database driver: ${driver}`);
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
// ../../src/core/database/schema/schema.ts
|
|
2412
|
+
class SchemaBuilder {
|
|
2413
|
+
#driver;
|
|
2414
|
+
#statements = [];
|
|
2415
|
+
constructor(driver) {
|
|
2416
|
+
this.#driver = driver;
|
|
2417
|
+
}
|
|
2418
|
+
create(table, callback) {
|
|
2419
|
+
const blueprint = new Blueprint(table, "create");
|
|
2420
|
+
callback(blueprint);
|
|
2421
|
+
this.#statements.push(...grammarForDriver(this.#driver).compile(blueprint));
|
|
2422
|
+
return this;
|
|
2423
|
+
}
|
|
2424
|
+
table(table, callback) {
|
|
2425
|
+
const blueprint = new Blueprint(table, "alter");
|
|
2426
|
+
callback(blueprint);
|
|
2427
|
+
this.#statements.push(...grammarForDriver(this.#driver).compile(blueprint));
|
|
2428
|
+
return this;
|
|
2429
|
+
}
|
|
2430
|
+
drop(table) {
|
|
2431
|
+
const blueprint = new Blueprint(table, "drop");
|
|
2432
|
+
this.#statements.push(...grammarForDriver(this.#driver).compile(blueprint));
|
|
2433
|
+
return this;
|
|
2434
|
+
}
|
|
2435
|
+
toSql() {
|
|
2436
|
+
return [...this.#statements];
|
|
2437
|
+
}
|
|
2438
|
+
async execute(db2) {
|
|
2439
|
+
for (const statement of this.#statements) {
|
|
2440
|
+
await db2.unsafe(statement);
|
|
2441
|
+
}
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
class Schema {
|
|
2446
|
+
static builder(driver) {
|
|
2447
|
+
return new SchemaBuilder(driver ?? resolveDatabaseDriver());
|
|
2448
|
+
}
|
|
2449
|
+
static async run(db2, driver, callback) {
|
|
2450
|
+
const schema = Schema.builder(driver);
|
|
2451
|
+
await callback(schema);
|
|
2452
|
+
await schema.execute(db2);
|
|
2453
|
+
}
|
|
2454
|
+
}
|
|
2455
|
+
function createSchemaBuilder(db2, driver) {
|
|
2456
|
+
const builder = Schema.builder(driver);
|
|
2457
|
+
return Object.assign(builder, {
|
|
2458
|
+
async commit() {
|
|
2459
|
+
await builder.execute(db2);
|
|
2460
|
+
}
|
|
2461
|
+
});
|
|
2462
|
+
}
|
|
2463
|
+
// ../../src/core/database/seeders/runner.ts
|
|
2464
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
2465
|
+
import { join as join2 } from "path";
|
|
2466
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
2467
|
+
async function loadSeedersFromDirectory(directory) {
|
|
2468
|
+
const entries = await readdir2(directory);
|
|
2469
|
+
const seederFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
2470
|
+
const loadedSeeders = await Promise.all(seederFiles.map(async (fileName) => {
|
|
2471
|
+
const moduleUrl = pathToFileURL2(join2(directory, fileName)).href;
|
|
2472
|
+
const module = await import(moduleUrl);
|
|
2473
|
+
return module.default;
|
|
2474
|
+
}));
|
|
2475
|
+
return loadedSeeders.filter((seeder) => seeder?.name !== undefined);
|
|
2476
|
+
}
|
|
2477
|
+
async function runSeedersFromDirectory(directory, db2, options) {
|
|
2478
|
+
const seeders = await loadSeedersFromDirectory(directory);
|
|
2479
|
+
if (seeders.length === 0) {
|
|
2480
|
+
return 0;
|
|
2481
|
+
}
|
|
2482
|
+
for (const seeder of seeders) {
|
|
2483
|
+
options?.onSeeder?.(seeder.name);
|
|
2484
|
+
await seeder.run(db2);
|
|
2485
|
+
}
|
|
2486
|
+
return seeders.length;
|
|
2487
|
+
}
|
|
1194
2488
|
// ../../src/core/database/table.ts
|
|
1195
2489
|
function defineTable(definition) {
|
|
1196
2490
|
return definition;
|
|
1197
2491
|
}
|
|
2492
|
+
// ../../src/core/database/connection.ts
|
|
2493
|
+
function createDatabaseConnection2(source) {
|
|
2494
|
+
return {
|
|
2495
|
+
async unsafe(query, params = []) {
|
|
2496
|
+
return await source.unsafe(query, params);
|
|
2497
|
+
}
|
|
2498
|
+
};
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// ../../src/core/database/transaction.ts
|
|
2502
|
+
async function runInTransaction(operation) {
|
|
2503
|
+
return await connection_default.begin(async (transaction) => {
|
|
2504
|
+
return await operation(createDatabaseConnection2(transaction));
|
|
2505
|
+
});
|
|
2506
|
+
}
|
|
1198
2507
|
// ../../src/core/logging/logger.ts
|
|
1199
2508
|
class Logger {
|
|
1200
2509
|
channel;
|
|
@@ -1235,6 +2544,8 @@ var appLogger = new Logger("app");
|
|
|
1235
2544
|
var CORE_QUEUE_TOKEN = "core.queue";
|
|
1236
2545
|
var CORE_POLICY_GATE_TOKEN = "core.policyGate";
|
|
1237
2546
|
var CORE_AUTH_TOKEN = "core.auth";
|
|
2547
|
+
var CORE_TOKEN_SERVICE_TOKEN = "core.tokenService";
|
|
2548
|
+
var DEFAULT_QUEUE_DRIVER = "sync";
|
|
1238
2549
|
|
|
1239
2550
|
// ../../src/bootstrap/applicationRegistry.ts
|
|
1240
2551
|
var activeContext;
|
|
@@ -1262,6 +2573,9 @@ function resolveApplicationConfig() {
|
|
|
1262
2573
|
function resolveApplicationLogger() {
|
|
1263
2574
|
return appLogger;
|
|
1264
2575
|
}
|
|
2576
|
+
function resolveApplicationDependencies() {
|
|
2577
|
+
return requireActiveApplicationContext().dependencies;
|
|
2578
|
+
}
|
|
1265
2579
|
|
|
1266
2580
|
// ../../src/core/mail/mailer.ts
|
|
1267
2581
|
function resolveSmtpConfig() {
|
|
@@ -1446,7 +2760,7 @@ function mailer() {
|
|
|
1446
2760
|
// ../../src/core/storage/storage.ts
|
|
1447
2761
|
var {S3Client } = globalThis.Bun;
|
|
1448
2762
|
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
1449
|
-
import { dirname, join as
|
|
2763
|
+
import { dirname, join as join3 } from "path";
|
|
1450
2764
|
|
|
1451
2765
|
class LocalStorageDriver {
|
|
1452
2766
|
rootDirectory;
|
|
@@ -1454,7 +2768,7 @@ class LocalStorageDriver {
|
|
|
1454
2768
|
this.rootDirectory = rootDirectory;
|
|
1455
2769
|
}
|
|
1456
2770
|
resolvePath(path) {
|
|
1457
|
-
return
|
|
2771
|
+
return join3(this.rootDirectory, path.replace(/^\/+/, ""));
|
|
1458
2772
|
}
|
|
1459
2773
|
async put(path, contents) {
|
|
1460
2774
|
const absolutePath = this.resolvePath(path);
|
|
@@ -1585,14 +2899,466 @@ function mail() {
|
|
|
1585
2899
|
function storageFacade() {
|
|
1586
2900
|
return storage();
|
|
1587
2901
|
}
|
|
1588
|
-
// ../../src/
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
2902
|
+
// ../../src/core/http/bodySizeLimitMiddleware.ts
|
|
2903
|
+
var DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
2904
|
+
function resolveMaxBodyBytes() {
|
|
2905
|
+
const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
|
|
2906
|
+
if (!raw) {
|
|
2907
|
+
return DEFAULT_MAX_BODY_BYTES;
|
|
2908
|
+
}
|
|
2909
|
+
const parsed = Number.parseInt(raw, 10);
|
|
2910
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
2911
|
+
return DEFAULT_MAX_BODY_BYTES;
|
|
2912
|
+
}
|
|
2913
|
+
return parsed;
|
|
2914
|
+
}
|
|
2915
|
+
function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
|
|
2916
|
+
return async (request, next) => {
|
|
2917
|
+
const contentLength = request.headers.get("content-length");
|
|
2918
|
+
if (contentLength) {
|
|
2919
|
+
const bytes = Number.parseInt(contentLength, 10);
|
|
2920
|
+
if (Number.isInteger(bytes) && bytes > maxBytes) {
|
|
2921
|
+
const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
|
|
2922
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
return await next();
|
|
2926
|
+
};
|
|
2927
|
+
}
|
|
2928
|
+
// ../../src/core/http/csrfToken.ts
|
|
2929
|
+
import { createHmac, randomBytes, timingSafeEqual } from "crypto";
|
|
2930
|
+
|
|
2931
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
2932
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
2933
|
+
var requestMetaContext = new AsyncLocalStorage3;
|
|
2934
|
+
function runWithRequestMeta(meta, callback) {
|
|
2935
|
+
return requestMetaContext.run(meta, callback);
|
|
2936
|
+
}
|
|
2937
|
+
function currentRequestMeta() {
|
|
2938
|
+
return requestMetaContext.getStore() ?? {
|
|
2939
|
+
ipAddress: null,
|
|
2940
|
+
userAgent: null
|
|
2941
|
+
};
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
// ../../src/core/http/csrfToken.ts
|
|
2945
|
+
var CSRF_COOKIE = "workhub_csrf";
|
|
2946
|
+
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
2947
|
+
function resolveCsrfSecret() {
|
|
2948
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
|
|
2949
|
+
}
|
|
2950
|
+
function signCsrfToken(token, issuedAt) {
|
|
2951
|
+
const payload = `${token}.${issuedAt}`;
|
|
2952
|
+
const signature = createHmac("sha256", resolveCsrfSecret()).update(payload).digest("hex");
|
|
2953
|
+
return `${payload}.${signature}`;
|
|
2954
|
+
}
|
|
2955
|
+
function readCsrfCookie(request) {
|
|
2956
|
+
const cookieHeader = request.headers.get("cookie");
|
|
2957
|
+
if (!cookieHeader) {
|
|
2958
|
+
return null;
|
|
2959
|
+
}
|
|
2960
|
+
for (const part of cookieHeader.split(";")) {
|
|
2961
|
+
const [name, ...rest] = part.trim().split("=");
|
|
2962
|
+
if (name === CSRF_COOKIE) {
|
|
2963
|
+
return decodeURIComponent(rest.join("="));
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
return null;
|
|
2967
|
+
}
|
|
2968
|
+
function parseSignedCsrfValue(cookieValue) {
|
|
2969
|
+
const parts = cookieValue.split(".");
|
|
2970
|
+
if (parts.length !== 3) {
|
|
2971
|
+
return null;
|
|
2972
|
+
}
|
|
2973
|
+
const [token, issuedAtRaw, cookieSignature] = parts;
|
|
2974
|
+
if (!token || !issuedAtRaw || !cookieSignature) {
|
|
2975
|
+
return null;
|
|
2976
|
+
}
|
|
2977
|
+
const issuedAt = Number.parseInt(issuedAtRaw, 10);
|
|
2978
|
+
if (!Number.isFinite(issuedAt)) {
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
if (Date.now() - issuedAt > CSRF_TTL_MS) {
|
|
2982
|
+
return null;
|
|
2983
|
+
}
|
|
2984
|
+
const expectedSignature = signCsrfToken(token, issuedAt).split(".").pop();
|
|
2985
|
+
if (!expectedSignature) {
|
|
2986
|
+
return null;
|
|
2987
|
+
}
|
|
2988
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
2989
|
+
const actualBuffer = Buffer.from(cookieSignature);
|
|
2990
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
2991
|
+
return null;
|
|
2992
|
+
}
|
|
2993
|
+
if (!timingSafeEqual(expectedBuffer, actualBuffer)) {
|
|
2994
|
+
return null;
|
|
2995
|
+
}
|
|
2996
|
+
return { token, issuedAt };
|
|
2997
|
+
}
|
|
2998
|
+
function createCsrfTokenCookie() {
|
|
2999
|
+
const token = randomBytes(24).toString("hex");
|
|
3000
|
+
const issuedAt = Date.now();
|
|
3001
|
+
const value = signCsrfToken(token, issuedAt);
|
|
3002
|
+
return {
|
|
3003
|
+
token,
|
|
3004
|
+
cookie: `${CSRF_COOKIE}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=3600`
|
|
3005
|
+
};
|
|
3006
|
+
}
|
|
3007
|
+
function resolveCsrfToken(request) {
|
|
3008
|
+
const cookieValue = readCsrfCookie(request);
|
|
3009
|
+
if (cookieValue) {
|
|
3010
|
+
const parsed = parseSignedCsrfValue(cookieValue);
|
|
3011
|
+
if (parsed) {
|
|
3012
|
+
return { token: parsed.token };
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
return createCsrfTokenCookie();
|
|
3016
|
+
}
|
|
3017
|
+
function readSubmittedCsrfToken(request) {
|
|
3018
|
+
const headerToken = request.headers.get("x-csrf-token")?.trim();
|
|
3019
|
+
if (headerToken) {
|
|
3020
|
+
return headerToken;
|
|
3021
|
+
}
|
|
3022
|
+
return null;
|
|
3023
|
+
}
|
|
3024
|
+
async function readSubmittedCsrfTokenFromBody(request) {
|
|
3025
|
+
const headerToken = readSubmittedCsrfToken(request);
|
|
3026
|
+
if (headerToken) {
|
|
3027
|
+
return headerToken;
|
|
3028
|
+
}
|
|
3029
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
3030
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
3031
|
+
const formData = await request.clone().formData();
|
|
3032
|
+
const field = formData.get("_token");
|
|
3033
|
+
if (typeof field === "string" && field.trim().length > 0) {
|
|
3034
|
+
return field.trim();
|
|
3035
|
+
}
|
|
3036
|
+
}
|
|
3037
|
+
return null;
|
|
3038
|
+
}
|
|
3039
|
+
function verifyCsrfToken(request, submittedToken) {
|
|
3040
|
+
if (!submittedToken) {
|
|
3041
|
+
return false;
|
|
3042
|
+
}
|
|
3043
|
+
const cookieValue = readCsrfCookie(request);
|
|
3044
|
+
if (!cookieValue) {
|
|
3045
|
+
return false;
|
|
3046
|
+
}
|
|
3047
|
+
const parsed = parseSignedCsrfValue(cookieValue);
|
|
3048
|
+
if (!parsed) {
|
|
3049
|
+
return false;
|
|
3050
|
+
}
|
|
3051
|
+
const submittedBuffer = Buffer.from(submittedToken);
|
|
3052
|
+
const expectedBuffer = Buffer.from(parsed.token);
|
|
3053
|
+
if (submittedBuffer.length !== expectedBuffer.length) {
|
|
3054
|
+
return false;
|
|
3055
|
+
}
|
|
3056
|
+
return timingSafeEqual(submittedBuffer, expectedBuffer);
|
|
3057
|
+
}
|
|
3058
|
+
function resolveCsrfTokenForRequest(request) {
|
|
3059
|
+
const metaToken = currentRequestMeta().csrfToken;
|
|
3060
|
+
if (metaToken) {
|
|
3061
|
+
return metaToken;
|
|
3062
|
+
}
|
|
3063
|
+
return resolveCsrfToken(request).token;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// ../../src/core/http/csrfMiddleware.ts
|
|
3067
|
+
var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
3068
|
+
function appendSetCookie(response, cookie) {
|
|
3069
|
+
const headers = new Headers(response.headers);
|
|
3070
|
+
headers.append("set-cookie", cookie);
|
|
3071
|
+
return new Response(response.body, {
|
|
3072
|
+
status: response.status,
|
|
3073
|
+
statusText: response.statusText,
|
|
3074
|
+
headers
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
function createCsrfMiddleware() {
|
|
3078
|
+
return async (request, next) => {
|
|
3079
|
+
const method = request.method.toUpperCase();
|
|
3080
|
+
if (!MUTATING_METHODS.has(method)) {
|
|
3081
|
+
const csrf = resolveCsrfToken(request);
|
|
3082
|
+
const meta = currentRequestMeta();
|
|
3083
|
+
meta.csrfToken = csrf.token;
|
|
3084
|
+
const response = await next();
|
|
3085
|
+
if (!csrf.cookie) {
|
|
3086
|
+
return response;
|
|
3087
|
+
}
|
|
3088
|
+
return appendSetCookie(response, csrf.cookie);
|
|
3089
|
+
}
|
|
3090
|
+
const submitted = await readSubmittedCsrfTokenFromBody(request);
|
|
3091
|
+
if (!verifyCsrfToken(request, submitted)) {
|
|
3092
|
+
throw new ForbiddenError("Invalid or missing CSRF token.");
|
|
3093
|
+
}
|
|
3094
|
+
return await next();
|
|
3095
|
+
};
|
|
3096
|
+
}
|
|
3097
|
+
// ../../src/core/http/etag.ts
|
|
3098
|
+
import { createHash } from "crypto";
|
|
3099
|
+
function isEtagEnabled() {
|
|
3100
|
+
return (process.env.FEATURE_ETAG ?? "true") !== "false";
|
|
3101
|
+
}
|
|
3102
|
+
function formatWeakEtag(digest) {
|
|
3103
|
+
return `W/"${digest}"`;
|
|
3104
|
+
}
|
|
3105
|
+
function computeEtagFromJson(data) {
|
|
3106
|
+
const digest = createHash("sha256").update(JSON.stringify(data)).digest("hex").slice(0, 32);
|
|
3107
|
+
return formatWeakEtag(digest);
|
|
3108
|
+
}
|
|
3109
|
+
function etagFromResource(resource) {
|
|
3110
|
+
const version = resource.updated_at ?? resource.created_at ?? "";
|
|
3111
|
+
const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
|
|
3112
|
+
const digest = createHash("sha256").update(`${String(resource.id ?? "0")}:${versionText}`).digest("hex").slice(0, 32);
|
|
3113
|
+
return formatWeakEtag(digest);
|
|
3114
|
+
}
|
|
3115
|
+
function normalizeEtag(value) {
|
|
3116
|
+
return value.trim();
|
|
3117
|
+
}
|
|
3118
|
+
function etagValuesMatch(left, right) {
|
|
3119
|
+
return normalizeEtag(left) === normalizeEtag(right);
|
|
3120
|
+
}
|
|
3121
|
+
function parseEtagList(header) {
|
|
3122
|
+
if (!header) {
|
|
3123
|
+
return [];
|
|
3124
|
+
}
|
|
3125
|
+
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
3126
|
+
}
|
|
3127
|
+
function ifNoneMatchSatisfied(request, etag) {
|
|
3128
|
+
const header = request.headers.get("if-none-match");
|
|
3129
|
+
if (!header) {
|
|
3130
|
+
return false;
|
|
3131
|
+
}
|
|
3132
|
+
if (header.trim() === "*") {
|
|
3133
|
+
return true;
|
|
3134
|
+
}
|
|
3135
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
3136
|
+
}
|
|
3137
|
+
function ifMatchSatisfied(request, etag) {
|
|
3138
|
+
const header = request.headers.get("if-match");
|
|
3139
|
+
if (!header) {
|
|
3140
|
+
return false;
|
|
3141
|
+
}
|
|
3142
|
+
if (header.trim() === "*") {
|
|
3143
|
+
return true;
|
|
3144
|
+
}
|
|
3145
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
3146
|
+
}
|
|
3147
|
+
function assertIfMatch(request, etag, options = {}) {
|
|
3148
|
+
const header = request.headers.get("if-match");
|
|
3149
|
+
if (!header) {
|
|
3150
|
+
if (options.required) {
|
|
3151
|
+
throw new PreconditionFailedError("If-Match header is required.");
|
|
3152
|
+
}
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
if (!ifMatchSatisfied(request, etag)) {
|
|
3156
|
+
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
function applyEtagHeaders(headers, etag) {
|
|
3160
|
+
const next = new Headers(headers);
|
|
3161
|
+
next.set("ETag", etag);
|
|
3162
|
+
next.set("Cache-Control", "private, must-revalidate");
|
|
3163
|
+
next.append("Vary", "Authorization");
|
|
3164
|
+
next.append("Vary", "X-Tenant-Id");
|
|
3165
|
+
return next;
|
|
3166
|
+
}
|
|
3167
|
+
function notModifiedResponse(etag) {
|
|
3168
|
+
return new Response(null, {
|
|
3169
|
+
status: 304,
|
|
3170
|
+
headers: applyEtagHeaders(new Headers, etag)
|
|
3171
|
+
});
|
|
3172
|
+
}
|
|
3173
|
+
function applyConditionalGet(request, response, etag) {
|
|
3174
|
+
if (!isEtagEnabled()) {
|
|
3175
|
+
return response;
|
|
3176
|
+
}
|
|
3177
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
3178
|
+
return notModifiedResponse(etag);
|
|
3179
|
+
}
|
|
3180
|
+
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
3181
|
+
return new Response(response.body, {
|
|
3182
|
+
status: response.status,
|
|
3183
|
+
statusText: response.statusText,
|
|
3184
|
+
headers
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
// ../../src/core/tenant/tenantContext.ts
|
|
3188
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
3189
|
+
var tenantContext = new AsyncLocalStorage4;
|
|
3190
|
+
function runWithTenant(tenant, callback) {
|
|
3191
|
+
return tenantContext.run(tenant, callback);
|
|
3192
|
+
}
|
|
3193
|
+
function currentTenant() {
|
|
3194
|
+
return tenantContext.getStore() ?? null;
|
|
3195
|
+
}
|
|
3196
|
+
function currentTenantId() {
|
|
3197
|
+
return currentTenant()?.id ?? 1;
|
|
3198
|
+
}
|
|
3199
|
+
function rateLimitMultiplierForPlan(plan) {
|
|
3200
|
+
switch (plan) {
|
|
3201
|
+
case "enterprise":
|
|
3202
|
+
return 4;
|
|
3203
|
+
case "pro":
|
|
3204
|
+
return 2;
|
|
3205
|
+
default:
|
|
3206
|
+
return 1;
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
// ../../src/core/http/validation.ts
|
|
3211
|
+
function buildRequestCacheKey(fallbackPath, request) {
|
|
3212
|
+
if (!request) {
|
|
3213
|
+
return fallbackPath;
|
|
3214
|
+
}
|
|
3215
|
+
const url = new URL(request.url);
|
|
3216
|
+
const user = currentAuthUser();
|
|
3217
|
+
const authScope = user ? `u:${user.id}` : "guest";
|
|
3218
|
+
const tenantScope = `t:${currentTenantId()}`;
|
|
3219
|
+
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
3220
|
+
}
|
|
3221
|
+
function getQueryParams(request) {
|
|
3222
|
+
if (!request) {
|
|
3223
|
+
return new URLSearchParams;
|
|
3224
|
+
}
|
|
3225
|
+
return new URL(request.url).searchParams;
|
|
3226
|
+
}
|
|
3227
|
+
function parseOptionalPositiveIntQueryParam(params, name) {
|
|
3228
|
+
const value = params.get(name);
|
|
3229
|
+
if (value === null || value.trim() === "") {
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
const parsed = Number.parseInt(value, 10);
|
|
3233
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
3234
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
|
|
3235
|
+
}
|
|
3236
|
+
return parsed;
|
|
3237
|
+
}
|
|
3238
|
+
function parseOptionalBooleanQueryParam(params, name) {
|
|
3239
|
+
const value = params.get(name);
|
|
3240
|
+
if (value === null || value.trim() === "") {
|
|
3241
|
+
return;
|
|
3242
|
+
}
|
|
3243
|
+
switch (value.toLowerCase()) {
|
|
3244
|
+
case "true":
|
|
3245
|
+
case "1":
|
|
3246
|
+
return true;
|
|
3247
|
+
case "false":
|
|
3248
|
+
case "0":
|
|
3249
|
+
return false;
|
|
3250
|
+
default:
|
|
3251
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
function parseOptionalEnumQueryParam(params, name, allowedValues) {
|
|
3255
|
+
const value = params.get(name);
|
|
3256
|
+
if (value === null || value.trim() === "") {
|
|
3257
|
+
return;
|
|
3258
|
+
}
|
|
3259
|
+
if (!allowedValues.includes(value)) {
|
|
3260
|
+
throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
|
|
3261
|
+
}
|
|
3262
|
+
return value;
|
|
3263
|
+
}
|
|
3264
|
+
function expectObject(value, label = "request body") {
|
|
3265
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
3266
|
+
throw new BadRequestError(`${label} must be a JSON object.`);
|
|
3267
|
+
}
|
|
3268
|
+
return value;
|
|
3269
|
+
}
|
|
3270
|
+
async function parseJsonBody(request, validator) {
|
|
3271
|
+
let payload;
|
|
3272
|
+
try {
|
|
3273
|
+
payload = await request.json();
|
|
3274
|
+
} catch {
|
|
3275
|
+
throw new BadRequestError("Request body must be valid JSON.");
|
|
3276
|
+
}
|
|
3277
|
+
return validator(payload);
|
|
3278
|
+
}
|
|
3279
|
+
function readRequiredString(payload, field, options = {}) {
|
|
3280
|
+
const value = payload[field];
|
|
3281
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
3282
|
+
throw new BadRequestError(`"${field}" is required and must be a string.`);
|
|
3283
|
+
}
|
|
3284
|
+
const trimmed = value.trim();
|
|
3285
|
+
if (options.minLength !== undefined && trimmed.length < options.minLength) {
|
|
3286
|
+
throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
|
|
3287
|
+
}
|
|
3288
|
+
if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
|
|
3289
|
+
throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
|
|
3290
|
+
}
|
|
3291
|
+
if (options.pattern && !options.pattern.test(trimmed)) {
|
|
3292
|
+
throw new BadRequestError(`"${field}" has an invalid format.`);
|
|
3293
|
+
}
|
|
3294
|
+
return trimmed;
|
|
3295
|
+
}
|
|
3296
|
+
function readOptionalString(payload, field, options = {}) {
|
|
3297
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
return readRequiredString(payload, field, options);
|
|
3301
|
+
}
|
|
3302
|
+
function readRequiredEnum(payload, field, allowedValues) {
|
|
3303
|
+
const value = readRequiredString(payload, field);
|
|
3304
|
+
if (!allowedValues.includes(value)) {
|
|
3305
|
+
throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
|
|
3306
|
+
}
|
|
3307
|
+
return value;
|
|
3308
|
+
}
|
|
3309
|
+
function readOptionalEnum(payload, field, allowedValues) {
|
|
3310
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
return readRequiredEnum(payload, field, allowedValues);
|
|
3314
|
+
}
|
|
3315
|
+
function readRequiredPositiveInt(payload, field) {
|
|
3316
|
+
const value = payload[field];
|
|
3317
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
3318
|
+
throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
|
|
3319
|
+
}
|
|
3320
|
+
return value;
|
|
3321
|
+
}
|
|
3322
|
+
function readOptionalPositiveInt(payload, field) {
|
|
3323
|
+
if (!(field in payload) || payload[field] === undefined) {
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
3326
|
+
return readRequiredPositiveInt(payload, field);
|
|
3327
|
+
}
|
|
3328
|
+
function parsePositiveIntParam(value, name = "id") {
|
|
3329
|
+
const parsed = Number.parseInt(value, 10);
|
|
3330
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
3331
|
+
throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
|
|
3332
|
+
}
|
|
3333
|
+
return parsed;
|
|
3334
|
+
}
|
|
3335
|
+
|
|
3336
|
+
// ../../src/core/http/formRequest.ts
|
|
3337
|
+
class FormRequest {
|
|
3338
|
+
authorize(_request) {
|
|
3339
|
+
return true;
|
|
3340
|
+
}
|
|
3341
|
+
async validate(request) {
|
|
3342
|
+
if (!await this.authorize(request)) {
|
|
3343
|
+
throw new ForbiddenError;
|
|
3344
|
+
}
|
|
3345
|
+
return await parseJsonBody(request, (payload) => this.parse(payload));
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
|
|
3349
|
+
class QueryFormRequest {
|
|
3350
|
+
validate(request) {
|
|
3351
|
+
return this.parseQuery(request);
|
|
3352
|
+
}
|
|
3353
|
+
}
|
|
3354
|
+
// ../../src/config/frontend.ts
|
|
3355
|
+
function readFrontendMode() {
|
|
3356
|
+
const mode = (process.env.FRONTEND_MODE ?? "api").trim();
|
|
3357
|
+
if (mode === "server-htmx") {
|
|
3358
|
+
return "server-htmx";
|
|
3359
|
+
}
|
|
3360
|
+
if (mode === "spa-react") {
|
|
3361
|
+
return "spa-react";
|
|
1596
3362
|
}
|
|
1597
3363
|
return "api";
|
|
1598
3364
|
}
|
|
@@ -1601,9 +3367,37 @@ function isViewsEnabled() {
|
|
|
1601
3367
|
}
|
|
1602
3368
|
|
|
1603
3369
|
// ../../src/core/view/etaViewEngine.ts
|
|
1604
|
-
import { join as
|
|
3370
|
+
import { join as join4 } from "path";
|
|
1605
3371
|
import { Eta } from "eta";
|
|
1606
|
-
var DEFAULT_VIEWS_DIRECTORY =
|
|
3372
|
+
var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
|
|
3373
|
+
var DEFAULT_LAYOUT = "layouts/app.eta";
|
|
3374
|
+
|
|
3375
|
+
class EtaViewEngine {
|
|
3376
|
+
eta;
|
|
3377
|
+
resolveLayoutData;
|
|
3378
|
+
constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
|
|
3379
|
+
this.eta = new Eta({
|
|
3380
|
+
views: viewsDirectory,
|
|
3381
|
+
autoTrim: false
|
|
3382
|
+
});
|
|
3383
|
+
this.resolveLayoutData = resolveLayoutData;
|
|
3384
|
+
}
|
|
3385
|
+
async render(name, data = {}, options = {}) {
|
|
3386
|
+
const template = name.endsWith(".eta") ? name : `${name}.eta`;
|
|
3387
|
+
const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
|
|
3388
|
+
const mergedData = { ...layoutData, ...data };
|
|
3389
|
+
const body = await this.eta.renderAsync(template, mergedData);
|
|
3390
|
+
const layout = options.layout ?? DEFAULT_LAYOUT;
|
|
3391
|
+
if (layout === false) {
|
|
3392
|
+
return body;
|
|
3393
|
+
}
|
|
3394
|
+
const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
|
|
3395
|
+
return await this.eta.renderAsync(layoutTemplate, {
|
|
3396
|
+
...mergedData,
|
|
3397
|
+
body
|
|
3398
|
+
});
|
|
3399
|
+
}
|
|
3400
|
+
}
|
|
1607
3401
|
// ../../src/core/view/htmlResponse.ts
|
|
1608
3402
|
function htmlResponse(html, init = {}) {
|
|
1609
3403
|
return new Response(html, {
|
|
@@ -1614,6 +3408,145 @@ function htmlResponse(html, init = {}) {
|
|
|
1614
3408
|
}
|
|
1615
3409
|
});
|
|
1616
3410
|
}
|
|
3411
|
+
function isHtmxRequest(request) {
|
|
3412
|
+
return request.headers.get("HX-Request") === "true";
|
|
3413
|
+
}
|
|
3414
|
+
// ../../src/core/auth/oauth/oidcProvider.ts
|
|
3415
|
+
class OidcProvider {
|
|
3416
|
+
options;
|
|
3417
|
+
name;
|
|
3418
|
+
constructor(options) {
|
|
3419
|
+
this.options = options;
|
|
3420
|
+
this.name = options.name;
|
|
3421
|
+
}
|
|
3422
|
+
getAuthorizationUrl(state) {
|
|
3423
|
+
const params = new URLSearchParams({
|
|
3424
|
+
client_id: this.options.clientId,
|
|
3425
|
+
redirect_uri: this.options.redirectUri,
|
|
3426
|
+
response_type: "code",
|
|
3427
|
+
scope: (this.options.scopes ?? ["openid", "email", "profile"]).join(" "),
|
|
3428
|
+
state
|
|
3429
|
+
});
|
|
3430
|
+
return `${this.options.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
|
|
3431
|
+
}
|
|
3432
|
+
async exchangeCode(code) {
|
|
3433
|
+
const tokenResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/token`, {
|
|
3434
|
+
method: "POST",
|
|
3435
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3436
|
+
body: new URLSearchParams({
|
|
3437
|
+
grant_type: "authorization_code",
|
|
3438
|
+
code,
|
|
3439
|
+
redirect_uri: this.options.redirectUri,
|
|
3440
|
+
client_id: this.options.clientId,
|
|
3441
|
+
client_secret: this.options.clientSecret
|
|
3442
|
+
})
|
|
3443
|
+
});
|
|
3444
|
+
const tokenBody = await tokenResponse.json();
|
|
3445
|
+
if (!tokenBody.access_token) {
|
|
3446
|
+
throw new Error("OIDC token exchange failed.");
|
|
3447
|
+
}
|
|
3448
|
+
const profileResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/userinfo`, {
|
|
3449
|
+
headers: { authorization: `Bearer ${tokenBody.access_token}` }
|
|
3450
|
+
});
|
|
3451
|
+
const profile = await profileResponse.json();
|
|
3452
|
+
return {
|
|
3453
|
+
providerUserId: profile.sub,
|
|
3454
|
+
email: profile.email ?? `${profile.sub}@oidc.local`,
|
|
3455
|
+
name: profile.name ?? profile.sub
|
|
3456
|
+
};
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
// ../../src/core/auth/oauth/providers.ts
|
|
3461
|
+
class GitHubOAuthProvider {
|
|
3462
|
+
options;
|
|
3463
|
+
name = "github";
|
|
3464
|
+
constructor(options) {
|
|
3465
|
+
this.options = options;
|
|
3466
|
+
}
|
|
3467
|
+
getAuthorizationUrl(state) {
|
|
3468
|
+
const params = new URLSearchParams({
|
|
3469
|
+
client_id: this.options.clientId,
|
|
3470
|
+
redirect_uri: this.options.redirectUri,
|
|
3471
|
+
scope: "read:user user:email",
|
|
3472
|
+
state
|
|
3473
|
+
});
|
|
3474
|
+
return `https://github.com/login/oauth/authorize?${params.toString()}`;
|
|
3475
|
+
}
|
|
3476
|
+
async exchangeCode(code) {
|
|
3477
|
+
const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
|
|
3478
|
+
method: "POST",
|
|
3479
|
+
headers: {
|
|
3480
|
+
accept: "application/json",
|
|
3481
|
+
"content-type": "application/json"
|
|
3482
|
+
},
|
|
3483
|
+
body: JSON.stringify({
|
|
3484
|
+
client_id: this.options.clientId,
|
|
3485
|
+
client_secret: this.options.clientSecret,
|
|
3486
|
+
code,
|
|
3487
|
+
redirect_uri: this.options.redirectUri
|
|
3488
|
+
})
|
|
3489
|
+
});
|
|
3490
|
+
const tokenBody = await tokenResponse.json();
|
|
3491
|
+
if (!tokenBody.access_token) {
|
|
3492
|
+
throw new Error("GitHub OAuth token exchange failed.");
|
|
3493
|
+
}
|
|
3494
|
+
const profileResponse = await fetch("https://api.github.com/user", {
|
|
3495
|
+
headers: {
|
|
3496
|
+
authorization: `Bearer ${tokenBody.access_token}`,
|
|
3497
|
+
accept: "application/json",
|
|
3498
|
+
"user-agent": "workhub"
|
|
3499
|
+
}
|
|
3500
|
+
});
|
|
3501
|
+
const profile = await profileResponse.json();
|
|
3502
|
+
return {
|
|
3503
|
+
providerUserId: String(profile.id),
|
|
3504
|
+
email: profile.email ?? `${profile.login}@users.noreply.github.com`,
|
|
3505
|
+
name: profile.name ?? profile.login
|
|
3506
|
+
};
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
|
|
3510
|
+
class MockOAuthProvider {
|
|
3511
|
+
profile;
|
|
3512
|
+
name = "mock";
|
|
3513
|
+
constructor(profile) {
|
|
3514
|
+
this.profile = profile;
|
|
3515
|
+
}
|
|
3516
|
+
getAuthorizationUrl(state) {
|
|
3517
|
+
return `https://mock.oauth/authorize?state=${encodeURIComponent(state)}`;
|
|
3518
|
+
}
|
|
3519
|
+
async exchangeCode(code) {
|
|
3520
|
+
if (code !== "valid-code") {
|
|
3521
|
+
throw new Error("Invalid OAuth code.");
|
|
3522
|
+
}
|
|
3523
|
+
return this.profile;
|
|
3524
|
+
}
|
|
3525
|
+
}
|
|
3526
|
+
|
|
3527
|
+
// ../../src/core/auth/oauth/samlProvider.ts
|
|
3528
|
+
class SamlProvider {
|
|
3529
|
+
loginUrl;
|
|
3530
|
+
name = "saml";
|
|
3531
|
+
constructor(loginUrl) {
|
|
3532
|
+
this.loginUrl = loginUrl;
|
|
3533
|
+
}
|
|
3534
|
+
getAuthorizationUrl(state) {
|
|
3535
|
+
return `${this.loginUrl}?state=${encodeURIComponent(state)}`;
|
|
3536
|
+
}
|
|
3537
|
+
async exchangeCode(code) {
|
|
3538
|
+
if (!code.startsWith("saml:")) {
|
|
3539
|
+
throw new Error("Invalid SAML assertion reference.");
|
|
3540
|
+
}
|
|
3541
|
+
const [, email, name] = code.split(":");
|
|
3542
|
+
return {
|
|
3543
|
+
providerUserId: email ?? "saml-user",
|
|
3544
|
+
email: email ?? "saml-user@workhub.test",
|
|
3545
|
+
name: name ?? "SAML User"
|
|
3546
|
+
};
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
|
|
1617
3550
|
// ../../src/config/features.ts
|
|
1618
3551
|
function readFeatureFlags() {
|
|
1619
3552
|
return {
|
|
@@ -1652,12 +3585,18 @@ var apiTokenTable = defineTable({
|
|
|
1652
3585
|
});
|
|
1653
3586
|
|
|
1654
3587
|
// ../../src/core/auth/password.ts
|
|
3588
|
+
async function hashPassword(password) {
|
|
3589
|
+
return await Bun.password.hash(password, {
|
|
3590
|
+
algorithm: "bcrypt",
|
|
3591
|
+
cost: 10
|
|
3592
|
+
});
|
|
3593
|
+
}
|
|
1655
3594
|
async function verifyPassword(password, passwordHash) {
|
|
1656
3595
|
return await Bun.password.verify(password, passwordHash);
|
|
1657
3596
|
}
|
|
1658
3597
|
|
|
1659
3598
|
// ../../src/core/crypto/fieldEncryption.ts
|
|
1660
|
-
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
3599
|
+
import { createCipheriv, createDecipheriv, createHmac as createHmac2, randomBytes as randomBytes2 } from "crypto";
|
|
1661
3600
|
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
1662
3601
|
var IV_LENGTH = 12;
|
|
1663
3602
|
var TAG_LENGTH = 16;
|
|
@@ -1685,6 +3624,14 @@ function isFieldEncryptionEnabled() {
|
|
|
1685
3624
|
}
|
|
1686
3625
|
return (process.env.APP_ENV ?? "local") === "production";
|
|
1687
3626
|
}
|
|
3627
|
+
function encryptField(plaintext, key) {
|
|
3628
|
+
const iv = randomBytes2(IV_LENGTH);
|
|
3629
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
3630
|
+
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3631
|
+
const tag = cipher.getAuthTag();
|
|
3632
|
+
const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
3633
|
+
return `${ENCRYPTION_PREFIX}${payload}`;
|
|
3634
|
+
}
|
|
1688
3635
|
function decryptField(value, key) {
|
|
1689
3636
|
if (!value.startsWith(ENCRYPTION_PREFIX)) {
|
|
1690
3637
|
return value;
|
|
@@ -1697,8 +3644,47 @@ function decryptField(value, key) {
|
|
|
1697
3644
|
decipher.setAuthTag(tag);
|
|
1698
3645
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
1699
3646
|
}
|
|
3647
|
+
function hashLookupValue(normalizedValue, key) {
|
|
3648
|
+
return createHmac2("sha256", key).update(normalizedValue).digest("hex");
|
|
3649
|
+
}
|
|
3650
|
+
function normalizeEmail(email) {
|
|
3651
|
+
return email.trim().toLowerCase();
|
|
3652
|
+
}
|
|
3653
|
+
function protectEmail(email) {
|
|
3654
|
+
const normalized = normalizeEmail(email);
|
|
3655
|
+
const key = resolveEncryptionKey();
|
|
3656
|
+
if (!key || !isFieldEncryptionEnabled()) {
|
|
3657
|
+
return { storedEmail: normalized, emailLookup: normalized };
|
|
3658
|
+
}
|
|
3659
|
+
return {
|
|
3660
|
+
storedEmail: encryptField(normalized, key),
|
|
3661
|
+
emailLookup: hashLookupValue(normalized, key)
|
|
3662
|
+
};
|
|
3663
|
+
}
|
|
3664
|
+
function revealEmail(storedEmail) {
|
|
3665
|
+
const key = resolveEncryptionKey();
|
|
3666
|
+
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
3667
|
+
return storedEmail;
|
|
3668
|
+
}
|
|
3669
|
+
return decryptField(storedEmail, key);
|
|
3670
|
+
}
|
|
3671
|
+
function emailLookupForQuery(email) {
|
|
3672
|
+
const normalized = normalizeEmail(email);
|
|
3673
|
+
const key = resolveEncryptionKey();
|
|
3674
|
+
if (!key || !isFieldEncryptionEnabled()) {
|
|
3675
|
+
return normalized;
|
|
3676
|
+
}
|
|
3677
|
+
return hashLookupValue(normalized, key);
|
|
3678
|
+
}
|
|
1700
3679
|
|
|
1701
3680
|
// ../../src/core/crypto/mfaSecret.ts
|
|
3681
|
+
function protectMfaSecret(secret) {
|
|
3682
|
+
const key = resolveEncryptionKey();
|
|
3683
|
+
if (!isFieldEncryptionEnabled() || !key) {
|
|
3684
|
+
return secret;
|
|
3685
|
+
}
|
|
3686
|
+
return encryptField(secret, key);
|
|
3687
|
+
}
|
|
1702
3688
|
function revealMfaSecret(stored) {
|
|
1703
3689
|
if (!stored) {
|
|
1704
3690
|
return null;
|
|
@@ -1710,16 +3696,6 @@ function revealMfaSecret(stored) {
|
|
|
1710
3696
|
return decryptField(stored, key);
|
|
1711
3697
|
}
|
|
1712
3698
|
|
|
1713
|
-
// ../../src/core/http/requestMetaContext.ts
|
|
1714
|
-
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
1715
|
-
var requestMetaContext = new AsyncLocalStorage3;
|
|
1716
|
-
function currentRequestMeta() {
|
|
1717
|
-
return requestMetaContext.getStore() ?? {
|
|
1718
|
-
ipAddress: null,
|
|
1719
|
-
userAgent: null
|
|
1720
|
-
};
|
|
1721
|
-
}
|
|
1722
|
-
|
|
1723
3699
|
// ../../src/core/security/securityEvents.ts
|
|
1724
3700
|
function logSecurityEvent(event, details = {}) {
|
|
1725
3701
|
const meta = currentRequestMeta();
|
|
@@ -1749,7 +3725,7 @@ function resolveDefaultTokenExpiryDays() {
|
|
|
1749
3725
|
}
|
|
1750
3726
|
|
|
1751
3727
|
// ../../src/core/security/totp.ts
|
|
1752
|
-
import { createHmac as
|
|
3728
|
+
import { createHmac as createHmac3 } from "crypto";
|
|
1753
3729
|
function decodeBase32(input) {
|
|
1754
3730
|
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
1755
3731
|
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
@@ -1771,7 +3747,7 @@ function generateTotp(secret, counter, digits = 6) {
|
|
|
1771
3747
|
const key = decodeBase32(secret);
|
|
1772
3748
|
const buffer = Buffer.alloc(8);
|
|
1773
3749
|
buffer.writeBigUInt64BE(BigInt(counter));
|
|
1774
|
-
const digest =
|
|
3750
|
+
const digest = createHmac3("sha1", key).update(buffer).digest();
|
|
1775
3751
|
const lastByte = digest[digest.length - 1] ?? 0;
|
|
1776
3752
|
const offset = lastByte & 15;
|
|
1777
3753
|
const b0 = digest[offset] ?? 0;
|
|
@@ -1795,16 +3771,6 @@ function verifyTotp(secret, token, window = 1) {
|
|
|
1795
3771
|
return false;
|
|
1796
3772
|
}
|
|
1797
3773
|
|
|
1798
|
-
// ../../src/core/tenant/tenantContext.ts
|
|
1799
|
-
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
1800
|
-
var tenantContext = new AsyncLocalStorage4;
|
|
1801
|
-
function currentTenant() {
|
|
1802
|
-
return tenantContext.getStore() ?? null;
|
|
1803
|
-
}
|
|
1804
|
-
function currentTenantId() {
|
|
1805
|
-
return currentTenant()?.id ?? 1;
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
3774
|
// ../../src/domain/abilities.ts
|
|
1809
3775
|
var MEMBER_ABILITIES = [
|
|
1810
3776
|
"organizations:read",
|
|
@@ -1972,11 +3938,160 @@ var userTable = defineTable({
|
|
|
1972
3938
|
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
1973
3939
|
});
|
|
1974
3940
|
|
|
1975
|
-
// ../../src/core/
|
|
1976
|
-
|
|
3941
|
+
// ../../src/core/auth/tokenHash.ts
|
|
3942
|
+
import { createHash as createHash2, createHmac as createHmac4 } from "crypto";
|
|
3943
|
+
function resolveTokenPepper() {
|
|
3944
|
+
return process.env.TOKEN_HASH_PEPPER?.trim() ?? "workhub-dev-token-pepper";
|
|
3945
|
+
}
|
|
3946
|
+
function hashApiToken(token) {
|
|
3947
|
+
const pepper = resolveTokenPepper();
|
|
3948
|
+
if (pepper && pepper !== "workhub-dev-token-pepper") {
|
|
3949
|
+
return createHmac4("sha256", pepper).update(token).digest("hex");
|
|
3950
|
+
}
|
|
3951
|
+
return createHash2("sha256").update(token).digest("hex");
|
|
3952
|
+
}
|
|
3953
|
+
|
|
3954
|
+
// ../../src/modules/user/provider.ts
|
|
3955
|
+
var tokenServiceToken = CORE_TOKEN_SERVICE_TOKEN;
|
|
1977
3956
|
|
|
1978
3957
|
// ../../src/core/http/flashSession.ts
|
|
3958
|
+
import { createHmac as createHmac5, timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
3959
|
+
var FLASH_COOKIE = "workhub_flash";
|
|
1979
3960
|
var FLASH_TTL_MS = 60 * 1000;
|
|
3961
|
+
function resolveFlashSecret() {
|
|
3962
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
|
|
3963
|
+
}
|
|
3964
|
+
function signFlashPayload(payload, issuedAt) {
|
|
3965
|
+
const signature = createHmac5("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
|
|
3966
|
+
return `${payload}.${issuedAt}.${signature}`;
|
|
3967
|
+
}
|
|
3968
|
+
function readFlashCookie(request) {
|
|
3969
|
+
const cookieHeader = request.headers.get("cookie");
|
|
3970
|
+
if (!cookieHeader) {
|
|
3971
|
+
return null;
|
|
3972
|
+
}
|
|
3973
|
+
for (const part of cookieHeader.split(";")) {
|
|
3974
|
+
const [name, ...rest] = part.trim().split("=");
|
|
3975
|
+
if (name === FLASH_COOKIE) {
|
|
3976
|
+
return decodeURIComponent(rest.join("="));
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
return null;
|
|
3980
|
+
}
|
|
3981
|
+
function parseFlashCookie(cookieValue) {
|
|
3982
|
+
const parts = cookieValue.split(".");
|
|
3983
|
+
if (parts.length < 3) {
|
|
3984
|
+
return null;
|
|
3985
|
+
}
|
|
3986
|
+
const signature = parts.pop();
|
|
3987
|
+
const issuedAtRaw = parts.pop();
|
|
3988
|
+
const payload = parts.join(".");
|
|
3989
|
+
if (!signature || !issuedAtRaw || !payload) {
|
|
3990
|
+
return null;
|
|
3991
|
+
}
|
|
3992
|
+
const issuedAt = Number.parseInt(issuedAtRaw, 10);
|
|
3993
|
+
if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
|
|
3994
|
+
return null;
|
|
3995
|
+
}
|
|
3996
|
+
const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
|
|
3997
|
+
if (!expectedSignature) {
|
|
3998
|
+
return null;
|
|
3999
|
+
}
|
|
4000
|
+
const expectedBuffer = Buffer.from(expectedSignature);
|
|
4001
|
+
const actualBuffer = Buffer.from(signature);
|
|
4002
|
+
if (expectedBuffer.length !== actualBuffer.length) {
|
|
4003
|
+
return null;
|
|
4004
|
+
}
|
|
4005
|
+
if (!timingSafeEqual2(expectedBuffer, actualBuffer)) {
|
|
4006
|
+
return null;
|
|
4007
|
+
}
|
|
4008
|
+
try {
|
|
4009
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
4010
|
+
if (!parsed?.message || typeof parsed.message !== "string") {
|
|
4011
|
+
return null;
|
|
4012
|
+
}
|
|
4013
|
+
if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
|
|
4014
|
+
return null;
|
|
4015
|
+
}
|
|
4016
|
+
return parsed;
|
|
4017
|
+
} catch {
|
|
4018
|
+
return null;
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
function createFlashCookie(message) {
|
|
4022
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
|
|
4023
|
+
const issuedAt = Date.now();
|
|
4024
|
+
const value = signFlashPayload(payload, issuedAt);
|
|
4025
|
+
return `${FLASH_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
|
|
4026
|
+
}
|
|
4027
|
+
function clearFlashCookie() {
|
|
4028
|
+
return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
4029
|
+
}
|
|
4030
|
+
function pullFlash(request) {
|
|
4031
|
+
const cookieValue = readFlashCookie(request);
|
|
4032
|
+
if (!cookieValue) {
|
|
4033
|
+
return null;
|
|
4034
|
+
}
|
|
4035
|
+
return parseFlashCookie(cookieValue);
|
|
4036
|
+
}
|
|
4037
|
+
function flashResponse(response, message) {
|
|
4038
|
+
const headers = new Headers(response.headers);
|
|
4039
|
+
headers.append("set-cookie", createFlashCookie(message));
|
|
4040
|
+
return new Response(response.body, {
|
|
4041
|
+
status: response.status,
|
|
4042
|
+
statusText: response.statusText,
|
|
4043
|
+
headers
|
|
4044
|
+
});
|
|
4045
|
+
}
|
|
4046
|
+
function withFlashClear(response) {
|
|
4047
|
+
const headers = new Headers(response.headers);
|
|
4048
|
+
headers.append("set-cookie", clearFlashCookie());
|
|
4049
|
+
return new Response(response.body, {
|
|
4050
|
+
status: response.status,
|
|
4051
|
+
statusText: response.statusText,
|
|
4052
|
+
headers
|
|
4053
|
+
});
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
// ../../src/core/view/webLayoutData.ts
|
|
4057
|
+
async function resolveWebLayoutData(container, request) {
|
|
4058
|
+
const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
|
|
4059
|
+
const flash = request ? currentRequestMeta().flash ?? pullFlash(request) : null;
|
|
4060
|
+
const authUser = currentAuthUser();
|
|
4061
|
+
if (!authUser) {
|
|
4062
|
+
return { authUser: null, csrfToken, flash };
|
|
4063
|
+
}
|
|
4064
|
+
const userId = Number(authUser.id);
|
|
4065
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
4066
|
+
return { authUser: null, csrfToken, flash };
|
|
4067
|
+
}
|
|
4068
|
+
if (!container.has(tokenServiceToken)) {
|
|
4069
|
+
return {
|
|
4070
|
+
authUser: {
|
|
4071
|
+
id: userId,
|
|
4072
|
+
email: "",
|
|
4073
|
+
role: authUser.role ?? "member"
|
|
4074
|
+
},
|
|
4075
|
+
csrfToken,
|
|
4076
|
+
flash
|
|
4077
|
+
};
|
|
4078
|
+
}
|
|
4079
|
+
const tokenService = container.resolve(tokenServiceToken);
|
|
4080
|
+
try {
|
|
4081
|
+
const user = await tokenService.findByIdOrThrow(userId);
|
|
4082
|
+
return {
|
|
4083
|
+
authUser: {
|
|
4084
|
+
id: userId,
|
|
4085
|
+
email: user.email ?? "",
|
|
4086
|
+
role: authUser.role ?? user.role ?? "member"
|
|
4087
|
+
},
|
|
4088
|
+
csrfToken,
|
|
4089
|
+
flash
|
|
4090
|
+
};
|
|
4091
|
+
} catch {
|
|
4092
|
+
return { authUser: null, csrfToken, flash };
|
|
4093
|
+
}
|
|
4094
|
+
}
|
|
1980
4095
|
// ../../src/core/http/contentNegotiation.ts
|
|
1981
4096
|
function requestPrefersJson(request) {
|
|
1982
4097
|
if (!request) {
|
|
@@ -2036,127 +4151,123 @@ function webErrorResponse(error, request) {
|
|
|
2036
4151
|
status: mappedError.status
|
|
2037
4152
|
});
|
|
2038
4153
|
}
|
|
2039
|
-
// ../../src/core/http/
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
}
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
|
-
return normalizeEtag(left) === normalizeEtag(right);
|
|
2058
|
-
}
|
|
2059
|
-
function parseEtagList(header) {
|
|
2060
|
-
if (!header) {
|
|
2061
|
-
return [];
|
|
2062
|
-
}
|
|
2063
|
-
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
2064
|
-
}
|
|
2065
|
-
function ifNoneMatchSatisfied(request, etag) {
|
|
2066
|
-
const header = request.headers.get("if-none-match");
|
|
2067
|
-
if (!header) {
|
|
2068
|
-
return false;
|
|
2069
|
-
}
|
|
2070
|
-
if (header.trim() === "*") {
|
|
2071
|
-
return true;
|
|
2072
|
-
}
|
|
2073
|
-
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
2074
|
-
}
|
|
2075
|
-
function ifMatchSatisfied(request, etag) {
|
|
2076
|
-
const header = request.headers.get("if-match");
|
|
2077
|
-
if (!header) {
|
|
2078
|
-
return false;
|
|
2079
|
-
}
|
|
2080
|
-
if (header.trim() === "*") {
|
|
2081
|
-
return true;
|
|
2082
|
-
}
|
|
2083
|
-
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
4154
|
+
// ../../src/core/http/authMiddleware.ts
|
|
4155
|
+
function createAuthMiddleware(auth2) {
|
|
4156
|
+
return async (request, next) => {
|
|
4157
|
+
const user = await auth2.resolve(request);
|
|
4158
|
+
return await runWithAuthUser(user, async () => {
|
|
4159
|
+
const response = await next();
|
|
4160
|
+
if (user) {
|
|
4161
|
+
const headers = new Headers(response.headers);
|
|
4162
|
+
headers.set("x-authenticated-user-id", String(user.id));
|
|
4163
|
+
return new Response(response.body, {
|
|
4164
|
+
status: response.status,
|
|
4165
|
+
statusText: response.statusText,
|
|
4166
|
+
headers
|
|
4167
|
+
});
|
|
4168
|
+
}
|
|
4169
|
+
return response;
|
|
4170
|
+
});
|
|
4171
|
+
};
|
|
2084
4172
|
}
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
4173
|
+
// ../../src/core/http/authorizeMiddleware.ts
|
|
4174
|
+
function createAuthorizeMiddleware(gate, auth2, resource, action) {
|
|
4175
|
+
return async (request, next) => {
|
|
4176
|
+
const user = await auth2.resolve(request);
|
|
4177
|
+
if (!gate.allows(resource, action, user)) {
|
|
4178
|
+
const error = new ForbiddenError;
|
|
4179
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
2090
4180
|
}
|
|
2091
|
-
return;
|
|
2092
|
-
}
|
|
2093
|
-
if (!ifMatchSatisfied(request, etag)) {
|
|
2094
|
-
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
2095
|
-
}
|
|
2096
|
-
}
|
|
2097
|
-
function applyEtagHeaders(headers, etag) {
|
|
2098
|
-
const next = new Headers(headers);
|
|
2099
|
-
next.set("ETag", etag);
|
|
2100
|
-
next.set("Cache-Control", "private, must-revalidate");
|
|
2101
|
-
next.append("Vary", "Authorization");
|
|
2102
|
-
next.append("Vary", "X-Tenant-Id");
|
|
2103
|
-
return next;
|
|
4181
|
+
return await next();
|
|
4182
|
+
};
|
|
2104
4183
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
4184
|
+
// ../../src/core/http/conditionalResponse.ts
|
|
4185
|
+
function jsonResponse(data, init = {}) {
|
|
4186
|
+
return Response.json(data, {
|
|
4187
|
+
status: init.status ?? 200,
|
|
4188
|
+
headers: init.headers
|
|
2109
4189
|
});
|
|
2110
4190
|
}
|
|
2111
|
-
function
|
|
2112
|
-
if (!isEtagEnabled()) {
|
|
2113
|
-
return
|
|
4191
|
+
function conditionalJsonResponse(request, data, init = {}) {
|
|
4192
|
+
if (!request || !isEtagEnabled()) {
|
|
4193
|
+
return jsonResponse(data, init);
|
|
2114
4194
|
}
|
|
4195
|
+
const etag = computeEtagFromJson(data);
|
|
2115
4196
|
if (ifNoneMatchSatisfied(request, etag)) {
|
|
2116
4197
|
return notModifiedResponse(etag);
|
|
2117
4198
|
}
|
|
2118
|
-
const
|
|
4199
|
+
const response = jsonResponse(data, init);
|
|
4200
|
+
const headers = new Headers(response.headers);
|
|
4201
|
+
headers.set("ETag", etag);
|
|
4202
|
+
headers.set("Cache-Control", "private, must-revalidate");
|
|
4203
|
+
headers.append("Vary", "Authorization");
|
|
4204
|
+
headers.append("Vary", "X-Tenant-Id");
|
|
2119
4205
|
return new Response(response.body, {
|
|
2120
4206
|
status: response.status,
|
|
2121
4207
|
statusText: response.statusText,
|
|
2122
4208
|
headers
|
|
2123
4209
|
});
|
|
2124
4210
|
}
|
|
2125
|
-
// ../../src/core/http/
|
|
2126
|
-
function
|
|
2127
|
-
|
|
2128
|
-
return new URLSearchParams;
|
|
2129
|
-
}
|
|
2130
|
-
return new URL(request.url).searchParams;
|
|
4211
|
+
// ../../src/core/http/middleware.ts
|
|
4212
|
+
function isRouteHandler(value) {
|
|
4213
|
+
return typeof value === "function";
|
|
2131
4214
|
}
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
payload = await request.json();
|
|
2136
|
-
} catch {
|
|
2137
|
-
throw new BadRequestError("Request body must be valid JSON.");
|
|
4215
|
+
function isMethodRouteMap(value) {
|
|
4216
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
4217
|
+
return false;
|
|
2138
4218
|
}
|
|
2139
|
-
|
|
4219
|
+
const entries = Object.entries(value);
|
|
4220
|
+
return entries.length > 0 && entries.every(([, handler]) => isRouteHandler(handler));
|
|
2140
4221
|
}
|
|
2141
|
-
function
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
4222
|
+
function composeMiddleware(...middleware) {
|
|
4223
|
+
return (handler) => {
|
|
4224
|
+
return async (request) => {
|
|
4225
|
+
let index = 0;
|
|
4226
|
+
const dispatch = async () => {
|
|
4227
|
+
if (index >= middleware.length) {
|
|
4228
|
+
return await handler(request);
|
|
4229
|
+
}
|
|
4230
|
+
const current = middleware[index];
|
|
4231
|
+
index += 1;
|
|
4232
|
+
if (!current) {
|
|
4233
|
+
return await handler(request);
|
|
4234
|
+
}
|
|
4235
|
+
return await current(request, dispatch);
|
|
4236
|
+
};
|
|
4237
|
+
return await dispatch();
|
|
4238
|
+
};
|
|
4239
|
+
};
|
|
2147
4240
|
}
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
4241
|
+
async function requestIdMiddleware(request, next) {
|
|
4242
|
+
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
4243
|
+
const response = await next();
|
|
4244
|
+
const headers = new Headers(response.headers);
|
|
4245
|
+
headers.set("x-request-id", requestId);
|
|
4246
|
+
return new Response(response.body, {
|
|
4247
|
+
status: response.status,
|
|
4248
|
+
statusText: response.statusText,
|
|
4249
|
+
headers
|
|
4250
|
+
});
|
|
4251
|
+
}
|
|
4252
|
+
function wrapRouteHandler(handler, middleware) {
|
|
4253
|
+
if (isMethodRouteMap(handler)) {
|
|
4254
|
+
const wrapped = {};
|
|
4255
|
+
for (const [method, routeHandler] of Object.entries(handler)) {
|
|
4256
|
+
wrapped[method] = composeMiddleware(...middleware)(routeHandler);
|
|
2157
4257
|
}
|
|
2158
|
-
return
|
|
4258
|
+
return wrapped;
|
|
4259
|
+
}
|
|
4260
|
+
if (isRouteHandler(handler)) {
|
|
4261
|
+
return composeMiddleware(...middleware)(handler);
|
|
4262
|
+
}
|
|
4263
|
+
return handler;
|
|
4264
|
+
}
|
|
4265
|
+
function applyMiddlewareToRoutes(routes, middleware) {
|
|
4266
|
+
const wrapped = {};
|
|
4267
|
+
for (const [path, routeHandler] of Object.entries(routes)) {
|
|
4268
|
+
wrapped[path] = wrapRouteHandler(routeHandler, middleware);
|
|
2159
4269
|
}
|
|
4270
|
+
return wrapped;
|
|
2160
4271
|
}
|
|
2161
4272
|
// ../../src/core/http/pagination.ts
|
|
2162
4273
|
var DEFAULT_PER_PAGE = 15;
|
|
@@ -2189,14 +4300,74 @@ function parsePaginationQuery(request) {
|
|
|
2189
4300
|
function paginatedResponse(data, meta, init = {}) {
|
|
2190
4301
|
return Response.json({ data, meta }, init);
|
|
2191
4302
|
}
|
|
4303
|
+
// ../../src/core/http/requireAuthMiddleware.ts
|
|
4304
|
+
function createRequireAuthMiddleware(auth2) {
|
|
4305
|
+
return async (request, next) => {
|
|
4306
|
+
if (!await auth2.check(request)) {
|
|
4307
|
+
const error = new UnauthorizedError;
|
|
4308
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
4309
|
+
}
|
|
4310
|
+
return await next();
|
|
4311
|
+
};
|
|
4312
|
+
}
|
|
4313
|
+
// ../../src/core/http/resources.ts
|
|
4314
|
+
function serializeDate(value) {
|
|
4315
|
+
return value instanceof Date ? value.toISOString() : value;
|
|
4316
|
+
}
|
|
4317
|
+
function toResourceCollection(items, transformer) {
|
|
4318
|
+
return items.map(transformer);
|
|
4319
|
+
}
|
|
4320
|
+
function toPaginatedResourceCollection(items, meta, transformer) {
|
|
4321
|
+
return {
|
|
4322
|
+
data: toResourceCollection(items, transformer),
|
|
4323
|
+
meta
|
|
4324
|
+
};
|
|
4325
|
+
}
|
|
4326
|
+
// ../../src/core/http/routeMiddleware.ts
|
|
4327
|
+
function withMiddleware(...middleware) {
|
|
4328
|
+
const wrap = composeMiddleware(...middleware);
|
|
4329
|
+
return (handler) => {
|
|
4330
|
+
return wrap(handler);
|
|
4331
|
+
};
|
|
4332
|
+
}
|
|
4333
|
+
// ../../src/core/http/routeModelBinding.ts
|
|
4334
|
+
function bindRouteModel(param, resolver, handler) {
|
|
4335
|
+
return async (request) => {
|
|
4336
|
+
const id = parsePositiveIntParam(String(request.params[param]), String(param));
|
|
4337
|
+
const model = await resolver(id, request);
|
|
4338
|
+
return await handler(request, model);
|
|
4339
|
+
};
|
|
4340
|
+
}
|
|
2192
4341
|
// ../../src/core/http/securedRouteModelBinding.ts
|
|
2193
4342
|
function isMutatingPolicyAction(action) {
|
|
2194
4343
|
return action === "update" || action === "delete";
|
|
2195
4344
|
}
|
|
2196
|
-
function securedBindRouteModel(param, resolver, authorization, handler) {
|
|
4345
|
+
function securedBindRouteModel(param, resolver, authorization, handler) {
|
|
4346
|
+
return async (request) => {
|
|
4347
|
+
const id = parsePositiveIntParam(String(request.params[param]), String(param));
|
|
4348
|
+
const model = await resolver(id, request);
|
|
4349
|
+
const gate = resolveApplicationPolicyGate();
|
|
4350
|
+
const auth2 = resolveApplicationAuth();
|
|
4351
|
+
gate.authorize(authorization.resource, authorization.action, await auth2.resolve(request), model);
|
|
4352
|
+
if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
|
|
4353
|
+
assertIfMatch(request, etagFromResource(model), {
|
|
4354
|
+
required: authorization.requireIfMatch ?? true
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
const response = await handler(request, model);
|
|
4358
|
+
if (isEtagEnabled() && authorization.action === "view") {
|
|
4359
|
+
return applyConditionalGet(request, response, etagFromResource(model));
|
|
4360
|
+
}
|
|
4361
|
+
return response;
|
|
4362
|
+
};
|
|
4363
|
+
}
|
|
4364
|
+
function securedBindRouteModelByKey(param, resolver, authorization, handler) {
|
|
2197
4365
|
return async (request) => {
|
|
2198
|
-
const
|
|
2199
|
-
|
|
4366
|
+
const key = String(request.params[param] ?? "").trim();
|
|
4367
|
+
if (!key) {
|
|
4368
|
+
throw new BadRequestError(`Missing route parameter "${String(param)}".`);
|
|
4369
|
+
}
|
|
4370
|
+
const model = await resolver(key, request);
|
|
2200
4371
|
const gate = resolveApplicationPolicyGate();
|
|
2201
4372
|
const auth2 = resolveApplicationAuth();
|
|
2202
4373
|
gate.authorize(authorization.resource, authorization.action, await auth2.resolve(request), model);
|
|
@@ -2226,16 +4397,79 @@ var ALLOWED_UPLOAD_MIME_TYPES = new Set([
|
|
|
2226
4397
|
"text/plain",
|
|
2227
4398
|
"text/csv"
|
|
2228
4399
|
]);
|
|
4400
|
+
function resolveMaxUploadBytes() {
|
|
4401
|
+
const raw = process.env.MAX_UPLOAD_BYTES?.trim() ?? process.env.MAX_REQUEST_BODY_BYTES?.trim();
|
|
4402
|
+
if (!raw) {
|
|
4403
|
+
return DEFAULT_MAX_UPLOAD_BYTES;
|
|
4404
|
+
}
|
|
4405
|
+
const parsed = Number.parseInt(raw, 10);
|
|
4406
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
4407
|
+
return DEFAULT_MAX_UPLOAD_BYTES;
|
|
4408
|
+
}
|
|
4409
|
+
return parsed;
|
|
4410
|
+
}
|
|
4411
|
+
function normalizeMimeType(mimeType) {
|
|
4412
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
4413
|
+
}
|
|
4414
|
+
function isAllowedMimeType(mimeType) {
|
|
4415
|
+
const normalized = normalizeMimeType(mimeType);
|
|
4416
|
+
if (!normalized || normalized === "application/octet-stream") {
|
|
4417
|
+
return true;
|
|
4418
|
+
}
|
|
4419
|
+
return ALLOWED_UPLOAD_MIME_TYPES.has(normalized);
|
|
4420
|
+
}
|
|
4421
|
+
|
|
4422
|
+
// ../../src/core/http/parseMultipartUpload.ts
|
|
4423
|
+
function normalizeMimeType2(mimeType) {
|
|
4424
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() || "application/octet-stream";
|
|
4425
|
+
}
|
|
4426
|
+
function sanitizeUploadFileName(name) {
|
|
4427
|
+
const base = name.split(/[/\\]/).pop()?.trim() ?? "upload";
|
|
4428
|
+
const sanitized = base.replace(/[^\w.\-()+ ]+/g, "_").slice(0, 200);
|
|
4429
|
+
return sanitized.length > 0 ? sanitized : "upload";
|
|
4430
|
+
}
|
|
4431
|
+
async function parseMultipartUpload(request, fieldName = "file") {
|
|
4432
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4433
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
4434
|
+
throw new BadRequestError("Expected multipart form data.");
|
|
4435
|
+
}
|
|
4436
|
+
const formData = await request.formData();
|
|
4437
|
+
const value = formData.get(fieldName);
|
|
4438
|
+
if (!(value instanceof File)) {
|
|
4439
|
+
throw new BadRequestError(`Missing upload field "${fieldName}".`);
|
|
4440
|
+
}
|
|
4441
|
+
if (value.size <= 0) {
|
|
4442
|
+
throw new BadRequestError("Uploaded file is empty.");
|
|
4443
|
+
}
|
|
4444
|
+
const maxBytes = resolveMaxUploadBytes();
|
|
4445
|
+
if (value.size > maxBytes) {
|
|
4446
|
+
throw new PayloadTooLargeError(`Upload exceeds the ${maxBytes} byte limit.`);
|
|
4447
|
+
}
|
|
4448
|
+
const mimeType = normalizeMimeType2(value.type.trim() || "application/octet-stream");
|
|
4449
|
+
if (!isAllowedMimeType(mimeType)) {
|
|
4450
|
+
throw new BadRequestError(`File type "${mimeType}" is not allowed.`);
|
|
4451
|
+
}
|
|
4452
|
+
return {
|
|
4453
|
+
fileName: sanitizeUploadFileName(value.name),
|
|
4454
|
+
mimeType,
|
|
4455
|
+
size: value.size,
|
|
4456
|
+
contents: new Uint8Array(await value.arrayBuffer())
|
|
4457
|
+
};
|
|
4458
|
+
}
|
|
4459
|
+
// ../../src/core/http/route.ts
|
|
4460
|
+
function getRouteParams(request) {
|
|
4461
|
+
return request.params;
|
|
4462
|
+
}
|
|
2229
4463
|
|
|
2230
4464
|
// ../../src/core/http/index.ts
|
|
2231
|
-
function
|
|
4465
|
+
function jsonResponse2(data, init = {}) {
|
|
2232
4466
|
return Response.json(data, {
|
|
2233
4467
|
status: init.status ?? 200,
|
|
2234
4468
|
headers: init.headers
|
|
2235
4469
|
});
|
|
2236
4470
|
}
|
|
2237
4471
|
function createdResponse(data, init = {}) {
|
|
2238
|
-
return
|
|
4472
|
+
return jsonResponse2(data, { ...init, status: init.status ?? 201 });
|
|
2239
4473
|
}
|
|
2240
4474
|
function noContentResponse() {
|
|
2241
4475
|
return new Response(null, { status: 204 });
|
|
@@ -2261,6 +4495,73 @@ function withErrorHandling(handler) {
|
|
|
2261
4495
|
}
|
|
2262
4496
|
};
|
|
2263
4497
|
}
|
|
4498
|
+
// ../../src/core/http/loginThrottleMiddleware.ts
|
|
4499
|
+
var {RedisClient } = globalThis.Bun;
|
|
4500
|
+
function resolveLoginIdentity(request) {
|
|
4501
|
+
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
4502
|
+
}
|
|
4503
|
+
async function resolveLoginEmail(request) {
|
|
4504
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4505
|
+
try {
|
|
4506
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
4507
|
+
const formData = await request.clone().formData();
|
|
4508
|
+
const email = formData.get("email");
|
|
4509
|
+
return typeof email === "string" ? email.trim().toLowerCase() : "unknown";
|
|
4510
|
+
}
|
|
4511
|
+
const payload = await request.clone().json();
|
|
4512
|
+
return typeof payload.email === "string" ? payload.email.trim().toLowerCase() : "unknown";
|
|
4513
|
+
} catch {
|
|
4514
|
+
return "unknown";
|
|
4515
|
+
}
|
|
4516
|
+
}
|
|
4517
|
+
function createLoginThrottleMiddleware(options) {
|
|
4518
|
+
const client = new RedisClient(options.redisUrl);
|
|
4519
|
+
const prefix = options.keyPrefix ?? "workhub:login-throttle:";
|
|
4520
|
+
return async (request, next) => {
|
|
4521
|
+
const identity = resolveLoginIdentity(request);
|
|
4522
|
+
const email = await resolveLoginEmail(request);
|
|
4523
|
+
const throttleKey = `${prefix}${identity}:${email}`;
|
|
4524
|
+
const attempts = Number(await client.incr(throttleKey));
|
|
4525
|
+
if (attempts === 1) {
|
|
4526
|
+
await client.expire(throttleKey, options.decaySeconds);
|
|
4527
|
+
}
|
|
4528
|
+
if (attempts > options.maxAttempts) {
|
|
4529
|
+
return Response.json({ error: "Too many login attempts. Try again later." }, {
|
|
4530
|
+
status: 429,
|
|
4531
|
+
headers: {
|
|
4532
|
+
"retry-after": String(options.decaySeconds)
|
|
4533
|
+
}
|
|
4534
|
+
});
|
|
4535
|
+
}
|
|
4536
|
+
return await next();
|
|
4537
|
+
};
|
|
4538
|
+
}
|
|
4539
|
+
// ../../src/core/http/memoryThrottleMiddleware.ts
|
|
4540
|
+
var buckets = new Map;
|
|
4541
|
+
function createMemoryThrottleMiddleware(options) {
|
|
4542
|
+
const prefix = options.keyPrefix ?? "workhub:memory-throttle:";
|
|
4543
|
+
return async (request, next) => {
|
|
4544
|
+
const path = new URL(request.url).pathname;
|
|
4545
|
+
const identity = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("authorization")?.slice(0, 32) ?? "unknown";
|
|
4546
|
+
const key = `${prefix}${identity}:${path}`;
|
|
4547
|
+
const now = Date.now();
|
|
4548
|
+
const existing = buckets.get(key);
|
|
4549
|
+
if (!existing || existing.resetAt <= now) {
|
|
4550
|
+
buckets.set(key, { count: 1, resetAt: now + options.decaySeconds * 1000 });
|
|
4551
|
+
return await next();
|
|
4552
|
+
}
|
|
4553
|
+
existing.count += 1;
|
|
4554
|
+
if (existing.count > options.maxAttempts) {
|
|
4555
|
+
return Response.json({ error: "Too many requests." }, {
|
|
4556
|
+
status: 429,
|
|
4557
|
+
headers: {
|
|
4558
|
+
"retry-after": String(options.decaySeconds)
|
|
4559
|
+
}
|
|
4560
|
+
});
|
|
4561
|
+
}
|
|
4562
|
+
return await next();
|
|
4563
|
+
};
|
|
4564
|
+
}
|
|
2264
4565
|
// ../../src/core/metrics/prometheus.ts
|
|
2265
4566
|
class PrometheusRegistry {
|
|
2266
4567
|
httpRequestsTotal = new Map;
|
|
@@ -2347,6 +4648,128 @@ function createMetricsMiddleware() {
|
|
|
2347
4648
|
return response;
|
|
2348
4649
|
};
|
|
2349
4650
|
}
|
|
4651
|
+
// ../../src/core/http/requireWebAuthMiddleware.ts
|
|
4652
|
+
function createRequireWebAuthMiddleware(auth2) {
|
|
4653
|
+
return async (request, next) => {
|
|
4654
|
+
const user = await auth2.resolve(request);
|
|
4655
|
+
if (user) {
|
|
4656
|
+
return await next();
|
|
4657
|
+
}
|
|
4658
|
+
if (requestPrefersJson(request)) {
|
|
4659
|
+
throw new UnauthorizedError;
|
|
4660
|
+
}
|
|
4661
|
+
const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
|
|
4662
|
+
return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
|
|
4663
|
+
};
|
|
4664
|
+
}
|
|
4665
|
+
// ../../src/config/app.ts
|
|
4666
|
+
var appConfig = {
|
|
4667
|
+
name: "WorkHub",
|
|
4668
|
+
env: process.env.APP_ENV ?? "local",
|
|
4669
|
+
debug: (process.env.APP_DEBUG ?? "true") !== "false",
|
|
4670
|
+
url: process.env.APP_URL ?? "http://localhost:3000",
|
|
4671
|
+
apiPrefix: process.env.API_PREFIX ?? "/api/v1"
|
|
4672
|
+
};
|
|
4673
|
+
|
|
4674
|
+
// ../../src/config/contentSecurityPolicy.ts
|
|
4675
|
+
function strictApiContentSecurityPolicy() {
|
|
4676
|
+
return "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
|
|
4677
|
+
}
|
|
4678
|
+
function serverHtmxContentSecurityPolicy() {
|
|
4679
|
+
return [
|
|
4680
|
+
"default-src 'self'",
|
|
4681
|
+
"script-src 'self' https://unpkg.com",
|
|
4682
|
+
"style-src 'self'",
|
|
4683
|
+
"connect-src 'self'",
|
|
4684
|
+
"img-src 'self'",
|
|
4685
|
+
"font-src 'self'",
|
|
4686
|
+
"form-action 'self'",
|
|
4687
|
+
"frame-ancestors 'none'",
|
|
4688
|
+
"base-uri 'self'"
|
|
4689
|
+
].join("; ");
|
|
4690
|
+
}
|
|
4691
|
+
function spaContentSecurityPolicy() {
|
|
4692
|
+
return [
|
|
4693
|
+
"default-src 'self'",
|
|
4694
|
+
"script-src 'self'",
|
|
4695
|
+
"style-src 'self'",
|
|
4696
|
+
"connect-src 'self'",
|
|
4697
|
+
"img-src 'self'",
|
|
4698
|
+
"font-src 'self'",
|
|
4699
|
+
"frame-ancestors 'none'",
|
|
4700
|
+
"base-uri 'self'"
|
|
4701
|
+
].join("; ");
|
|
4702
|
+
}
|
|
4703
|
+
function resolveContentSecurityPolicy(response) {
|
|
4704
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4705
|
+
if (!contentType.includes("text/html")) {
|
|
4706
|
+
return strictApiContentSecurityPolicy();
|
|
4707
|
+
}
|
|
4708
|
+
const frontendMode = (process.env.FRONTEND_MODE ?? "api").trim();
|
|
4709
|
+
if (frontendMode === "server-htmx") {
|
|
4710
|
+
return serverHtmxContentSecurityPolicy();
|
|
4711
|
+
}
|
|
4712
|
+
if (frontendMode === "spa-react") {
|
|
4713
|
+
return spaContentSecurityPolicy();
|
|
4714
|
+
}
|
|
4715
|
+
return strictApiContentSecurityPolicy();
|
|
4716
|
+
}
|
|
4717
|
+
|
|
4718
|
+
// ../../src/core/http/securityHeadersMiddleware.ts
|
|
4719
|
+
function createSecurityHeadersMiddleware() {
|
|
4720
|
+
return async (_request, next) => {
|
|
4721
|
+
const response = await next();
|
|
4722
|
+
const headers = new Headers(response.headers);
|
|
4723
|
+
headers.set("X-Content-Type-Options", "nosniff");
|
|
4724
|
+
headers.set("X-Frame-Options", "DENY");
|
|
4725
|
+
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
|
4726
|
+
headers.set("X-XSS-Protection", "0");
|
|
4727
|
+
headers.set("Content-Security-Policy", resolveContentSecurityPolicy(response));
|
|
4728
|
+
if (appConfig.env === "production") {
|
|
4729
|
+
headers.set("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
|
|
4730
|
+
}
|
|
4731
|
+
return new Response(response.body, {
|
|
4732
|
+
status: response.status,
|
|
4733
|
+
statusText: response.statusText,
|
|
4734
|
+
headers
|
|
4735
|
+
});
|
|
4736
|
+
};
|
|
4737
|
+
}
|
|
4738
|
+
// ../../src/core/http/throttleMiddleware.ts
|
|
4739
|
+
var {RedisClient: RedisClient2 } = globalThis.Bun;
|
|
4740
|
+
function resolveThrottleIdentity(request) {
|
|
4741
|
+
const user = currentAuthUser();
|
|
4742
|
+
if (user?.tokenId !== undefined) {
|
|
4743
|
+
return `token:${user.tokenId}`;
|
|
4744
|
+
}
|
|
4745
|
+
if (user) {
|
|
4746
|
+
return `user:${user.id}`;
|
|
4747
|
+
}
|
|
4748
|
+
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
4749
|
+
}
|
|
4750
|
+
function createThrottleMiddleware(options) {
|
|
4751
|
+
const client = new RedisClient2(options.redisUrl);
|
|
4752
|
+
const prefix = options.keyPrefix ?? "workhub:throttle:";
|
|
4753
|
+
return async (request, next) => {
|
|
4754
|
+
const identity = resolveThrottleIdentity(request);
|
|
4755
|
+
const path = new URL(request.url).pathname;
|
|
4756
|
+
const throttleKey = `${prefix}${identity}:${path}`;
|
|
4757
|
+
const attempts = Number(await client.incr(throttleKey));
|
|
4758
|
+
if (attempts === 1) {
|
|
4759
|
+
await client.expire(throttleKey, options.decaySeconds);
|
|
4760
|
+
}
|
|
4761
|
+
const maxAttempts = options.maxAttempts * rateLimitMultiplierForPlan(currentTenant()?.plan ?? "free");
|
|
4762
|
+
if (attempts > maxAttempts) {
|
|
4763
|
+
return Response.json({ error: "Too many requests." }, {
|
|
4764
|
+
status: 429,
|
|
4765
|
+
headers: {
|
|
4766
|
+
"retry-after": String(options.decaySeconds)
|
|
4767
|
+
}
|
|
4768
|
+
});
|
|
4769
|
+
}
|
|
4770
|
+
return await next();
|
|
4771
|
+
};
|
|
4772
|
+
}
|
|
2350
4773
|
// ../../src/core/http/parseFormBody.ts
|
|
2351
4774
|
async function parseFormBody(request) {
|
|
2352
4775
|
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
@@ -2388,6 +4811,49 @@ class WebFormRequest {
|
|
|
2388
4811
|
}
|
|
2389
4812
|
}
|
|
2390
4813
|
}
|
|
4814
|
+
// ../../src/core/lifecycle/gracefulShutdown.ts
|
|
4815
|
+
var shutdownHandlers = new Map;
|
|
4816
|
+
var shutdownInstalled = false;
|
|
4817
|
+
var shuttingDown = false;
|
|
4818
|
+
function registerShutdownHandler(name, handler) {
|
|
4819
|
+
shutdownHandlers.set(name, handler);
|
|
4820
|
+
return () => {
|
|
4821
|
+
shutdownHandlers.delete(name);
|
|
4822
|
+
};
|
|
4823
|
+
}
|
|
4824
|
+
async function runGracefulShutdown(signal) {
|
|
4825
|
+
if (shuttingDown) {
|
|
4826
|
+
return;
|
|
4827
|
+
}
|
|
4828
|
+
shuttingDown = true;
|
|
4829
|
+
console.log(`[shutdown] Received ${signal}, draining ${shutdownHandlers.size} handler(s)...`);
|
|
4830
|
+
for (const [name, handler] of shutdownHandlers) {
|
|
4831
|
+
try {
|
|
4832
|
+
await handler();
|
|
4833
|
+
console.log(`[shutdown] Completed ${name}`);
|
|
4834
|
+
} catch (error) {
|
|
4835
|
+
console.error(`[shutdown] Failed ${name}:`, error);
|
|
4836
|
+
}
|
|
4837
|
+
}
|
|
4838
|
+
}
|
|
4839
|
+
function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
|
|
4840
|
+
if (shutdownInstalled) {
|
|
4841
|
+
return;
|
|
4842
|
+
}
|
|
4843
|
+
shutdownInstalled = true;
|
|
4844
|
+
for (const signal of signals) {
|
|
4845
|
+
process.on(signal, () => {
|
|
4846
|
+
runGracefulShutdown(signal).finally(() => {
|
|
4847
|
+
process.exit(0);
|
|
4848
|
+
});
|
|
4849
|
+
});
|
|
4850
|
+
}
|
|
4851
|
+
}
|
|
4852
|
+
function resetGracefulShutdownForTests() {
|
|
4853
|
+
shutdownHandlers.clear();
|
|
4854
|
+
shutdownInstalled = false;
|
|
4855
|
+
shuttingDown = false;
|
|
4856
|
+
}
|
|
2391
4857
|
// ../../src/core/queue/index.ts
|
|
2392
4858
|
class Job {
|
|
2393
4859
|
maxAttempts;
|
|
@@ -2413,6 +4879,333 @@ class AsyncQueue {
|
|
|
2413
4879
|
function createQueue(driver) {
|
|
2414
4880
|
return driver === "async" ? new AsyncQueue : new SyncQueue;
|
|
2415
4881
|
}
|
|
4882
|
+
// ../../src/core/queue/failedJobTable.ts
|
|
4883
|
+
var failedJobTable = defineTable({
|
|
4884
|
+
name: "failed_job",
|
|
4885
|
+
primaryKey: "id",
|
|
4886
|
+
columns: ["id", "job_name", "payload", "exception", "failed_at"],
|
|
4887
|
+
defaultOrderBy: { column: "failed_at", direction: "DESC" }
|
|
4888
|
+
});
|
|
4889
|
+
|
|
4890
|
+
// ../../src/core/queue/failedJobRepository.ts
|
|
4891
|
+
class FailedJobRepository extends baseRepository_default {
|
|
4892
|
+
constructor() {
|
|
4893
|
+
super(failedJobTable);
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
var failedJobRepository_default = FailedJobRepository;
|
|
4897
|
+
|
|
4898
|
+
// ../../src/core/queue/failedJobService.ts
|
|
4899
|
+
class FailedJobService {
|
|
4900
|
+
repository;
|
|
4901
|
+
constructor(repository) {
|
|
4902
|
+
this.repository = repository;
|
|
4903
|
+
}
|
|
4904
|
+
async recordFailure(input) {
|
|
4905
|
+
return await this.repository.create({
|
|
4906
|
+
job_name: input.jobName,
|
|
4907
|
+
payload: input.payload,
|
|
4908
|
+
exception: input.exception,
|
|
4909
|
+
failed_at: new Date
|
|
4910
|
+
});
|
|
4911
|
+
}
|
|
4912
|
+
listRecent(limit = 50) {
|
|
4913
|
+
return this.repository.findAll({
|
|
4914
|
+
limit,
|
|
4915
|
+
orderBy: { column: "failed_at", direction: "DESC" }
|
|
4916
|
+
});
|
|
4917
|
+
}
|
|
4918
|
+
async retry(id) {
|
|
4919
|
+
const failedJob = await this.repository.findByIdOrThrow(id, (jobId) => new Error(`Failed job ${jobId} not found.`));
|
|
4920
|
+
await this.repository.deleteById(id);
|
|
4921
|
+
return failedJob;
|
|
4922
|
+
}
|
|
4923
|
+
async flush() {
|
|
4924
|
+
const jobs = await this.repository.findAll();
|
|
4925
|
+
let deleted = 0;
|
|
4926
|
+
for (const job of jobs) {
|
|
4927
|
+
if (await this.repository.deleteById(job.id)) {
|
|
4928
|
+
deleted += 1;
|
|
4929
|
+
}
|
|
4930
|
+
}
|
|
4931
|
+
return deleted;
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
var failedJobService_default = FailedJobService;
|
|
4935
|
+
|
|
4936
|
+
// ../../src/core/queue/jobRegistry.ts
|
|
4937
|
+
class JobRegistry {
|
|
4938
|
+
constructor() {}
|
|
4939
|
+
factories = new Map;
|
|
4940
|
+
instances = new WeakMap;
|
|
4941
|
+
register(name, factory) {
|
|
4942
|
+
this.factories.set(name, factory);
|
|
4943
|
+
}
|
|
4944
|
+
resolveName(job) {
|
|
4945
|
+
return this.instances.get(job);
|
|
4946
|
+
}
|
|
4947
|
+
track(name, job) {
|
|
4948
|
+
this.instances.set(job, name);
|
|
4949
|
+
return job;
|
|
4950
|
+
}
|
|
4951
|
+
create(name) {
|
|
4952
|
+
const factory = this.factories.get(name);
|
|
4953
|
+
if (!factory) {
|
|
4954
|
+
return;
|
|
4955
|
+
}
|
|
4956
|
+
return factory();
|
|
4957
|
+
}
|
|
4958
|
+
names() {
|
|
4959
|
+
return [...this.factories.keys()];
|
|
4960
|
+
}
|
|
4961
|
+
}
|
|
4962
|
+
var jobRegistry = new JobRegistry;
|
|
4963
|
+
|
|
4964
|
+
// ../../src/core/queue/redisQueue.ts
|
|
4965
|
+
var {RedisClient: RedisClient3 } = globalThis.Bun;
|
|
4966
|
+
|
|
4967
|
+
// ../../src/config/queue.ts
|
|
4968
|
+
var queueConfig = {
|
|
4969
|
+
driver: process.env.QUEUE_DRIVER === "redis" || process.env.QUEUE_DRIVER === "async" || process.env.QUEUE_DRIVER === "sync" ? process.env.QUEUE_DRIVER : DEFAULT_QUEUE_DRIVER,
|
|
4970
|
+
maxAttempts: Number(process.env.QUEUE_MAX_ATTEMPTS ?? "3"),
|
|
4971
|
+
backoffMs: Number(process.env.QUEUE_BACKOFF_MS ?? "1000")
|
|
4972
|
+
};
|
|
4973
|
+
|
|
4974
|
+
// ../../src/core/queue/jobRunner.ts
|
|
4975
|
+
async function runQueueJob(envelope, failedJobs) {
|
|
4976
|
+
const job = jobRegistry.create(envelope.name);
|
|
4977
|
+
if (!job) {
|
|
4978
|
+
throw new Error(`Unknown job "${envelope.name}".`);
|
|
4979
|
+
}
|
|
4980
|
+
const attempts = envelope.attempts ?? 0;
|
|
4981
|
+
try {
|
|
4982
|
+
await job.handle(envelope.payload);
|
|
4983
|
+
} catch (error) {
|
|
4984
|
+
const nextAttempt = attempts + 1;
|
|
4985
|
+
const maxAttempts = job.maxAttempts ?? queueConfig.maxAttempts;
|
|
4986
|
+
if (nextAttempt < maxAttempts) {
|
|
4987
|
+
const backoffMs = job.backoffMs ?? queueConfig.backoffMs;
|
|
4988
|
+
await new Promise((resolve) => setTimeout(resolve, backoffMs * nextAttempt));
|
|
4989
|
+
await runQueueJob({
|
|
4990
|
+
...envelope,
|
|
4991
|
+
attempts: nextAttempt
|
|
4992
|
+
}, failedJobs);
|
|
4993
|
+
return;
|
|
4994
|
+
}
|
|
4995
|
+
await failedJobs.recordFailure({
|
|
4996
|
+
jobName: envelope.name,
|
|
4997
|
+
payload: envelope.payload,
|
|
4998
|
+
exception: error instanceof Error ? error.stack ?? error.message : String(error)
|
|
4999
|
+
});
|
|
5000
|
+
throw error;
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
// ../../src/core/queue/redisQueue.ts
|
|
5005
|
+
var QUEUE_LIST_KEY = "workhub:queue:default";
|
|
5006
|
+
var QUEUE_HIGH_KEY = "workhub:queue:high";
|
|
5007
|
+
var QUEUE_LOW_KEY = "workhub:queue:low";
|
|
5008
|
+
var QUEUE_KEYS = [QUEUE_HIGH_KEY, QUEUE_LIST_KEY, QUEUE_LOW_KEY];
|
|
5009
|
+
function queueKeyForPriority(priority = "default") {
|
|
5010
|
+
switch (priority) {
|
|
5011
|
+
case "high":
|
|
5012
|
+
return QUEUE_HIGH_KEY;
|
|
5013
|
+
case "low":
|
|
5014
|
+
return QUEUE_LOW_KEY;
|
|
5015
|
+
default:
|
|
5016
|
+
return QUEUE_LIST_KEY;
|
|
5017
|
+
}
|
|
5018
|
+
}
|
|
5019
|
+
function parseQueueJobEnvelope(rawPayload) {
|
|
5020
|
+
let parsed;
|
|
5021
|
+
try {
|
|
5022
|
+
parsed = JSON.parse(rawPayload);
|
|
5023
|
+
} catch {
|
|
5024
|
+
console.error("[QueueWorker] Ignoring malformed queue payload");
|
|
5025
|
+
return null;
|
|
5026
|
+
}
|
|
5027
|
+
if (!parsed || typeof parsed !== "object") {
|
|
5028
|
+
console.error("[QueueWorker] Ignoring non-object queue payload");
|
|
5029
|
+
return null;
|
|
5030
|
+
}
|
|
5031
|
+
const envelope = parsed;
|
|
5032
|
+
if (typeof envelope.name !== "string" || envelope.name.length === 0) {
|
|
5033
|
+
console.error("[QueueWorker] Ignoring queue payload without job name");
|
|
5034
|
+
return null;
|
|
5035
|
+
}
|
|
5036
|
+
if (!jobRegistry.create(envelope.name)) {
|
|
5037
|
+
console.error(`[QueueWorker] Ignoring unknown job name: ${envelope.name}`);
|
|
5038
|
+
return null;
|
|
5039
|
+
}
|
|
5040
|
+
if (envelope.payload !== undefined && (typeof envelope.payload !== "object" || envelope.payload === null)) {
|
|
5041
|
+
console.error("[QueueWorker] Ignoring queue payload with invalid payload object");
|
|
5042
|
+
return null;
|
|
5043
|
+
}
|
|
5044
|
+
return {
|
|
5045
|
+
name: envelope.name,
|
|
5046
|
+
payload: envelope.payload ?? {},
|
|
5047
|
+
attempts: typeof envelope.attempts === "number" ? envelope.attempts : 0
|
|
5048
|
+
};
|
|
5049
|
+
}
|
|
5050
|
+
|
|
5051
|
+
class RedisQueue {
|
|
5052
|
+
client;
|
|
5053
|
+
constructor(redisUrl) {
|
|
5054
|
+
this.client = new RedisClient3(redisUrl);
|
|
5055
|
+
}
|
|
5056
|
+
async dispatch(job, payload) {
|
|
5057
|
+
const name = jobRegistry.resolveName(job);
|
|
5058
|
+
if (!name) {
|
|
5059
|
+
throw new Error("Job is not registered with the queue worker registry.");
|
|
5060
|
+
}
|
|
5061
|
+
const envelope = {
|
|
5062
|
+
name,
|
|
5063
|
+
payload,
|
|
5064
|
+
attempts: 0
|
|
5065
|
+
};
|
|
5066
|
+
const queueKey = queueKeyForPriority(job.priority);
|
|
5067
|
+
await this.client.lpush(queueKey, JSON.stringify(envelope));
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
|
|
5071
|
+
class QueueWorker {
|
|
5072
|
+
failedJobs;
|
|
5073
|
+
timeoutSeconds;
|
|
5074
|
+
running = false;
|
|
5075
|
+
stopping = false;
|
|
5076
|
+
client;
|
|
5077
|
+
constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
|
|
5078
|
+
this.failedJobs = failedJobs;
|
|
5079
|
+
this.timeoutSeconds = timeoutSeconds;
|
|
5080
|
+
this.client = new RedisClient3(redisUrl);
|
|
5081
|
+
}
|
|
5082
|
+
requestStop() {
|
|
5083
|
+
this.stopping = true;
|
|
5084
|
+
}
|
|
5085
|
+
isRunning() {
|
|
5086
|
+
return this.running;
|
|
5087
|
+
}
|
|
5088
|
+
async processNext() {
|
|
5089
|
+
let result = null;
|
|
5090
|
+
for (const queueKey of QUEUE_KEYS) {
|
|
5091
|
+
result = await this.client.brpop(queueKey, 1);
|
|
5092
|
+
if (result) {
|
|
5093
|
+
break;
|
|
5094
|
+
}
|
|
5095
|
+
}
|
|
5096
|
+
if (!result) {
|
|
5097
|
+
result = await this.client.brpop(QUEUE_LIST_KEY, this.timeoutSeconds);
|
|
5098
|
+
}
|
|
5099
|
+
if (!result) {
|
|
5100
|
+
return false;
|
|
5101
|
+
}
|
|
5102
|
+
const [, rawPayload] = result;
|
|
5103
|
+
const envelope = parseQueueJobEnvelope(rawPayload);
|
|
5104
|
+
if (!envelope) {
|
|
5105
|
+
return true;
|
|
5106
|
+
}
|
|
5107
|
+
try {
|
|
5108
|
+
await runQueueJob(envelope, this.failedJobs);
|
|
5109
|
+
} catch (error) {
|
|
5110
|
+
console.error("[QueueWorker] Job failed:", error);
|
|
5111
|
+
}
|
|
5112
|
+
return true;
|
|
5113
|
+
}
|
|
5114
|
+
async run() {
|
|
5115
|
+
this.running = true;
|
|
5116
|
+
while (!this.stopping) {
|
|
5117
|
+
await this.processNext();
|
|
5118
|
+
}
|
|
5119
|
+
this.running = false;
|
|
5120
|
+
}
|
|
5121
|
+
close() {
|
|
5122
|
+
this.client.close();
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
|
|
5126
|
+
// ../../src/core/queue/resilientQueue.ts
|
|
5127
|
+
class ResilientQueue {
|
|
5128
|
+
failedJobs;
|
|
5129
|
+
asyncDispatch;
|
|
5130
|
+
constructor(failedJobs, asyncDispatch = false) {
|
|
5131
|
+
this.failedJobs = failedJobs;
|
|
5132
|
+
this.asyncDispatch = asyncDispatch;
|
|
5133
|
+
}
|
|
5134
|
+
async dispatch(job, payload) {
|
|
5135
|
+
const name = jobRegistry.resolveName(job);
|
|
5136
|
+
if (!name) {
|
|
5137
|
+
throw new Error("Job is not registered with the queue worker registry.");
|
|
5138
|
+
}
|
|
5139
|
+
const envelope = {
|
|
5140
|
+
name,
|
|
5141
|
+
payload,
|
|
5142
|
+
attempts: 0
|
|
5143
|
+
};
|
|
5144
|
+
if (this.asyncDispatch) {
|
|
5145
|
+
setTimeout(() => {
|
|
5146
|
+
runQueueJob(envelope, this.failedJobs).catch((error) => {
|
|
5147
|
+
console.error("[ResilientQueue] Job failed:", error);
|
|
5148
|
+
});
|
|
5149
|
+
}, 0);
|
|
5150
|
+
return;
|
|
5151
|
+
}
|
|
5152
|
+
await runQueueJob(envelope, this.failedJobs);
|
|
5153
|
+
}
|
|
5154
|
+
}
|
|
5155
|
+
|
|
5156
|
+
// ../../src/core/queue/publicQueue.ts
|
|
5157
|
+
function createFailedJobService() {
|
|
5158
|
+
return new failedJobService_default(new failedJobRepository_default);
|
|
5159
|
+
}
|
|
5160
|
+
function createTrackedJob(name, job) {
|
|
5161
|
+
return jobRegistry.track(name, job);
|
|
5162
|
+
}
|
|
5163
|
+
function createProductionQueue(driver, options = {}) {
|
|
5164
|
+
options.registerJobs?.();
|
|
5165
|
+
const failedJobs = options.failedJobs ?? createFailedJobService();
|
|
5166
|
+
if (driver === "redis") {
|
|
5167
|
+
if (!options.redisUrl) {
|
|
5168
|
+
throw new Error('QUEUE_DRIVER="redis" requires REDIS_URL to be set.');
|
|
5169
|
+
}
|
|
5170
|
+
return new RedisQueue(options.redisUrl);
|
|
5171
|
+
}
|
|
5172
|
+
return new ResilientQueue(failedJobs, driver === "async");
|
|
5173
|
+
}
|
|
5174
|
+
function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
|
|
5175
|
+
return new QueueWorker(redisUrl, failedJobs);
|
|
5176
|
+
}
|
|
5177
|
+
// ../../src/core/scheduler/schedule.ts
|
|
5178
|
+
class Schedule {
|
|
5179
|
+
tasks = [];
|
|
5180
|
+
command(expression, name, run) {
|
|
5181
|
+
this.tasks.push({ expression, name, run });
|
|
5182
|
+
return this;
|
|
5183
|
+
}
|
|
5184
|
+
dueTasks(now = new Date) {
|
|
5185
|
+
const minute = now.getMinutes();
|
|
5186
|
+
return this.tasks.filter((task) => {
|
|
5187
|
+
if (task.expression === "* * * * *") {
|
|
5188
|
+
return true;
|
|
5189
|
+
}
|
|
5190
|
+
if (task.expression.startsWith("*/")) {
|
|
5191
|
+
const interval = Number.parseInt(task.expression.slice(2), 10);
|
|
5192
|
+
return Number.isInteger(interval) && interval > 0 && minute % interval === 0;
|
|
5193
|
+
}
|
|
5194
|
+
return false;
|
|
5195
|
+
});
|
|
5196
|
+
}
|
|
5197
|
+
tasksList() {
|
|
5198
|
+
return [...this.tasks];
|
|
5199
|
+
}
|
|
5200
|
+
}
|
|
5201
|
+
var appSchedule = new Schedule;
|
|
5202
|
+
async function runDueScheduledTasks(schedule = appSchedule, now = new Date) {
|
|
5203
|
+
const due = schedule.dueTasks(now);
|
|
5204
|
+
for (const task of due) {
|
|
5205
|
+
await task.run();
|
|
5206
|
+
}
|
|
5207
|
+
return due.length;
|
|
5208
|
+
}
|
|
2416
5209
|
// ../../src/core/validation/rules.ts
|
|
2417
5210
|
function required() {
|
|
2418
5211
|
return (field, value) => {
|
|
@@ -2455,6 +5248,45 @@ function maxLength(maximum) {
|
|
|
2455
5248
|
return;
|
|
2456
5249
|
};
|
|
2457
5250
|
}
|
|
5251
|
+
function pattern(expression) {
|
|
5252
|
+
return (field, value) => {
|
|
5253
|
+
if (typeof value !== "string") {
|
|
5254
|
+
return;
|
|
5255
|
+
}
|
|
5256
|
+
if (!expression.test(value.trim())) {
|
|
5257
|
+
return `"${field}" has an invalid format.`;
|
|
5258
|
+
}
|
|
5259
|
+
return;
|
|
5260
|
+
};
|
|
5261
|
+
}
|
|
5262
|
+
function enumRule(allowedValues) {
|
|
5263
|
+
return (field, value) => {
|
|
5264
|
+
if (typeof value !== "string") {
|
|
5265
|
+
return;
|
|
5266
|
+
}
|
|
5267
|
+
if (!allowedValues.includes(value)) {
|
|
5268
|
+
return `"${field}" must be one of: ${allowedValues.join(", ")}.`;
|
|
5269
|
+
}
|
|
5270
|
+
return;
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
function optional() {
|
|
5274
|
+
return () => {
|
|
5275
|
+
return;
|
|
5276
|
+
};
|
|
5277
|
+
}
|
|
5278
|
+
function integerRule() {
|
|
5279
|
+
return (field, value) => {
|
|
5280
|
+
if (value === undefined || value === null || value === "") {
|
|
5281
|
+
return;
|
|
5282
|
+
}
|
|
5283
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5284
|
+
if (!Number.isInteger(parsed)) {
|
|
5285
|
+
return `"${field}" must be an integer.`;
|
|
5286
|
+
}
|
|
5287
|
+
return;
|
|
5288
|
+
};
|
|
5289
|
+
}
|
|
2458
5290
|
function emailRule() {
|
|
2459
5291
|
return (field, value) => {
|
|
2460
5292
|
if (typeof value !== "string") {
|
|
@@ -2467,6 +5299,40 @@ function emailRule() {
|
|
|
2467
5299
|
return;
|
|
2468
5300
|
};
|
|
2469
5301
|
}
|
|
5302
|
+
function confirmed(fieldName) {
|
|
5303
|
+
return (field, value, payload) => {
|
|
5304
|
+
const confirmationKey = `${fieldName}_confirmation`;
|
|
5305
|
+
const confirmation = payload[confirmationKey];
|
|
5306
|
+
if (value !== confirmation) {
|
|
5307
|
+
return `"${field}" confirmation does not match.`;
|
|
5308
|
+
}
|
|
5309
|
+
return;
|
|
5310
|
+
};
|
|
5311
|
+
}
|
|
5312
|
+
function positiveIntegerRule() {
|
|
5313
|
+
return (field, value) => {
|
|
5314
|
+
if (value === undefined || value === null || value === "") {
|
|
5315
|
+
return;
|
|
5316
|
+
}
|
|
5317
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5318
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
5319
|
+
return `"${field}" must be a positive integer.`;
|
|
5320
|
+
}
|
|
5321
|
+
return;
|
|
5322
|
+
};
|
|
5323
|
+
}
|
|
5324
|
+
function integerRange(minimum, maximum) {
|
|
5325
|
+
return (field, value) => {
|
|
5326
|
+
if (value === undefined || value === null || value === "") {
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5330
|
+
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
5331
|
+
return `"${field}" must be an integer between ${minimum} and ${maximum}.`;
|
|
5332
|
+
}
|
|
5333
|
+
return;
|
|
5334
|
+
};
|
|
5335
|
+
}
|
|
2470
5336
|
function validateObject(payload, schema) {
|
|
2471
5337
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
2472
5338
|
throw new ValidationError("Request body must be a JSON object.");
|
|
@@ -2491,15 +5357,29 @@ function validateObject(payload, schema) {
|
|
|
2491
5357
|
return output;
|
|
2492
5358
|
}
|
|
2493
5359
|
export {
|
|
5360
|
+
withMigrationLock,
|
|
5361
|
+
withMiddleware,
|
|
2494
5362
|
withErrorHandling,
|
|
2495
5363
|
validateObject,
|
|
5364
|
+
toResourceCollection,
|
|
5365
|
+
toPaginatedResourceCollection,
|
|
2496
5366
|
stringRule,
|
|
2497
5367
|
storageFacade as storage,
|
|
5368
|
+
serializeDate,
|
|
5369
|
+
securedBindRouteModelByKey,
|
|
2498
5370
|
securedBindRouteModel,
|
|
5371
|
+
runWithDatabaseConnection,
|
|
2499
5372
|
runWithAuthUser,
|
|
5373
|
+
runSeedersFromDirectory,
|
|
5374
|
+
runInTransaction,
|
|
5375
|
+
runGracefulShutdown,
|
|
5376
|
+
runDueScheduledTasks,
|
|
2500
5377
|
rollbackDatabase,
|
|
5378
|
+
resolveWebLayoutData,
|
|
2501
5379
|
resolveService,
|
|
5380
|
+
resolveDatabaseDriver,
|
|
2502
5381
|
required,
|
|
5382
|
+
registerShutdownHandler,
|
|
2503
5383
|
registerModelRepository,
|
|
2504
5384
|
queue,
|
|
2505
5385
|
prometheusRegistry,
|
|
@@ -2514,53 +5394,105 @@ export {
|
|
|
2514
5394
|
mailer,
|
|
2515
5395
|
mail,
|
|
2516
5396
|
log,
|
|
5397
|
+
loadSeedersFromDirectory,
|
|
2517
5398
|
loadMigrationsFromDirectory,
|
|
2518
|
-
jsonResponse,
|
|
5399
|
+
jsonResponse2 as jsonResponse,
|
|
5400
|
+
jobRegistry,
|
|
5401
|
+
isHtmxRequest,
|
|
2519
5402
|
isEtagEnabled,
|
|
5403
|
+
installGracefulShutdownSignals,
|
|
5404
|
+
inferReferencedTable,
|
|
5405
|
+
indexHasOneRelation,
|
|
2520
5406
|
indexHasManyRelation,
|
|
2521
5407
|
indexBelongsToRelation,
|
|
5408
|
+
indexBelongsToManyRelation,
|
|
5409
|
+
hydrateValue,
|
|
5410
|
+
htmlResponse,
|
|
5411
|
+
hasOne,
|
|
2522
5412
|
hasMany,
|
|
5413
|
+
grammarForDriver,
|
|
2523
5414
|
getMigrationStatus,
|
|
5415
|
+
getActiveDatabaseConnection,
|
|
2524
5416
|
freshDatabase,
|
|
5417
|
+
filterMassAssignable,
|
|
2525
5418
|
events,
|
|
2526
5419
|
etagFromResource,
|
|
2527
5420
|
emailRule,
|
|
5421
|
+
dehydrateValue,
|
|
2528
5422
|
defineTable,
|
|
2529
5423
|
currentAuthUser,
|
|
2530
5424
|
createdResponse,
|
|
5425
|
+
createTrackedJob,
|
|
5426
|
+
createThrottleMiddleware,
|
|
5427
|
+
createSecurityHeadersMiddleware,
|
|
5428
|
+
createSchemaBuilder,
|
|
5429
|
+
createRequireWebAuthMiddleware,
|
|
5430
|
+
createRequireAuthMiddleware,
|
|
5431
|
+
createQueueWorker,
|
|
2531
5432
|
createQueue,
|
|
5433
|
+
createProductionQueue,
|
|
2532
5434
|
createMetricsMiddleware,
|
|
5435
|
+
createMemoryThrottleMiddleware,
|
|
5436
|
+
createLoginThrottleMiddleware,
|
|
5437
|
+
createFailedJobService,
|
|
5438
|
+
createCsrfMiddleware,
|
|
5439
|
+
createBodySizeLimitMiddleware,
|
|
5440
|
+
createAuthorizeMiddleware,
|
|
5441
|
+
createAuthMiddleware,
|
|
2533
5442
|
config,
|
|
5443
|
+
composeMiddleware,
|
|
5444
|
+
compileBlueprint,
|
|
2534
5445
|
cache,
|
|
2535
|
-
bindDatabaseConnection,
|
|
5446
|
+
bindDatabaseConnection2 as bindDatabaseConnection,
|
|
5447
|
+
belongsToMany,
|
|
2536
5448
|
belongsTo,
|
|
2537
5449
|
auth,
|
|
2538
5450
|
assertIfMatch,
|
|
5451
|
+
applyMiddlewareToRoutes,
|
|
5452
|
+
applyCasts,
|
|
5453
|
+
appSchedule,
|
|
5454
|
+
WhereBuilder,
|
|
2539
5455
|
WebFormRequest,
|
|
2540
5456
|
ValidationError,
|
|
5457
|
+
UnsupportedSchemaFeatureError,
|
|
2541
5458
|
UnprocessableEntityError,
|
|
2542
5459
|
UnauthorizedError,
|
|
2543
5460
|
SyncQueue,
|
|
2544
5461
|
StorageManager,
|
|
5462
|
+
SqliteGrammar,
|
|
2545
5463
|
ServiceContainer,
|
|
5464
|
+
Schema,
|
|
5465
|
+
Schedule,
|
|
5466
|
+
ResilientQueue,
|
|
2546
5467
|
RepositoryQuery,
|
|
5468
|
+
RedisQueue,
|
|
5469
|
+
QueueWorker,
|
|
2547
5470
|
PrometheusRegistry,
|
|
2548
5471
|
PreconditionFailedError,
|
|
5472
|
+
PostgresGrammar,
|
|
2549
5473
|
PolicyGate,
|
|
2550
5474
|
Policy,
|
|
2551
5475
|
NotFoundError,
|
|
5476
|
+
MySqlGrammar,
|
|
2552
5477
|
Model,
|
|
2553
5478
|
Mailer,
|
|
2554
5479
|
LogMailDriver,
|
|
2555
5480
|
LocalStorageDriver,
|
|
2556
5481
|
Job,
|
|
2557
5482
|
FormRequest,
|
|
5483
|
+
ForeignIdColumnDefinition,
|
|
2558
5484
|
ForbiddenError,
|
|
5485
|
+
failedJobService_default as FailedJobService,
|
|
5486
|
+
failedJobRepository_default as FailedJobRepository,
|
|
2559
5487
|
EventBus,
|
|
5488
|
+
EtaViewEngine,
|
|
5489
|
+
DEFAULT_VIEWS_DIRECTORY,
|
|
2560
5490
|
ConflictError,
|
|
2561
5491
|
ConfigStore,
|
|
5492
|
+
ColumnDefinition,
|
|
2562
5493
|
repository_default as CacheRepository,
|
|
2563
5494
|
CACHE_TAGS,
|
|
5495
|
+
Blueprint,
|
|
2564
5496
|
baseRepository_default as BaseRepository,
|
|
2565
5497
|
BadRequestError,
|
|
2566
5498
|
AsyncQueue
|