@getstrata/core 0.5.13 → 0.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/bootstrap/contracts.d.ts +2 -0
- package/dist/bootstrap/httpKernel.d.ts +1 -2
- package/dist/core/admin/registry.d.ts +1 -0
- package/dist/core/auth/membershipService.d.ts +5 -1
- package/dist/core/database/baseRepository.d.ts +6 -1
- package/dist/core/database/connectionContext.d.ts +2 -1
- package/dist/core/database/defaultConnection.d.ts +6 -0
- package/dist/core/database/queryProxy.d.ts +3 -0
- package/dist/core/database/repositoryConnection.d.ts +3 -3
- package/dist/core/jobs/dispatchWebhookJob.d.ts +0 -1
- package/dist/core/security/safeFetch.d.ts +2 -0
- package/dist/core/security/safeUrl.d.ts +16 -1
- package/dist/core/storage/storage.d.ts +6 -3
- package/dist/core/tenant/tenantDatabaseScope.d.ts +2 -1
- package/dist/db/connection/index.d.ts +1 -1
- package/dist/entries/auth/accessControl.js +1 -113
- package/dist/entries/auth/authContext.js +1 -15
- package/dist/entries/auth/guard.js +1 -3044
- package/dist/entries/auth/membershipContext.js +1 -276
- package/dist/entries/auth/membershipScope.js +1 -390
- package/dist/entries/auth/membershipService.js +1 -480
- package/dist/entries/auth/policy.js +1 -134
- package/dist/entries/database.js +1 -2398
- package/dist/entries/http/csrfToken.js +0 -6
- package/dist/entries/http/middleware.js +1 -68
- package/dist/entries/http/requestMetaContext.js +1 -18
- package/dist/entries/http/webErrorResponse.js +94 -563
- package/dist/entries/http/webFormRequest.js +0 -131
- package/dist/entries/http.js +1 -4091
- package/dist/entries/jobs/dispatchWebhookJob.js +109 -102
- package/dist/entries/queue/createAppQueue.js +111 -631
- package/dist/entries/queue/jobRunner.js +0 -3
- package/dist/entries/queue/publicQueue.js +45 -546
- package/dist/entries/queue/queueMetrics.js +111 -631
- package/dist/entries/security/safeUrl.js +29 -0
- package/dist/entries/security/securityEvents.js +1 -41
- package/dist/entries/storage/storage.js +14 -4
- package/dist/entries/tenant/tenantContext.js +1 -30
- package/dist/entries/tenant/tenantMiddleware.js +1 -312
- package/dist/entries/tracing/traceContext.js +1 -15
- package/dist/entries/view.js +94 -563
- package/dist/framework/public-api.d.ts +36 -4
- package/dist/index.js +2270 -1666
- package/dist/modules/user/repository.d.ts +1 -0
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -180,6 +180,7 @@ function formatAdminValue(value, type = "text") {
|
|
|
180
180
|
// ../../src/core/admin/registry.ts
|
|
181
181
|
class AdminResourceRegistry {
|
|
182
182
|
resources = new Map;
|
|
183
|
+
constructor() {}
|
|
183
184
|
register(resource) {
|
|
184
185
|
if (this.resources.has(resource.name)) {
|
|
185
186
|
throw new Error(`Admin resource "${resource.name}" is already registered.`);
|
|
@@ -190,7 +191,12 @@ class AdminResourceRegistry {
|
|
|
190
191
|
return this.resources.get(name);
|
|
191
192
|
}
|
|
192
193
|
list() {
|
|
193
|
-
|
|
194
|
+
const definitions = [];
|
|
195
|
+
for (const resource of this.resources.values()) {
|
|
196
|
+
const { handlers: _handlers, ...definition } = resource;
|
|
197
|
+
definitions.push(definition);
|
|
198
|
+
}
|
|
199
|
+
return definitions;
|
|
194
200
|
}
|
|
195
201
|
all() {
|
|
196
202
|
return [...this.resources.values()];
|
|
@@ -199,15 +205,6 @@ class AdminResourceRegistry {
|
|
|
199
205
|
this.resources.clear();
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
|
-
// ../../src/core/auth/authContext.ts
|
|
203
|
-
import { AsyncLocalStorage } from "async_hooks";
|
|
204
|
-
var authContext = new AsyncLocalStorage;
|
|
205
|
-
function runWithAuthUser(user, callback) {
|
|
206
|
-
return authContext.run(user, callback);
|
|
207
|
-
}
|
|
208
|
-
function currentAuthUser() {
|
|
209
|
-
return authContext.getStore() ?? null;
|
|
210
|
-
}
|
|
211
208
|
// ../../src/core/errors/http.ts
|
|
212
209
|
class HttpError extends Error {
|
|
213
210
|
status;
|
|
@@ -274,125 +271,97 @@ class PreconditionFailedError extends HttpError {
|
|
|
274
271
|
}
|
|
275
272
|
}
|
|
276
273
|
|
|
277
|
-
// ../../src/core/auth/
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
}
|
|
286
|
-
update(_user, _resource) {
|
|
287
|
-
return false;
|
|
288
|
-
}
|
|
289
|
-
delete(_user, _resource) {
|
|
290
|
-
return false;
|
|
291
|
-
}
|
|
274
|
+
// ../../src/core/auth/authContext.ts
|
|
275
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
276
|
+
var authContext = new AsyncLocalStorage;
|
|
277
|
+
function runWithAuthUser(user, callback) {
|
|
278
|
+
return authContext.run(user, callback);
|
|
279
|
+
}
|
|
280
|
+
function currentAuthUser() {
|
|
281
|
+
return authContext.getStore() ?? null;
|
|
292
282
|
}
|
|
293
|
-
var BLOCKED_POLICY_ACTIONS = new Set([
|
|
294
|
-
"constructor",
|
|
295
|
-
"toString",
|
|
296
|
-
"valueOf",
|
|
297
|
-
"hasOwnProperty",
|
|
298
|
-
"isPrototypeOf",
|
|
299
|
-
"propertyIsEnumerable",
|
|
300
|
-
"__proto__"
|
|
301
|
-
]);
|
|
302
283
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (!policy) {
|
|
312
|
-
return false;
|
|
313
|
-
}
|
|
314
|
-
if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
|
|
315
|
-
return false;
|
|
316
|
-
}
|
|
317
|
-
const handler = policy[action];
|
|
318
|
-
if (typeof handler !== "function") {
|
|
319
|
-
return false;
|
|
320
|
-
}
|
|
321
|
-
const resolvedUser = user === undefined ? currentAuthUser() : user;
|
|
322
|
-
return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
|
|
323
|
-
}
|
|
324
|
-
authorize(resource, action, user, model) {
|
|
325
|
-
if (!this.allows(resource, action, user, model)) {
|
|
326
|
-
throw new ForbiddenError;
|
|
327
|
-
}
|
|
328
|
-
}
|
|
284
|
+
// ../../src/core/auth/accessControl.ts
|
|
285
|
+
var ROLE_RANK = {
|
|
286
|
+
member: 1,
|
|
287
|
+
admin: 2,
|
|
288
|
+
owner: 3
|
|
289
|
+
};
|
|
290
|
+
function isGlobalAdmin(user) {
|
|
291
|
+
return user?.role === "admin";
|
|
329
292
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
tags;
|
|
334
|
-
constructor(store, tags) {
|
|
335
|
-
this.store = store;
|
|
336
|
-
this.tags = tags;
|
|
293
|
+
function hasMinimumOrgRole(role, minimum) {
|
|
294
|
+
if (!role) {
|
|
295
|
+
return false;
|
|
337
296
|
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
297
|
+
return ROLE_RANK[role] >= ROLE_RANK[minimum];
|
|
298
|
+
}
|
|
299
|
+
function resolveUserId(user) {
|
|
300
|
+
const userId = typeof user.id === "number" ? user.id : Number(user.id);
|
|
301
|
+
if (!Number.isInteger(userId) || userId <= 0) {
|
|
302
|
+
throw new ForbiddenError("Invalid authenticated user.");
|
|
342
303
|
}
|
|
343
|
-
|
|
344
|
-
|
|
304
|
+
return userId;
|
|
305
|
+
}
|
|
306
|
+
// ../../src/domain/abilities.ts
|
|
307
|
+
var MEMBER_ABILITIES = [
|
|
308
|
+
"organizations:read",
|
|
309
|
+
"projects:read",
|
|
310
|
+
"projects:create",
|
|
311
|
+
"tasks:read",
|
|
312
|
+
"tasks:create",
|
|
313
|
+
"comments:read",
|
|
314
|
+
"comments:create",
|
|
315
|
+
"attachments:read",
|
|
316
|
+
"attachments:create",
|
|
317
|
+
"auth:tokens:read",
|
|
318
|
+
"auth:tokens:write"
|
|
319
|
+
];
|
|
320
|
+
var ADMIN_ABILITIES = [
|
|
321
|
+
...MEMBER_ABILITIES,
|
|
322
|
+
"organizations:create",
|
|
323
|
+
"organizations:update",
|
|
324
|
+
"organizations:delete",
|
|
325
|
+
"projects:update",
|
|
326
|
+
"projects:delete",
|
|
327
|
+
"tasks:update",
|
|
328
|
+
"tasks:delete",
|
|
329
|
+
"comments:update",
|
|
330
|
+
"comments:delete",
|
|
331
|
+
"attachments:delete",
|
|
332
|
+
"webhooks:read",
|
|
333
|
+
"webhooks:write",
|
|
334
|
+
"audit:read"
|
|
335
|
+
];
|
|
336
|
+
var PLATFORM_ADMIN_ABILITIES = ["*"];
|
|
337
|
+
function resolveAbilitiesForRole(role) {
|
|
338
|
+
if (role === "admin") {
|
|
339
|
+
return [...PLATFORM_ADMIN_ABILITIES];
|
|
345
340
|
}
|
|
341
|
+
return [...MEMBER_ABILITIES];
|
|
346
342
|
}
|
|
347
|
-
var taggedCache_default = TaggedCache;
|
|
348
343
|
|
|
349
|
-
// ../../src/
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
}
|
|
364
|
-
async flush() {
|
|
365
|
-
await this.store.clear();
|
|
366
|
-
}
|
|
367
|
-
tags(...names) {
|
|
368
|
-
return new taggedCache_default(this.store, names);
|
|
369
|
-
}
|
|
370
|
-
async getOrSet(key, loader, ttlMs) {
|
|
371
|
-
return this.remember(key, loader, ttlMs);
|
|
372
|
-
}
|
|
373
|
-
async invalidate(key) {
|
|
374
|
-
return this.forget(key);
|
|
375
|
-
}
|
|
376
|
-
async invalidateByPrefix(prefix) {
|
|
377
|
-
return this.store.invalidateByPrefix(prefix);
|
|
378
|
-
}
|
|
379
|
-
async clear() {
|
|
380
|
-
await this.flush();
|
|
381
|
-
}
|
|
382
|
-
async size() {
|
|
383
|
-
return this.store.size();
|
|
384
|
-
}
|
|
344
|
+
// ../../src/config/features.ts
|
|
345
|
+
function readFeatureFlags() {
|
|
346
|
+
return {
|
|
347
|
+
webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
|
|
348
|
+
fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
|
|
349
|
+
auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
|
|
350
|
+
oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
|
|
351
|
+
samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
|
|
352
|
+
scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
|
|
353
|
+
billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
|
|
354
|
+
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
|
|
355
|
+
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
|
|
356
|
+
emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
|
|
357
|
+
mfa: (process.env.FEATURE_MFA ?? "false") === "true"
|
|
358
|
+
};
|
|
385
359
|
}
|
|
386
|
-
var
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
tasks: "tasks",
|
|
392
|
-
comments: "comments",
|
|
393
|
-
attachments: "attachments",
|
|
394
|
-
reports: "reports"
|
|
395
|
-
};
|
|
360
|
+
var featureFlags = readFeatureFlags();
|
|
361
|
+
function isFeatureEnabled(feature) {
|
|
362
|
+
return readFeatureFlags()[feature];
|
|
363
|
+
}
|
|
364
|
+
|
|
396
365
|
// ../../src/core/events/eventBus.ts
|
|
397
366
|
class EventBus {
|
|
398
367
|
constructor() {}
|
|
@@ -598,14 +567,6 @@ function appendWhereParts(tableName, where, params) {
|
|
|
598
567
|
}
|
|
599
568
|
return clauses.join(" AND ");
|
|
600
569
|
}
|
|
601
|
-
function buildWhereClause(tableName, where = {}) {
|
|
602
|
-
const params = [];
|
|
603
|
-
const body = appendWhereParts(tableName, where, params);
|
|
604
|
-
return {
|
|
605
|
-
clause: body.length > 0 ? ` WHERE ${body}` : "",
|
|
606
|
-
params
|
|
607
|
-
};
|
|
608
|
-
}
|
|
609
570
|
function buildWhereNodeClause(tableName, node, params) {
|
|
610
571
|
if ("where" in node) {
|
|
611
572
|
return appendWhereParts(tableName, node.where, params);
|
|
@@ -1053,18 +1014,19 @@ function indexMorphToRelation(children, parentsByType, relation) {
|
|
|
1053
1014
|
return result;
|
|
1054
1015
|
}
|
|
1055
1016
|
|
|
1056
|
-
// ../../src/
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
1060
|
-
}
|
|
1061
|
-
var databaseConfig = {
|
|
1062
|
-
url: process.env.DATABASE_URL ?? "",
|
|
1063
|
-
poolMax: readInteger("DB_POOL_MAX", 10),
|
|
1064
|
-
idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
|
|
1065
|
-
maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
|
|
1066
|
-
connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
|
|
1017
|
+
// ../../src/core/database/boundConnection.ts
|
|
1018
|
+
var boundConnectionHolder = {
|
|
1019
|
+
connection: null
|
|
1067
1020
|
};
|
|
1021
|
+
function bindDatabaseConnection(connection) {
|
|
1022
|
+
boundConnectionHolder.connection = connection;
|
|
1023
|
+
}
|
|
1024
|
+
function getBoundDatabaseConnection() {
|
|
1025
|
+
return boundConnectionHolder.connection;
|
|
1026
|
+
}
|
|
1027
|
+
function resetBoundDatabaseConnection() {
|
|
1028
|
+
boundConnectionHolder.connection = null;
|
|
1029
|
+
}
|
|
1068
1030
|
|
|
1069
1031
|
// ../../src/core/database/connectionContext.ts
|
|
1070
1032
|
import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
|
|
@@ -1075,72 +1037,68 @@ function runWithDatabaseConnection(connection, callback) {
|
|
|
1075
1037
|
function getActiveDatabaseConnection(fallback) {
|
|
1076
1038
|
return activeConnection.getStore() ?? fallback;
|
|
1077
1039
|
}
|
|
1040
|
+
function hasActiveDatabaseConnection() {
|
|
1041
|
+
return activeConnection.getStore() !== undefined;
|
|
1042
|
+
}
|
|
1078
1043
|
|
|
1079
|
-
// ../../src/
|
|
1080
|
-
var
|
|
1081
|
-
function
|
|
1082
|
-
|
|
1083
|
-
|
|
1044
|
+
// ../../src/core/database/queryProxy.ts
|
|
1045
|
+
var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
|
|
1046
|
+
function createDatabaseQueryProxy(pool) {
|
|
1047
|
+
function resolveDatabase() {
|
|
1048
|
+
return getActiveDatabaseConnection(pool);
|
|
1084
1049
|
}
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1050
|
+
function resolveDatabaseForProperty(property) {
|
|
1051
|
+
if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
|
|
1052
|
+
return pool;
|
|
1053
|
+
}
|
|
1054
|
+
return resolveDatabase();
|
|
1055
|
+
}
|
|
1056
|
+
return new Proxy(function database() {}, {
|
|
1057
|
+
apply(_target, _thisArg, args) {
|
|
1058
|
+
return resolveDatabase()(...args);
|
|
1059
|
+
},
|
|
1060
|
+
get(_target, property) {
|
|
1061
|
+
const connection = resolveDatabaseForProperty(property);
|
|
1062
|
+
const value = connection[property];
|
|
1063
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
1064
|
+
}
|
|
1091
1065
|
});
|
|
1092
1066
|
}
|
|
1093
1067
|
|
|
1094
|
-
// ../../src/
|
|
1095
|
-
var
|
|
1068
|
+
// ../../src/core/database/defaultConnection.ts
|
|
1069
|
+
var defaultPool = {
|
|
1096
1070
|
connection: null
|
|
1097
1071
|
};
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1072
|
+
var defaultQuery = {
|
|
1073
|
+
connection: null
|
|
1074
|
+
};
|
|
1075
|
+
function registerDefaultDatabasePool(connection) {
|
|
1076
|
+
defaultPool.connection = connection;
|
|
1077
|
+
defaultQuery.connection = createDatabaseQueryProxy(connection);
|
|
1103
1078
|
}
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1079
|
+
function getDefaultDatabasePool() {
|
|
1080
|
+
if (!defaultPool.connection) {
|
|
1081
|
+
throw new Error("Default database pool is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
|
|
1082
|
+
}
|
|
1083
|
+
return defaultPool.connection;
|
|
1107
1084
|
}
|
|
1108
|
-
function
|
|
1109
|
-
if (
|
|
1110
|
-
|
|
1085
|
+
function getDefaultDatabaseQuery() {
|
|
1086
|
+
if (!defaultQuery.connection) {
|
|
1087
|
+
throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
|
|
1111
1088
|
}
|
|
1112
|
-
return
|
|
1089
|
+
return defaultQuery.connection;
|
|
1113
1090
|
}
|
|
1114
|
-
|
|
1091
|
+
|
|
1092
|
+
// ../../src/core/database/repositoryConnection.ts
|
|
1093
|
+
function resolveRepositoryConnection() {
|
|
1094
|
+
return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
|
|
1095
|
+
}
|
|
1096
|
+
var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
|
|
1115
1097
|
apply(_target, _thisArg, args) {
|
|
1116
|
-
return
|
|
1098
|
+
return resolveRepositoryConnection()(...args);
|
|
1117
1099
|
},
|
|
1118
1100
|
get(_target, property) {
|
|
1119
|
-
const connection =
|
|
1120
|
-
const value = connection[property];
|
|
1121
|
-
return typeof value === "function" ? value.bind(connection) : value;
|
|
1122
|
-
}
|
|
1123
|
-
});
|
|
1124
|
-
var connection_default = db;
|
|
1125
|
-
|
|
1126
|
-
// ../../src/core/database/boundConnection.ts
|
|
1127
|
-
var boundConnectionHolder = {
|
|
1128
|
-
connection: null
|
|
1129
|
-
};
|
|
1130
|
-
function bindDatabaseConnection(connection) {
|
|
1131
|
-
boundConnectionHolder.connection = connection;
|
|
1132
|
-
}
|
|
1133
|
-
function getBoundDatabaseConnection() {
|
|
1134
|
-
return boundConnectionHolder.connection;
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
// ../../src/core/database/repositoryConnection.ts
|
|
1138
|
-
function resolveRepositoryConnection() {
|
|
1139
|
-
return getBoundDatabaseConnection() ?? connection_default;
|
|
1140
|
-
}
|
|
1141
|
-
var repositoryConnection = new Proxy({}, {
|
|
1142
|
-
get(_target, property) {
|
|
1143
|
-
const connection = resolveRepositoryConnection();
|
|
1101
|
+
const connection = resolveRepositoryConnection();
|
|
1144
1102
|
const value = connection[property];
|
|
1145
1103
|
return typeof value === "function" ? value.bind(connection) : value;
|
|
1146
1104
|
}
|
|
@@ -1701,130 +1659,14 @@ class BaseRepository {
|
|
|
1701
1659
|
}
|
|
1702
1660
|
}
|
|
1703
1661
|
var baseRepository_default = BaseRepository;
|
|
1704
|
-
// ../../src/core/database/bindConnection.ts
|
|
1705
|
-
function bindDatabaseConnection2(connection) {
|
|
1706
|
-
bindDatabaseConnection(connection);
|
|
1707
|
-
}
|
|
1708
1662
|
// ../../src/core/database/connection.ts
|
|
1709
|
-
function
|
|
1663
|
+
function createDatabaseConnection(source) {
|
|
1710
1664
|
return {
|
|
1711
1665
|
async unsafe(query, params = []) {
|
|
1712
1666
|
return await source.unsafe(query, params);
|
|
1713
1667
|
}
|
|
1714
1668
|
};
|
|
1715
1669
|
}
|
|
1716
|
-
// ../../src/core/database/migrations/advisoryLock.ts
|
|
1717
|
-
var MIGRATION_LOCK_KEY = 42424242;
|
|
1718
|
-
async function withMigrationLock(db2, callback, lockKey = MIGRATION_LOCK_KEY) {
|
|
1719
|
-
await db2.unsafe("SELECT pg_advisory_lock($1)", [lockKey]);
|
|
1720
|
-
try {
|
|
1721
|
-
return await callback();
|
|
1722
|
-
} finally {
|
|
1723
|
-
await db2.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
|
|
1724
|
-
}
|
|
1725
|
-
}
|
|
1726
|
-
// ../../src/core/database/migrations/runner.ts
|
|
1727
|
-
import { readdir } from "fs/promises";
|
|
1728
|
-
import { join } from "path";
|
|
1729
|
-
import { pathToFileURL } from "url";
|
|
1730
|
-
var MIGRATIONS_TABLE = "framework_migrations";
|
|
1731
|
-
async function ensureMigrationsTable(db2) {
|
|
1732
|
-
await db2.unsafe(`
|
|
1733
|
-
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
1734
|
-
name TEXT PRIMARY KEY,
|
|
1735
|
-
batch INTEGER NOT NULL,
|
|
1736
|
-
run_on TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
1737
|
-
)
|
|
1738
|
-
`);
|
|
1739
|
-
}
|
|
1740
|
-
async function getAppliedMigrations(db2) {
|
|
1741
|
-
await ensureMigrationsTable(db2);
|
|
1742
|
-
return await db2.unsafe(`
|
|
1743
|
-
SELECT name, batch
|
|
1744
|
-
FROM ${MIGRATIONS_TABLE}
|
|
1745
|
-
ORDER BY batch ASC, name ASC
|
|
1746
|
-
`);
|
|
1747
|
-
}
|
|
1748
|
-
async function loadMigrationsFromDirectory(directory) {
|
|
1749
|
-
const entries = await readdir(directory);
|
|
1750
|
-
const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
1751
|
-
const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
|
|
1752
|
-
const moduleUrl = pathToFileURL(join(directory, fileName)).href;
|
|
1753
|
-
const module = await import(moduleUrl);
|
|
1754
|
-
return module.default;
|
|
1755
|
-
}));
|
|
1756
|
-
return loadedMigrations.filter((migration) => migration?.name !== undefined);
|
|
1757
|
-
}
|
|
1758
|
-
async function getMigrationStatus(db2, migrations) {
|
|
1759
|
-
const applied = await getAppliedMigrations(db2);
|
|
1760
|
-
const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
|
|
1761
|
-
return migrations.map(({ name }) => ({
|
|
1762
|
-
name,
|
|
1763
|
-
status: appliedByName.has(name) ? "up" : "pending",
|
|
1764
|
-
batch: appliedByName.get(name) ?? null
|
|
1765
|
-
}));
|
|
1766
|
-
}
|
|
1767
|
-
async function runPendingMigrations(db2, migrations, options = {}) {
|
|
1768
|
-
const applied = await getAppliedMigrations(db2);
|
|
1769
|
-
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
1770
|
-
const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
|
|
1771
|
-
const pendingMigrations = migrations.filter(({ name }) => !appliedNames.has(name));
|
|
1772
|
-
for (const migration of pendingMigrations) {
|
|
1773
|
-
options.onMigration?.(migration.name);
|
|
1774
|
-
await migration.up(db2);
|
|
1775
|
-
const inserted = await db2.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name, batch) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING name`, [migration.name, nextBatch]);
|
|
1776
|
-
if (inserted.length === 0) {
|
|
1777
|
-
throw new Error(`Migration ${migration.name} was applied but not recorded.`);
|
|
1778
|
-
}
|
|
1779
|
-
}
|
|
1780
|
-
return pendingMigrations.length;
|
|
1781
|
-
}
|
|
1782
|
-
async function migrateDatabase(db2, migrations, options = {}) {
|
|
1783
|
-
const { advisoryLock = false, onMigration } = options;
|
|
1784
|
-
if (advisoryLock) {
|
|
1785
|
-
return withMigrationLock(db2, () => runPendingMigrations(db2, migrations, { onMigration }));
|
|
1786
|
-
}
|
|
1787
|
-
return runPendingMigrations(db2, migrations, { onMigration });
|
|
1788
|
-
}
|
|
1789
|
-
async function rollbackDatabase(db2, migrations, options = {}) {
|
|
1790
|
-
const applied = await getAppliedMigrations(db2);
|
|
1791
|
-
if (applied.length === 0) {
|
|
1792
|
-
return 0;
|
|
1793
|
-
}
|
|
1794
|
-
const lastBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0);
|
|
1795
|
-
const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
|
|
1796
|
-
let rolledBack = 0;
|
|
1797
|
-
for (const migration of [...migrations].reverse()) {
|
|
1798
|
-
if (!migrationsToRollback.has(migration.name)) {
|
|
1799
|
-
continue;
|
|
1800
|
-
}
|
|
1801
|
-
options.onMigration?.(migration.name);
|
|
1802
|
-
await migration.down(db2);
|
|
1803
|
-
await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = $1`, [migration.name]);
|
|
1804
|
-
rolledBack += 1;
|
|
1805
|
-
}
|
|
1806
|
-
return rolledBack;
|
|
1807
|
-
}
|
|
1808
|
-
async function freshDatabase(db2, migrations, options = {}) {
|
|
1809
|
-
const runFresh = async () => {
|
|
1810
|
-
const applied = await getAppliedMigrations(db2);
|
|
1811
|
-
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
1812
|
-
const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
|
|
1813
|
-
for (const migration of [...appliedMigrations].reverse()) {
|
|
1814
|
-
options.onMigration?.(migration.name);
|
|
1815
|
-
await migration.down(db2);
|
|
1816
|
-
}
|
|
1817
|
-
if (appliedMigrations.length > 0) {
|
|
1818
|
-
await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
|
|
1819
|
-
}
|
|
1820
|
-
await runPendingMigrations(db2, migrations, options);
|
|
1821
|
-
};
|
|
1822
|
-
if (options.advisoryLock) {
|
|
1823
|
-
await withMigrationLock(db2, runFresh);
|
|
1824
|
-
return;
|
|
1825
|
-
}
|
|
1826
|
-
await runFresh();
|
|
1827
|
-
}
|
|
1828
1670
|
// ../../src/core/database/model.ts
|
|
1829
1671
|
var modelRepositories = new WeakMap;
|
|
1830
1672
|
var modelGlobalScopes = new WeakMap;
|
|
@@ -2736,9 +2578,9 @@ class SchemaBuilder {
|
|
|
2736
2578
|
toSql() {
|
|
2737
2579
|
return [...this.#statements];
|
|
2738
2580
|
}
|
|
2739
|
-
async execute(
|
|
2581
|
+
async execute(db) {
|
|
2740
2582
|
for (const statement of this.#statements) {
|
|
2741
|
-
await
|
|
2583
|
+
await db.unsafe(statement);
|
|
2742
2584
|
}
|
|
2743
2585
|
}
|
|
2744
2586
|
}
|
|
@@ -2747,45 +2589,20 @@ class Schema {
|
|
|
2747
2589
|
static builder(driver) {
|
|
2748
2590
|
return new SchemaBuilder(driver ?? resolveDatabaseDriver());
|
|
2749
2591
|
}
|
|
2750
|
-
static async run(
|
|
2592
|
+
static async run(db, driver, callback) {
|
|
2751
2593
|
const schema = Schema.builder(driver);
|
|
2752
2594
|
await callback(schema);
|
|
2753
|
-
await schema.execute(
|
|
2595
|
+
await schema.execute(db);
|
|
2754
2596
|
}
|
|
2755
2597
|
}
|
|
2756
|
-
function createSchemaBuilder(
|
|
2598
|
+
function createSchemaBuilder(db, driver) {
|
|
2757
2599
|
const builder = Schema.builder(driver);
|
|
2758
2600
|
return Object.assign(builder, {
|
|
2759
2601
|
async commit() {
|
|
2760
|
-
await builder.execute(
|
|
2602
|
+
await builder.execute(db);
|
|
2761
2603
|
}
|
|
2762
2604
|
});
|
|
2763
2605
|
}
|
|
2764
|
-
// ../../src/core/database/seeders/runner.ts
|
|
2765
|
-
import { readdir as readdir2 } from "fs/promises";
|
|
2766
|
-
import { join as join2 } from "path";
|
|
2767
|
-
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
2768
|
-
async function loadSeedersFromDirectory(directory) {
|
|
2769
|
-
const entries = await readdir2(directory);
|
|
2770
|
-
const seederFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
2771
|
-
const loadedSeeders = await Promise.all(seederFiles.map(async (fileName) => {
|
|
2772
|
-
const moduleUrl = pathToFileURL2(join2(directory, fileName)).href;
|
|
2773
|
-
const module = await import(moduleUrl);
|
|
2774
|
-
return module.default;
|
|
2775
|
-
}));
|
|
2776
|
-
return loadedSeeders.filter((seeder) => seeder?.name !== undefined);
|
|
2777
|
-
}
|
|
2778
|
-
async function runSeedersFromDirectory(directory, db2, options) {
|
|
2779
|
-
const seeders = await loadSeedersFromDirectory(directory);
|
|
2780
|
-
if (seeders.length === 0) {
|
|
2781
|
-
return 0;
|
|
2782
|
-
}
|
|
2783
|
-
for (const seeder of seeders) {
|
|
2784
|
-
options?.onSeeder?.(seeder.name);
|
|
2785
|
-
await seeder.run(db2);
|
|
2786
|
-
}
|
|
2787
|
-
return seeders.length;
|
|
2788
|
-
}
|
|
2789
2606
|
// ../../src/core/database/table.ts
|
|
2790
2607
|
function defineTable(definition) {
|
|
2791
2608
|
return definition;
|
|
@@ -2800,1419 +2617,1830 @@ async function runInTransaction(operation) {
|
|
|
2800
2617
|
throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
|
|
2801
2618
|
}
|
|
2802
2619
|
return await pool.begin(async (transaction) => {
|
|
2803
|
-
return await operation(
|
|
2620
|
+
return await operation(createDatabaseConnection(transaction));
|
|
2804
2621
|
});
|
|
2805
2622
|
}
|
|
2806
|
-
// ../../src/
|
|
2807
|
-
function
|
|
2808
|
-
const
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2623
|
+
// ../../src/config/database.ts
|
|
2624
|
+
function readInteger(name, fallback) {
|
|
2625
|
+
const parsed = Number.parseInt(process.env[name] ?? String(fallback), 10);
|
|
2626
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
|
|
2627
|
+
}
|
|
2628
|
+
var databaseConfig = {
|
|
2629
|
+
url: process.env.DATABASE_URL ?? "",
|
|
2630
|
+
poolMax: readInteger("DB_POOL_MAX", 10),
|
|
2631
|
+
idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
|
|
2632
|
+
maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
|
|
2633
|
+
connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
|
|
2634
|
+
};
|
|
2635
|
+
|
|
2636
|
+
// ../../src/db/connection/createConnection.ts
|
|
2637
|
+
var {SQL } = globalThis.Bun;
|
|
2638
|
+
function createDatabaseConnection2(config) {
|
|
2639
|
+
if (!config.url) {
|
|
2640
|
+
throw new Error("DATABASE_URL is not configured. Set DATABASE_URL before starting the app or running integration tests.");
|
|
2815
2641
|
}
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2642
|
+
return new SQL({
|
|
2643
|
+
url: config.url,
|
|
2644
|
+
max: config.poolMax,
|
|
2645
|
+
idleTimeout: config.idleTimeoutSeconds,
|
|
2646
|
+
maxLifetime: config.maxLifetimeSeconds,
|
|
2647
|
+
connectionTimeout: config.connectionTimeoutSeconds
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
|
|
2651
|
+
// ../../src/db/connection/index.ts
|
|
2652
|
+
var connectionHolder = {
|
|
2653
|
+
connection: null
|
|
2654
|
+
};
|
|
2655
|
+
function getDatabase() {
|
|
2656
|
+
if (!connectionHolder.connection) {
|
|
2657
|
+
connectionHolder.connection = createDatabaseConnection2(databaseConfig);
|
|
2658
|
+
registerDefaultDatabasePool(connectionHolder.connection);
|
|
2819
2659
|
}
|
|
2820
|
-
return
|
|
2821
|
-
host,
|
|
2822
|
-
port,
|
|
2823
|
-
from,
|
|
2824
|
-
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
2825
|
-
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
2826
|
-
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
2827
|
-
};
|
|
2660
|
+
return connectionHolder.connection;
|
|
2828
2661
|
}
|
|
2829
|
-
function
|
|
2830
|
-
|
|
2662
|
+
function getDb() {
|
|
2663
|
+
getDatabase();
|
|
2664
|
+
return getDefaultDatabaseQuery();
|
|
2831
2665
|
}
|
|
2832
|
-
function
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
const
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
remainder = remainder.slice(index + 2);
|
|
2841
|
-
if (line.length >= 4 && line[3] === "-") {
|
|
2842
|
-
continue;
|
|
2843
|
-
}
|
|
2844
|
-
responses.push(line);
|
|
2666
|
+
var db = new Proxy(function database() {}, {
|
|
2667
|
+
apply(_target, _thisArg, args) {
|
|
2668
|
+
return getDb()(...args);
|
|
2669
|
+
},
|
|
2670
|
+
get(_target, property) {
|
|
2671
|
+
const connection = getDb();
|
|
2672
|
+
const value = connection[property];
|
|
2673
|
+
return typeof value === "function" ? value.bind(connection) : value;
|
|
2845
2674
|
}
|
|
2846
|
-
|
|
2675
|
+
});
|
|
2676
|
+
var connection_default = db;
|
|
2677
|
+
|
|
2678
|
+
// ../../src/modules/user/apiTokenTable.ts
|
|
2679
|
+
var apiTokenTable = defineTable({
|
|
2680
|
+
name: "api_token",
|
|
2681
|
+
primaryKey: "id",
|
|
2682
|
+
columns: [
|
|
2683
|
+
"id",
|
|
2684
|
+
"user_id",
|
|
2685
|
+
"name",
|
|
2686
|
+
"token_hash",
|
|
2687
|
+
"abilities",
|
|
2688
|
+
"last_used_at",
|
|
2689
|
+
"expires_at",
|
|
2690
|
+
"created_at"
|
|
2691
|
+
],
|
|
2692
|
+
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
2693
|
+
});
|
|
2694
|
+
|
|
2695
|
+
// ../../src/core/auth/password.ts
|
|
2696
|
+
async function verifyPassword(password, passwordHash) {
|
|
2697
|
+
return await Bun.password.verify(password, passwordHash);
|
|
2847
2698
|
}
|
|
2848
|
-
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2699
|
+
|
|
2700
|
+
// ../../src/core/crypto/fieldEncryption.ts
|
|
2701
|
+
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
2702
|
+
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
2703
|
+
var IV_LENGTH = 12;
|
|
2704
|
+
var TAG_LENGTH = 16;
|
|
2705
|
+
function resolveEncryptionKey() {
|
|
2706
|
+
const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
|
|
2707
|
+
if (!raw) {
|
|
2708
|
+
return null;
|
|
2853
2709
|
}
|
|
2854
|
-
|
|
2710
|
+
if (/^[0-9a-f]{64}$/i.test(raw)) {
|
|
2711
|
+
return Buffer.from(raw, "hex");
|
|
2712
|
+
}
|
|
2713
|
+
const decoded = Buffer.from(raw, "base64");
|
|
2714
|
+
if (decoded.length === 32) {
|
|
2715
|
+
return decoded;
|
|
2716
|
+
}
|
|
2717
|
+
throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
|
|
2855
2718
|
}
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
}
|
|
2866
|
-
waiters.push({ resolve, reject });
|
|
2867
|
-
});
|
|
2868
|
-
const socket = await Bun.connect({
|
|
2869
|
-
hostname: config.host,
|
|
2870
|
-
port: config.port,
|
|
2871
|
-
socket: {
|
|
2872
|
-
open() {},
|
|
2873
|
-
data(_socket, chunk) {
|
|
2874
|
-
buffer += Buffer.from(chunk).toString("utf8");
|
|
2875
|
-
const parsed = parseSmtpResponses(buffer);
|
|
2876
|
-
buffer = parsed.remainder;
|
|
2877
|
-
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
2878
|
-
const response = parsed.responses.shift();
|
|
2879
|
-
waiters.shift()?.resolve(response);
|
|
2880
|
-
}
|
|
2881
|
-
},
|
|
2882
|
-
error(_socket, error) {
|
|
2883
|
-
const pending = waiters.splice(0);
|
|
2884
|
-
for (const waiter of pending) {
|
|
2885
|
-
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
|
2886
|
-
}
|
|
2887
|
-
}
|
|
2888
|
-
}
|
|
2889
|
-
});
|
|
2890
|
-
return { socket, readResponse };
|
|
2719
|
+
function isFieldEncryptionEnabled() {
|
|
2720
|
+
const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
|
|
2721
|
+
if (featureFlag === "false") {
|
|
2722
|
+
return false;
|
|
2723
|
+
}
|
|
2724
|
+
if (featureFlag === "true") {
|
|
2725
|
+
return true;
|
|
2726
|
+
}
|
|
2727
|
+
return (process.env.APP_ENV ?? "local") === "production";
|
|
2891
2728
|
}
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
|
|
2895
|
-
|
|
2896
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2729
|
+
function decryptField(value, key) {
|
|
2730
|
+
if (!value.startsWith(ENCRYPTION_PREFIX)) {
|
|
2731
|
+
return value;
|
|
2732
|
+
}
|
|
2733
|
+
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
2734
|
+
const iv = payload.subarray(0, IV_LENGTH);
|
|
2735
|
+
const tag = payload.subarray(payload.length - TAG_LENGTH);
|
|
2736
|
+
const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
|
|
2737
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
2738
|
+
decipher.setAuthTag(tag);
|
|
2739
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
2740
|
+
}
|
|
2741
|
+
|
|
2742
|
+
// ../../src/core/crypto/mfaSecret.ts
|
|
2743
|
+
function revealMfaSecret(stored) {
|
|
2744
|
+
if (!stored) {
|
|
2745
|
+
return null;
|
|
2746
|
+
}
|
|
2747
|
+
const key = resolveEncryptionKey();
|
|
2748
|
+
if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
|
|
2749
|
+
return stored;
|
|
2750
|
+
}
|
|
2751
|
+
return decryptField(stored, key);
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// ../../src/core/http/requestMetaContext.ts
|
|
2755
|
+
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
2756
|
+
var requestMetaContext = new AsyncLocalStorage3;
|
|
2757
|
+
function runWithRequestMeta(meta, callback) {
|
|
2758
|
+
return requestMetaContext.run(meta, callback);
|
|
2759
|
+
}
|
|
2760
|
+
function currentRequestMeta() {
|
|
2761
|
+
return requestMetaContext.getStore() ?? {
|
|
2762
|
+
ipAddress: null,
|
|
2763
|
+
userAgent: null
|
|
2764
|
+
};
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// ../../src/core/security/securityEvents.ts
|
|
2768
|
+
function logSecurityEvent(event, details = {}) {
|
|
2769
|
+
const meta = currentRequestMeta();
|
|
2770
|
+
const user = currentAuthUser();
|
|
2771
|
+
console.log(JSON.stringify({
|
|
2772
|
+
level: "security",
|
|
2773
|
+
event,
|
|
2774
|
+
timestamp: new Date().toISOString(),
|
|
2775
|
+
ip_address: meta.ipAddress ?? null,
|
|
2776
|
+
user_agent: meta.userAgent ?? null,
|
|
2777
|
+
user_id: user?.id ?? null,
|
|
2778
|
+
...details
|
|
2779
|
+
}));
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
// ../../src/core/security/tokenExpiry.ts
|
|
2783
|
+
function resolveDefaultTokenExpiryDays() {
|
|
2784
|
+
const raw = process.env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim();
|
|
2785
|
+
if (!raw) {
|
|
2786
|
+
return null;
|
|
2787
|
+
}
|
|
2788
|
+
const parsed = Number.parseInt(raw, 10);
|
|
2789
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
2790
|
+
return null;
|
|
2791
|
+
}
|
|
2792
|
+
return parsed;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
// ../../src/core/security/totp.ts
|
|
2796
|
+
import { createHmac as createHmac2 } from "crypto";
|
|
2797
|
+
function decodeBase32(input) {
|
|
2798
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
|
|
2799
|
+
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
2800
|
+
let bits = "";
|
|
2801
|
+
for (const char of normalized) {
|
|
2802
|
+
const value = alphabet.indexOf(char);
|
|
2803
|
+
if (value === -1) {
|
|
2804
|
+
throw new Error("Invalid base32 character in MFA secret.");
|
|
2909
2805
|
}
|
|
2910
|
-
|
|
2911
|
-
`);
|
|
2912
|
-
await waitForSmtpResponse(readResponse, ["250"]);
|
|
2913
|
-
await socket.write(`RCPT TO:<${message.to}>\r
|
|
2914
|
-
`);
|
|
2915
|
-
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
2916
|
-
await socket.write(`DATA\r
|
|
2917
|
-
`);
|
|
2918
|
-
await waitForSmtpResponse(readResponse, ["354"]);
|
|
2919
|
-
const payload = buildSmtpPayload(config.from, message);
|
|
2920
|
-
await socket.write(payload);
|
|
2921
|
-
await waitForSmtpResponse(readResponse, ["250"]);
|
|
2922
|
-
await socket.write(`QUIT\r
|
|
2923
|
-
`);
|
|
2924
|
-
await waitForSmtpResponse(readResponse, ["221"]);
|
|
2925
|
-
} finally {
|
|
2926
|
-
socket.end();
|
|
2806
|
+
bits += value.toString(2).padStart(5, "0");
|
|
2927
2807
|
}
|
|
2808
|
+
const bytes = [];
|
|
2809
|
+
for (let index = 0;index + 8 <= bits.length; index += 8) {
|
|
2810
|
+
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
|
2811
|
+
}
|
|
2812
|
+
return Buffer.from(bytes);
|
|
2928
2813
|
}
|
|
2929
|
-
function
|
|
2930
|
-
const
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
message.html,
|
|
2948
|
-
`--${boundary}--`,
|
|
2949
|
-
""
|
|
2950
|
-
];
|
|
2951
|
-
return [...headers, "", ...parts, ".", ""].join(`\r
|
|
2952
|
-
`);
|
|
2814
|
+
function generateTotp(secret, counter, digits = 6) {
|
|
2815
|
+
const key = decodeBase32(secret);
|
|
2816
|
+
const buffer = Buffer.alloc(8);
|
|
2817
|
+
buffer.writeBigUInt64BE(BigInt(counter));
|
|
2818
|
+
const digest = createHmac2("sha1", key).update(buffer).digest();
|
|
2819
|
+
const lastByte = digest[digest.length - 1] ?? 0;
|
|
2820
|
+
const offset = lastByte & 15;
|
|
2821
|
+
const b0 = digest[offset] ?? 0;
|
|
2822
|
+
const b1 = digest[offset + 1] ?? 0;
|
|
2823
|
+
const b2 = digest[offset + 2] ?? 0;
|
|
2824
|
+
const b3 = digest[offset + 3] ?? 0;
|
|
2825
|
+
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
2826
|
+
return String(code % 10 ** digits).padStart(digits, "0");
|
|
2827
|
+
}
|
|
2828
|
+
function verifyTotp(secret, token, window = 1) {
|
|
2829
|
+
const normalized = token.trim();
|
|
2830
|
+
if (!/^\d{6}$/u.test(normalized)) {
|
|
2831
|
+
return false;
|
|
2953
2832
|
}
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2833
|
+
const timestep = Math.floor(Date.now() / 30000);
|
|
2834
|
+
for (let offset = -window;offset <= window; offset += 1) {
|
|
2835
|
+
if (generateTotp(secret, timestep + offset) === normalized) {
|
|
2836
|
+
return true;
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
return false;
|
|
2957
2840
|
}
|
|
2958
2841
|
|
|
2959
|
-
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2842
|
+
// ../../src/core/tenant/tenantContext.ts
|
|
2843
|
+
import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
|
|
2844
|
+
var tenantContext = new AsyncLocalStorage4;
|
|
2845
|
+
function runWithTenant(tenant, callback) {
|
|
2846
|
+
return tenantContext.run(tenant, callback);
|
|
2847
|
+
}
|
|
2848
|
+
function currentTenant() {
|
|
2849
|
+
return tenantContext.getStore() ?? null;
|
|
2850
|
+
}
|
|
2851
|
+
function currentTenantId() {
|
|
2852
|
+
return currentTenant()?.id ?? 1;
|
|
2853
|
+
}
|
|
2854
|
+
function rateLimitMultiplierForPlan(plan) {
|
|
2855
|
+
switch (plan) {
|
|
2856
|
+
case "enterprise":
|
|
2857
|
+
return 4;
|
|
2858
|
+
case "pro":
|
|
2859
|
+
return 2;
|
|
2860
|
+
default:
|
|
2861
|
+
return 1;
|
|
2969
2862
|
}
|
|
2970
2863
|
}
|
|
2971
2864
|
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2865
|
+
// ../../src/modules/user/authService.ts
|
|
2866
|
+
class AuthService {
|
|
2867
|
+
users;
|
|
2868
|
+
tokens;
|
|
2869
|
+
oauthIdentities;
|
|
2870
|
+
oauthProviders = new Map;
|
|
2871
|
+
constructor(users, tokens, oauthIdentities) {
|
|
2872
|
+
this.users = users;
|
|
2873
|
+
this.tokens = tokens;
|
|
2874
|
+
this.oauthIdentities = oauthIdentities;
|
|
2978
2875
|
}
|
|
2979
|
-
|
|
2980
|
-
|
|
2876
|
+
registerOAuthProvider(provider) {
|
|
2877
|
+
this.oauthProviders.set(provider.name, provider);
|
|
2878
|
+
}
|
|
2879
|
+
getOAuthProvider(name) {
|
|
2880
|
+
return this.oauthProviders.get(name);
|
|
2881
|
+
}
|
|
2882
|
+
async loginWithPassword(email, password, options = {}) {
|
|
2883
|
+
const user = await this.users.findByEmail(email);
|
|
2884
|
+
if (!user?.password_hash) {
|
|
2885
|
+
logSecurityEvent("auth_login_failed", { reason: "unknown_user", email });
|
|
2886
|
+
throw new UnauthorizedError("Invalid credentials.");
|
|
2887
|
+
}
|
|
2888
|
+
const valid = await verifyPassword(password, user.password_hash);
|
|
2889
|
+
if (!valid) {
|
|
2890
|
+
logSecurityEvent("auth_login_failed", { reason: "invalid_password", user_id: user.id });
|
|
2891
|
+
throw new UnauthorizedError("Invalid credentials.");
|
|
2892
|
+
}
|
|
2893
|
+
if (isFeatureEnabled("emailVerification") && !user.email_verified_at) {
|
|
2894
|
+
logSecurityEvent("auth_login_blocked", { reason: "email_unverified", user_id: user.id });
|
|
2895
|
+
throw new UnauthorizedError("Email address is not verified.");
|
|
2896
|
+
}
|
|
2897
|
+
if (isFeatureEnabled("mfa") && user.mfa_enabled) {
|
|
2898
|
+
const mfaSecret = revealMfaSecret(user.mfa_secret);
|
|
2899
|
+
if (!mfaSecret || !options.mfaCode || !verifyTotp(mfaSecret, options.mfaCode)) {
|
|
2900
|
+
logSecurityEvent("auth_login_failed", { reason: "invalid_mfa", user_id: user.id });
|
|
2901
|
+
throw new UnauthorizedError("Invalid MFA code.");
|
|
2902
|
+
}
|
|
2903
|
+
}
|
|
2904
|
+
logSecurityEvent("auth_login_success", { user_id: user.id, method: "password" });
|
|
2905
|
+
return await this.tokens.createToken(user.id, {
|
|
2906
|
+
name: "password-login",
|
|
2907
|
+
abilities: resolveAbilitiesForRole(user.role),
|
|
2908
|
+
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
2909
|
+
});
|
|
2910
|
+
}
|
|
2911
|
+
async loginWithOAuth(providerName, code) {
|
|
2912
|
+
const provider = this.oauthProviders.get(providerName);
|
|
2913
|
+
if (!provider) {
|
|
2914
|
+
throw new UnauthorizedError("Unsupported OAuth provider.");
|
|
2915
|
+
}
|
|
2916
|
+
const profile = await provider.exchangeCode(code);
|
|
2917
|
+
const user = await this.findOrCreateOAuthUser(providerName, profile);
|
|
2918
|
+
logSecurityEvent("auth_login_success", { user_id: user.id, method: `oauth:${providerName}` });
|
|
2919
|
+
return await this.tokens.createToken(user.id, {
|
|
2920
|
+
name: `${providerName}-oauth`,
|
|
2921
|
+
abilities: resolveAbilitiesForRole(user.role),
|
|
2922
|
+
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
buildOAuthAuthorizationUrl(providerName, state) {
|
|
2926
|
+
const provider = this.oauthProviders.get(providerName);
|
|
2927
|
+
if (!provider) {
|
|
2928
|
+
throw new UnauthorizedError("Unsupported OAuth provider.");
|
|
2929
|
+
}
|
|
2930
|
+
return provider.getAuthorizationUrl(state);
|
|
2931
|
+
}
|
|
2932
|
+
async findOrCreateOAuthUser(providerName, profile) {
|
|
2933
|
+
const existingIdentity = await this.oauthIdentities.findByProviderUser(providerName, profile.providerUserId);
|
|
2934
|
+
if (existingIdentity) {
|
|
2935
|
+
return await this.users.findByIdOrThrow(existingIdentity.user_id);
|
|
2936
|
+
}
|
|
2937
|
+
const existingUser = await this.users.findByEmail(profile.email);
|
|
2938
|
+
const user = existingUser ?? await this.users.create({
|
|
2939
|
+
name: profile.name,
|
|
2940
|
+
email: profile.email,
|
|
2941
|
+
role: "member",
|
|
2942
|
+
tenant_id: currentTenantId(),
|
|
2943
|
+
email_verified_at: new Date,
|
|
2944
|
+
created_at: new Date,
|
|
2945
|
+
updated_at: new Date
|
|
2946
|
+
});
|
|
2947
|
+
await this.oauthIdentities.create({
|
|
2948
|
+
user_id: user.id,
|
|
2949
|
+
provider: providerName,
|
|
2950
|
+
provider_user_id: profile.providerUserId,
|
|
2951
|
+
email: profile.email,
|
|
2952
|
+
created_at: new Date
|
|
2953
|
+
});
|
|
2954
|
+
return user;
|
|
2981
2955
|
}
|
|
2982
2956
|
}
|
|
2983
2957
|
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2958
|
+
// ../../src/modules/user/notificationTable.ts
|
|
2959
|
+
var notificationTable = defineTable({
|
|
2960
|
+
name: "notification",
|
|
2961
|
+
primaryKey: "id",
|
|
2962
|
+
columns: ["id", "user_id", "tenant_id", "type", "title", "body", "data", "read_at", "created_at"],
|
|
2963
|
+
defaultOrderBy: { column: "created_at", direction: "DESC" }
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
// ../../src/modules/user/oauthIdentityRepository.ts
|
|
2967
|
+
var oauthIdentityTable = defineTable({
|
|
2968
|
+
name: "oauth_identity",
|
|
2969
|
+
primaryKey: "id",
|
|
2970
|
+
columns: ["id", "user_id", "provider", "provider_user_id", "email", "created_at"]
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
// ../../src/modules/user/table.ts
|
|
2974
|
+
var userTable = defineTable({
|
|
2975
|
+
name: "users",
|
|
2976
|
+
primaryKey: "id",
|
|
2977
|
+
columns: [
|
|
2978
|
+
"id",
|
|
2979
|
+
"name",
|
|
2980
|
+
"email",
|
|
2981
|
+
"email_lookup",
|
|
2982
|
+
"role",
|
|
2983
|
+
"tenant_id",
|
|
2984
|
+
"password_hash",
|
|
2985
|
+
"email_verified_at",
|
|
2986
|
+
"mfa_secret",
|
|
2987
|
+
"mfa_enabled",
|
|
2988
|
+
"created_at",
|
|
2989
|
+
"updated_at"
|
|
2990
|
+
],
|
|
2991
|
+
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
2992
|
+
});
|
|
2993
|
+
|
|
2994
|
+
// ../../src/modules/user/provider.ts
|
|
2995
|
+
var tokenServiceToken = CORE_TOKEN_SERVICE_TOKEN;
|
|
2996
|
+
|
|
2997
|
+
// ../../src/core/auth/guard.ts
|
|
2998
|
+
function devHeaderAbilities(role) {
|
|
2999
|
+
if (role === "admin") {
|
|
3000
|
+
return [...ADMIN_ABILITIES];
|
|
2991
3001
|
}
|
|
3002
|
+
return [...MEMBER_ABILITIES];
|
|
2992
3003
|
}
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
3004
|
+
|
|
3005
|
+
class GuestGuard {
|
|
3006
|
+
resolve(request) {
|
|
3007
|
+
const userId = request.headers.get("x-authenticated-user-id");
|
|
3008
|
+
if (!userId) {
|
|
3009
|
+
return null;
|
|
3010
|
+
}
|
|
3011
|
+
const role = request.headers.get("x-authenticated-user-role");
|
|
3012
|
+
return {
|
|
3013
|
+
id: userId,
|
|
3014
|
+
abilities: devHeaderAbilities(role),
|
|
3015
|
+
...role ? { role } : {}
|
|
3016
|
+
};
|
|
2997
3017
|
}
|
|
2998
|
-
return new LogMailDriver;
|
|
2999
|
-
}
|
|
3000
|
-
var appMailer = new Mailer(createMailDriver());
|
|
3001
|
-
function mailer() {
|
|
3002
|
-
return appMailer;
|
|
3003
3018
|
}
|
|
3004
3019
|
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
class LocalStorageDriver {
|
|
3011
|
-
rootDirectory;
|
|
3012
|
-
constructor(rootDirectory) {
|
|
3013
|
-
this.rootDirectory = rootDirectory;
|
|
3014
|
-
}
|
|
3015
|
-
resolvePath(path) {
|
|
3016
|
-
return join3(this.rootDirectory, path.replace(/^\/+/, ""));
|
|
3017
|
-
}
|
|
3018
|
-
async put(path, contents) {
|
|
3019
|
-
const absolutePath = this.resolvePath(path);
|
|
3020
|
-
await mkdir(dirname(absolutePath), { recursive: true });
|
|
3021
|
-
await writeFile(absolutePath, contents);
|
|
3022
|
-
return path;
|
|
3020
|
+
class ApiTokenGuard {
|
|
3021
|
+
options;
|
|
3022
|
+
constructor(options) {
|
|
3023
|
+
this.options = options;
|
|
3023
3024
|
}
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
} catch {
|
|
3025
|
+
resolve(request) {
|
|
3026
|
+
const authorization = request.headers.get("authorization");
|
|
3027
|
+
if (!authorization?.startsWith("Bearer ")) {
|
|
3028
3028
|
return null;
|
|
3029
3029
|
}
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
await unlink(this.resolvePath(path));
|
|
3034
|
-
return true;
|
|
3035
|
-
} catch {
|
|
3036
|
-
return false;
|
|
3030
|
+
const token = authorization.slice("Bearer ".length).trim();
|
|
3031
|
+
if (token !== this.options.token) {
|
|
3032
|
+
return null;
|
|
3037
3033
|
}
|
|
3034
|
+
return this.options.user;
|
|
3038
3035
|
}
|
|
3039
3036
|
}
|
|
3040
3037
|
|
|
3041
|
-
class
|
|
3042
|
-
|
|
3043
|
-
constructor(
|
|
3044
|
-
this.
|
|
3045
|
-
}
|
|
3046
|
-
async
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
}
|
|
3050
|
-
async get(path) {
|
|
3051
|
-
const normalizedPath = path.replace(/^\/+/, "");
|
|
3052
|
-
const file = this.client.file(normalizedPath);
|
|
3053
|
-
if (!await file.exists()) {
|
|
3038
|
+
class DatabaseTokenGuard {
|
|
3039
|
+
container;
|
|
3040
|
+
constructor(container) {
|
|
3041
|
+
this.container = container;
|
|
3042
|
+
}
|
|
3043
|
+
async resolve(request) {
|
|
3044
|
+
const authorization = request.headers.get("authorization");
|
|
3045
|
+
if (!authorization?.startsWith("Bearer ")) {
|
|
3054
3046
|
return null;
|
|
3055
3047
|
}
|
|
3056
|
-
|
|
3048
|
+
const token = authorization.slice("Bearer ".length).trim();
|
|
3049
|
+
if (!token) {
|
|
3050
|
+
return null;
|
|
3051
|
+
}
|
|
3052
|
+
if (!this.container.has(tokenServiceToken)) {
|
|
3053
|
+
return null;
|
|
3054
|
+
}
|
|
3055
|
+
const tokenService = this.container.resolve(tokenServiceToken);
|
|
3056
|
+
return await tokenService.resolveUserFromToken(token);
|
|
3057
3057
|
}
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3058
|
+
}
|
|
3059
|
+
|
|
3060
|
+
class CompositeGuard {
|
|
3061
|
+
guards;
|
|
3062
|
+
constructor(guards) {
|
|
3063
|
+
this.guards = guards;
|
|
3064
|
+
}
|
|
3065
|
+
async resolve(request) {
|
|
3066
|
+
for (const guard of this.guards) {
|
|
3067
|
+
const user = await Promise.resolve(guard.resolve(request));
|
|
3068
|
+
if (user) {
|
|
3069
|
+
return user;
|
|
3070
|
+
}
|
|
3064
3071
|
}
|
|
3072
|
+
return null;
|
|
3065
3073
|
}
|
|
3066
3074
|
}
|
|
3067
3075
|
|
|
3068
|
-
class
|
|
3069
|
-
|
|
3070
|
-
constructor(
|
|
3071
|
-
this.
|
|
3076
|
+
class AuthManager {
|
|
3077
|
+
guard;
|
|
3078
|
+
constructor(guard) {
|
|
3079
|
+
this.guard = guard;
|
|
3072
3080
|
}
|
|
3073
|
-
|
|
3074
|
-
|
|
3081
|
+
async resolve(request) {
|
|
3082
|
+
if (request) {
|
|
3083
|
+
return await Promise.resolve(this.guard.resolve(request));
|
|
3084
|
+
}
|
|
3085
|
+
return currentAuthUser();
|
|
3075
3086
|
}
|
|
3076
|
-
|
|
3077
|
-
return this.
|
|
3087
|
+
user(request) {
|
|
3088
|
+
return this.resolve(request);
|
|
3078
3089
|
}
|
|
3079
|
-
|
|
3080
|
-
return this.
|
|
3090
|
+
async check(request) {
|
|
3091
|
+
return await this.user(request) !== null;
|
|
3081
3092
|
}
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
|
|
3093
|
+
async requireUser(request) {
|
|
3094
|
+
const user = await this.user(request);
|
|
3095
|
+
if (!user) {
|
|
3096
|
+
throw new UnauthorizedError;
|
|
3097
|
+
}
|
|
3098
|
+
return user;
|
|
3089
3099
|
}
|
|
3090
|
-
return {
|
|
3091
|
-
accessKeyId,
|
|
3092
|
-
secretAccessKey,
|
|
3093
|
-
bucket,
|
|
3094
|
-
...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
|
|
3095
|
-
...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
|
|
3096
|
-
};
|
|
3097
|
-
}
|
|
3098
|
-
function createS3Client(config = resolveS3Config()) {
|
|
3099
|
-
return new S3Client({
|
|
3100
|
-
accessKeyId: config.accessKeyId,
|
|
3101
|
-
secretAccessKey: config.secretAccessKey,
|
|
3102
|
-
bucket: config.bucket,
|
|
3103
|
-
...config.region ? { region: config.region } : {},
|
|
3104
|
-
...config.endpoint ? { endpoint: config.endpoint } : {}
|
|
3105
|
-
});
|
|
3106
3100
|
}
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3101
|
+
// ../../src/core/auth/membershipContext.ts
|
|
3102
|
+
import { AsyncLocalStorage as AsyncLocalStorage5 } from "async_hooks";
|
|
3103
|
+
|
|
3104
|
+
// ../../src/modules/organization/memberRepository.ts
|
|
3105
|
+
class OrganizationMemberRepository {
|
|
3106
|
+
constructor() {}
|
|
3107
|
+
async findMembership(userId, organizationId) {
|
|
3108
|
+
const rows = await connection_default`
|
|
3109
|
+
SELECT id, organization_id, user_id, role, created_at
|
|
3110
|
+
FROM organization_member
|
|
3111
|
+
WHERE user_id = ${userId} AND organization_id = ${organizationId}
|
|
3112
|
+
LIMIT 1
|
|
3113
|
+
`;
|
|
3114
|
+
return rows[0] ?? null;
|
|
3111
3115
|
}
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3116
|
+
async listForUser(userId) {
|
|
3117
|
+
return await connection_default`
|
|
3118
|
+
SELECT id, organization_id, user_id, role, created_at
|
|
3119
|
+
FROM organization_member
|
|
3120
|
+
WHERE user_id = ${userId}
|
|
3121
|
+
ORDER BY organization_id
|
|
3122
|
+
`;
|
|
3123
|
+
}
|
|
3124
|
+
async listForOrganization(organizationId) {
|
|
3125
|
+
return await connection_default`
|
|
3126
|
+
SELECT id, organization_id, user_id, role, created_at
|
|
3127
|
+
FROM organization_member
|
|
3128
|
+
WHERE organization_id = ${organizationId}
|
|
3129
|
+
ORDER BY id
|
|
3130
|
+
`;
|
|
3131
|
+
}
|
|
3132
|
+
async addMember(input) {
|
|
3133
|
+
const rows = await connection_default`
|
|
3134
|
+
INSERT INTO organization_member (organization_id, user_id, role)
|
|
3135
|
+
VALUES (${input.organizationId}, ${input.userId}, ${input.role ?? "member"})
|
|
3136
|
+
RETURNING id, organization_id, user_id, role, created_at
|
|
3137
|
+
`;
|
|
3138
|
+
const row = rows[0];
|
|
3139
|
+
if (!row) {
|
|
3140
|
+
throw new Error("Organization member insert did not return a row.");
|
|
3141
|
+
}
|
|
3142
|
+
return row;
|
|
3143
|
+
}
|
|
3144
|
+
async removeMember(organizationId, userId) {
|
|
3145
|
+
const rows = await connection_default`
|
|
3146
|
+
DELETE FROM organization_member
|
|
3147
|
+
WHERE organization_id = ${organizationId} AND user_id = ${userId}
|
|
3148
|
+
RETURNING id
|
|
3149
|
+
`;
|
|
3150
|
+
return rows.length > 0;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
var memberRepository_default = OrganizationMemberRepository;
|
|
3118
3154
|
|
|
3119
|
-
// ../../src/core/
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3155
|
+
// ../../src/core/auth/membershipContext.ts
|
|
3156
|
+
var membershipContext = new AsyncLocalStorage5;
|
|
3157
|
+
var membershipRepository = new memberRepository_default;
|
|
3158
|
+
async function runWithMembershipContext(callback) {
|
|
3159
|
+
const user = currentAuthUser();
|
|
3160
|
+
if (!user || isGlobalAdmin(user)) {
|
|
3161
|
+
return await callback();
|
|
3162
|
+
}
|
|
3163
|
+
const memberships = await membershipRepository.listForUser(resolveUserId(user));
|
|
3164
|
+
const context = {
|
|
3165
|
+
organizationIds: memberships.map((membership) => membership.organization_id),
|
|
3166
|
+
rolesByOrganizationId: new Map(memberships.map((membership) => [membership.organization_id, membership.role]))
|
|
3167
|
+
};
|
|
3168
|
+
return await membershipContext.run(context, callback);
|
|
3125
3169
|
}
|
|
3126
|
-
function
|
|
3127
|
-
return
|
|
3170
|
+
function currentOrgRole(organizationId) {
|
|
3171
|
+
return membershipContext.getStore()?.rolesByOrganizationId.get(organizationId) ?? null;
|
|
3128
3172
|
}
|
|
3129
|
-
function
|
|
3130
|
-
return
|
|
3173
|
+
function hasOrgMembership(organizationId) {
|
|
3174
|
+
return currentOrgRole(organizationId) !== null;
|
|
3131
3175
|
}
|
|
3132
|
-
function
|
|
3133
|
-
return
|
|
3176
|
+
function currentOrganizationIds() {
|
|
3177
|
+
return membershipContext.getStore()?.organizationIds ?? [];
|
|
3134
3178
|
}
|
|
3135
|
-
function
|
|
3136
|
-
|
|
3179
|
+
function hasMinimumOrgRole2(organizationId, minimum) {
|
|
3180
|
+
const role = currentOrgRole(organizationId);
|
|
3181
|
+
if (!role) {
|
|
3182
|
+
return false;
|
|
3183
|
+
}
|
|
3184
|
+
const ranks = {
|
|
3185
|
+
member: 1,
|
|
3186
|
+
admin: 2,
|
|
3187
|
+
owner: 3
|
|
3188
|
+
};
|
|
3189
|
+
return ranks[role] >= ranks[minimum];
|
|
3137
3190
|
}
|
|
3138
|
-
|
|
3139
|
-
|
|
3191
|
+
// ../../src/core/auth/membershipContextMiddleware.ts
|
|
3192
|
+
function createMembershipContextMiddleware() {
|
|
3193
|
+
return async (_request, next) => {
|
|
3194
|
+
return await runWithMembershipContext(async () => await next());
|
|
3195
|
+
};
|
|
3140
3196
|
}
|
|
3141
|
-
|
|
3142
|
-
|
|
3197
|
+
|
|
3198
|
+
// ../../src/core/auth/membershipMiddleware.ts
|
|
3199
|
+
function createMembershipMiddleware() {
|
|
3200
|
+
return createMembershipContextMiddleware();
|
|
3143
3201
|
}
|
|
3144
|
-
|
|
3145
|
-
|
|
3202
|
+
// ../../src/core/auth/membershipScope.ts
|
|
3203
|
+
function resolveOrganizationScope() {
|
|
3204
|
+
const user = currentAuthUser();
|
|
3205
|
+
if (!user) {
|
|
3206
|
+
return null;
|
|
3207
|
+
}
|
|
3208
|
+
if (isGlobalAdmin(user)) {
|
|
3209
|
+
return null;
|
|
3210
|
+
}
|
|
3211
|
+
return currentOrganizationIds();
|
|
3146
3212
|
}
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
if (!raw) {
|
|
3152
|
-
return DEFAULT_MAX_BODY_BYTES;
|
|
3213
|
+
function scopedOrganizationIds(requestedOrganizationId) {
|
|
3214
|
+
const scope = resolveOrganizationScope();
|
|
3215
|
+
if (scope === null) {
|
|
3216
|
+
return requestedOrganizationId === undefined ? null : [requestedOrganizationId];
|
|
3153
3217
|
}
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
return DEFAULT_MAX_BODY_BYTES;
|
|
3218
|
+
if (requestedOrganizationId !== undefined) {
|
|
3219
|
+
return scope.includes(requestedOrganizationId) ? [requestedOrganizationId] : [];
|
|
3157
3220
|
}
|
|
3158
|
-
return
|
|
3221
|
+
return scope;
|
|
3159
3222
|
}
|
|
3160
|
-
function
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
}
|
|
3170
|
-
|
|
3223
|
+
function appendOrganizationScope(where, requestedOrganizationId) {
|
|
3224
|
+
const organizationIds = scopedOrganizationIds(requestedOrganizationId);
|
|
3225
|
+
if (organizationIds === null) {
|
|
3226
|
+
return where;
|
|
3227
|
+
}
|
|
3228
|
+
if (organizationIds.length === 0) {
|
|
3229
|
+
return {
|
|
3230
|
+
...where,
|
|
3231
|
+
organization_id: [-1]
|
|
3232
|
+
};
|
|
3233
|
+
}
|
|
3234
|
+
return {
|
|
3235
|
+
...where,
|
|
3236
|
+
organization_id: organizationIds.length === 1 ? organizationIds[0] : organizationIds
|
|
3171
3237
|
};
|
|
3172
3238
|
}
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
const value = cookies.get(name);
|
|
3178
|
-
if (value) {
|
|
3179
|
-
return value;
|
|
3239
|
+
function appendProjectScope(where, accessibleProjectIds, requestedProjectId) {
|
|
3240
|
+
if (accessibleProjectIds === null) {
|
|
3241
|
+
if (requestedProjectId === undefined) {
|
|
3242
|
+
return where;
|
|
3180
3243
|
}
|
|
3244
|
+
return {
|
|
3245
|
+
...where,
|
|
3246
|
+
project_id: requestedProjectId
|
|
3247
|
+
};
|
|
3181
3248
|
}
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
const idx = part.indexOf("=");
|
|
3188
|
-
if (idx === -1)
|
|
3189
|
-
continue;
|
|
3190
|
-
const cookieName = part.slice(0, idx).trim();
|
|
3191
|
-
if (cookieName !== name)
|
|
3192
|
-
continue;
|
|
3193
|
-
return decodeURIComponent(part.slice(idx + 1).trim());
|
|
3249
|
+
if (accessibleProjectIds.length === 0) {
|
|
3250
|
+
return {
|
|
3251
|
+
...where,
|
|
3252
|
+
project_id: [-1]
|
|
3253
|
+
};
|
|
3194
3254
|
}
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
}
|
|
3200
|
-
// ../../src/core/http/csrfToken.ts
|
|
3201
|
-
import { timingSafeEqual } from "crypto";
|
|
3202
|
-
|
|
3203
|
-
// ../../src/core/http/requestMetaContext.ts
|
|
3204
|
-
import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
|
|
3205
|
-
var requestMetaContext = new AsyncLocalStorage3;
|
|
3206
|
-
function runWithRequestMeta(meta, callback) {
|
|
3207
|
-
return requestMetaContext.run(meta, callback);
|
|
3208
|
-
}
|
|
3209
|
-
function currentRequestMeta() {
|
|
3210
|
-
return requestMetaContext.getStore() ?? {
|
|
3211
|
-
ipAddress: null,
|
|
3212
|
-
userAgent: null
|
|
3213
|
-
};
|
|
3214
|
-
}
|
|
3215
|
-
|
|
3216
|
-
// ../../src/core/http/csrfToken.ts
|
|
3217
|
-
var CSRF_COOKIE = "workhub_csrf";
|
|
3218
|
-
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
3219
|
-
function resolveCsrfSecret() {
|
|
3220
|
-
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
|
|
3221
|
-
}
|
|
3222
|
-
function csrfVerifyOptions() {
|
|
3223
|
-
return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
|
|
3224
|
-
}
|
|
3225
|
-
function tokensMatch(left, right) {
|
|
3226
|
-
const leftBuffer = Buffer.from(left);
|
|
3227
|
-
const rightBuffer = Buffer.from(right);
|
|
3228
|
-
if (leftBuffer.length !== rightBuffer.length) {
|
|
3229
|
-
return false;
|
|
3255
|
+
if (requestedProjectId !== undefined) {
|
|
3256
|
+
return {
|
|
3257
|
+
...where,
|
|
3258
|
+
project_id: accessibleProjectIds.includes(requestedProjectId) ? requestedProjectId : -1
|
|
3259
|
+
};
|
|
3230
3260
|
}
|
|
3231
|
-
return
|
|
3261
|
+
return {
|
|
3262
|
+
...where,
|
|
3263
|
+
project_id: accessibleProjectIds
|
|
3264
|
+
};
|
|
3232
3265
|
}
|
|
3233
|
-
function
|
|
3234
|
-
const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
|
|
3266
|
+
function emptyPaginateResult(page, perPage) {
|
|
3235
3267
|
return {
|
|
3236
|
-
|
|
3237
|
-
|
|
3268
|
+
data: [],
|
|
3269
|
+
meta: {
|
|
3270
|
+
page,
|
|
3271
|
+
per_page: perPage,
|
|
3272
|
+
total: 0,
|
|
3273
|
+
last_page: 1
|
|
3274
|
+
}
|
|
3238
3275
|
};
|
|
3239
3276
|
}
|
|
3240
|
-
function
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
return { token: cookieValue };
|
|
3277
|
+
function assertResourceInCurrentTenant(resourceTenantId, resourceLabel, resourceId) {
|
|
3278
|
+
if (resourceTenantId !== currentTenantId()) {
|
|
3279
|
+
throw new NotFoundError(`${resourceLabel} ${resourceId} not found.`);
|
|
3244
3280
|
}
|
|
3245
|
-
return createCsrfTokenCookie();
|
|
3246
3281
|
}
|
|
3247
|
-
function
|
|
3248
|
-
const
|
|
3249
|
-
if (
|
|
3250
|
-
return
|
|
3282
|
+
function assertOrganizationReadable(organizationId) {
|
|
3283
|
+
const user = currentAuthUser();
|
|
3284
|
+
if (!user || isGlobalAdmin(user)) {
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
3287
|
+
const organizationIds = scopedOrganizationIds();
|
|
3288
|
+
if (organizationIds !== null && !organizationIds.includes(organizationId)) {
|
|
3289
|
+
throw new NotFoundError(`Organization ${organizationId} not found.`);
|
|
3251
3290
|
}
|
|
3252
|
-
return null;
|
|
3253
3291
|
}
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
|
|
3292
|
+
// ../../src/core/auth/membershipService.ts
|
|
3293
|
+
class MembershipService {
|
|
3294
|
+
members;
|
|
3295
|
+
constructor(members = membershipRepository) {
|
|
3296
|
+
this.members = members;
|
|
3258
3297
|
}
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3298
|
+
async listOrganizationIdsForUser(userId) {
|
|
3299
|
+
const memberships = await this.members.listForUser(userId);
|
|
3300
|
+
return memberships.map((membership) => membership.organization_id);
|
|
3301
|
+
}
|
|
3302
|
+
async getOrgRole(userId, organizationId) {
|
|
3303
|
+
const membership = await this.members.findMembership(userId, organizationId);
|
|
3304
|
+
return membership?.role ?? null;
|
|
3305
|
+
}
|
|
3306
|
+
async requireOrgAccess(organizationId, minimumRole = "member", user = currentAuthUser()) {
|
|
3307
|
+
if (!user) {
|
|
3308
|
+
throw new ForbiddenError("Authentication required.");
|
|
3265
3309
|
}
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
return legacyField.trim();
|
|
3310
|
+
if (isGlobalAdmin(user)) {
|
|
3311
|
+
return "owner";
|
|
3269
3312
|
}
|
|
3313
|
+
const role = await this.getOrgRole(resolveUserId(user), organizationId);
|
|
3314
|
+
if (!role || !hasMinimumOrgRole(role, minimumRole)) {
|
|
3315
|
+
throw new ForbiddenError("Organization membership required.");
|
|
3316
|
+
}
|
|
3317
|
+
return role;
|
|
3318
|
+
}
|
|
3319
|
+
async filterAccessibleOrganizationIds(organizationIds, user = currentAuthUser()) {
|
|
3320
|
+
if (!user) {
|
|
3321
|
+
return [];
|
|
3322
|
+
}
|
|
3323
|
+
if (isGlobalAdmin(user)) {
|
|
3324
|
+
return organizationIds;
|
|
3325
|
+
}
|
|
3326
|
+
const allowed = new Set(await this.listOrganizationIdsForUser(resolveUserId(user)));
|
|
3327
|
+
return organizationIds.filter((organizationId) => allowed.has(organizationId));
|
|
3328
|
+
}
|
|
3329
|
+
async addOwnerOnOrganizationCreate(organizationId, userId) {
|
|
3330
|
+
await this.members.addMember({
|
|
3331
|
+
organizationId,
|
|
3332
|
+
userId,
|
|
3333
|
+
role: "owner"
|
|
3334
|
+
});
|
|
3335
|
+
}
|
|
3336
|
+
listMembersForOrganization(organizationId) {
|
|
3337
|
+
return this.members.listForOrganization(organizationId);
|
|
3338
|
+
}
|
|
3339
|
+
addMember(input) {
|
|
3340
|
+
return this.members.addMember(input);
|
|
3341
|
+
}
|
|
3342
|
+
removeMember(organizationId, userId) {
|
|
3343
|
+
return this.members.removeMember(organizationId, userId);
|
|
3270
3344
|
}
|
|
3271
|
-
return null;
|
|
3272
3345
|
}
|
|
3273
|
-
function
|
|
3274
|
-
|
|
3346
|
+
function resolveMembershipService() {
|
|
3347
|
+
const dependencies = resolveApplicationDependencies();
|
|
3348
|
+
if (dependencies.container.has("core.membership")) {
|
|
3349
|
+
return dependencies.container.resolve("core.membership");
|
|
3350
|
+
}
|
|
3351
|
+
return new MembershipService;
|
|
3352
|
+
}
|
|
3353
|
+
var membershipService_default = MembershipService;
|
|
3354
|
+
// ../../src/core/auth/policy.ts
|
|
3355
|
+
class Policy {
|
|
3356
|
+
constructor() {}
|
|
3357
|
+
view(_user, _resource) {
|
|
3275
3358
|
return false;
|
|
3276
3359
|
}
|
|
3277
|
-
|
|
3278
|
-
if (!cookieValue) {
|
|
3360
|
+
create(_user) {
|
|
3279
3361
|
return false;
|
|
3280
3362
|
}
|
|
3281
|
-
|
|
3363
|
+
update(_user, _resource) {
|
|
3282
3364
|
return false;
|
|
3283
3365
|
}
|
|
3284
|
-
|
|
3285
|
-
|
|
3286
|
-
function resolveCsrfTokenForRequest(request) {
|
|
3287
|
-
const metaToken = currentRequestMeta().csrfToken;
|
|
3288
|
-
if (metaToken) {
|
|
3289
|
-
return metaToken;
|
|
3366
|
+
delete(_user, _resource) {
|
|
3367
|
+
return false;
|
|
3290
3368
|
}
|
|
3291
|
-
return resolveCsrfToken(request).token;
|
|
3292
3369
|
}
|
|
3370
|
+
var BLOCKED_POLICY_ACTIONS = new Set([
|
|
3371
|
+
"constructor",
|
|
3372
|
+
"toString",
|
|
3373
|
+
"valueOf",
|
|
3374
|
+
"hasOwnProperty",
|
|
3375
|
+
"isPrototypeOf",
|
|
3376
|
+
"propertyIsEnumerable",
|
|
3377
|
+
"__proto__"
|
|
3378
|
+
]);
|
|
3293
3379
|
|
|
3294
|
-
|
|
3295
|
-
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
}
|
|
3305
|
-
function createCsrfMiddleware() {
|
|
3306
|
-
return async (request, next) => {
|
|
3307
|
-
const method = request.method.toUpperCase();
|
|
3308
|
-
if (!MUTATING_METHODS.has(method)) {
|
|
3309
|
-
const csrf = resolveCsrfToken(request);
|
|
3310
|
-
const meta = currentRequestMeta();
|
|
3311
|
-
meta.csrfToken = csrf.token;
|
|
3312
|
-
const response = await next();
|
|
3313
|
-
if (!csrf.cookie) {
|
|
3314
|
-
return response;
|
|
3315
|
-
}
|
|
3316
|
-
return appendSetCookie(response, csrf.cookie);
|
|
3380
|
+
class PolicyGate {
|
|
3381
|
+
constructor() {}
|
|
3382
|
+
policies = new Map;
|
|
3383
|
+
register(resource, policy) {
|
|
3384
|
+
this.policies.set(resource, policy);
|
|
3385
|
+
}
|
|
3386
|
+
allows(resource, action, user, model) {
|
|
3387
|
+
const policy = this.policies.get(resource);
|
|
3388
|
+
if (!policy) {
|
|
3389
|
+
return false;
|
|
3317
3390
|
}
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
throw new ForbiddenError("Invalid or missing CSRF token.");
|
|
3391
|
+
if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
|
|
3392
|
+
return false;
|
|
3321
3393
|
}
|
|
3322
|
-
|
|
3323
|
-
|
|
3394
|
+
const handler = policy[action];
|
|
3395
|
+
if (typeof handler !== "function") {
|
|
3396
|
+
return false;
|
|
3397
|
+
}
|
|
3398
|
+
const resolvedUser = user === undefined ? currentAuthUser() : user;
|
|
3399
|
+
return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
|
|
3400
|
+
}
|
|
3401
|
+
authorize(resource, action, user, model) {
|
|
3402
|
+
if (!this.allows(resource, action, user, model)) {
|
|
3403
|
+
throw new ForbiddenError;
|
|
3404
|
+
}
|
|
3405
|
+
}
|
|
3324
3406
|
}
|
|
3325
|
-
// ../../src/core/
|
|
3326
|
-
|
|
3327
|
-
|
|
3328
|
-
const expiresIn = options.expiresIn ?? DEFAULT_CSRF_TTL_MS;
|
|
3329
|
-
const maxAge = options.maxAge ?? expiresIn;
|
|
3330
|
-
return {
|
|
3331
|
-
generate(_sessionKey) {
|
|
3332
|
-
return Bun.CSRF.generate(secret, { expiresIn });
|
|
3333
|
-
},
|
|
3334
|
-
verify(token, _sessionKey) {
|
|
3335
|
-
if (!token) {
|
|
3336
|
-
return false;
|
|
3337
|
-
}
|
|
3338
|
-
return Bun.CSRF.verify(token, { secret, maxAge });
|
|
3339
|
-
},
|
|
3340
|
-
secret
|
|
3341
|
-
};
|
|
3407
|
+
// ../../src/core/database/bindConnection.ts
|
|
3408
|
+
function bindDatabaseConnection2(connection) {
|
|
3409
|
+
bindDatabaseConnection(connection);
|
|
3342
3410
|
}
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3411
|
+
|
|
3412
|
+
// ../../src/domain/scim.ts
|
|
3413
|
+
var TEST_SCIM_BEARER_TOKEN = "workhub-scim-test-token";
|
|
3414
|
+
|
|
3415
|
+
// ../../src/core/security/timingSafeCompare.ts
|
|
3416
|
+
import { timingSafeEqual } from "crypto";
|
|
3417
|
+
function timingSafeCompareString(left, right) {
|
|
3418
|
+
const leftBuffer = Buffer.from(left);
|
|
3419
|
+
const rightBuffer = Buffer.from(right);
|
|
3420
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
3421
|
+
return false;
|
|
3422
|
+
}
|
|
3423
|
+
return timingSafeEqual(leftBuffer, rightBuffer);
|
|
3346
3424
|
}
|
|
3347
3425
|
|
|
3348
|
-
// ../../src/core/
|
|
3349
|
-
function
|
|
3350
|
-
|
|
3426
|
+
// ../../src/core/security/scimTenantTokens.ts
|
|
3427
|
+
function parseScimTenantTokens(raw) {
|
|
3428
|
+
const tokens = new Map;
|
|
3429
|
+
if (!raw?.trim()) {
|
|
3430
|
+
return tokens;
|
|
3431
|
+
}
|
|
3432
|
+
for (const entry of raw.split(",")) {
|
|
3433
|
+
const [tenantPart, tokenPart] = entry.split(":");
|
|
3434
|
+
if (!tenantPart || !tokenPart) {
|
|
3435
|
+
continue;
|
|
3436
|
+
}
|
|
3437
|
+
const tenantId = Number.parseInt(tenantPart.trim(), 10);
|
|
3438
|
+
const token = tokenPart.trim();
|
|
3439
|
+
if (Number.isInteger(tenantId) && tenantId > 0 && token.length > 0) {
|
|
3440
|
+
tokens.set(tenantId, token);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
return tokens;
|
|
3351
3444
|
}
|
|
3352
|
-
function
|
|
3353
|
-
|
|
3445
|
+
function resolveScimTenantFromToken(token) {
|
|
3446
|
+
const tenantTokens = parseScimTenantTokens(process.env.SCIM_TENANT_TOKENS);
|
|
3447
|
+
for (const [tenantId, expectedToken] of tenantTokens) {
|
|
3448
|
+
if (timingSafeCompareString(token, expectedToken)) {
|
|
3449
|
+
return tenantId;
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
const fallbackToken = process.env.SCIM_BEARER_TOKEN ?? TEST_SCIM_BEARER_TOKEN;
|
|
3453
|
+
if (timingSafeCompareString(token, fallbackToken)) {
|
|
3454
|
+
return 1;
|
|
3455
|
+
}
|
|
3456
|
+
return null;
|
|
3354
3457
|
}
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3458
|
+
|
|
3459
|
+
// ../../src/core/tenant/resolveTenant.ts
|
|
3460
|
+
async function resolveTenant(tenantId) {
|
|
3461
|
+
const rows = await repositoryConnection`
|
|
3462
|
+
SELECT id, slug, plan, region
|
|
3463
|
+
FROM tenant
|
|
3464
|
+
WHERE id = ${tenantId}
|
|
3465
|
+
LIMIT 1
|
|
3466
|
+
`;
|
|
3467
|
+
const row = rows[0];
|
|
3468
|
+
return row ? { id: row.id, slug: row.slug, plan: row.plan, region: row.region ?? "eu" } : null;
|
|
3358
3469
|
}
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3470
|
+
|
|
3471
|
+
// ../../src/core/tenant/tenantDatabaseScope.ts
|
|
3472
|
+
async function applyTenantContextToTransaction(transaction, tenantId) {
|
|
3473
|
+
await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
|
|
3474
|
+
await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
|
|
3475
|
+
}
|
|
3476
|
+
async function runWithTenantDatabase(tenant, callback) {
|
|
3477
|
+
if (hasActiveDatabaseConnection()) {
|
|
3478
|
+
const activeConnection2 = getActiveDatabaseConnection(getDefaultDatabasePool());
|
|
3479
|
+
await applyTenantContextToTransaction(activeConnection2, tenant.id);
|
|
3480
|
+
return await runWithTenant(tenant, callback);
|
|
3481
|
+
}
|
|
3482
|
+
return await getDefaultDatabasePool().begin(async (transaction) => {
|
|
3483
|
+
await applyTenantContextToTransaction(transaction, tenant.id);
|
|
3484
|
+
return await runWithDatabaseConnection(transaction, async () => {
|
|
3485
|
+
return await runWithTenant(tenant, callback);
|
|
3486
|
+
});
|
|
3487
|
+
});
|
|
3364
3488
|
}
|
|
3365
|
-
function
|
|
3366
|
-
return
|
|
3489
|
+
function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
|
|
3490
|
+
return hasActiveDatabaseConnection() && currentTenant()?.id === tenantId;
|
|
3367
3491
|
}
|
|
3368
|
-
|
|
3369
|
-
|
|
3492
|
+
|
|
3493
|
+
// ../../src/core/auth/scimAuthMiddleware.ts
|
|
3494
|
+
function createScimAuthMiddleware() {
|
|
3495
|
+
return async (request, next) => {
|
|
3496
|
+
const authorization = request.headers.get("authorization");
|
|
3497
|
+
if (!authorization?.startsWith("Bearer ")) {
|
|
3498
|
+
return jsonScimError("SCIM bearer token required.", 401);
|
|
3499
|
+
}
|
|
3500
|
+
const token = authorization.slice("Bearer ".length).trim();
|
|
3501
|
+
const tenantId = resolveScimTenantFromToken(token);
|
|
3502
|
+
if (tenantId === null) {
|
|
3503
|
+
return jsonScimError("Invalid SCIM bearer token.", 401);
|
|
3504
|
+
}
|
|
3505
|
+
const tenant = await resolveTenant(tenantId);
|
|
3506
|
+
if (!tenant) {
|
|
3507
|
+
return jsonScimError("SCIM tenant not found.", 401);
|
|
3508
|
+
}
|
|
3509
|
+
return await runWithTenantDatabase(tenant, async () => {
|
|
3510
|
+
bindDatabaseConnection2(getActiveDatabaseConnection(getDefaultDatabasePool()));
|
|
3511
|
+
try {
|
|
3512
|
+
return await next();
|
|
3513
|
+
} finally {
|
|
3514
|
+
resetBoundDatabaseConnection();
|
|
3515
|
+
}
|
|
3516
|
+
});
|
|
3517
|
+
};
|
|
3370
3518
|
}
|
|
3371
|
-
function
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3519
|
+
function jsonScimError(detail, status) {
|
|
3520
|
+
return Response.json({
|
|
3521
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
3522
|
+
detail,
|
|
3523
|
+
status: String(status)
|
|
3524
|
+
}, {
|
|
3525
|
+
status,
|
|
3526
|
+
headers: { "content-type": "application/scim+json" }
|
|
3527
|
+
});
|
|
3376
3528
|
}
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3529
|
+
// ../../src/core/cache/taggedCache.ts
|
|
3530
|
+
class TaggedCache {
|
|
3531
|
+
store;
|
|
3532
|
+
tags;
|
|
3533
|
+
constructor(store, tags) {
|
|
3534
|
+
this.store = store;
|
|
3535
|
+
this.tags = tags;
|
|
3381
3536
|
}
|
|
3382
|
-
|
|
3383
|
-
|
|
3537
|
+
async remember(key, callback, ttlMs) {
|
|
3538
|
+
const value = await this.store.getOrSet(key, callback, ttlMs);
|
|
3539
|
+
await this.store.attachTags(key, this.tags);
|
|
3540
|
+
return value;
|
|
3541
|
+
}
|
|
3542
|
+
async flush() {
|
|
3543
|
+
return this.store.flushTags(this.tags);
|
|
3384
3544
|
}
|
|
3385
|
-
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
3386
3545
|
}
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3546
|
+
var taggedCache_default = TaggedCache;
|
|
3547
|
+
|
|
3548
|
+
// ../../src/core/cache/repository.ts
|
|
3549
|
+
class CacheRepository {
|
|
3550
|
+
store;
|
|
3551
|
+
constructor(store) {
|
|
3552
|
+
this.store = store;
|
|
3391
3553
|
}
|
|
3392
|
-
|
|
3393
|
-
return
|
|
3554
|
+
async get(key) {
|
|
3555
|
+
return this.store.get(key);
|
|
3556
|
+
}
|
|
3557
|
+
async remember(key, callback, ttlMs) {
|
|
3558
|
+
return this.store.getOrSet(key, callback, ttlMs);
|
|
3559
|
+
}
|
|
3560
|
+
async forget(key) {
|
|
3561
|
+
return this.store.invalidate(key);
|
|
3562
|
+
}
|
|
3563
|
+
async flush() {
|
|
3564
|
+
await this.store.clear();
|
|
3565
|
+
}
|
|
3566
|
+
tags(...names) {
|
|
3567
|
+
return new taggedCache_default(this.store, names);
|
|
3568
|
+
}
|
|
3569
|
+
async getOrSet(key, loader, ttlMs) {
|
|
3570
|
+
return this.remember(key, loader, ttlMs);
|
|
3571
|
+
}
|
|
3572
|
+
async invalidate(key) {
|
|
3573
|
+
return this.forget(key);
|
|
3574
|
+
}
|
|
3575
|
+
async invalidateByPrefix(prefix) {
|
|
3576
|
+
return this.store.invalidateByPrefix(prefix);
|
|
3394
3577
|
}
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
function assertIfMatch(request, etag, options = {}) {
|
|
3398
|
-
const header = request.headers.get("if-match");
|
|
3399
|
-
if (!header) {
|
|
3400
|
-
if (options.required) {
|
|
3401
|
-
throw new PreconditionFailedError("If-Match header is required.");
|
|
3402
|
-
}
|
|
3403
|
-
return;
|
|
3578
|
+
async clear() {
|
|
3579
|
+
await this.flush();
|
|
3404
3580
|
}
|
|
3405
|
-
|
|
3406
|
-
|
|
3581
|
+
async size() {
|
|
3582
|
+
return this.store.size();
|
|
3407
3583
|
}
|
|
3408
3584
|
}
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
if (ifNoneMatchSatisfied(request, etag)) {
|
|
3428
|
-
return notModifiedResponse(etag);
|
|
3585
|
+
var repository_default2 = CacheRepository;
|
|
3586
|
+
// ../../src/core/cache/tags.ts
|
|
3587
|
+
var CACHE_TAGS = {
|
|
3588
|
+
organizations: "organizations",
|
|
3589
|
+
projects: "projects",
|
|
3590
|
+
tasks: "tasks",
|
|
3591
|
+
comments: "comments",
|
|
3592
|
+
attachments: "attachments",
|
|
3593
|
+
reports: "reports"
|
|
3594
|
+
};
|
|
3595
|
+
// ../../src/core/database/migrations/advisoryLock.ts
|
|
3596
|
+
var MIGRATION_LOCK_KEY = 42424242;
|
|
3597
|
+
async function withMigrationLock(db2, callback, lockKey = MIGRATION_LOCK_KEY) {
|
|
3598
|
+
await db2.unsafe("SELECT pg_advisory_lock($1)", [lockKey]);
|
|
3599
|
+
try {
|
|
3600
|
+
return await callback();
|
|
3601
|
+
} finally {
|
|
3602
|
+
await db2.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
|
|
3429
3603
|
}
|
|
3430
|
-
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
3431
|
-
return new Response(response.body, {
|
|
3432
|
-
status: response.status,
|
|
3433
|
-
statusText: response.statusText,
|
|
3434
|
-
headers
|
|
3435
|
-
});
|
|
3436
3604
|
}
|
|
3437
|
-
// ../../src/core/
|
|
3438
|
-
import {
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3605
|
+
// ../../src/core/database/migrations/runner.ts
|
|
3606
|
+
import { readdir } from "fs/promises";
|
|
3607
|
+
import { join } from "path";
|
|
3608
|
+
import { pathToFileURL } from "url";
|
|
3609
|
+
var MIGRATIONS_TABLE = "framework_migrations";
|
|
3610
|
+
async function ensureMigrationsTable(db2) {
|
|
3611
|
+
await db2.unsafe(`
|
|
3612
|
+
CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
|
|
3613
|
+
name TEXT PRIMARY KEY,
|
|
3614
|
+
batch INTEGER NOT NULL,
|
|
3615
|
+
run_on TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
3616
|
+
)
|
|
3617
|
+
`);
|
|
3442
3618
|
}
|
|
3443
|
-
function
|
|
3444
|
-
|
|
3619
|
+
async function getAppliedMigrations(db2) {
|
|
3620
|
+
await ensureMigrationsTable(db2);
|
|
3621
|
+
return await db2.unsafe(`
|
|
3622
|
+
SELECT name, batch
|
|
3623
|
+
FROM ${MIGRATIONS_TABLE}
|
|
3624
|
+
ORDER BY batch ASC, name ASC
|
|
3625
|
+
`);
|
|
3445
3626
|
}
|
|
3446
|
-
function
|
|
3447
|
-
|
|
3627
|
+
async function loadMigrationsFromDirectory(directory) {
|
|
3628
|
+
const entries = await readdir(directory);
|
|
3629
|
+
const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
3630
|
+
const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
|
|
3631
|
+
const moduleUrl = pathToFileURL(join(directory, fileName)).href;
|
|
3632
|
+
const module = await import(moduleUrl);
|
|
3633
|
+
return module.default;
|
|
3634
|
+
}));
|
|
3635
|
+
return loadedMigrations.filter((migration) => migration?.name !== undefined);
|
|
3448
3636
|
}
|
|
3449
|
-
function
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
}
|
|
3637
|
+
async function getMigrationStatus(db2, migrations) {
|
|
3638
|
+
const applied = await getAppliedMigrations(db2);
|
|
3639
|
+
const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
|
|
3640
|
+
return migrations.map(({ name }) => ({
|
|
3641
|
+
name,
|
|
3642
|
+
status: appliedByName.has(name) ? "up" : "pending",
|
|
3643
|
+
batch: appliedByName.get(name) ?? null
|
|
3644
|
+
}));
|
|
3458
3645
|
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3646
|
+
async function runPendingMigrations(db2, migrations, options = {}) {
|
|
3647
|
+
const applied = await getAppliedMigrations(db2);
|
|
3648
|
+
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
3649
|
+
const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
|
|
3650
|
+
const pendingMigrations = migrations.filter(({ name }) => !appliedNames.has(name));
|
|
3651
|
+
for (const migration of pendingMigrations) {
|
|
3652
|
+
options.onMigration?.(migration.name);
|
|
3653
|
+
await migration.up(db2);
|
|
3654
|
+
const inserted = await db2.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name, batch) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING name`, [migration.name, nextBatch]);
|
|
3655
|
+
if (inserted.length === 0) {
|
|
3656
|
+
throw new Error(`Migration ${migration.name} was applied but not recorded.`);
|
|
3657
|
+
}
|
|
3464
3658
|
}
|
|
3465
|
-
|
|
3466
|
-
const user = currentAuthUser();
|
|
3467
|
-
const authScope = user ? `u:${user.id}` : "guest";
|
|
3468
|
-
const tenantScope = `t:${currentTenantId()}`;
|
|
3469
|
-
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
3659
|
+
return pendingMigrations.length;
|
|
3470
3660
|
}
|
|
3471
|
-
function
|
|
3472
|
-
|
|
3473
|
-
|
|
3661
|
+
async function migrateDatabase(db2, migrations, options = {}) {
|
|
3662
|
+
const { advisoryLock = false, onMigration } = options;
|
|
3663
|
+
if (advisoryLock) {
|
|
3664
|
+
return withMigrationLock(db2, () => runPendingMigrations(db2, migrations, { onMigration }));
|
|
3474
3665
|
}
|
|
3475
|
-
return
|
|
3666
|
+
return runPendingMigrations(db2, migrations, { onMigration });
|
|
3476
3667
|
}
|
|
3477
|
-
function
|
|
3478
|
-
const
|
|
3479
|
-
if (
|
|
3480
|
-
return;
|
|
3668
|
+
async function rollbackDatabase(db2, migrations, options = {}) {
|
|
3669
|
+
const applied = await getAppliedMigrations(db2);
|
|
3670
|
+
if (applied.length === 0) {
|
|
3671
|
+
return 0;
|
|
3481
3672
|
}
|
|
3482
|
-
const
|
|
3483
|
-
|
|
3484
|
-
|
|
3673
|
+
const lastBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0);
|
|
3674
|
+
const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
|
|
3675
|
+
let rolledBack = 0;
|
|
3676
|
+
for (const migration of [...migrations].reverse()) {
|
|
3677
|
+
if (!migrationsToRollback.has(migration.name)) {
|
|
3678
|
+
continue;
|
|
3679
|
+
}
|
|
3680
|
+
options.onMigration?.(migration.name);
|
|
3681
|
+
await migration.down(db2);
|
|
3682
|
+
await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = $1`, [migration.name]);
|
|
3683
|
+
rolledBack += 1;
|
|
3485
3684
|
}
|
|
3486
|
-
return
|
|
3685
|
+
return rolledBack;
|
|
3487
3686
|
}
|
|
3488
|
-
function
|
|
3489
|
-
const
|
|
3490
|
-
|
|
3687
|
+
async function freshDatabase(db2, migrations, options = {}) {
|
|
3688
|
+
const runFresh = async () => {
|
|
3689
|
+
const applied = await getAppliedMigrations(db2);
|
|
3690
|
+
const appliedNames = new Set(applied.map(({ name }) => name));
|
|
3691
|
+
const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
|
|
3692
|
+
for (const migration of [...appliedMigrations].reverse()) {
|
|
3693
|
+
options.onMigration?.(migration.name);
|
|
3694
|
+
await migration.down(db2);
|
|
3695
|
+
}
|
|
3696
|
+
if (appliedMigrations.length > 0) {
|
|
3697
|
+
await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
|
|
3698
|
+
}
|
|
3699
|
+
await runPendingMigrations(db2, migrations, options);
|
|
3700
|
+
};
|
|
3701
|
+
if (options.advisoryLock) {
|
|
3702
|
+
await withMigrationLock(db2, runFresh);
|
|
3491
3703
|
return;
|
|
3492
3704
|
}
|
|
3493
|
-
|
|
3494
|
-
case "true":
|
|
3495
|
-
case "1":
|
|
3496
|
-
return true;
|
|
3497
|
-
case "false":
|
|
3498
|
-
case "0":
|
|
3499
|
-
return false;
|
|
3500
|
-
default:
|
|
3501
|
-
throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
|
|
3502
|
-
}
|
|
3705
|
+
await runFresh();
|
|
3503
3706
|
}
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3707
|
+
// ../../src/core/database/seeders/runner.ts
|
|
3708
|
+
import { readdir as readdir2 } from "fs/promises";
|
|
3709
|
+
import { join as join2 } from "path";
|
|
3710
|
+
import { pathToFileURL as pathToFileURL2 } from "url";
|
|
3711
|
+
async function loadSeedersFromDirectory(directory) {
|
|
3712
|
+
const entries = await readdir2(directory);
|
|
3713
|
+
const seederFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
|
|
3714
|
+
const loadedSeeders = await Promise.all(seederFiles.map(async (fileName) => {
|
|
3715
|
+
const moduleUrl = pathToFileURL2(join2(directory, fileName)).href;
|
|
3716
|
+
const module = await import(moduleUrl);
|
|
3717
|
+
return module.default;
|
|
3718
|
+
}));
|
|
3719
|
+
return loadedSeeders.filter((seeder) => seeder?.name !== undefined);
|
|
3513
3720
|
}
|
|
3514
|
-
function
|
|
3515
|
-
|
|
3516
|
-
|
|
3721
|
+
async function runSeedersFromDirectory(directory, db2, options) {
|
|
3722
|
+
const seeders = await loadSeedersFromDirectory(directory);
|
|
3723
|
+
if (seeders.length === 0) {
|
|
3724
|
+
return 0;
|
|
3517
3725
|
}
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
let payload;
|
|
3522
|
-
try {
|
|
3523
|
-
payload = await request.json();
|
|
3524
|
-
} catch {
|
|
3525
|
-
throw new BadRequestError("Request body must be valid JSON.");
|
|
3726
|
+
for (const seeder of seeders) {
|
|
3727
|
+
options?.onSeeder?.(seeder.name);
|
|
3728
|
+
await seeder.run(db2);
|
|
3526
3729
|
}
|
|
3527
|
-
return
|
|
3730
|
+
return seeders.length;
|
|
3528
3731
|
}
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
const trimmed = value.trim();
|
|
3535
|
-
if (options.minLength !== undefined && trimmed.length < options.minLength) {
|
|
3536
|
-
throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
|
|
3732
|
+
// ../../src/core/mail/mailer.ts
|
|
3733
|
+
function resolveSmtpConfig() {
|
|
3734
|
+
const host = process.env.MAIL_HOST?.trim();
|
|
3735
|
+
if (!host) {
|
|
3736
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
|
|
3537
3737
|
}
|
|
3538
|
-
|
|
3539
|
-
|
|
3738
|
+
const from = process.env.MAIL_FROM?.trim();
|
|
3739
|
+
if (!from) {
|
|
3740
|
+
throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
|
|
3540
3741
|
}
|
|
3541
|
-
|
|
3542
|
-
|
|
3742
|
+
const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
|
|
3743
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
3744
|
+
throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
|
|
3543
3745
|
}
|
|
3544
|
-
return
|
|
3746
|
+
return {
|
|
3747
|
+
host,
|
|
3748
|
+
port,
|
|
3749
|
+
from,
|
|
3750
|
+
secure: (process.env.MAIL_SECURE ?? "false") === "true",
|
|
3751
|
+
...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
|
|
3752
|
+
...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
|
|
3753
|
+
};
|
|
3545
3754
|
}
|
|
3546
|
-
function
|
|
3547
|
-
|
|
3548
|
-
|
|
3755
|
+
function encodeBase64(value) {
|
|
3756
|
+
return Buffer.from(value, "utf8").toString("base64");
|
|
3757
|
+
}
|
|
3758
|
+
function parseSmtpResponses(buffer) {
|
|
3759
|
+
const responses = [];
|
|
3760
|
+
let remainder = buffer;
|
|
3761
|
+
while (remainder.includes(`\r
|
|
3762
|
+
`)) {
|
|
3763
|
+
const index = remainder.indexOf(`\r
|
|
3764
|
+
`);
|
|
3765
|
+
const line = remainder.slice(0, index);
|
|
3766
|
+
remainder = remainder.slice(index + 2);
|
|
3767
|
+
if (line.length >= 4 && line[3] === "-") {
|
|
3768
|
+
continue;
|
|
3769
|
+
}
|
|
3770
|
+
responses.push(line);
|
|
3549
3771
|
}
|
|
3550
|
-
return
|
|
3772
|
+
return { responses, remainder };
|
|
3551
3773
|
}
|
|
3552
|
-
function
|
|
3553
|
-
const
|
|
3554
|
-
|
|
3555
|
-
|
|
3774
|
+
async function waitForSmtpResponse(readResponse, expectedCodes) {
|
|
3775
|
+
const response = await readResponse();
|
|
3776
|
+
const code = response.slice(0, 3);
|
|
3777
|
+
if (!expectedCodes.includes(code)) {
|
|
3778
|
+
throw new Error(`Unexpected SMTP response: ${response}`);
|
|
3556
3779
|
}
|
|
3557
|
-
return
|
|
3780
|
+
return response;
|
|
3558
3781
|
}
|
|
3559
|
-
function
|
|
3560
|
-
|
|
3561
|
-
|
|
3562
|
-
|
|
3563
|
-
|
|
3782
|
+
async function openSmtpConnection(config) {
|
|
3783
|
+
let buffer = "";
|
|
3784
|
+
const waiters = [];
|
|
3785
|
+
const readResponse = () => new Promise((resolve, reject) => {
|
|
3786
|
+
const parsed = parseSmtpResponses(buffer);
|
|
3787
|
+
if (parsed.responses.length > 0) {
|
|
3788
|
+
buffer = parsed.remainder;
|
|
3789
|
+
resolve(parsed.responses.shift());
|
|
3790
|
+
return;
|
|
3791
|
+
}
|
|
3792
|
+
waiters.push({ resolve, reject });
|
|
3793
|
+
});
|
|
3794
|
+
const socket = await Bun.connect({
|
|
3795
|
+
hostname: config.host,
|
|
3796
|
+
port: config.port,
|
|
3797
|
+
socket: {
|
|
3798
|
+
open() {},
|
|
3799
|
+
data(_socket, chunk) {
|
|
3800
|
+
buffer += Buffer.from(chunk).toString("utf8");
|
|
3801
|
+
const parsed = parseSmtpResponses(buffer);
|
|
3802
|
+
buffer = parsed.remainder;
|
|
3803
|
+
while (parsed.responses.length > 0 && waiters.length > 0) {
|
|
3804
|
+
const response = parsed.responses.shift();
|
|
3805
|
+
waiters.shift()?.resolve(response);
|
|
3806
|
+
}
|
|
3807
|
+
},
|
|
3808
|
+
error(_socket, error) {
|
|
3809
|
+
const pending = waiters.splice(0);
|
|
3810
|
+
for (const waiter of pending) {
|
|
3811
|
+
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
|
3812
|
+
}
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
});
|
|
3816
|
+
return { socket, readResponse };
|
|
3564
3817
|
}
|
|
3565
|
-
function
|
|
3566
|
-
const
|
|
3567
|
-
|
|
3568
|
-
|
|
3818
|
+
async function defaultSmtpTransport(config, message) {
|
|
3819
|
+
const { socket, readResponse } = await openSmtpConnection(config);
|
|
3820
|
+
try {
|
|
3821
|
+
await waitForSmtpResponse(readResponse, ["220"]);
|
|
3822
|
+
await socket.write(`EHLO workhub.local\r
|
|
3823
|
+
`);
|
|
3824
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
3825
|
+
if (config.username && config.password) {
|
|
3826
|
+
await socket.write(`AUTH LOGIN\r
|
|
3827
|
+
`);
|
|
3828
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
3829
|
+
await socket.write(`${encodeBase64(config.username)}\r
|
|
3830
|
+
`);
|
|
3831
|
+
await waitForSmtpResponse(readResponse, ["334"]);
|
|
3832
|
+
await socket.write(`${encodeBase64(config.password)}\r
|
|
3833
|
+
`);
|
|
3834
|
+
await waitForSmtpResponse(readResponse, ["235"]);
|
|
3835
|
+
}
|
|
3836
|
+
await socket.write(`MAIL FROM:<${config.from}>\r
|
|
3837
|
+
`);
|
|
3838
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
3839
|
+
await socket.write(`RCPT TO:<${message.to}>\r
|
|
3840
|
+
`);
|
|
3841
|
+
await waitForSmtpResponse(readResponse, ["250", "251"]);
|
|
3842
|
+
await socket.write(`DATA\r
|
|
3843
|
+
`);
|
|
3844
|
+
await waitForSmtpResponse(readResponse, ["354"]);
|
|
3845
|
+
const payload = buildSmtpPayload(config.from, message);
|
|
3846
|
+
await socket.write(payload);
|
|
3847
|
+
await waitForSmtpResponse(readResponse, ["250"]);
|
|
3848
|
+
await socket.write(`QUIT\r
|
|
3849
|
+
`);
|
|
3850
|
+
await waitForSmtpResponse(readResponse, ["221"]);
|
|
3851
|
+
} finally {
|
|
3852
|
+
socket.end();
|
|
3569
3853
|
}
|
|
3570
|
-
return value;
|
|
3571
3854
|
}
|
|
3572
|
-
function
|
|
3573
|
-
|
|
3574
|
-
|
|
3855
|
+
function buildSmtpPayload(from, message) {
|
|
3856
|
+
const headers = [
|
|
3857
|
+
`From: ${from}`,
|
|
3858
|
+
`To: ${message.to}`,
|
|
3859
|
+
`Subject: ${message.subject}`,
|
|
3860
|
+
"MIME-Version: 1.0"
|
|
3861
|
+
];
|
|
3862
|
+
if (message.html) {
|
|
3863
|
+
const boundary = `strata-${Date.now().toString(36)}`;
|
|
3864
|
+
headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
|
|
3865
|
+
const parts = [
|
|
3866
|
+
`--${boundary}`,
|
|
3867
|
+
"Content-Type: text/plain; charset=utf-8",
|
|
3868
|
+
"",
|
|
3869
|
+
message.body,
|
|
3870
|
+
`--${boundary}`,
|
|
3871
|
+
"Content-Type: text/html; charset=utf-8",
|
|
3872
|
+
"",
|
|
3873
|
+
message.html,
|
|
3874
|
+
`--${boundary}--`,
|
|
3875
|
+
""
|
|
3876
|
+
];
|
|
3877
|
+
return [...headers, "", ...parts, ".", ""].join(`\r
|
|
3878
|
+
`);
|
|
3575
3879
|
}
|
|
3576
|
-
|
|
3880
|
+
headers.push("Content-Type: text/plain; charset=utf-8");
|
|
3881
|
+
return [...headers, "", message.body, ".", ""].join(`\r
|
|
3882
|
+
`);
|
|
3577
3883
|
}
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3884
|
+
|
|
3885
|
+
class LogMailDriver {
|
|
3886
|
+
async send(message) {
|
|
3887
|
+
console.log(JSON.stringify({
|
|
3888
|
+
level: "info",
|
|
3889
|
+
channel: "mail",
|
|
3890
|
+
to: message.to,
|
|
3891
|
+
subject: message.subject,
|
|
3892
|
+
body: message.body,
|
|
3893
|
+
...message.html ? { html: message.html } : {}
|
|
3894
|
+
}));
|
|
3582
3895
|
}
|
|
3583
|
-
return parsed;
|
|
3584
3896
|
}
|
|
3585
3897
|
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3898
|
+
class SmtpMailDriver {
|
|
3899
|
+
config;
|
|
3900
|
+
transport;
|
|
3901
|
+
constructor(config, transport = defaultSmtpTransport) {
|
|
3902
|
+
this.config = config;
|
|
3903
|
+
this.transport = transport;
|
|
3590
3904
|
}
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
throw new ForbiddenError;
|
|
3594
|
-
}
|
|
3595
|
-
return await parseJsonBody(request, (payload) => this.parse(payload));
|
|
3905
|
+
send(message) {
|
|
3906
|
+
return this.transport(this.config, message);
|
|
3596
3907
|
}
|
|
3597
3908
|
}
|
|
3598
3909
|
|
|
3599
|
-
class
|
|
3600
|
-
|
|
3601
|
-
|
|
3910
|
+
class Mailer {
|
|
3911
|
+
driver;
|
|
3912
|
+
constructor(driver) {
|
|
3913
|
+
this.driver = driver;
|
|
3602
3914
|
}
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
function readFrontendMode() {
|
|
3606
|
-
const mode = (process.env.FRONTEND_MODE ?? "api").trim();
|
|
3607
|
-
if (mode === "server-htmx") {
|
|
3608
|
-
return "server-htmx";
|
|
3915
|
+
send(message) {
|
|
3916
|
+
return this.driver.send(message);
|
|
3609
3917
|
}
|
|
3610
|
-
|
|
3611
|
-
|
|
3918
|
+
}
|
|
3919
|
+
function createMailDriver() {
|
|
3920
|
+
const driver = process.env.MAIL_DRIVER ?? "log";
|
|
3921
|
+
if (driver === "smtp") {
|
|
3922
|
+
return new SmtpMailDriver(resolveSmtpConfig());
|
|
3612
3923
|
}
|
|
3613
|
-
return
|
|
3924
|
+
return new LogMailDriver;
|
|
3614
3925
|
}
|
|
3615
|
-
|
|
3616
|
-
|
|
3926
|
+
var appMailer = new Mailer(createMailDriver());
|
|
3927
|
+
function mailer() {
|
|
3928
|
+
return appMailer;
|
|
3617
3929
|
}
|
|
3618
3930
|
|
|
3619
|
-
// ../../src/core/
|
|
3620
|
-
import {
|
|
3621
|
-
import {
|
|
3622
|
-
var
|
|
3623
|
-
var DEFAULT_LAYOUT = "layouts/app.eta";
|
|
3931
|
+
// ../../src/core/storage/storage.ts
|
|
3932
|
+
import { mkdir, readFile, unlink, writeFile } from "fs/promises";
|
|
3933
|
+
import { dirname, join as join3 } from "path";
|
|
3934
|
+
var {S3Client } = globalThis.Bun;
|
|
3624
3935
|
|
|
3625
|
-
class
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
this.eta = new Eta({
|
|
3630
|
-
views: viewsDirectory,
|
|
3631
|
-
autoTrim: false
|
|
3632
|
-
});
|
|
3633
|
-
this.resolveLayoutData = resolveLayoutData;
|
|
3936
|
+
class LocalStorageDriver {
|
|
3937
|
+
rootDirectory;
|
|
3938
|
+
constructor(rootDirectory) {
|
|
3939
|
+
this.rootDirectory = rootDirectory;
|
|
3634
3940
|
}
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
|
|
3638
|
-
const mergedData = { ...layoutData, ...data };
|
|
3639
|
-
const body = await this.eta.renderAsync(template, mergedData);
|
|
3640
|
-
const layout = options.layout ?? DEFAULT_LAYOUT;
|
|
3641
|
-
if (layout === false) {
|
|
3642
|
-
return body;
|
|
3643
|
-
}
|
|
3644
|
-
const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
|
|
3645
|
-
return await this.eta.renderAsync(layoutTemplate, {
|
|
3646
|
-
...mergedData,
|
|
3647
|
-
body
|
|
3648
|
-
});
|
|
3941
|
+
resolveRootDirectory() {
|
|
3942
|
+
return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
|
|
3649
3943
|
}
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
function htmlResponse(html, init = {}) {
|
|
3653
|
-
return new Response(html, {
|
|
3654
|
-
status: init.status ?? 200,
|
|
3655
|
-
statusText: init.statusText,
|
|
3656
|
-
headers: {
|
|
3657
|
-
"Content-Type": "text/html; charset=utf-8"
|
|
3658
|
-
}
|
|
3659
|
-
});
|
|
3660
|
-
}
|
|
3661
|
-
function isHtmxRequest(request) {
|
|
3662
|
-
return request.headers.get("HX-Request") === "true";
|
|
3663
|
-
}
|
|
3664
|
-
// ../../src/core/auth/oauth/oidcProvider.ts
|
|
3665
|
-
class OidcProvider {
|
|
3666
|
-
options;
|
|
3667
|
-
name;
|
|
3668
|
-
constructor(options) {
|
|
3669
|
-
this.options = options;
|
|
3670
|
-
this.name = options.name;
|
|
3671
|
-
}
|
|
3672
|
-
getAuthorizationUrl(state) {
|
|
3673
|
-
const params = new URLSearchParams({
|
|
3674
|
-
client_id: this.options.clientId,
|
|
3675
|
-
redirect_uri: this.options.redirectUri,
|
|
3676
|
-
response_type: "code",
|
|
3677
|
-
scope: (this.options.scopes ?? ["openid", "email", "profile"]).join(" "),
|
|
3678
|
-
state
|
|
3679
|
-
});
|
|
3680
|
-
return `${this.options.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
|
|
3681
|
-
}
|
|
3682
|
-
async exchangeCode(code) {
|
|
3683
|
-
const tokenResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/token`, {
|
|
3684
|
-
method: "POST",
|
|
3685
|
-
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
3686
|
-
body: new URLSearchParams({
|
|
3687
|
-
grant_type: "authorization_code",
|
|
3688
|
-
code,
|
|
3689
|
-
redirect_uri: this.options.redirectUri,
|
|
3690
|
-
client_id: this.options.clientId,
|
|
3691
|
-
client_secret: this.options.clientSecret
|
|
3692
|
-
})
|
|
3693
|
-
});
|
|
3694
|
-
const tokenBody = await tokenResponse.json();
|
|
3695
|
-
if (!tokenBody.access_token) {
|
|
3696
|
-
throw new Error("OIDC token exchange failed.");
|
|
3697
|
-
}
|
|
3698
|
-
const profileResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/userinfo`, {
|
|
3699
|
-
headers: { authorization: `Bearer ${tokenBody.access_token}` }
|
|
3700
|
-
});
|
|
3701
|
-
const profile = await profileResponse.json();
|
|
3702
|
-
return {
|
|
3703
|
-
providerUserId: profile.sub,
|
|
3704
|
-
email: profile.email ?? `${profile.sub}@oidc.local`,
|
|
3705
|
-
name: profile.name ?? profile.sub
|
|
3706
|
-
};
|
|
3944
|
+
resolvePath(path) {
|
|
3945
|
+
return join3(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
|
|
3707
3946
|
}
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
name = "github";
|
|
3714
|
-
constructor(options) {
|
|
3715
|
-
this.options = options;
|
|
3947
|
+
async put(path, contents) {
|
|
3948
|
+
const absolutePath = this.resolvePath(path);
|
|
3949
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
3950
|
+
await writeFile(absolutePath, contents);
|
|
3951
|
+
return path;
|
|
3716
3952
|
}
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
"content-type": "application/json"
|
|
3732
|
-
},
|
|
3733
|
-
body: JSON.stringify({
|
|
3734
|
-
client_id: this.options.clientId,
|
|
3735
|
-
client_secret: this.options.clientSecret,
|
|
3736
|
-
code,
|
|
3737
|
-
redirect_uri: this.options.redirectUri
|
|
3738
|
-
})
|
|
3739
|
-
});
|
|
3740
|
-
const tokenBody = await tokenResponse.json();
|
|
3741
|
-
if (!tokenBody.access_token) {
|
|
3742
|
-
throw new Error("GitHub OAuth token exchange failed.");
|
|
3743
|
-
}
|
|
3744
|
-
const profileResponse = await fetch("https://api.github.com/user", {
|
|
3745
|
-
headers: {
|
|
3746
|
-
authorization: `Bearer ${tokenBody.access_token}`,
|
|
3747
|
-
accept: "application/json",
|
|
3748
|
-
"user-agent": "workhub"
|
|
3749
|
-
}
|
|
3750
|
-
});
|
|
3751
|
-
const profile = await profileResponse.json();
|
|
3752
|
-
return {
|
|
3753
|
-
providerUserId: String(profile.id),
|
|
3754
|
-
email: profile.email ?? `${profile.login}@users.noreply.github.com`,
|
|
3755
|
-
name: profile.name ?? profile.login
|
|
3756
|
-
};
|
|
3953
|
+
async get(path) {
|
|
3954
|
+
try {
|
|
3955
|
+
return await readFile(this.resolvePath(path));
|
|
3956
|
+
} catch {
|
|
3957
|
+
return null;
|
|
3958
|
+
}
|
|
3959
|
+
}
|
|
3960
|
+
async delete(path) {
|
|
3961
|
+
try {
|
|
3962
|
+
await unlink(this.resolvePath(path));
|
|
3963
|
+
return true;
|
|
3964
|
+
} catch {
|
|
3965
|
+
return false;
|
|
3966
|
+
}
|
|
3757
3967
|
}
|
|
3758
3968
|
}
|
|
3759
3969
|
|
|
3760
|
-
class
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3970
|
+
class S3StorageDriver {
|
|
3971
|
+
client;
|
|
3972
|
+
constructor(client) {
|
|
3973
|
+
this.client = client;
|
|
3974
|
+
}
|
|
3975
|
+
async put(path, contents) {
|
|
3976
|
+
await this.client.write(path.replace(/^\/+/, ""), contents);
|
|
3977
|
+
return path;
|
|
3765
3978
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3979
|
+
async get(path) {
|
|
3980
|
+
const normalizedPath = path.replace(/^\/+/, "");
|
|
3981
|
+
const file = this.client.file(normalizedPath);
|
|
3982
|
+
if (!await file.exists()) {
|
|
3983
|
+
return null;
|
|
3984
|
+
}
|
|
3985
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
3768
3986
|
}
|
|
3769
|
-
async
|
|
3770
|
-
|
|
3771
|
-
|
|
3987
|
+
async delete(path) {
|
|
3988
|
+
try {
|
|
3989
|
+
await this.client.unlink(path.replace(/^\/+/, ""));
|
|
3990
|
+
return true;
|
|
3991
|
+
} catch {
|
|
3992
|
+
return false;
|
|
3772
3993
|
}
|
|
3773
|
-
return this.profile;
|
|
3774
3994
|
}
|
|
3775
3995
|
}
|
|
3776
3996
|
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
constructor(loginUrl) {
|
|
3782
|
-
this.loginUrl = loginUrl;
|
|
3997
|
+
class StorageManager {
|
|
3998
|
+
driver;
|
|
3999
|
+
constructor(driver) {
|
|
4000
|
+
this.driver = driver;
|
|
3783
4001
|
}
|
|
3784
|
-
|
|
3785
|
-
return
|
|
4002
|
+
put(path, contents) {
|
|
4003
|
+
return this.driver.put(path, contents);
|
|
3786
4004
|
}
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
return {
|
|
3793
|
-
providerUserId: email ?? "saml-user",
|
|
3794
|
-
email: email ?? "saml-user@workhub.test",
|
|
3795
|
-
name: name ?? "SAML User"
|
|
3796
|
-
};
|
|
4005
|
+
get(path) {
|
|
4006
|
+
return this.driver.get(path);
|
|
4007
|
+
}
|
|
4008
|
+
delete(path) {
|
|
4009
|
+
return this.driver.delete(path);
|
|
3797
4010
|
}
|
|
3798
4011
|
}
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
4012
|
+
function resolveS3Config() {
|
|
4013
|
+
const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
|
|
4014
|
+
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
|
|
4015
|
+
const bucket = process.env.AWS_BUCKET?.trim();
|
|
4016
|
+
if (!accessKeyId || !secretAccessKey || !bucket) {
|
|
4017
|
+
throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
|
|
4018
|
+
}
|
|
3802
4019
|
return {
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
|
|
3809
|
-
billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
|
|
3810
|
-
siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
|
|
3811
|
-
publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
|
|
3812
|
-
emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
|
|
3813
|
-
mfa: (process.env.FEATURE_MFA ?? "false") === "true"
|
|
4020
|
+
accessKeyId,
|
|
4021
|
+
secretAccessKey,
|
|
4022
|
+
bucket,
|
|
4023
|
+
...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
|
|
4024
|
+
...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
|
|
3814
4025
|
};
|
|
3815
4026
|
}
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
primaryKey: "id",
|
|
3824
|
-
columns: [
|
|
3825
|
-
"id",
|
|
3826
|
-
"user_id",
|
|
3827
|
-
"name",
|
|
3828
|
-
"token_hash",
|
|
3829
|
-
"abilities",
|
|
3830
|
-
"last_used_at",
|
|
3831
|
-
"expires_at",
|
|
3832
|
-
"created_at"
|
|
3833
|
-
],
|
|
3834
|
-
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
3835
|
-
});
|
|
3836
|
-
|
|
3837
|
-
// ../../src/core/auth/password.ts
|
|
3838
|
-
async function hashPassword(password) {
|
|
3839
|
-
return await Bun.password.hash(password, {
|
|
3840
|
-
algorithm: "bcrypt",
|
|
3841
|
-
cost: 10
|
|
4027
|
+
function createS3Client(config = resolveS3Config()) {
|
|
4028
|
+
return new S3Client({
|
|
4029
|
+
accessKeyId: config.accessKeyId,
|
|
4030
|
+
secretAccessKey: config.secretAccessKey,
|
|
4031
|
+
bucket: config.bucket,
|
|
4032
|
+
...config.region ? { region: config.region } : {},
|
|
4033
|
+
...config.endpoint ? { endpoint: config.endpoint } : {}
|
|
3842
4034
|
});
|
|
3843
4035
|
}
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
// ../../src/core/crypto/fieldEncryption.ts
|
|
3849
|
-
import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
|
|
3850
|
-
var ENCRYPTION_PREFIX = "enc:v1:";
|
|
3851
|
-
var IV_LENGTH = 12;
|
|
3852
|
-
var TAG_LENGTH = 16;
|
|
3853
|
-
function resolveEncryptionKey() {
|
|
3854
|
-
const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
|
|
3855
|
-
if (!raw) {
|
|
3856
|
-
return null;
|
|
3857
|
-
}
|
|
3858
|
-
if (/^[0-9a-f]{64}$/i.test(raw)) {
|
|
3859
|
-
return Buffer.from(raw, "hex");
|
|
3860
|
-
}
|
|
3861
|
-
const decoded = Buffer.from(raw, "base64");
|
|
3862
|
-
if (decoded.length === 32) {
|
|
3863
|
-
return decoded;
|
|
4036
|
+
function createStorageDriver() {
|
|
4037
|
+
const driver = process.env.STORAGE_DRIVER ?? "local";
|
|
4038
|
+
if (driver === "s3") {
|
|
4039
|
+
return new S3StorageDriver(createS3Client());
|
|
3864
4040
|
}
|
|
3865
|
-
|
|
4041
|
+
return new LocalStorageDriver;
|
|
3866
4042
|
}
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
if (
|
|
3870
|
-
|
|
3871
|
-
}
|
|
3872
|
-
if (featureFlag === "true") {
|
|
3873
|
-
return true;
|
|
4043
|
+
var defaultStorage = { current: null };
|
|
4044
|
+
function storage() {
|
|
4045
|
+
if (!defaultStorage.current) {
|
|
4046
|
+
defaultStorage.current = new StorageManager(createStorageDriver());
|
|
3874
4047
|
}
|
|
3875
|
-
return
|
|
4048
|
+
return defaultStorage.current;
|
|
3876
4049
|
}
|
|
3877
|
-
function
|
|
3878
|
-
|
|
3879
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
3880
|
-
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
3881
|
-
const tag = cipher.getAuthTag();
|
|
3882
|
-
const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
|
|
3883
|
-
return `${ENCRYPTION_PREFIX}${payload}`;
|
|
4050
|
+
function resetDefaultStorage() {
|
|
4051
|
+
defaultStorage.current = null;
|
|
3884
4052
|
}
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
|
|
3890
|
-
const iv = payload.subarray(0, IV_LENGTH);
|
|
3891
|
-
const tag = payload.subarray(payload.length - TAG_LENGTH);
|
|
3892
|
-
const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
|
|
3893
|
-
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
3894
|
-
decipher.setAuthTag(tag);
|
|
3895
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
4053
|
+
|
|
4054
|
+
// ../../src/core/facades/index.ts
|
|
4055
|
+
function cache() {
|
|
4056
|
+
return resolveApplicationCache();
|
|
3896
4057
|
}
|
|
3897
|
-
function
|
|
3898
|
-
return
|
|
4058
|
+
function auth() {
|
|
4059
|
+
return resolveApplicationAuth();
|
|
3899
4060
|
}
|
|
3900
|
-
function
|
|
3901
|
-
return
|
|
4061
|
+
function policyGate() {
|
|
4062
|
+
return resolveApplicationPolicyGate();
|
|
3902
4063
|
}
|
|
3903
|
-
function
|
|
3904
|
-
|
|
3905
|
-
const key = resolveEncryptionKey();
|
|
3906
|
-
if (!key || !isFieldEncryptionEnabled()) {
|
|
3907
|
-
return { storedEmail: normalized, emailLookup: normalized };
|
|
3908
|
-
}
|
|
3909
|
-
return {
|
|
3910
|
-
storedEmail: encryptField(normalized, key),
|
|
3911
|
-
emailLookup: hashLookupValue(normalized, key)
|
|
3912
|
-
};
|
|
4064
|
+
function queue() {
|
|
4065
|
+
return resolveApplicationQueue();
|
|
3913
4066
|
}
|
|
3914
|
-
function
|
|
3915
|
-
|
|
3916
|
-
if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
|
|
3917
|
-
return storedEmail;
|
|
3918
|
-
}
|
|
3919
|
-
return decryptField(storedEmail, key);
|
|
4067
|
+
function events() {
|
|
4068
|
+
return eventBus;
|
|
3920
4069
|
}
|
|
3921
|
-
function
|
|
3922
|
-
|
|
3923
|
-
const key = resolveEncryptionKey();
|
|
3924
|
-
if (!key || !isFieldEncryptionEnabled()) {
|
|
3925
|
-
return normalized;
|
|
3926
|
-
}
|
|
3927
|
-
return hashLookupValue(normalized, key);
|
|
4070
|
+
function config(key) {
|
|
4071
|
+
return resolveApplicationConfig().get(key);
|
|
3928
4072
|
}
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
function protectMfaSecret(secret) {
|
|
3932
|
-
const key = resolveEncryptionKey();
|
|
3933
|
-
if (!isFieldEncryptionEnabled() || !key) {
|
|
3934
|
-
return secret;
|
|
3935
|
-
}
|
|
3936
|
-
return encryptField(secret, key);
|
|
4073
|
+
function log() {
|
|
4074
|
+
return resolveApplicationLogger();
|
|
3937
4075
|
}
|
|
3938
|
-
function
|
|
3939
|
-
|
|
3940
|
-
|
|
4076
|
+
function mail() {
|
|
4077
|
+
return mailer();
|
|
4078
|
+
}
|
|
4079
|
+
function storageFacade() {
|
|
4080
|
+
return storage();
|
|
4081
|
+
}
|
|
4082
|
+
// ../../src/core/http/bodySizeLimitMiddleware.ts
|
|
4083
|
+
var DEFAULT_MAX_BODY_BYTES = 1048576;
|
|
4084
|
+
function resolveMaxBodyBytes() {
|
|
4085
|
+
const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
|
|
4086
|
+
if (!raw) {
|
|
4087
|
+
return DEFAULT_MAX_BODY_BYTES;
|
|
3941
4088
|
}
|
|
3942
|
-
const
|
|
3943
|
-
if (!
|
|
3944
|
-
return
|
|
4089
|
+
const parsed = Number.parseInt(raw, 10);
|
|
4090
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
4091
|
+
return DEFAULT_MAX_BODY_BYTES;
|
|
3945
4092
|
}
|
|
3946
|
-
return
|
|
4093
|
+
return parsed;
|
|
4094
|
+
}
|
|
4095
|
+
function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
|
|
4096
|
+
return async (request, next) => {
|
|
4097
|
+
const contentLength = request.headers.get("content-length");
|
|
4098
|
+
if (contentLength) {
|
|
4099
|
+
const bytes = Number.parseInt(contentLength, 10);
|
|
4100
|
+
if (Number.isInteger(bytes) && bytes > maxBytes) {
|
|
4101
|
+
const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
|
|
4102
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
4103
|
+
}
|
|
4104
|
+
}
|
|
4105
|
+
return await next();
|
|
4106
|
+
};
|
|
4107
|
+
}
|
|
4108
|
+
// ../../src/core/crypto/nonCryptographicHash.ts
|
|
4109
|
+
function nonCryptographicDigest(input) {
|
|
4110
|
+
return Bun.hash(input).toString(16);
|
|
3947
4111
|
}
|
|
3948
4112
|
|
|
3949
|
-
// ../../src/core/
|
|
3950
|
-
function
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
4113
|
+
// ../../src/core/http/etag.ts
|
|
4114
|
+
function isEtagEnabled() {
|
|
4115
|
+
return (process.env.FEATURE_ETAG ?? "true") !== "false";
|
|
4116
|
+
}
|
|
4117
|
+
function formatWeakEtag(digest) {
|
|
4118
|
+
return `W/"${digest}"`;
|
|
4119
|
+
}
|
|
4120
|
+
function computeEtagFromJson(data) {
|
|
4121
|
+
const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
|
|
4122
|
+
return formatWeakEtag(digest);
|
|
4123
|
+
}
|
|
4124
|
+
function etagFromResource(resource) {
|
|
4125
|
+
const version = resource.updated_at ?? resource.created_at ?? "";
|
|
4126
|
+
const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
|
|
4127
|
+
const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
|
|
4128
|
+
return formatWeakEtag(digest);
|
|
4129
|
+
}
|
|
4130
|
+
function normalizeEtag(value) {
|
|
4131
|
+
return value.trim();
|
|
4132
|
+
}
|
|
4133
|
+
function etagValuesMatch(left, right) {
|
|
4134
|
+
return normalizeEtag(left) === normalizeEtag(right);
|
|
4135
|
+
}
|
|
4136
|
+
function parseEtagList(header) {
|
|
4137
|
+
if (!header) {
|
|
4138
|
+
return [];
|
|
4139
|
+
}
|
|
4140
|
+
return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
|
|
4141
|
+
}
|
|
4142
|
+
function ifNoneMatchSatisfied(request, etag) {
|
|
4143
|
+
const header = request.headers.get("if-none-match");
|
|
4144
|
+
if (!header) {
|
|
4145
|
+
return false;
|
|
4146
|
+
}
|
|
4147
|
+
if (header.trim() === "*") {
|
|
4148
|
+
return true;
|
|
4149
|
+
}
|
|
4150
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
3962
4151
|
}
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
if (!raw) {
|
|
3968
|
-
return null;
|
|
4152
|
+
function ifMatchSatisfied(request, etag) {
|
|
4153
|
+
const header = request.headers.get("if-match");
|
|
4154
|
+
if (!header) {
|
|
4155
|
+
return false;
|
|
3969
4156
|
}
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
return null;
|
|
4157
|
+
if (header.trim() === "*") {
|
|
4158
|
+
return true;
|
|
3973
4159
|
}
|
|
3974
|
-
return
|
|
4160
|
+
return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
|
|
3975
4161
|
}
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
const normalized = input.replace(/=+$/u, "").toUpperCase();
|
|
3982
|
-
let bits = "";
|
|
3983
|
-
for (const char of normalized) {
|
|
3984
|
-
const value = alphabet.indexOf(char);
|
|
3985
|
-
if (value === -1) {
|
|
3986
|
-
throw new Error("Invalid base32 character in MFA secret.");
|
|
4162
|
+
function assertIfMatch(request, etag, options = {}) {
|
|
4163
|
+
const header = request.headers.get("if-match");
|
|
4164
|
+
if (!header) {
|
|
4165
|
+
if (options.required) {
|
|
4166
|
+
throw new PreconditionFailedError("If-Match header is required.");
|
|
3987
4167
|
}
|
|
3988
|
-
|
|
4168
|
+
return;
|
|
3989
4169
|
}
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
|
|
4170
|
+
if (!ifMatchSatisfied(request, etag)) {
|
|
4171
|
+
throw new PreconditionFailedError("Resource ETag does not match If-Match.");
|
|
3993
4172
|
}
|
|
3994
|
-
return Buffer.from(bytes);
|
|
3995
4173
|
}
|
|
3996
|
-
function
|
|
3997
|
-
const
|
|
3998
|
-
|
|
3999
|
-
|
|
4000
|
-
|
|
4001
|
-
|
|
4002
|
-
|
|
4003
|
-
const b0 = digest[offset] ?? 0;
|
|
4004
|
-
const b1 = digest[offset + 1] ?? 0;
|
|
4005
|
-
const b2 = digest[offset + 2] ?? 0;
|
|
4006
|
-
const b3 = digest[offset + 3] ?? 0;
|
|
4007
|
-
const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
|
|
4008
|
-
return String(code % 10 ** digits).padStart(digits, "0");
|
|
4174
|
+
function applyEtagHeaders(headers, etag) {
|
|
4175
|
+
const next = new Headers(headers);
|
|
4176
|
+
next.set("ETag", etag);
|
|
4177
|
+
next.set("Cache-Control", "private, must-revalidate");
|
|
4178
|
+
next.append("Vary", "Authorization");
|
|
4179
|
+
next.append("Vary", "X-Tenant-Id");
|
|
4180
|
+
return next;
|
|
4009
4181
|
}
|
|
4010
|
-
function
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4182
|
+
function notModifiedResponse(etag) {
|
|
4183
|
+
return new Response(null, {
|
|
4184
|
+
status: 304,
|
|
4185
|
+
headers: applyEtagHeaders(new Headers, etag)
|
|
4186
|
+
});
|
|
4187
|
+
}
|
|
4188
|
+
function applyConditionalGet(request, response, etag) {
|
|
4189
|
+
if (!isEtagEnabled()) {
|
|
4190
|
+
return response;
|
|
4014
4191
|
}
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
if (generateTotp(secret, timestep + offset) === normalized) {
|
|
4018
|
-
return true;
|
|
4019
|
-
}
|
|
4192
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
4193
|
+
return notModifiedResponse(etag);
|
|
4020
4194
|
}
|
|
4021
|
-
|
|
4195
|
+
const headers = applyEtagHeaders(new Headers(response.headers), etag);
|
|
4196
|
+
return new Response(response.body, {
|
|
4197
|
+
status: response.status,
|
|
4198
|
+
statusText: response.statusText,
|
|
4199
|
+
headers
|
|
4200
|
+
});
|
|
4022
4201
|
}
|
|
4023
4202
|
|
|
4024
|
-
// ../../src/
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
"attachments:create",
|
|
4035
|
-
"auth:tokens:read",
|
|
4036
|
-
"auth:tokens:write"
|
|
4037
|
-
];
|
|
4038
|
-
var ADMIN_ABILITIES = [
|
|
4039
|
-
...MEMBER_ABILITIES,
|
|
4040
|
-
"organizations:create",
|
|
4041
|
-
"organizations:update",
|
|
4042
|
-
"organizations:delete",
|
|
4043
|
-
"projects:update",
|
|
4044
|
-
"projects:delete",
|
|
4045
|
-
"tasks:update",
|
|
4046
|
-
"tasks:delete",
|
|
4047
|
-
"comments:update",
|
|
4048
|
-
"comments:delete",
|
|
4049
|
-
"attachments:delete",
|
|
4050
|
-
"webhooks:read",
|
|
4051
|
-
"webhooks:write",
|
|
4052
|
-
"audit:read"
|
|
4053
|
-
];
|
|
4054
|
-
var PLATFORM_ADMIN_ABILITIES = ["*"];
|
|
4055
|
-
function resolveAbilitiesForRole(role) {
|
|
4056
|
-
if (role === "admin") {
|
|
4057
|
-
return [...PLATFORM_ADMIN_ABILITIES];
|
|
4203
|
+
// ../../src/core/http/conditionalResponse.ts
|
|
4204
|
+
function jsonResponse(data, init = {}) {
|
|
4205
|
+
return Response.json(data, {
|
|
4206
|
+
status: init.status ?? 200,
|
|
4207
|
+
headers: init.headers
|
|
4208
|
+
});
|
|
4209
|
+
}
|
|
4210
|
+
function conditionalJsonResponse(request, data, init = {}) {
|
|
4211
|
+
if (!request || !isEtagEnabled()) {
|
|
4212
|
+
return jsonResponse(data, init);
|
|
4058
4213
|
}
|
|
4059
|
-
|
|
4214
|
+
const etag = computeEtagFromJson(data);
|
|
4215
|
+
if (ifNoneMatchSatisfied(request, etag)) {
|
|
4216
|
+
return notModifiedResponse(etag);
|
|
4217
|
+
}
|
|
4218
|
+
const response = jsonResponse(data, init);
|
|
4219
|
+
const headers = new Headers(response.headers);
|
|
4220
|
+
headers.set("ETag", etag);
|
|
4221
|
+
headers.set("Cache-Control", "private, must-revalidate");
|
|
4222
|
+
headers.append("Vary", "Authorization");
|
|
4223
|
+
headers.append("Vary", "X-Tenant-Id");
|
|
4224
|
+
return new Response(response.body, {
|
|
4225
|
+
status: response.status,
|
|
4226
|
+
statusText: response.statusText,
|
|
4227
|
+
headers
|
|
4228
|
+
});
|
|
4060
4229
|
}
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4065
|
-
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
this.users = users;
|
|
4070
|
-
this.tokens = tokens;
|
|
4071
|
-
this.oauthIdentities = oauthIdentities;
|
|
4230
|
+
// ../../src/core/http/cookies.ts
|
|
4231
|
+
function readRequestCookie(request, name) {
|
|
4232
|
+
const cookies = request.cookies;
|
|
4233
|
+
if (cookies && typeof cookies.get === "function") {
|
|
4234
|
+
const value = cookies.get(name);
|
|
4235
|
+
if (value) {
|
|
4236
|
+
return value;
|
|
4237
|
+
}
|
|
4072
4238
|
}
|
|
4073
|
-
|
|
4074
|
-
|
|
4239
|
+
const header = request.headers.get("cookie");
|
|
4240
|
+
if (!header) {
|
|
4241
|
+
return null;
|
|
4075
4242
|
}
|
|
4076
|
-
|
|
4077
|
-
|
|
4243
|
+
for (const part of header.split(";")) {
|
|
4244
|
+
const idx = part.indexOf("=");
|
|
4245
|
+
if (idx === -1)
|
|
4246
|
+
continue;
|
|
4247
|
+
const cookieName = part.slice(0, idx).trim();
|
|
4248
|
+
if (cookieName !== name)
|
|
4249
|
+
continue;
|
|
4250
|
+
return decodeURIComponent(part.slice(idx + 1).trim());
|
|
4078
4251
|
}
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4252
|
+
return null;
|
|
4253
|
+
}
|
|
4254
|
+
function readBunRequestCookie(request, name) {
|
|
4255
|
+
return request.cookies.get(name) ?? readRequestCookie(request, name);
|
|
4256
|
+
}
|
|
4257
|
+
// ../../src/config/cors.ts
|
|
4258
|
+
var corsConfig = {
|
|
4259
|
+
allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean),
|
|
4260
|
+
allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
|
|
4261
|
+
allowedHeaders: [
|
|
4262
|
+
"Authorization",
|
|
4263
|
+
"Content-Type",
|
|
4264
|
+
"X-Request-Id",
|
|
4265
|
+
"X-Tenant-Id",
|
|
4266
|
+
"X-Authenticated-User-Id",
|
|
4267
|
+
"X-Authenticated-User-Role",
|
|
4268
|
+
"If-Match",
|
|
4269
|
+
"If-None-Match"
|
|
4270
|
+
],
|
|
4271
|
+
maxAgeSeconds: 86400
|
|
4272
|
+
};
|
|
4273
|
+
|
|
4274
|
+
// ../../src/core/http/corsMiddleware.ts
|
|
4275
|
+
function createCorsMiddleware() {
|
|
4276
|
+
return async (request, next) => {
|
|
4277
|
+
if (request.method === "OPTIONS") {
|
|
4278
|
+
return new Response(null, {
|
|
4279
|
+
status: 204,
|
|
4280
|
+
headers: buildCorsHeaders(request)
|
|
4281
|
+
});
|
|
4093
4282
|
}
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
throw new UnauthorizedError("Invalid MFA code.");
|
|
4099
|
-
}
|
|
4283
|
+
const response = await next();
|
|
4284
|
+
const headers = new Headers(response.headers);
|
|
4285
|
+
for (const [key, value] of buildCorsHeaders(request)) {
|
|
4286
|
+
headers.set(key, value);
|
|
4100
4287
|
}
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
4288
|
+
return new Response(response.body, {
|
|
4289
|
+
status: response.status,
|
|
4290
|
+
statusText: response.statusText,
|
|
4291
|
+
headers
|
|
4106
4292
|
});
|
|
4293
|
+
};
|
|
4294
|
+
}
|
|
4295
|
+
function buildCorsHeaders(request) {
|
|
4296
|
+
const headers = new Headers;
|
|
4297
|
+
const origin = request.headers.get("origin");
|
|
4298
|
+
const allowedOrigins = corsConfig.allowedOrigins;
|
|
4299
|
+
const allowOrigin = allowedOrigins.includes("*") || origin && allowedOrigins.includes(origin) ? origin ?? "*" : allowedOrigins[0] ?? "*";
|
|
4300
|
+
headers.set("Access-Control-Allow-Origin", allowOrigin);
|
|
4301
|
+
headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
|
|
4302
|
+
headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
|
|
4303
|
+
headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
|
|
4304
|
+
headers.set("Vary", "Origin");
|
|
4305
|
+
return headers;
|
|
4306
|
+
}
|
|
4307
|
+
// ../../src/core/http/csrfToken.ts
|
|
4308
|
+
import { timingSafeEqual as timingSafeEqual2 } from "crypto";
|
|
4309
|
+
var CSRF_COOKIE = "workhub_csrf";
|
|
4310
|
+
var CSRF_TTL_MS = 60 * 60 * 1000;
|
|
4311
|
+
function resolveCsrfSecret() {
|
|
4312
|
+
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
|
|
4313
|
+
}
|
|
4314
|
+
function csrfVerifyOptions() {
|
|
4315
|
+
return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
|
|
4316
|
+
}
|
|
4317
|
+
function tokensMatch(left, right) {
|
|
4318
|
+
const leftBuffer = Buffer.from(left);
|
|
4319
|
+
const rightBuffer = Buffer.from(right);
|
|
4320
|
+
if (leftBuffer.length !== rightBuffer.length) {
|
|
4321
|
+
return false;
|
|
4322
|
+
}
|
|
4323
|
+
return timingSafeEqual2(leftBuffer, rightBuffer);
|
|
4324
|
+
}
|
|
4325
|
+
function createCsrfTokenCookie() {
|
|
4326
|
+
const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
|
|
4327
|
+
return {
|
|
4328
|
+
token,
|
|
4329
|
+
cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
|
|
4330
|
+
};
|
|
4331
|
+
}
|
|
4332
|
+
function resolveCsrfToken(request) {
|
|
4333
|
+
const cookieValue = readRequestCookie(request, CSRF_COOKIE);
|
|
4334
|
+
if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
|
|
4335
|
+
return { token: cookieValue };
|
|
4107
4336
|
}
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
const user = await this.findOrCreateOAuthUser(providerName, profile);
|
|
4115
|
-
logSecurityEvent("auth_login_success", { user_id: user.id, method: `oauth:${providerName}` });
|
|
4116
|
-
return await this.tokens.createToken(user.id, {
|
|
4117
|
-
name: `${providerName}-oauth`,
|
|
4118
|
-
abilities: resolveAbilitiesForRole(user.role),
|
|
4119
|
-
expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
|
|
4120
|
-
});
|
|
4337
|
+
return createCsrfTokenCookie();
|
|
4338
|
+
}
|
|
4339
|
+
function readSubmittedCsrfToken(request) {
|
|
4340
|
+
const headerToken = request.headers.get("x-csrf-token")?.trim();
|
|
4341
|
+
if (headerToken) {
|
|
4342
|
+
return headerToken;
|
|
4121
4343
|
}
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
return
|
|
4344
|
+
return null;
|
|
4345
|
+
}
|
|
4346
|
+
async function readSubmittedCsrfTokenFromBody(request) {
|
|
4347
|
+
const headerToken = readSubmittedCsrfToken(request);
|
|
4348
|
+
if (headerToken) {
|
|
4349
|
+
return headerToken;
|
|
4128
4350
|
}
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4351
|
+
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
|
|
4352
|
+
if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
|
|
4353
|
+
const formData = await request.clone().formData();
|
|
4354
|
+
const field = formData.get("_token");
|
|
4355
|
+
if (typeof field === "string" && field.trim().length > 0) {
|
|
4356
|
+
return field.trim();
|
|
4357
|
+
}
|
|
4358
|
+
const legacyField = formData.get("_csrf");
|
|
4359
|
+
if (typeof legacyField === "string" && legacyField.trim().length > 0) {
|
|
4360
|
+
return legacyField.trim();
|
|
4133
4361
|
}
|
|
4134
|
-
const existingUser = await this.users.findByEmail(profile.email);
|
|
4135
|
-
const user = existingUser ?? await this.users.create({
|
|
4136
|
-
name: profile.name,
|
|
4137
|
-
email: profile.email,
|
|
4138
|
-
role: "member",
|
|
4139
|
-
tenant_id: currentTenantId(),
|
|
4140
|
-
email_verified_at: new Date,
|
|
4141
|
-
created_at: new Date,
|
|
4142
|
-
updated_at: new Date
|
|
4143
|
-
});
|
|
4144
|
-
await this.oauthIdentities.create({
|
|
4145
|
-
user_id: user.id,
|
|
4146
|
-
provider: providerName,
|
|
4147
|
-
provider_user_id: profile.providerUserId,
|
|
4148
|
-
email: profile.email,
|
|
4149
|
-
created_at: new Date
|
|
4150
|
-
});
|
|
4151
|
-
return user;
|
|
4152
4362
|
}
|
|
4363
|
+
return null;
|
|
4153
4364
|
}
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
}
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
primaryKey: "id",
|
|
4167
|
-
columns: ["id", "user_id", "provider", "provider_user_id", "email", "created_at"]
|
|
4168
|
-
});
|
|
4169
|
-
|
|
4170
|
-
// ../../src/modules/user/table.ts
|
|
4171
|
-
var userTable = defineTable({
|
|
4172
|
-
name: "users",
|
|
4173
|
-
primaryKey: "id",
|
|
4174
|
-
columns: [
|
|
4175
|
-
"id",
|
|
4176
|
-
"name",
|
|
4177
|
-
"email",
|
|
4178
|
-
"email_lookup",
|
|
4179
|
-
"role",
|
|
4180
|
-
"tenant_id",
|
|
4181
|
-
"password_hash",
|
|
4182
|
-
"email_verified_at",
|
|
4183
|
-
"mfa_secret",
|
|
4184
|
-
"mfa_enabled",
|
|
4185
|
-
"created_at",
|
|
4186
|
-
"updated_at"
|
|
4187
|
-
],
|
|
4188
|
-
defaultOrderBy: { column: "id", direction: "ASC" }
|
|
4189
|
-
});
|
|
4190
|
-
|
|
4191
|
-
// ../../src/core/auth/tokenHash.ts
|
|
4192
|
-
import { createHash, createHmac as createHmac3 } from "crypto";
|
|
4193
|
-
function resolveTokenPepper() {
|
|
4194
|
-
return process.env.TOKEN_HASH_PEPPER?.trim() ?? "workhub-dev-token-pepper";
|
|
4365
|
+
function verifyCsrfToken(request, submittedToken) {
|
|
4366
|
+
if (!submittedToken) {
|
|
4367
|
+
return false;
|
|
4368
|
+
}
|
|
4369
|
+
const cookieValue = readRequestCookie(request, CSRF_COOKIE);
|
|
4370
|
+
if (!cookieValue) {
|
|
4371
|
+
return false;
|
|
4372
|
+
}
|
|
4373
|
+
if (!tokensMatch(submittedToken, cookieValue)) {
|
|
4374
|
+
return false;
|
|
4375
|
+
}
|
|
4376
|
+
return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
|
|
4195
4377
|
}
|
|
4196
|
-
function
|
|
4197
|
-
const
|
|
4198
|
-
if (
|
|
4199
|
-
return
|
|
4378
|
+
function resolveCsrfTokenForRequest(request) {
|
|
4379
|
+
const metaToken = currentRequestMeta().csrfToken;
|
|
4380
|
+
if (metaToken) {
|
|
4381
|
+
return metaToken;
|
|
4200
4382
|
}
|
|
4201
|
-
return
|
|
4383
|
+
return resolveCsrfToken(request).token;
|
|
4202
4384
|
}
|
|
4203
4385
|
|
|
4204
|
-
// ../../src/
|
|
4205
|
-
var
|
|
4206
|
-
|
|
4386
|
+
// ../../src/core/http/csrfMiddleware.ts
|
|
4387
|
+
var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
4388
|
+
function appendSetCookie(response, cookie) {
|
|
4389
|
+
const headers = new Headers(response.headers);
|
|
4390
|
+
headers.append("set-cookie", cookie);
|
|
4391
|
+
return new Response(response.body, {
|
|
4392
|
+
status: response.status,
|
|
4393
|
+
statusText: response.statusText,
|
|
4394
|
+
headers
|
|
4395
|
+
});
|
|
4396
|
+
}
|
|
4397
|
+
function createCsrfMiddleware() {
|
|
4398
|
+
return async (request, next) => {
|
|
4399
|
+
const method = request.method.toUpperCase();
|
|
4400
|
+
if (!MUTATING_METHODS.has(method)) {
|
|
4401
|
+
const csrf = resolveCsrfToken(request);
|
|
4402
|
+
const meta = currentRequestMeta();
|
|
4403
|
+
meta.csrfToken = csrf.token;
|
|
4404
|
+
const response = await next();
|
|
4405
|
+
if (!csrf.cookie) {
|
|
4406
|
+
return response;
|
|
4407
|
+
}
|
|
4408
|
+
return appendSetCookie(response, csrf.cookie);
|
|
4409
|
+
}
|
|
4410
|
+
const submitted = await readSubmittedCsrfTokenFromBody(request);
|
|
4411
|
+
if (!verifyCsrfToken(request, submitted)) {
|
|
4412
|
+
throw new ForbiddenError("Invalid or missing CSRF token.");
|
|
4413
|
+
}
|
|
4414
|
+
return await next();
|
|
4415
|
+
};
|
|
4416
|
+
}
|
|
4417
|
+
// ../../src/core/http/csrfProtection.ts
|
|
4418
|
+
var DEFAULT_CSRF_TTL_MS = 60 * 60 * 1000;
|
|
4419
|
+
function createCsrfProtection(secret, options = {}) {
|
|
4420
|
+
const expiresIn = options.expiresIn ?? DEFAULT_CSRF_TTL_MS;
|
|
4421
|
+
const maxAge = options.maxAge ?? expiresIn;
|
|
4422
|
+
return {
|
|
4423
|
+
generate(_sessionKey) {
|
|
4424
|
+
return Bun.CSRF.generate(secret, { expiresIn });
|
|
4425
|
+
},
|
|
4426
|
+
verify(token, _sessionKey) {
|
|
4427
|
+
if (!token) {
|
|
4428
|
+
return false;
|
|
4429
|
+
}
|
|
4430
|
+
return Bun.CSRF.verify(token, { secret, maxAge });
|
|
4431
|
+
},
|
|
4432
|
+
secret
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4207
4435
|
// ../../src/core/http/flashSession.ts
|
|
4208
|
-
import { createHmac as
|
|
4436
|
+
import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
|
|
4209
4437
|
var FLASH_COOKIE = "workhub_flash";
|
|
4210
4438
|
var FLASH_TTL_MS = 60 * 1000;
|
|
4211
4439
|
function resolveFlashSecret() {
|
|
4212
4440
|
return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
|
|
4213
4441
|
}
|
|
4214
4442
|
function signFlashPayload(payload, issuedAt) {
|
|
4215
|
-
const signature =
|
|
4443
|
+
const signature = createHmac3("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
|
|
4216
4444
|
return `${payload}.${issuedAt}.${signature}`;
|
|
4217
4445
|
}
|
|
4218
4446
|
function readFlashCookie(request) {
|
|
@@ -4252,7 +4480,7 @@ function parseFlashCookie(cookieValue) {
|
|
|
4252
4480
|
if (expectedBuffer.length !== actualBuffer.length) {
|
|
4253
4481
|
return null;
|
|
4254
4482
|
}
|
|
4255
|
-
if (!
|
|
4483
|
+
if (!timingSafeEqual3(expectedBuffer, actualBuffer)) {
|
|
4256
4484
|
return null;
|
|
4257
4485
|
}
|
|
4258
4486
|
try {
|
|
@@ -4268,41 +4496,146 @@ function parseFlashCookie(cookieValue) {
|
|
|
4268
4496
|
return null;
|
|
4269
4497
|
}
|
|
4270
4498
|
}
|
|
4271
|
-
function
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4499
|
+
function clearFlashCookie() {
|
|
4500
|
+
return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
|
|
4501
|
+
}
|
|
4502
|
+
function pullFlash(request) {
|
|
4503
|
+
const cookieValue = readFlashCookie(request);
|
|
4504
|
+
if (!cookieValue) {
|
|
4505
|
+
return null;
|
|
4506
|
+
}
|
|
4507
|
+
return parseFlashCookie(cookieValue);
|
|
4508
|
+
}
|
|
4509
|
+
function withFlashClear(response) {
|
|
4510
|
+
const headers = new Headers(response.headers);
|
|
4511
|
+
headers.append("set-cookie", clearFlashCookie());
|
|
4512
|
+
return new Response(response.body, {
|
|
4513
|
+
status: response.status,
|
|
4514
|
+
statusText: response.statusText,
|
|
4515
|
+
headers
|
|
4516
|
+
});
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
// ../../src/core/http/flashMiddleware.ts
|
|
4520
|
+
function createFlashMiddleware() {
|
|
4521
|
+
return async (request, next) => {
|
|
4522
|
+
const flash = pullFlash(request);
|
|
4523
|
+
const meta = currentRequestMeta();
|
|
4524
|
+
return await runWithRequestMeta({ ...meta, request, flash }, async () => {
|
|
4525
|
+
const response = await next();
|
|
4526
|
+
if (flash) {
|
|
4527
|
+
return withFlashClear(response);
|
|
4528
|
+
}
|
|
4529
|
+
return response;
|
|
4530
|
+
});
|
|
4531
|
+
};
|
|
4532
|
+
}
|
|
4533
|
+
// ../../src/core/http/validation.ts
|
|
4534
|
+
function buildRequestCacheKey(fallbackPath, request) {
|
|
4535
|
+
if (!request) {
|
|
4536
|
+
return fallbackPath;
|
|
4537
|
+
}
|
|
4538
|
+
const url = new URL(request.url);
|
|
4539
|
+
const user = currentAuthUser();
|
|
4540
|
+
const authScope = user ? `u:${user.id}` : "guest";
|
|
4541
|
+
const tenantScope = `t:${currentTenantId()}`;
|
|
4542
|
+
return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
|
|
4543
|
+
}
|
|
4544
|
+
function getQueryParams(request) {
|
|
4545
|
+
if (!request) {
|
|
4546
|
+
return new URLSearchParams;
|
|
4547
|
+
}
|
|
4548
|
+
return new URL(request.url).searchParams;
|
|
4549
|
+
}
|
|
4550
|
+
async function parseJsonBody(request, validator) {
|
|
4551
|
+
let payload;
|
|
4552
|
+
try {
|
|
4553
|
+
payload = await request.json();
|
|
4554
|
+
} catch {
|
|
4555
|
+
throw new BadRequestError("Request body must be valid JSON.");
|
|
4556
|
+
}
|
|
4557
|
+
return validator(payload);
|
|
4558
|
+
}
|
|
4559
|
+
function parsePositiveIntParam(value, name = "id") {
|
|
4560
|
+
const parsed = Number.parseInt(value, 10);
|
|
4561
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
4562
|
+
throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
|
|
4563
|
+
}
|
|
4564
|
+
return parsed;
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4567
|
+
// ../../src/core/http/formRequest.ts
|
|
4568
|
+
class FormRequest {
|
|
4569
|
+
authorize(_request) {
|
|
4570
|
+
return true;
|
|
4571
|
+
}
|
|
4572
|
+
async validate(request) {
|
|
4573
|
+
if (!await this.authorize(request)) {
|
|
4574
|
+
throw new ForbiddenError;
|
|
4575
|
+
}
|
|
4576
|
+
return await parseJsonBody(request, (payload) => this.parse(payload));
|
|
4577
|
+
}
|
|
4578
|
+
}
|
|
4579
|
+
// ../../src/config/frontend.ts
|
|
4580
|
+
function readFrontendMode() {
|
|
4581
|
+
const mode = (process.env.FRONTEND_MODE ?? "api").trim();
|
|
4582
|
+
if (mode === "server-htmx") {
|
|
4583
|
+
return "server-htmx";
|
|
4584
|
+
}
|
|
4585
|
+
if (mode === "spa-react") {
|
|
4586
|
+
return "spa-react";
|
|
4587
|
+
}
|
|
4588
|
+
return "api";
|
|
4589
|
+
}
|
|
4590
|
+
function isViewsEnabled() {
|
|
4591
|
+
return readFrontendMode() === "server-htmx";
|
|
4279
4592
|
}
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4593
|
+
|
|
4594
|
+
// ../../src/core/view/etaViewEngine.ts
|
|
4595
|
+
import { join as join4 } from "path";
|
|
4596
|
+
import { Eta } from "eta";
|
|
4597
|
+
var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
|
|
4598
|
+
var DEFAULT_LAYOUT = "layouts/app.eta";
|
|
4599
|
+
|
|
4600
|
+
class EtaViewEngine {
|
|
4601
|
+
eta;
|
|
4602
|
+
resolveLayoutData;
|
|
4603
|
+
constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
|
|
4604
|
+
this.eta = new Eta({
|
|
4605
|
+
views: viewsDirectory,
|
|
4606
|
+
autoTrim: false
|
|
4607
|
+
});
|
|
4608
|
+
this.resolveLayoutData = resolveLayoutData;
|
|
4609
|
+
}
|
|
4610
|
+
async render(name, data = {}, options = {}) {
|
|
4611
|
+
const template = name.endsWith(".eta") ? name : `${name}.eta`;
|
|
4612
|
+
const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
|
|
4613
|
+
const mergedData = { ...layoutData, ...data };
|
|
4614
|
+
const body = await this.eta.renderAsync(template, mergedData);
|
|
4615
|
+
const layout = options.layout ?? DEFAULT_LAYOUT;
|
|
4616
|
+
if (layout === false) {
|
|
4617
|
+
return body;
|
|
4618
|
+
}
|
|
4619
|
+
const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
|
|
4620
|
+
return await this.eta.renderAsync(layoutTemplate, {
|
|
4621
|
+
...mergedData,
|
|
4622
|
+
body
|
|
4623
|
+
});
|
|
4284
4624
|
}
|
|
4285
|
-
return parseFlashCookie(cookieValue);
|
|
4286
4625
|
}
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4626
|
+
// ../../src/core/view/htmlResponse.ts
|
|
4627
|
+
function htmlResponse(html, init = {}) {
|
|
4628
|
+
return new Response(html, {
|
|
4629
|
+
status: init.status ?? 200,
|
|
4630
|
+
statusText: init.statusText,
|
|
4631
|
+
headers: {
|
|
4632
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
4633
|
+
}
|
|
4294
4634
|
});
|
|
4295
4635
|
}
|
|
4296
|
-
function
|
|
4297
|
-
|
|
4298
|
-
headers.append("set-cookie", clearFlashCookie());
|
|
4299
|
-
return new Response(response.body, {
|
|
4300
|
-
status: response.status,
|
|
4301
|
-
statusText: response.statusText,
|
|
4302
|
-
headers
|
|
4303
|
-
});
|
|
4636
|
+
function isHtmxRequest(request) {
|
|
4637
|
+
return request.headers.get("HX-Request") === "true";
|
|
4304
4638
|
}
|
|
4305
|
-
|
|
4306
4639
|
// ../../src/core/view/webLayoutData.ts
|
|
4307
4640
|
async function resolveWebLayoutData(container, request) {
|
|
4308
4641
|
const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
|
|
@@ -4423,7 +4756,7 @@ function createAuthMiddleware(auth2) {
|
|
|
4423
4756
|
// ../../src/core/http/authorizeMiddleware.ts
|
|
4424
4757
|
function createAuthorizeMiddleware(gate, auth2, resource, action) {
|
|
4425
4758
|
return async (request, next) => {
|
|
4426
|
-
const user = await auth2.resolve(request);
|
|
4759
|
+
const user = currentAuthUser() ?? await auth2.resolve(request);
|
|
4427
4760
|
if (!gate.allows(resource, action, user)) {
|
|
4428
4761
|
const error = new ForbiddenError;
|
|
4429
4762
|
return Response.json({ error: error.message }, { status: error.status });
|
|
@@ -4431,33 +4764,6 @@ function createAuthorizeMiddleware(gate, auth2, resource, action) {
|
|
|
4431
4764
|
return await next();
|
|
4432
4765
|
};
|
|
4433
4766
|
}
|
|
4434
|
-
// ../../src/core/http/conditionalResponse.ts
|
|
4435
|
-
function jsonResponse(data, init = {}) {
|
|
4436
|
-
return Response.json(data, {
|
|
4437
|
-
status: init.status ?? 200,
|
|
4438
|
-
headers: init.headers
|
|
4439
|
-
});
|
|
4440
|
-
}
|
|
4441
|
-
function conditionalJsonResponse(request, data, init = {}) {
|
|
4442
|
-
if (!request || !isEtagEnabled()) {
|
|
4443
|
-
return jsonResponse(data, init);
|
|
4444
|
-
}
|
|
4445
|
-
const etag = computeEtagFromJson(data);
|
|
4446
|
-
if (ifNoneMatchSatisfied(request, etag)) {
|
|
4447
|
-
return notModifiedResponse(etag);
|
|
4448
|
-
}
|
|
4449
|
-
const response = jsonResponse(data, init);
|
|
4450
|
-
const headers = new Headers(response.headers);
|
|
4451
|
-
headers.set("ETag", etag);
|
|
4452
|
-
headers.set("Cache-Control", "private, must-revalidate");
|
|
4453
|
-
headers.append("Vary", "Authorization");
|
|
4454
|
-
headers.append("Vary", "X-Tenant-Id");
|
|
4455
|
-
return new Response(response.body, {
|
|
4456
|
-
status: response.status,
|
|
4457
|
-
statusText: response.statusText,
|
|
4458
|
-
headers
|
|
4459
|
-
});
|
|
4460
|
-
}
|
|
4461
4767
|
// ../../src/core/http/middleware.ts
|
|
4462
4768
|
function isRouteHandler(value) {
|
|
4463
4769
|
return typeof value === "function";
|
|
@@ -4598,7 +4904,8 @@ function securedBindRouteModel(param, resolver, authorization, handler) {
|
|
|
4598
4904
|
const model = await resolver(id, request);
|
|
4599
4905
|
const gate = resolveApplicationPolicyGate();
|
|
4600
4906
|
const auth2 = resolveApplicationAuth();
|
|
4601
|
-
|
|
4907
|
+
const user = currentAuthUser() ?? await auth2.resolve(request);
|
|
4908
|
+
gate.authorize(authorization.resource, authorization.action, user, model);
|
|
4602
4909
|
if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
|
|
4603
4910
|
assertIfMatch(request, etagFromResource(model), {
|
|
4604
4911
|
required: authorization.requireIfMatch ?? true
|
|
@@ -4620,7 +4927,8 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
|
|
|
4620
4927
|
const model = await resolver(key, request);
|
|
4621
4928
|
const gate = resolveApplicationPolicyGate();
|
|
4622
4929
|
const auth2 = resolveApplicationAuth();
|
|
4623
|
-
|
|
4930
|
+
const user = currentAuthUser() ?? await auth2.resolve(request);
|
|
4931
|
+
gate.authorize(authorization.resource, authorization.action, user, model);
|
|
4624
4932
|
if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
|
|
4625
4933
|
assertIfMatch(request, etagFromResource(model), {
|
|
4626
4934
|
required: authorization.requireIfMatch ?? true
|
|
@@ -4706,10 +5014,6 @@ async function parseMultipartUpload(request, fieldName = "file") {
|
|
|
4706
5014
|
contents: new Uint8Array(await value.arrayBuffer())
|
|
4707
5015
|
};
|
|
4708
5016
|
}
|
|
4709
|
-
// ../../src/core/http/route.ts
|
|
4710
|
-
function getRouteParams(request) {
|
|
4711
|
-
return request.params;
|
|
4712
|
-
}
|
|
4713
5017
|
|
|
4714
5018
|
// ../../src/core/http/index.ts
|
|
4715
5019
|
function jsonResponse2(data, init = {}) {
|
|
@@ -4898,6 +5202,38 @@ function createMetricsMiddleware() {
|
|
|
4898
5202
|
return response;
|
|
4899
5203
|
};
|
|
4900
5204
|
}
|
|
5205
|
+
// ../../src/core/http/requireAbilityMiddleware.ts
|
|
5206
|
+
function createRequireAbilityMiddleware(abilityChecker) {
|
|
5207
|
+
return (ability) => {
|
|
5208
|
+
return async (_request, next) => {
|
|
5209
|
+
const user = currentAuthUser();
|
|
5210
|
+
try {
|
|
5211
|
+
abilityChecker.requireAbility(user, ability);
|
|
5212
|
+
} catch (error) {
|
|
5213
|
+
if (error instanceof ForbiddenError) {
|
|
5214
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
5215
|
+
}
|
|
5216
|
+
throw error;
|
|
5217
|
+
}
|
|
5218
|
+
return await next();
|
|
5219
|
+
};
|
|
5220
|
+
};
|
|
5221
|
+
}
|
|
5222
|
+
// ../../src/core/http/requireGlobalAdminMiddleware.ts
|
|
5223
|
+
function createRequireGlobalAdminMiddleware() {
|
|
5224
|
+
return async (_request, next) => {
|
|
5225
|
+
const user = currentAuthUser();
|
|
5226
|
+
if (!isGlobalAdmin(user)) {
|
|
5227
|
+
logSecurityEvent("privilege_escalation_blocked", {
|
|
5228
|
+
required_role: "platform_admin",
|
|
5229
|
+
path: new URL(_request.url).pathname
|
|
5230
|
+
});
|
|
5231
|
+
const error = new ForbiddenError("Platform admin access required.");
|
|
5232
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
5233
|
+
}
|
|
5234
|
+
return await next();
|
|
5235
|
+
};
|
|
5236
|
+
}
|
|
4901
5237
|
// ../../src/core/http/requireWebAuthMiddleware.ts
|
|
4902
5238
|
function createRequireWebAuthMiddleware(auth2) {
|
|
4903
5239
|
return async (request, next) => {
|
|
@@ -4912,6 +5248,25 @@ function createRequireWebAuthMiddleware(auth2) {
|
|
|
4912
5248
|
return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
|
|
4913
5249
|
};
|
|
4914
5250
|
}
|
|
5251
|
+
// ../../src/core/http/scimThrottleMiddleware.ts
|
|
5252
|
+
var {RedisClient: RedisClient2 } = globalThis.Bun;
|
|
5253
|
+
function createScimThrottleMiddleware(options) {
|
|
5254
|
+
const client = options.redisUrl ? new RedisClient2(options.redisUrl) : null;
|
|
5255
|
+
return async (request, next) => {
|
|
5256
|
+
const identity = request.headers.get("authorization")?.slice("Bearer ".length, "Bearer ".length + 16) ?? request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
5257
|
+
const key = `workhub:scim-throttle:${identity}`;
|
|
5258
|
+
if (client) {
|
|
5259
|
+
const attempts = Number(await client.incr(key));
|
|
5260
|
+
if (attempts === 1) {
|
|
5261
|
+
await client.expire(key, options.decaySeconds);
|
|
5262
|
+
}
|
|
5263
|
+
if (attempts > options.maxAttempts) {
|
|
5264
|
+
return Response.json({ error: "Too many SCIM requests." }, { status: 429, headers: { "retry-after": String(options.decaySeconds) } });
|
|
5265
|
+
}
|
|
5266
|
+
}
|
|
5267
|
+
return await next();
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
4915
5270
|
// ../../src/config/app.ts
|
|
4916
5271
|
var appConfig = {
|
|
4917
5272
|
name: "WorkHub",
|
|
@@ -4986,7 +5341,7 @@ function createSecurityHeadersMiddleware() {
|
|
|
4986
5341
|
};
|
|
4987
5342
|
}
|
|
4988
5343
|
// ../../src/core/http/throttleMiddleware.ts
|
|
4989
|
-
var {RedisClient:
|
|
5344
|
+
var {RedisClient: RedisClient3 } = globalThis.Bun;
|
|
4990
5345
|
function resolveThrottleIdentity(request) {
|
|
4991
5346
|
const user = currentAuthUser();
|
|
4992
5347
|
if (user?.tokenId !== undefined) {
|
|
@@ -4998,7 +5353,7 @@ function resolveThrottleIdentity(request) {
|
|
|
4998
5353
|
return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
4999
5354
|
}
|
|
5000
5355
|
function createThrottleMiddleware(options) {
|
|
5001
|
-
const client = new
|
|
5356
|
+
const client = new RedisClient3(options.redisUrl);
|
|
5002
5357
|
const prefix = options.keyPrefix ?? "workhub:throttle:";
|
|
5003
5358
|
return async (request, next) => {
|
|
5004
5359
|
const identity = resolveThrottleIdentity(request);
|
|
@@ -5099,10 +5454,28 @@ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
|
|
|
5099
5454
|
});
|
|
5100
5455
|
}
|
|
5101
5456
|
}
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5457
|
+
// ../../src/core/logging/requestLoggingMiddleware.ts
|
|
5458
|
+
function createRequestLoggingMiddleware() {
|
|
5459
|
+
return async (request, next) => {
|
|
5460
|
+
return await runWithRequestMeta({
|
|
5461
|
+
ipAddress: request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? request.headers.get("x-real-ip"),
|
|
5462
|
+
userAgent: request.headers.get("user-agent"),
|
|
5463
|
+
request
|
|
5464
|
+
}, async () => {
|
|
5465
|
+
const startedAt = performance.now();
|
|
5466
|
+
const requestId = request.headers.get("x-request-id") ?? crypto.randomUUID();
|
|
5467
|
+
const response = await next();
|
|
5468
|
+
const durationMs = Math.round(performance.now() - startedAt);
|
|
5469
|
+
appLogger.info("HTTP request completed", {
|
|
5470
|
+
requestId,
|
|
5471
|
+
method: request.method,
|
|
5472
|
+
path: new URL(request.url).pathname,
|
|
5473
|
+
status: response.status,
|
|
5474
|
+
durationMs
|
|
5475
|
+
});
|
|
5476
|
+
return response;
|
|
5477
|
+
});
|
|
5478
|
+
};
|
|
5106
5479
|
}
|
|
5107
5480
|
// ../../src/core/mail/markdownMail.ts
|
|
5108
5481
|
function escapeHtml(value) {
|
|
@@ -5399,7 +5772,7 @@ async function runQueueJob(envelope, failedJobs) {
|
|
|
5399
5772
|
}
|
|
5400
5773
|
|
|
5401
5774
|
// ../../src/core/queue/redisQueue.ts
|
|
5402
|
-
var {RedisClient:
|
|
5775
|
+
var {RedisClient: RedisClient4 } = globalThis.Bun;
|
|
5403
5776
|
var QUEUE_LIST_KEY = "workhub:queue:default";
|
|
5404
5777
|
var QUEUE_HIGH_KEY = "workhub:queue:high";
|
|
5405
5778
|
var QUEUE_LOW_KEY = "workhub:queue:low";
|
|
@@ -5449,7 +5822,7 @@ function parseQueueJobEnvelope(rawPayload) {
|
|
|
5449
5822
|
class RedisQueue {
|
|
5450
5823
|
client;
|
|
5451
5824
|
constructor(redisUrl) {
|
|
5452
|
-
this.client = new
|
|
5825
|
+
this.client = new RedisClient4(redisUrl);
|
|
5453
5826
|
}
|
|
5454
5827
|
async dispatch(job, payload) {
|
|
5455
5828
|
const name = jobRegistry.resolveName(job);
|
|
@@ -5475,7 +5848,7 @@ class QueueWorker {
|
|
|
5475
5848
|
constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
|
|
5476
5849
|
this.failedJobs = failedJobs;
|
|
5477
5850
|
this.timeoutSeconds = timeoutSeconds;
|
|
5478
|
-
this.client = new
|
|
5851
|
+
this.client = new RedisClient4(redisUrl);
|
|
5479
5852
|
}
|
|
5480
5853
|
requestStop() {
|
|
5481
5854
|
this.stopping = true;
|
|
@@ -5572,6 +5945,68 @@ function createProductionQueue(driver, options = {}) {
|
|
|
5572
5945
|
function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
|
|
5573
5946
|
return new QueueWorker(redisUrl, failedJobs);
|
|
5574
5947
|
}
|
|
5948
|
+
// ../../src/core/queue/queueMetrics.ts
|
|
5949
|
+
var {RedisClient: RedisClient5 } = globalThis.Bun;
|
|
5950
|
+
|
|
5951
|
+
// ../../src/core/security/safeUrl.ts
|
|
5952
|
+
var BLOCKED_HOSTNAMES = new Set([
|
|
5953
|
+
"localhost",
|
|
5954
|
+
"127.0.0.1",
|
|
5955
|
+
"0.0.0.0",
|
|
5956
|
+
"::1",
|
|
5957
|
+
"metadata.google.internal"
|
|
5958
|
+
]);
|
|
5959
|
+
|
|
5960
|
+
// ../../src/core/queue/queueMetrics.ts
|
|
5961
|
+
async function readRedisQueueDepth(redisUrl) {
|
|
5962
|
+
const client = new RedisClient5(redisUrl);
|
|
5963
|
+
const [high, defaultQueue, low] = await Promise.all([
|
|
5964
|
+
client.llen(QUEUE_HIGH_KEY),
|
|
5965
|
+
client.llen(QUEUE_LIST_KEY),
|
|
5966
|
+
client.llen(QUEUE_LOW_KEY)
|
|
5967
|
+
]);
|
|
5968
|
+
return {
|
|
5969
|
+
high: Number(high ?? 0),
|
|
5970
|
+
default: Number(defaultQueue ?? 0),
|
|
5971
|
+
low: Number(low ?? 0),
|
|
5972
|
+
total: Number(high ?? 0) + Number(defaultQueue ?? 0) + Number(low ?? 0)
|
|
5973
|
+
};
|
|
5974
|
+
}
|
|
5975
|
+
async function collectQueueMetrics() {
|
|
5976
|
+
const driver = queueConfig.driver;
|
|
5977
|
+
const failedJobs = createFailedJobService();
|
|
5978
|
+
const failedCount = (await failedJobs.listRecent(1000)).length;
|
|
5979
|
+
if (driver !== "redis") {
|
|
5980
|
+
return {
|
|
5981
|
+
driver,
|
|
5982
|
+
pending: {
|
|
5983
|
+
high: 0,
|
|
5984
|
+
default: 0,
|
|
5985
|
+
low: 0,
|
|
5986
|
+
total: 0
|
|
5987
|
+
},
|
|
5988
|
+
failedCount
|
|
5989
|
+
};
|
|
5990
|
+
}
|
|
5991
|
+
const redisUrl = process.env.REDIS_URL;
|
|
5992
|
+
if (!redisUrl) {
|
|
5993
|
+
return {
|
|
5994
|
+
driver,
|
|
5995
|
+
pending: {
|
|
5996
|
+
high: 0,
|
|
5997
|
+
default: 0,
|
|
5998
|
+
low: 0,
|
|
5999
|
+
total: 0
|
|
6000
|
+
},
|
|
6001
|
+
failedCount
|
|
6002
|
+
};
|
|
6003
|
+
}
|
|
6004
|
+
return {
|
|
6005
|
+
driver,
|
|
6006
|
+
pending: await readRedisQueueDepth(redisUrl),
|
|
6007
|
+
failedCount
|
|
6008
|
+
};
|
|
6009
|
+
}
|
|
5575
6010
|
// ../../src/core/scheduler/schedule.ts
|
|
5576
6011
|
class Schedule {
|
|
5577
6012
|
tasks = [];
|
|
@@ -5604,6 +6039,185 @@ async function runDueScheduledTasks(schedule = appSchedule, now = new Date) {
|
|
|
5604
6039
|
}
|
|
5605
6040
|
return due.length;
|
|
5606
6041
|
}
|
|
6042
|
+
// ../../src/core/security/publicReads.ts
|
|
6043
|
+
function isPublicReadsEnabled() {
|
|
6044
|
+
return isFeatureEnabled("publicReads");
|
|
6045
|
+
}
|
|
6046
|
+
function guestCanViewResource() {
|
|
6047
|
+
return isPublicReadsEnabled();
|
|
6048
|
+
}
|
|
6049
|
+
// ../../src/core/tenant/tenantMiddleware.ts
|
|
6050
|
+
import { createHash } from "crypto";
|
|
6051
|
+
|
|
6052
|
+
// ../../src/core/tenant/databaseTenantContext.ts
|
|
6053
|
+
async function runWithMigrationBypass(callback) {
|
|
6054
|
+
await repositoryConnection`SELECT set_config('app.bypass_rls', 'true', false)`;
|
|
6055
|
+
try {
|
|
6056
|
+
return await callback();
|
|
6057
|
+
} finally {
|
|
6058
|
+
await repositoryConnection`SELECT set_config('app.bypass_rls', 'false', false)`;
|
|
6059
|
+
}
|
|
6060
|
+
}
|
|
6061
|
+
|
|
6062
|
+
// ../../src/core/tenant/tenantMiddleware.ts
|
|
6063
|
+
var DEFAULT_TENANT = {
|
|
6064
|
+
id: 1,
|
|
6065
|
+
slug: "default",
|
|
6066
|
+
plan: "enterprise",
|
|
6067
|
+
region: "eu"
|
|
6068
|
+
};
|
|
6069
|
+
async function resolveUserTenantId(userId) {
|
|
6070
|
+
return await runWithMigrationBypass(async () => {
|
|
6071
|
+
const rows = await repositoryConnection`
|
|
6072
|
+
SELECT tenant_id
|
|
6073
|
+
FROM users
|
|
6074
|
+
WHERE id = ${userId}
|
|
6075
|
+
LIMIT 1
|
|
6076
|
+
`;
|
|
6077
|
+
return rows[0]?.tenant_id ?? DEFAULT_TENANT.id;
|
|
6078
|
+
});
|
|
6079
|
+
}
|
|
6080
|
+
function auditChecksum(payload) {
|
|
6081
|
+
return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
|
|
6082
|
+
}
|
|
6083
|
+
async function resolveTenantForRequest(request) {
|
|
6084
|
+
const user = currentAuthUser();
|
|
6085
|
+
const headerValue = request.headers.get("x-tenant-id")?.trim();
|
|
6086
|
+
const parsedHeader = headerValue !== undefined && headerValue.length > 0 ? Number.parseInt(headerValue, 10) : Number.NaN;
|
|
6087
|
+
if (user) {
|
|
6088
|
+
const userId = typeof user.id === "number" ? user.id : Number.parseInt(String(user.id), 10);
|
|
6089
|
+
if (Number.isInteger(userId) && userId > 0) {
|
|
6090
|
+
const userTenantId = await resolveUserTenantId(userId);
|
|
6091
|
+
if (!isGlobalAdmin(user)) {
|
|
6092
|
+
if (Number.isInteger(parsedHeader) && parsedHeader > 0 && parsedHeader !== userTenantId) {
|
|
6093
|
+
throw new ForbiddenError("Tenant header does not match your account.");
|
|
6094
|
+
}
|
|
6095
|
+
return await resolveTenant(userTenantId) ?? DEFAULT_TENANT;
|
|
6096
|
+
}
|
|
6097
|
+
if (Number.isInteger(parsedHeader) && parsedHeader > 0) {
|
|
6098
|
+
return await resolveTenant(parsedHeader) ?? DEFAULT_TENANT;
|
|
6099
|
+
}
|
|
6100
|
+
return await resolveTenant(userTenantId) ?? DEFAULT_TENANT;
|
|
6101
|
+
}
|
|
6102
|
+
}
|
|
6103
|
+
const tenantId = Number.isInteger(parsedHeader) && parsedHeader > 0 ? parsedHeader : DEFAULT_TENANT.id;
|
|
6104
|
+
return await resolveTenant(tenantId) ?? DEFAULT_TENANT;
|
|
6105
|
+
}
|
|
6106
|
+
function createTenantMiddleware() {
|
|
6107
|
+
return async (request, next) => {
|
|
6108
|
+
const pathname = new URL(request.url).pathname;
|
|
6109
|
+
if (pathname.startsWith("/scim/")) {
|
|
6110
|
+
return await next();
|
|
6111
|
+
}
|
|
6112
|
+
try {
|
|
6113
|
+
const tenant = await resolveTenantForRequest(request);
|
|
6114
|
+
return await runWithTenantDatabase(tenant, async () => {
|
|
6115
|
+
const response = await next();
|
|
6116
|
+
const headers = new Headers(response.headers);
|
|
6117
|
+
headers.set("x-tenant-id", String(tenant.id));
|
|
6118
|
+
headers.set("x-tenant-region", tenant.region);
|
|
6119
|
+
return new Response(response.body, {
|
|
6120
|
+
status: response.status,
|
|
6121
|
+
statusText: response.statusText,
|
|
6122
|
+
headers
|
|
6123
|
+
});
|
|
6124
|
+
});
|
|
6125
|
+
} catch (error) {
|
|
6126
|
+
if (error instanceof HttpError) {
|
|
6127
|
+
return Response.json({ error: error.message }, { status: error.status });
|
|
6128
|
+
}
|
|
6129
|
+
throw error;
|
|
6130
|
+
}
|
|
6131
|
+
};
|
|
6132
|
+
}
|
|
6133
|
+
// ../../src/core/tracing/traceContext.ts
|
|
6134
|
+
import { AsyncLocalStorage as AsyncLocalStorage6 } from "async_hooks";
|
|
6135
|
+
var traceContextStorage = new AsyncLocalStorage6;
|
|
6136
|
+
function runWithTraceContext(context, callback) {
|
|
6137
|
+
return traceContextStorage.run(context, callback);
|
|
6138
|
+
}
|
|
6139
|
+
function currentTraceId() {
|
|
6140
|
+
return traceContextStorage.getStore()?.traceId ?? null;
|
|
6141
|
+
}
|
|
6142
|
+
// ../../src/core/tracing/otel.ts
|
|
6143
|
+
import { randomBytes as randomBytes2 } from "crypto";
|
|
6144
|
+
function randomHex(bytes) {
|
|
6145
|
+
return randomBytes2(bytes).toString("hex");
|
|
6146
|
+
}
|
|
6147
|
+
function createSpan(input) {
|
|
6148
|
+
const spanId = randomHex(8);
|
|
6149
|
+
return {
|
|
6150
|
+
traceId: input.traceId,
|
|
6151
|
+
spanId,
|
|
6152
|
+
name: input.name,
|
|
6153
|
+
startTimeUnixNano: String(Math.floor(input.startedAt * 1e6)),
|
|
6154
|
+
endTimeUnixNano: String(Math.floor(input.endedAt * 1e6)),
|
|
6155
|
+
attributes: Object.entries(input.attributes ?? {}).map(([key, value]) => ({
|
|
6156
|
+
key,
|
|
6157
|
+
value: { stringValue: value }
|
|
6158
|
+
})),
|
|
6159
|
+
status: { code: 1 }
|
|
6160
|
+
};
|
|
6161
|
+
}
|
|
6162
|
+
async function exportOtelSpan(span) {
|
|
6163
|
+
const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT?.trim();
|
|
6164
|
+
if (!endpoint) {
|
|
6165
|
+
return;
|
|
6166
|
+
}
|
|
6167
|
+
const serviceName = process.env.OTEL_SERVICE_NAME?.trim() ?? "workhub-api";
|
|
6168
|
+
const url = endpoint.endsWith("/v1/traces") ? endpoint : `${endpoint.replace(/\/$/, "")}/v1/traces`;
|
|
6169
|
+
await fetch(url, {
|
|
6170
|
+
method: "POST",
|
|
6171
|
+
headers: { "content-type": "application/json" },
|
|
6172
|
+
body: JSON.stringify({
|
|
6173
|
+
resourceSpans: [
|
|
6174
|
+
{
|
|
6175
|
+
resource: {
|
|
6176
|
+
attributes: [{ key: "service.name", value: { stringValue: serviceName } }]
|
|
6177
|
+
},
|
|
6178
|
+
scopeSpans: [{ spans: [span] }]
|
|
6179
|
+
}
|
|
6180
|
+
]
|
|
6181
|
+
})
|
|
6182
|
+
});
|
|
6183
|
+
}
|
|
6184
|
+
|
|
6185
|
+
// ../../src/core/tracing/tracingMiddleware.ts
|
|
6186
|
+
function createTracingMiddleware() {
|
|
6187
|
+
return async (request, next) => {
|
|
6188
|
+
const traceId = (request.headers.get("x-trace-id") ?? crypto.randomUUID()).replace(/-/g, "");
|
|
6189
|
+
const spanId = crypto.randomUUID().replace(/-/g, "").slice(0, 16);
|
|
6190
|
+
const startedAt = performance.now();
|
|
6191
|
+
const path = new URL(request.url).pathname;
|
|
6192
|
+
return await runWithTraceContext({ traceId, spanId }, async () => {
|
|
6193
|
+
const response = await next();
|
|
6194
|
+
const endedAt = performance.now();
|
|
6195
|
+
const headers = new Headers(response.headers);
|
|
6196
|
+
headers.set("x-trace-id", traceId);
|
|
6197
|
+
headers.set("x-span-id", spanId);
|
|
6198
|
+
headers.set("traceparent", `00-${traceId}-${spanId}-01`);
|
|
6199
|
+
headers.set("server-timing", `app;dur=${(endedAt - startedAt).toFixed(2)}`);
|
|
6200
|
+
exportOtelSpan(createSpan({
|
|
6201
|
+
traceId,
|
|
6202
|
+
name: `${request.method} ${path}`,
|
|
6203
|
+
startedAt,
|
|
6204
|
+
endedAt,
|
|
6205
|
+
attributes: {
|
|
6206
|
+
"http.method": request.method,
|
|
6207
|
+
"http.route": path,
|
|
6208
|
+
"http.status_code": String(response.status)
|
|
6209
|
+
}
|
|
6210
|
+
})).catch(() => {
|
|
6211
|
+
return;
|
|
6212
|
+
});
|
|
6213
|
+
return new Response(response.body, {
|
|
6214
|
+
status: response.status,
|
|
6215
|
+
statusText: response.statusText,
|
|
6216
|
+
headers
|
|
6217
|
+
});
|
|
6218
|
+
});
|
|
6219
|
+
};
|
|
6220
|
+
}
|
|
5607
6221
|
// ../../src/core/validation/rules.ts
|
|
5608
6222
|
function required() {
|
|
5609
6223
|
return (field, value) => {
|
|
@@ -5646,45 +6260,6 @@ function maxLength(maximum) {
|
|
|
5646
6260
|
return;
|
|
5647
6261
|
};
|
|
5648
6262
|
}
|
|
5649
|
-
function pattern(expression) {
|
|
5650
|
-
return (field, value) => {
|
|
5651
|
-
if (typeof value !== "string") {
|
|
5652
|
-
return;
|
|
5653
|
-
}
|
|
5654
|
-
if (!expression.test(value.trim())) {
|
|
5655
|
-
return `"${field}" has an invalid format.`;
|
|
5656
|
-
}
|
|
5657
|
-
return;
|
|
5658
|
-
};
|
|
5659
|
-
}
|
|
5660
|
-
function enumRule(allowedValues) {
|
|
5661
|
-
return (field, value) => {
|
|
5662
|
-
if (typeof value !== "string") {
|
|
5663
|
-
return;
|
|
5664
|
-
}
|
|
5665
|
-
if (!allowedValues.includes(value)) {
|
|
5666
|
-
return `"${field}" must be one of: ${allowedValues.join(", ")}.`;
|
|
5667
|
-
}
|
|
5668
|
-
return;
|
|
5669
|
-
};
|
|
5670
|
-
}
|
|
5671
|
-
function optional() {
|
|
5672
|
-
return () => {
|
|
5673
|
-
return;
|
|
5674
|
-
};
|
|
5675
|
-
}
|
|
5676
|
-
function integerRule() {
|
|
5677
|
-
return (field, value) => {
|
|
5678
|
-
if (value === undefined || value === null || value === "") {
|
|
5679
|
-
return;
|
|
5680
|
-
}
|
|
5681
|
-
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5682
|
-
if (!Number.isInteger(parsed)) {
|
|
5683
|
-
return `"${field}" must be an integer.`;
|
|
5684
|
-
}
|
|
5685
|
-
return;
|
|
5686
|
-
};
|
|
5687
|
-
}
|
|
5688
6263
|
function emailRule() {
|
|
5689
6264
|
return (field, value) => {
|
|
5690
6265
|
if (typeof value !== "string") {
|
|
@@ -5697,40 +6272,6 @@ function emailRule() {
|
|
|
5697
6272
|
return;
|
|
5698
6273
|
};
|
|
5699
6274
|
}
|
|
5700
|
-
function confirmed(fieldName) {
|
|
5701
|
-
return (field, value, payload) => {
|
|
5702
|
-
const confirmationKey = `${fieldName}_confirmation`;
|
|
5703
|
-
const confirmation = payload[confirmationKey];
|
|
5704
|
-
if (value !== confirmation) {
|
|
5705
|
-
return `"${field}" confirmation does not match.`;
|
|
5706
|
-
}
|
|
5707
|
-
return;
|
|
5708
|
-
};
|
|
5709
|
-
}
|
|
5710
|
-
function positiveIntegerRule() {
|
|
5711
|
-
return (field, value) => {
|
|
5712
|
-
if (value === undefined || value === null || value === "") {
|
|
5713
|
-
return;
|
|
5714
|
-
}
|
|
5715
|
-
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5716
|
-
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
5717
|
-
return `"${field}" must be a positive integer.`;
|
|
5718
|
-
}
|
|
5719
|
-
return;
|
|
5720
|
-
};
|
|
5721
|
-
}
|
|
5722
|
-
function integerRange(minimum, maximum) {
|
|
5723
|
-
return (field, value) => {
|
|
5724
|
-
if (value === undefined || value === null || value === "") {
|
|
5725
|
-
return;
|
|
5726
|
-
}
|
|
5727
|
-
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
5728
|
-
if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
|
|
5729
|
-
return `"${field}" must be an integer between ${minimum} and ${maximum}.`;
|
|
5730
|
-
}
|
|
5731
|
-
return;
|
|
5732
|
-
};
|
|
5733
|
-
}
|
|
5734
6275
|
function validateObject(payload, schema) {
|
|
5735
6276
|
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
5736
6277
|
throw new ValidationError("Request body must be a JSON object.");
|
|
@@ -5755,6 +6296,7 @@ function validateObject(payload, schema) {
|
|
|
5755
6296
|
return output;
|
|
5756
6297
|
}
|
|
5757
6298
|
export {
|
|
6299
|
+
wrapRouteHandler,
|
|
5758
6300
|
wrapMarkdownMailLayout,
|
|
5759
6301
|
withMigrationLock,
|
|
5760
6302
|
withMiddleware,
|
|
@@ -5771,6 +6313,11 @@ export {
|
|
|
5771
6313
|
sendMarkdownMail,
|
|
5772
6314
|
securedBindRouteModelByKey,
|
|
5773
6315
|
securedBindRouteModel,
|
|
6316
|
+
scopedOrganizationIds,
|
|
6317
|
+
runWithTraceContext,
|
|
6318
|
+
runWithTenantDatabase,
|
|
6319
|
+
runWithTenant,
|
|
6320
|
+
runWithRequestMeta,
|
|
5774
6321
|
runWithDatabaseConnection,
|
|
5775
6322
|
runWithAuthUser,
|
|
5776
6323
|
runSeedersFromDirectory,
|
|
@@ -5780,7 +6327,12 @@ export {
|
|
|
5780
6327
|
runDueScheduledTasks,
|
|
5781
6328
|
rollbackDatabase,
|
|
5782
6329
|
resolveWebLayoutData,
|
|
6330
|
+
resolveUserTenantId,
|
|
6331
|
+
resolveUserId,
|
|
5783
6332
|
resolveService,
|
|
6333
|
+
resolveRepositoryConnection,
|
|
6334
|
+
resolveOrganizationScope,
|
|
6335
|
+
resolveMembershipService,
|
|
5784
6336
|
resolveDatabaseDriver,
|
|
5785
6337
|
resolveCsrfTokenForRequest,
|
|
5786
6338
|
resolveCsrfToken,
|
|
@@ -5791,10 +6343,14 @@ export {
|
|
|
5791
6343
|
resolveApplicationConfig,
|
|
5792
6344
|
resolveApplicationCache,
|
|
5793
6345
|
resolveApplicationAuth,
|
|
6346
|
+
resetDefaultStorage,
|
|
5794
6347
|
required,
|
|
6348
|
+
requestIdMiddleware,
|
|
6349
|
+
repositoryConnection,
|
|
5795
6350
|
renderMarkdownMail,
|
|
5796
6351
|
registerShutdownHandler,
|
|
5797
6352
|
registerModelRepository,
|
|
6353
|
+
registerDefaultDatabasePool,
|
|
5798
6354
|
readSubmittedCsrfTokenFromBody,
|
|
5799
6355
|
readSubmittedCsrfToken,
|
|
5800
6356
|
readRequestCookie,
|
|
@@ -5802,7 +6358,9 @@ export {
|
|
|
5802
6358
|
queue,
|
|
5803
6359
|
prometheusRegistry,
|
|
5804
6360
|
policyGate,
|
|
6361
|
+
parsePositiveIntParam,
|
|
5805
6362
|
parsePaginationQuery,
|
|
6363
|
+
parseMultipartUpload,
|
|
5806
6364
|
paginatedResponse,
|
|
5807
6365
|
normalizeMetricPath,
|
|
5808
6366
|
noContentResponse,
|
|
@@ -5815,12 +6373,16 @@ export {
|
|
|
5815
6373
|
markdownToHtml,
|
|
5816
6374
|
mailer,
|
|
5817
6375
|
mail,
|
|
6376
|
+
logSecurityEvent,
|
|
5818
6377
|
log,
|
|
5819
6378
|
loadSeedersFromDirectory,
|
|
5820
6379
|
loadMigrationsFromDirectory,
|
|
5821
6380
|
jsonResponse2 as jsonResponse,
|
|
5822
6381
|
jobRegistry,
|
|
6382
|
+
isPublicReadsEnabled,
|
|
6383
|
+
isInsideTenantDatabaseScope,
|
|
5823
6384
|
isHtmxRequest,
|
|
6385
|
+
isGlobalAdmin,
|
|
5824
6386
|
isEtagEnabled,
|
|
5825
6387
|
installGracefulShutdownSignals,
|
|
5826
6388
|
inferReferencedTable,
|
|
@@ -5833,55 +6395,90 @@ export {
|
|
|
5833
6395
|
indexBelongsToManyRelation,
|
|
5834
6396
|
hydrateValue,
|
|
5835
6397
|
htmlResponse,
|
|
6398
|
+
hasOrgMembership,
|
|
5836
6399
|
hasOne,
|
|
6400
|
+
hasMinimumOrgRole2 as hasMinimumOrgRole,
|
|
5837
6401
|
hasMany,
|
|
6402
|
+
guestCanViewResource,
|
|
5838
6403
|
grammarForDriver,
|
|
5839
6404
|
getMigrationStatus,
|
|
6405
|
+
getDefaultDatabaseQuery,
|
|
6406
|
+
getDefaultDatabasePool,
|
|
5840
6407
|
getActiveDatabaseConnection,
|
|
5841
6408
|
freshDatabase,
|
|
5842
6409
|
formatAdminValue,
|
|
5843
6410
|
filterMassAssignable,
|
|
5844
6411
|
events,
|
|
5845
6412
|
etagFromResource,
|
|
6413
|
+
emptyPaginateResult,
|
|
5846
6414
|
emailRule,
|
|
5847
6415
|
dehydrateValue,
|
|
5848
6416
|
defineTable,
|
|
6417
|
+
currentTraceId,
|
|
6418
|
+
currentTenantId,
|
|
6419
|
+
currentTenant,
|
|
6420
|
+
currentRequestMeta,
|
|
6421
|
+
currentOrganizationIds,
|
|
6422
|
+
currentOrgRole,
|
|
5849
6423
|
currentAuthUser,
|
|
5850
6424
|
createdResponse,
|
|
5851
6425
|
createTrackedJob,
|
|
6426
|
+
createTracingMiddleware,
|
|
5852
6427
|
createThrottleMiddleware,
|
|
6428
|
+
createTenantMiddleware,
|
|
6429
|
+
createStorageDriver,
|
|
5853
6430
|
createSecurityHeadersMiddleware,
|
|
6431
|
+
createScimThrottleMiddleware,
|
|
6432
|
+
createScimAuthMiddleware,
|
|
5854
6433
|
createSchemaBuilder,
|
|
5855
6434
|
createRequireWebAuthMiddleware,
|
|
6435
|
+
createRequireGlobalAdminMiddleware,
|
|
5856
6436
|
createRequireAuthMiddleware,
|
|
6437
|
+
createRequireAbilityMiddleware,
|
|
6438
|
+
createRequestLoggingMiddleware,
|
|
5857
6439
|
createQueueWorker,
|
|
5858
6440
|
createQueue,
|
|
5859
6441
|
createProductionQueue,
|
|
5860
6442
|
createNotificationDispatcher,
|
|
5861
6443
|
createMetricsMiddleware,
|
|
5862
6444
|
createMemoryThrottleMiddleware,
|
|
6445
|
+
createMembershipMiddleware,
|
|
5863
6446
|
createLoginThrottleMiddleware,
|
|
6447
|
+
createFlashMiddleware,
|
|
5864
6448
|
createFailedJobService,
|
|
5865
|
-
|
|
6449
|
+
createDatabaseQueryProxy,
|
|
6450
|
+
createDatabaseConnection,
|
|
5866
6451
|
createCsrfTokenCookie,
|
|
5867
6452
|
createCsrfProtection,
|
|
5868
6453
|
createCsrfMiddleware,
|
|
6454
|
+
createCorsMiddleware,
|
|
5869
6455
|
createBodySizeLimitMiddleware,
|
|
5870
6456
|
createAuthorizeMiddleware,
|
|
5871
6457
|
createAuthMiddleware,
|
|
5872
6458
|
config,
|
|
6459
|
+
conditionalJsonResponse,
|
|
5873
6460
|
composeMiddleware,
|
|
5874
6461
|
compileBlueprint,
|
|
6462
|
+
collectQueueMetrics,
|
|
5875
6463
|
cache,
|
|
5876
6464
|
buildSmtpPayload,
|
|
6465
|
+
buildRequestCacheKey,
|
|
5877
6466
|
buildMarkdownMailMessage,
|
|
6467
|
+
bindRouteModel,
|
|
5878
6468
|
bindDatabaseConnection2 as bindDatabaseConnection,
|
|
5879
6469
|
belongsToMany,
|
|
5880
6470
|
belongsTo,
|
|
6471
|
+
authContext,
|
|
5881
6472
|
auth,
|
|
6473
|
+
auditChecksum,
|
|
6474
|
+
assertResourceInCurrentTenant,
|
|
6475
|
+
assertOrganizationReadable,
|
|
5882
6476
|
assertIfMatch,
|
|
5883
6477
|
applyMiddlewareToRoutes,
|
|
6478
|
+
applyConditionalGet,
|
|
5884
6479
|
applyCasts,
|
|
6480
|
+
appendProjectScope,
|
|
6481
|
+
appendOrganizationScope,
|
|
5885
6482
|
appSchedule,
|
|
5886
6483
|
WhereBuilder,
|
|
5887
6484
|
WebFormRequest,
|
|
@@ -5909,10 +6506,12 @@ export {
|
|
|
5909
6506
|
NotFoundError,
|
|
5910
6507
|
MySqlGrammar,
|
|
5911
6508
|
Model,
|
|
6509
|
+
membershipService_default as MembershipService,
|
|
5912
6510
|
Mailer,
|
|
5913
6511
|
LogMailDriver,
|
|
5914
6512
|
LocalStorageDriver,
|
|
5915
6513
|
Job,
|
|
6514
|
+
GuestGuard,
|
|
5916
6515
|
FormRequest,
|
|
5917
6516
|
ForeignIdColumnDefinition,
|
|
5918
6517
|
ForbiddenError,
|
|
@@ -5920,15 +6519,20 @@ export {
|
|
|
5920
6519
|
failedJobRepository_default as FailedJobRepository,
|
|
5921
6520
|
EventBus,
|
|
5922
6521
|
EtaViewEngine,
|
|
6522
|
+
DatabaseTokenGuard,
|
|
5923
6523
|
DEFAULT_VIEWS_DIRECTORY,
|
|
6524
|
+
DEFAULT_TENANT,
|
|
5924
6525
|
ConflictError,
|
|
5925
6526
|
ConfigStore,
|
|
6527
|
+
CompositeGuard,
|
|
5926
6528
|
ColumnDefinition,
|
|
5927
|
-
|
|
6529
|
+
repository_default2 as CacheRepository,
|
|
5928
6530
|
CACHE_TAGS,
|
|
5929
6531
|
Blueprint,
|
|
5930
6532
|
baseRepository_default as BaseRepository,
|
|
5931
6533
|
BadRequestError,
|
|
6534
|
+
AuthManager,
|
|
5932
6535
|
AsyncQueue,
|
|
6536
|
+
ApiTokenGuard,
|
|
5933
6537
|
AdminResourceRegistry
|
|
5934
6538
|
};
|