@getstrata/core 0.5.14 → 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.
Files changed (41) hide show
  1. package/dist/bootstrap/contracts.d.ts +2 -0
  2. package/dist/core/auth/membershipService.d.ts +5 -1
  3. package/dist/core/database/baseRepository.d.ts +6 -1
  4. package/dist/core/database/connectionContext.d.ts +2 -1
  5. package/dist/core/database/defaultConnection.d.ts +6 -0
  6. package/dist/core/database/queryProxy.d.ts +3 -0
  7. package/dist/core/database/repositoryConnection.d.ts +3 -3
  8. package/dist/core/jobs/dispatchWebhookJob.d.ts +0 -1
  9. package/dist/core/security/safeFetch.d.ts +2 -0
  10. package/dist/core/security/safeUrl.d.ts +16 -1
  11. package/dist/core/tenant/tenantDatabaseScope.d.ts +2 -1
  12. package/dist/db/connection/index.d.ts +1 -1
  13. package/dist/entries/auth/accessControl.js +1 -113
  14. package/dist/entries/auth/authContext.js +1 -15
  15. package/dist/entries/auth/guard.js +1 -3044
  16. package/dist/entries/auth/membershipContext.js +1 -276
  17. package/dist/entries/auth/membershipScope.js +1 -390
  18. package/dist/entries/auth/membershipService.js +1 -480
  19. package/dist/entries/auth/policy.js +1 -134
  20. package/dist/entries/database.js +1 -2398
  21. package/dist/entries/http/csrfToken.js +0 -6
  22. package/dist/entries/http/middleware.js +1 -68
  23. package/dist/entries/http/requestMetaContext.js +1 -18
  24. package/dist/entries/http/webErrorResponse.js +94 -563
  25. package/dist/entries/http/webFormRequest.js +0 -131
  26. package/dist/entries/http.js +1 -4091
  27. package/dist/entries/jobs/dispatchWebhookJob.js +109 -102
  28. package/dist/entries/queue/createAppQueue.js +111 -631
  29. package/dist/entries/queue/jobRunner.js +0 -3
  30. package/dist/entries/queue/publicQueue.js +45 -546
  31. package/dist/entries/queue/queueMetrics.js +111 -631
  32. package/dist/entries/security/safeUrl.js +29 -0
  33. package/dist/entries/security/securityEvents.js +1 -41
  34. package/dist/entries/tenant/tenantContext.js +1 -30
  35. package/dist/entries/tenant/tenantMiddleware.js +1 -312
  36. package/dist/entries/tracing/traceContext.js +1 -15
  37. package/dist/entries/view.js +94 -563
  38. package/dist/framework/public-api.d.ts +29 -6
  39. package/dist/index.js +2125 -2000
  40. package/dist/modules/user/repository.d.ts +1 -0
  41. package/package.json +6 -5
package/dist/index.js CHANGED
@@ -205,139 +205,6 @@ class AdminResourceRegistry {
205
205
  this.resources.clear();
206
206
  }
207
207
  }
208
- // ../../src/core/auth/authContext.ts
209
- import { AsyncLocalStorage } from "async_hooks";
210
- var authContext = new AsyncLocalStorage;
211
- function runWithAuthUser(user, callback) {
212
- return authContext.run(user, callback);
213
- }
214
- function currentAuthUser() {
215
- return authContext.getStore() ?? null;
216
- }
217
- // ../../src/core/auth/membershipContext.ts
218
- import { AsyncLocalStorage as AsyncLocalStorage3 } from "async_hooks";
219
-
220
- // ../../src/config/database.ts
221
- function readInteger(name, fallback) {
222
- const parsed = Number.parseInt(process.env[name] ?? String(fallback), 10);
223
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
224
- }
225
- var databaseConfig = {
226
- url: process.env.DATABASE_URL ?? "",
227
- poolMax: readInteger("DB_POOL_MAX", 10),
228
- idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
229
- maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
230
- connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
231
- };
232
-
233
- // ../../src/core/database/connectionContext.ts
234
- import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
235
- var activeConnection = new AsyncLocalStorage2;
236
- function runWithDatabaseConnection(connection, callback) {
237
- return activeConnection.run(connection, callback);
238
- }
239
- function getActiveDatabaseConnection(fallback) {
240
- return activeConnection.getStore() ?? fallback;
241
- }
242
-
243
- // ../../src/db/connection/createConnection.ts
244
- var {SQL } = globalThis.Bun;
245
- function createDatabaseConnection(config) {
246
- if (!config.url) {
247
- throw new Error("DATABASE_URL is not configured. Set DATABASE_URL before starting the app or running integration tests.");
248
- }
249
- return new SQL({
250
- url: config.url,
251
- max: config.poolMax,
252
- idleTimeout: config.idleTimeoutSeconds,
253
- maxLifetime: config.maxLifetimeSeconds,
254
- connectionTimeout: config.connectionTimeoutSeconds
255
- });
256
- }
257
-
258
- // ../../src/db/connection/index.ts
259
- var connectionHolder = {
260
- connection: null
261
- };
262
- function getDatabase() {
263
- if (!connectionHolder.connection) {
264
- connectionHolder.connection = createDatabaseConnection(databaseConfig);
265
- }
266
- return connectionHolder.connection;
267
- }
268
- var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
269
- function resolveDatabase() {
270
- return getActiveDatabaseConnection(getDatabase());
271
- }
272
- function resolveDatabaseForProperty(property) {
273
- if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
274
- return getDatabase();
275
- }
276
- return resolveDatabase();
277
- }
278
- var db = new Proxy(function database() {}, {
279
- apply(_target, _thisArg, args) {
280
- return resolveDatabase()(...args);
281
- },
282
- get(_target, property) {
283
- const connection = resolveDatabaseForProperty(property);
284
- const value = connection[property];
285
- return typeof value === "function" ? value.bind(connection) : value;
286
- }
287
- });
288
- var connection_default = db;
289
-
290
- // ../../src/modules/organization/memberRepository.ts
291
- class OrganizationMemberRepository {
292
- constructor() {}
293
- async findMembership(userId, organizationId) {
294
- const rows = await connection_default`
295
- SELECT id, organization_id, user_id, role, created_at
296
- FROM organization_member
297
- WHERE user_id = ${userId} AND organization_id = ${organizationId}
298
- LIMIT 1
299
- `;
300
- return rows[0] ?? null;
301
- }
302
- async listForUser(userId) {
303
- return await connection_default`
304
- SELECT id, organization_id, user_id, role, created_at
305
- FROM organization_member
306
- WHERE user_id = ${userId}
307
- ORDER BY organization_id
308
- `;
309
- }
310
- async listForOrganization(organizationId) {
311
- return await connection_default`
312
- SELECT id, organization_id, user_id, role, created_at
313
- FROM organization_member
314
- WHERE organization_id = ${organizationId}
315
- ORDER BY id
316
- `;
317
- }
318
- async addMember(input) {
319
- const rows = await connection_default`
320
- INSERT INTO organization_member (organization_id, user_id, role)
321
- VALUES (${input.organizationId}, ${input.userId}, ${input.role ?? "member"})
322
- RETURNING id, organization_id, user_id, role, created_at
323
- `;
324
- const row = rows[0];
325
- if (!row) {
326
- throw new Error("Organization member insert did not return a row.");
327
- }
328
- return row;
329
- }
330
- async removeMember(organizationId, userId) {
331
- const rows = await connection_default`
332
- DELETE FROM organization_member
333
- WHERE organization_id = ${organizationId} AND user_id = ${userId}
334
- RETURNING id
335
- `;
336
- return rows.length > 0;
337
- }
338
- }
339
- var memberRepository_default = OrganizationMemberRepository;
340
-
341
208
  // ../../src/core/errors/http.ts
342
209
  class HttpError extends Error {
343
210
  status;
@@ -404,6 +271,16 @@ class PreconditionFailedError extends HttpError {
404
271
  }
405
272
  }
406
273
 
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;
282
+ }
283
+
407
284
  // ../../src/core/auth/accessControl.ts
408
285
  var ROLE_RANK = {
409
286
  member: 1,
@@ -419,13 +296,6 @@ function hasMinimumOrgRole(role, minimum) {
419
296
  }
420
297
  return ROLE_RANK[role] >= ROLE_RANK[minimum];
421
298
  }
422
- function requireAuthenticatedUser() {
423
- const user = currentAuthUser();
424
- if (!user) {
425
- throw new ForbiddenError("Authentication required.");
426
- }
427
- return user;
428
- }
429
299
  function resolveUserId(user) {
430
300
  const userId = typeof user.id === "number" ? user.id : Number(user.id);
431
301
  if (!Number.isInteger(userId) || userId <= 0) {
@@ -433,204 +303,95 @@ function resolveUserId(user) {
433
303
  }
434
304
  return userId;
435
305
  }
436
-
437
- // ../../src/core/auth/membershipContext.ts
438
- var membershipContext = new AsyncLocalStorage3;
439
- var membershipRepository = new memberRepository_default;
440
- async function runWithMembershipContext(callback) {
441
- const user = currentAuthUser();
442
- if (!user || isGlobalAdmin(user)) {
443
- return await callback();
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];
444
340
  }
445
- const memberships = await membershipRepository.listForUser(resolveUserId(user));
446
- const context = {
447
- organizationIds: memberships.map((membership) => membership.organization_id),
448
- rolesByOrganizationId: new Map(memberships.map((membership) => [membership.organization_id, membership.role]))
449
- };
450
- return await membershipContext.run(context, callback);
451
- }
452
- function currentOrgRole(organizationId) {
453
- return membershipContext.getStore()?.rolesByOrganizationId.get(organizationId) ?? null;
341
+ return [...MEMBER_ABILITIES];
454
342
  }
455
- function hasOrgMembership(organizationId) {
456
- return currentOrgRole(organizationId) !== null;
343
+
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
+ };
457
359
  }
458
- function currentOrganizationIds() {
459
- return membershipContext.getStore()?.organizationIds ?? [];
360
+ var featureFlags = readFeatureFlags();
361
+ function isFeatureEnabled(feature) {
362
+ return readFeatureFlags()[feature];
460
363
  }
461
- function hasMinimumOrgRole2(organizationId, minimum) {
462
- const role = currentOrgRole(organizationId);
463
- if (!role) {
464
- return false;
364
+
365
+ // ../../src/core/events/eventBus.ts
366
+ class EventBus {
367
+ constructor() {}
368
+ listeners = new Map;
369
+ listen(event, listener) {
370
+ const handlers = this.listeners.get(event) ?? new Set;
371
+ handlers.add(listener);
372
+ this.listeners.set(event, handlers);
373
+ return () => {
374
+ handlers.delete(listener);
375
+ if (handlers.size === 0) {
376
+ this.listeners.delete(event);
377
+ }
378
+ };
379
+ }
380
+ async dispatch(event, payload) {
381
+ const handlers = this.listeners.get(event);
382
+ if (!handlers || handlers.size === 0) {
383
+ return;
384
+ }
385
+ for (const handler of handlers) {
386
+ await handler(payload);
387
+ }
465
388
  }
466
- const ranks = {
467
- member: 1,
468
- admin: 2,
469
- owner: 3
470
- };
471
- return ranks[role] >= ranks[minimum];
472
389
  }
390
+ var eventBus = new EventBus;
473
391
 
474
- // ../../src/core/auth/membershipContextMiddleware.ts
475
- function createMembershipContextMiddleware() {
476
- return async (_request, next) => {
477
- return await runWithMembershipContext(async () => await next());
478
- };
479
- }
480
-
481
- // ../../src/core/auth/membershipMiddleware.ts
482
- function createMembershipMiddleware() {
483
- return createMembershipContextMiddleware();
484
- }
485
- // ../../src/core/auth/policy.ts
486
- class Policy {
487
- constructor() {}
488
- view(_user, _resource) {
489
- return false;
490
- }
491
- create(_user) {
492
- return false;
493
- }
494
- update(_user, _resource) {
495
- return false;
496
- }
497
- delete(_user, _resource) {
498
- return false;
499
- }
500
- }
501
- var BLOCKED_POLICY_ACTIONS = new Set([
502
- "constructor",
503
- "toString",
504
- "valueOf",
505
- "hasOwnProperty",
506
- "isPrototypeOf",
507
- "propertyIsEnumerable",
508
- "__proto__"
509
- ]);
510
-
511
- class PolicyGate {
512
- constructor() {}
513
- policies = new Map;
514
- register(resource, policy) {
515
- this.policies.set(resource, policy);
516
- }
517
- allows(resource, action, user, model) {
518
- const policy = this.policies.get(resource);
519
- if (!policy) {
520
- return false;
521
- }
522
- if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
523
- return false;
524
- }
525
- const handler = policy[action];
526
- if (typeof handler !== "function") {
527
- return false;
528
- }
529
- const resolvedUser = user === undefined ? currentAuthUser() : user;
530
- return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
531
- }
532
- authorize(resource, action, user, model) {
533
- if (!this.allows(resource, action, user, model)) {
534
- throw new ForbiddenError;
535
- }
536
- }
537
- }
538
- // ../../src/core/cache/taggedCache.ts
539
- class TaggedCache {
540
- store;
541
- tags;
542
- constructor(store, tags) {
543
- this.store = store;
544
- this.tags = tags;
545
- }
546
- async remember(key, callback, ttlMs) {
547
- const value = await this.store.getOrSet(key, callback, ttlMs);
548
- await this.store.attachTags(key, this.tags);
549
- return value;
550
- }
551
- async flush() {
552
- return this.store.flushTags(this.tags);
553
- }
554
- }
555
- var taggedCache_default = TaggedCache;
556
-
557
- // ../../src/core/cache/repository.ts
558
- class CacheRepository {
559
- store;
560
- constructor(store) {
561
- this.store = store;
562
- }
563
- async get(key) {
564
- return this.store.get(key);
565
- }
566
- async remember(key, callback, ttlMs) {
567
- return this.store.getOrSet(key, callback, ttlMs);
568
- }
569
- async forget(key) {
570
- return this.store.invalidate(key);
571
- }
572
- async flush() {
573
- await this.store.clear();
574
- }
575
- tags(...names) {
576
- return new taggedCache_default(this.store, names);
577
- }
578
- async getOrSet(key, loader, ttlMs) {
579
- return this.remember(key, loader, ttlMs);
580
- }
581
- async invalidate(key) {
582
- return this.forget(key);
583
- }
584
- async invalidateByPrefix(prefix) {
585
- return this.store.invalidateByPrefix(prefix);
586
- }
587
- async clear() {
588
- await this.flush();
589
- }
590
- async size() {
591
- return this.store.size();
592
- }
593
- }
594
- var repository_default = CacheRepository;
595
- // ../../src/core/cache/tags.ts
596
- var CACHE_TAGS = {
597
- organizations: "organizations",
598
- projects: "projects",
599
- tasks: "tasks",
600
- comments: "comments",
601
- attachments: "attachments",
602
- reports: "reports"
603
- };
604
- // ../../src/core/events/eventBus.ts
605
- class EventBus {
606
- constructor() {}
607
- listeners = new Map;
608
- listen(event, listener) {
609
- const handlers = this.listeners.get(event) ?? new Set;
610
- handlers.add(listener);
611
- this.listeners.set(event, handlers);
612
- return () => {
613
- handlers.delete(listener);
614
- if (handlers.size === 0) {
615
- this.listeners.delete(event);
616
- }
617
- };
618
- }
619
- async dispatch(event, payload) {
620
- const handlers = this.listeners.get(event);
621
- if (!handlers || handlers.size === 0) {
622
- return;
623
- }
624
- for (const handler of handlers) {
625
- await handler(payload);
626
- }
627
- }
628
- }
629
- var eventBus = new EventBus;
630
-
631
- // ../../src/core/events/index.ts
632
- function modelEventName(tableName, action) {
633
- return `${tableName}.${action}`;
392
+ // ../../src/core/events/index.ts
393
+ function modelEventName(tableName, action) {
394
+ return `${tableName}.${action}`;
634
395
  }
635
396
 
636
397
  // ../../src/core/pagination/index.ts
@@ -806,14 +567,6 @@ function appendWhereParts(tableName, where, params) {
806
567
  }
807
568
  return clauses.join(" AND ");
808
569
  }
809
- function buildWhereClause(tableName, where = {}) {
810
- const params = [];
811
- const body = appendWhereParts(tableName, where, params);
812
- return {
813
- clause: body.length > 0 ? ` WHERE ${body}` : "",
814
- params
815
- };
816
- }
817
570
  function buildWhereNodeClause(tableName, node, params) {
818
571
  if ("where" in node) {
819
572
  return appendWhereParts(tableName, node.where, params);
@@ -1271,12 +1024,79 @@ function bindDatabaseConnection(connection) {
1271
1024
  function getBoundDatabaseConnection() {
1272
1025
  return boundConnectionHolder.connection;
1273
1026
  }
1027
+ function resetBoundDatabaseConnection() {
1028
+ boundConnectionHolder.connection = null;
1029
+ }
1030
+
1031
+ // ../../src/core/database/connectionContext.ts
1032
+ import { AsyncLocalStorage as AsyncLocalStorage2 } from "async_hooks";
1033
+ var activeConnection = new AsyncLocalStorage2;
1034
+ function runWithDatabaseConnection(connection, callback) {
1035
+ return activeConnection.run(connection, callback);
1036
+ }
1037
+ function getActiveDatabaseConnection(fallback) {
1038
+ return activeConnection.getStore() ?? fallback;
1039
+ }
1040
+ function hasActiveDatabaseConnection() {
1041
+ return activeConnection.getStore() !== undefined;
1042
+ }
1043
+
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);
1049
+ }
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
+ }
1065
+ });
1066
+ }
1067
+
1068
+ // ../../src/core/database/defaultConnection.ts
1069
+ var defaultPool = {
1070
+ connection: null
1071
+ };
1072
+ var defaultQuery = {
1073
+ connection: null
1074
+ };
1075
+ function registerDefaultDatabasePool(connection) {
1076
+ defaultPool.connection = connection;
1077
+ defaultQuery.connection = createDatabaseQueryProxy(connection);
1078
+ }
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;
1084
+ }
1085
+ function getDefaultDatabaseQuery() {
1086
+ if (!defaultQuery.connection) {
1087
+ throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
1088
+ }
1089
+ return defaultQuery.connection;
1090
+ }
1274
1091
 
1275
1092
  // ../../src/core/database/repositoryConnection.ts
1276
1093
  function resolveRepositoryConnection() {
1277
- return getBoundDatabaseConnection() ?? connection_default;
1094
+ return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
1278
1095
  }
1279
- var repositoryConnection = new Proxy({}, {
1096
+ var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
1097
+ apply(_target, _thisArg, args) {
1098
+ return resolveRepositoryConnection()(...args);
1099
+ },
1280
1100
  get(_target, property) {
1281
1101
  const connection = resolveRepositoryConnection();
1282
1102
  const value = connection[property];
@@ -1839,156 +1659,40 @@ class BaseRepository {
1839
1659
  }
1840
1660
  }
1841
1661
  var baseRepository_default = BaseRepository;
1842
- // ../../src/core/database/bindConnection.ts
1843
- function bindDatabaseConnection2(connection) {
1844
- bindDatabaseConnection(connection);
1845
- }
1846
1662
  // ../../src/core/database/connection.ts
1847
- function createDatabaseConnection2(source) {
1663
+ function createDatabaseConnection(source) {
1848
1664
  return {
1849
1665
  async unsafe(query, params = []) {
1850
1666
  return await source.unsafe(query, params);
1851
1667
  }
1852
1668
  };
1853
1669
  }
1854
- // ../../src/core/database/migrations/advisoryLock.ts
1855
- var MIGRATION_LOCK_KEY = 42424242;
1856
- async function withMigrationLock(db2, callback, lockKey = MIGRATION_LOCK_KEY) {
1857
- await db2.unsafe("SELECT pg_advisory_lock($1)", [lockKey]);
1858
- try {
1859
- return await callback();
1860
- } finally {
1861
- await db2.unsafe("SELECT pg_advisory_unlock($1)", [lockKey]);
1670
+ // ../../src/core/database/model.ts
1671
+ var modelRepositories = new WeakMap;
1672
+ var modelGlobalScopes = new WeakMap;
1673
+ var modelBooted = new WeakSet;
1674
+ function resolveModelRepository(model) {
1675
+ const repository = modelRepositories.get(model);
1676
+ if (!repository) {
1677
+ throw new Error(`${model.name}.repository() is not implemented.`);
1862
1678
  }
1679
+ return repository;
1863
1680
  }
1864
- // ../../src/core/database/migrations/runner.ts
1865
- import { readdir } from "fs/promises";
1866
- import { join } from "path";
1867
- import { pathToFileURL } from "url";
1868
- var MIGRATIONS_TABLE = "framework_migrations";
1869
- async function ensureMigrationsTable(db2) {
1870
- await db2.unsafe(`
1871
- CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
1872
- name TEXT PRIMARY KEY,
1873
- batch INTEGER NOT NULL,
1874
- run_on TIMESTAMPTZ NOT NULL DEFAULT NOW()
1875
- )
1876
- `);
1681
+ function modelStatics(model) {
1682
+ return model;
1877
1683
  }
1878
- async function getAppliedMigrations(db2) {
1879
- await ensureMigrationsTable(db2);
1880
- return await db2.unsafe(`
1881
- SELECT name, batch
1882
- FROM ${MIGRATIONS_TABLE}
1883
- ORDER BY batch ASC, name ASC
1884
- `);
1684
+ function ensureBooted(model) {
1685
+ if (modelBooted.has(model)) {
1686
+ return;
1687
+ }
1688
+ modelBooted.add(model);
1689
+ const boot = model.boot;
1690
+ if (typeof boot === "function") {
1691
+ boot.call(model);
1692
+ }
1885
1693
  }
1886
- async function loadMigrationsFromDirectory(directory) {
1887
- const entries = await readdir(directory);
1888
- const migrationFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
1889
- const loadedMigrations = await Promise.all(migrationFiles.map(async (fileName) => {
1890
- const moduleUrl = pathToFileURL(join(directory, fileName)).href;
1891
- const module = await import(moduleUrl);
1892
- return module.default;
1893
- }));
1894
- return loadedMigrations.filter((migration) => migration?.name !== undefined);
1895
- }
1896
- async function getMigrationStatus(db2, migrations) {
1897
- const applied = await getAppliedMigrations(db2);
1898
- const appliedByName = new Map(applied.map(({ name, batch }) => [name, Number(batch)]));
1899
- return migrations.map(({ name }) => ({
1900
- name,
1901
- status: appliedByName.has(name) ? "up" : "pending",
1902
- batch: appliedByName.get(name) ?? null
1903
- }));
1904
- }
1905
- async function runPendingMigrations(db2, migrations, options = {}) {
1906
- const applied = await getAppliedMigrations(db2);
1907
- const appliedNames = new Set(applied.map(({ name }) => name));
1908
- const nextBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0) + 1;
1909
- const pendingMigrations = migrations.filter(({ name }) => !appliedNames.has(name));
1910
- for (const migration of pendingMigrations) {
1911
- options.onMigration?.(migration.name);
1912
- await migration.up(db2);
1913
- const inserted = await db2.unsafe(`INSERT INTO ${MIGRATIONS_TABLE} (name, batch) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING name`, [migration.name, nextBatch]);
1914
- if (inserted.length === 0) {
1915
- throw new Error(`Migration ${migration.name} was applied but not recorded.`);
1916
- }
1917
- }
1918
- return pendingMigrations.length;
1919
- }
1920
- async function migrateDatabase(db2, migrations, options = {}) {
1921
- const { advisoryLock = false, onMigration } = options;
1922
- if (advisoryLock) {
1923
- return withMigrationLock(db2, () => runPendingMigrations(db2, migrations, { onMigration }));
1924
- }
1925
- return runPendingMigrations(db2, migrations, { onMigration });
1926
- }
1927
- async function rollbackDatabase(db2, migrations, options = {}) {
1928
- const applied = await getAppliedMigrations(db2);
1929
- if (applied.length === 0) {
1930
- return 0;
1931
- }
1932
- const lastBatch = applied.reduce((currentMax, { batch }) => Math.max(currentMax, Number(batch)), 0);
1933
- const migrationsToRollback = new Set(applied.filter(({ batch }) => Number(batch) === lastBatch).map(({ name }) => name));
1934
- let rolledBack = 0;
1935
- for (const migration of [...migrations].reverse()) {
1936
- if (!migrationsToRollback.has(migration.name)) {
1937
- continue;
1938
- }
1939
- options.onMigration?.(migration.name);
1940
- await migration.down(db2);
1941
- await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = $1`, [migration.name]);
1942
- rolledBack += 1;
1943
- }
1944
- return rolledBack;
1945
- }
1946
- async function freshDatabase(db2, migrations, options = {}) {
1947
- const runFresh = async () => {
1948
- const applied = await getAppliedMigrations(db2);
1949
- const appliedNames = new Set(applied.map(({ name }) => name));
1950
- const appliedMigrations = migrations.filter(({ name }) => appliedNames.has(name));
1951
- for (const migration of [...appliedMigrations].reverse()) {
1952
- options.onMigration?.(migration.name);
1953
- await migration.down(db2);
1954
- }
1955
- if (appliedMigrations.length > 0) {
1956
- await db2.unsafe(`DELETE FROM ${MIGRATIONS_TABLE}`);
1957
- }
1958
- await runPendingMigrations(db2, migrations, options);
1959
- };
1960
- if (options.advisoryLock) {
1961
- await withMigrationLock(db2, runFresh);
1962
- return;
1963
- }
1964
- await runFresh();
1965
- }
1966
- // ../../src/core/database/model.ts
1967
- var modelRepositories = new WeakMap;
1968
- var modelGlobalScopes = new WeakMap;
1969
- var modelBooted = new WeakSet;
1970
- function resolveModelRepository(model) {
1971
- const repository = modelRepositories.get(model);
1972
- if (!repository) {
1973
- throw new Error(`${model.name}.repository() is not implemented.`);
1974
- }
1975
- return repository;
1976
- }
1977
- function modelStatics(model) {
1978
- return model;
1979
- }
1980
- function ensureBooted(model) {
1981
- if (modelBooted.has(model)) {
1982
- return;
1983
- }
1984
- modelBooted.add(model);
1985
- const boot = model.boot;
1986
- if (typeof boot === "function") {
1987
- boot.call(model);
1988
- }
1989
- }
1990
- function getGlobalScopes(model) {
1991
- return modelGlobalScopes.get(model) ?? [];
1694
+ function getGlobalScopes(model) {
1695
+ return modelGlobalScopes.get(model) ?? [];
1992
1696
  }
1993
1697
  function hydrateValue(value, cast) {
1994
1698
  if (value === null || value === undefined) {
@@ -2874,9 +2578,9 @@ class SchemaBuilder {
2874
2578
  toSql() {
2875
2579
  return [...this.#statements];
2876
2580
  }
2877
- async execute(db2) {
2581
+ async execute(db) {
2878
2582
  for (const statement of this.#statements) {
2879
- await db2.unsafe(statement);
2583
+ await db.unsafe(statement);
2880
2584
  }
2881
2585
  }
2882
2586
  }
@@ -2885,45 +2589,20 @@ class Schema {
2885
2589
  static builder(driver) {
2886
2590
  return new SchemaBuilder(driver ?? resolveDatabaseDriver());
2887
2591
  }
2888
- static async run(db2, driver, callback) {
2592
+ static async run(db, driver, callback) {
2889
2593
  const schema = Schema.builder(driver);
2890
2594
  await callback(schema);
2891
- await schema.execute(db2);
2595
+ await schema.execute(db);
2892
2596
  }
2893
2597
  }
2894
- function createSchemaBuilder(db2, driver) {
2598
+ function createSchemaBuilder(db, driver) {
2895
2599
  const builder = Schema.builder(driver);
2896
2600
  return Object.assign(builder, {
2897
2601
  async commit() {
2898
- await builder.execute(db2);
2602
+ await builder.execute(db);
2899
2603
  }
2900
2604
  });
2901
2605
  }
2902
- // ../../src/core/database/seeders/runner.ts
2903
- import { readdir as readdir2 } from "fs/promises";
2904
- import { join as join2 } from "path";
2905
- import { pathToFileURL as pathToFileURL2 } from "url";
2906
- async function loadSeedersFromDirectory(directory) {
2907
- const entries = await readdir2(directory);
2908
- const seederFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
2909
- const loadedSeeders = await Promise.all(seederFiles.map(async (fileName) => {
2910
- const moduleUrl = pathToFileURL2(join2(directory, fileName)).href;
2911
- const module = await import(moduleUrl);
2912
- return module.default;
2913
- }));
2914
- return loadedSeeders.filter((seeder) => seeder?.name !== undefined);
2915
- }
2916
- async function runSeedersFromDirectory(directory, db2, options) {
2917
- const seeders = await loadSeedersFromDirectory(directory);
2918
- if (seeders.length === 0) {
2919
- return 0;
2920
- }
2921
- for (const seeder of seeders) {
2922
- options?.onSeeder?.(seeder.name);
2923
- await seeder.run(db2);
2924
- }
2925
- return seeders.length;
2926
- }
2927
2606
  // ../../src/core/database/table.ts
2928
2607
  function defineTable(definition) {
2929
2608
  return definition;
@@ -2938,488 +2617,804 @@ async function runInTransaction(operation) {
2938
2617
  throw new Error("Active database connection does not support transactions. Bind a client with begin() via bindDatabaseConnection.");
2939
2618
  }
2940
2619
  return await pool.begin(async (transaction) => {
2941
- return await operation(createDatabaseConnection2(transaction));
2620
+ return await operation(createDatabaseConnection(transaction));
2942
2621
  });
2943
2622
  }
2944
- // ../../src/core/mail/mailer.ts
2945
- function resolveSmtpConfig() {
2946
- const host = process.env.MAIL_HOST?.trim();
2947
- if (!host) {
2948
- throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
2949
- }
2950
- const from = process.env.MAIL_FROM?.trim();
2951
- if (!from) {
2952
- throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
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.");
2953
2641
  }
2954
- const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
2955
- if (!Number.isInteger(port) || port <= 0) {
2956
- throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
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);
2957
2659
  }
2958
- return {
2959
- host,
2960
- port,
2961
- from,
2962
- secure: (process.env.MAIL_SECURE ?? "false") === "true",
2963
- ...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
2964
- ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
2965
- };
2660
+ return connectionHolder.connection;
2966
2661
  }
2967
- function encodeBase64(value) {
2968
- return Buffer.from(value, "utf8").toString("base64");
2662
+ function getDb() {
2663
+ getDatabase();
2664
+ return getDefaultDatabaseQuery();
2969
2665
  }
2970
- function parseSmtpResponses(buffer) {
2971
- const responses = [];
2972
- let remainder = buffer;
2973
- while (remainder.includes(`\r
2974
- `)) {
2975
- const index = remainder.indexOf(`\r
2976
- `);
2977
- const line = remainder.slice(0, index);
2978
- remainder = remainder.slice(index + 2);
2979
- if (line.length >= 4 && line[3] === "-") {
2980
- continue;
2981
- }
2982
- 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;
2983
2674
  }
2984
- return { responses, remainder };
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);
2985
2698
  }
2986
- async function waitForSmtpResponse(readResponse, expectedCodes) {
2987
- const response = await readResponse();
2988
- const code = response.slice(0, 3);
2989
- if (!expectedCodes.includes(code)) {
2990
- throw new Error(`Unexpected SMTP response: ${response}`);
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;
2991
2709
  }
2992
- return response;
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).");
2993
2718
  }
2994
- async function openSmtpConnection(config) {
2995
- let buffer = "";
2996
- const waiters = [];
2997
- const readResponse = () => new Promise((resolve, reject) => {
2998
- const parsed = parseSmtpResponses(buffer);
2999
- if (parsed.responses.length > 0) {
3000
- buffer = parsed.remainder;
3001
- resolve(parsed.responses.shift());
3002
- return;
3003
- }
3004
- waiters.push({ resolve, reject });
3005
- });
3006
- const socket = await Bun.connect({
3007
- hostname: config.host,
3008
- port: config.port,
3009
- socket: {
3010
- open() {},
3011
- data(_socket, chunk) {
3012
- buffer += Buffer.from(chunk).toString("utf8");
3013
- const parsed = parseSmtpResponses(buffer);
3014
- buffer = parsed.remainder;
3015
- while (parsed.responses.length > 0 && waiters.length > 0) {
3016
- const response = parsed.responses.shift();
3017
- waiters.shift()?.resolve(response);
3018
- }
3019
- },
3020
- error(_socket, error) {
3021
- const pending = waiters.splice(0);
3022
- for (const waiter of pending) {
3023
- waiter.reject(error instanceof Error ? error : new Error(String(error)));
3024
- }
3025
- }
3026
- }
3027
- });
3028
- return { socket, readResponse };
3029
- }
3030
- async function defaultSmtpTransport(config, message) {
3031
- const { socket, readResponse } = await openSmtpConnection(config);
3032
- try {
3033
- await waitForSmtpResponse(readResponse, ["220"]);
3034
- await socket.write(`EHLO workhub.local\r
3035
- `);
3036
- await waitForSmtpResponse(readResponse, ["250"]);
3037
- if (config.username && config.password) {
3038
- await socket.write(`AUTH LOGIN\r
3039
- `);
3040
- await waitForSmtpResponse(readResponse, ["334"]);
3041
- await socket.write(`${encodeBase64(config.username)}\r
3042
- `);
3043
- await waitForSmtpResponse(readResponse, ["334"]);
3044
- await socket.write(`${encodeBase64(config.password)}\r
3045
- `);
3046
- await waitForSmtpResponse(readResponse, ["235"]);
3047
- }
3048
- await socket.write(`MAIL FROM:<${config.from}>\r
3049
- `);
3050
- await waitForSmtpResponse(readResponse, ["250"]);
3051
- await socket.write(`RCPT TO:<${message.to}>\r
3052
- `);
3053
- await waitForSmtpResponse(readResponse, ["250", "251"]);
3054
- await socket.write(`DATA\r
3055
- `);
3056
- await waitForSmtpResponse(readResponse, ["354"]);
3057
- const payload = buildSmtpPayload(config.from, message);
3058
- await socket.write(payload);
3059
- await waitForSmtpResponse(readResponse, ["250"]);
3060
- await socket.write(`QUIT\r
3061
- `);
3062
- await waitForSmtpResponse(readResponse, ["221"]);
3063
- } finally {
3064
- socket.end();
2719
+ function isFieldEncryptionEnabled() {
2720
+ const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
2721
+ if (featureFlag === "false") {
2722
+ return false;
3065
2723
  }
3066
- }
3067
- function buildSmtpPayload(from, message) {
3068
- const headers = [
3069
- `From: ${from}`,
3070
- `To: ${message.to}`,
3071
- `Subject: ${message.subject}`,
3072
- "MIME-Version: 1.0"
3073
- ];
3074
- if (message.html) {
3075
- const boundary = `strata-${Date.now().toString(36)}`;
3076
- headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
3077
- const parts = [
3078
- `--${boundary}`,
3079
- "Content-Type: text/plain; charset=utf-8",
3080
- "",
3081
- message.body,
3082
- `--${boundary}`,
3083
- "Content-Type: text/html; charset=utf-8",
3084
- "",
3085
- message.html,
3086
- `--${boundary}--`,
3087
- ""
3088
- ];
3089
- return [...headers, "", ...parts, ".", ""].join(`\r
3090
- `);
2724
+ if (featureFlag === "true") {
2725
+ return true;
3091
2726
  }
3092
- headers.push("Content-Type: text/plain; charset=utf-8");
3093
- return [...headers, "", message.body, ".", ""].join(`\r
3094
- `);
2727
+ return (process.env.APP_ENV ?? "local") === "production";
3095
2728
  }
3096
-
3097
- class LogMailDriver {
3098
- async send(message) {
3099
- console.log(JSON.stringify({
3100
- level: "info",
3101
- channel: "mail",
3102
- to: message.to,
3103
- subject: message.subject,
3104
- body: message.body,
3105
- ...message.html ? { html: message.html } : {}
3106
- }));
2729
+ function decryptField(value, key) {
2730
+ if (!value.startsWith(ENCRYPTION_PREFIX)) {
2731
+ return value;
3107
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");
3108
2740
  }
3109
2741
 
3110
- class SmtpMailDriver {
3111
- config;
3112
- transport;
3113
- constructor(config, transport = defaultSmtpTransport) {
3114
- this.config = config;
3115
- this.transport = transport;
2742
+ // ../../src/core/crypto/mfaSecret.ts
2743
+ function revealMfaSecret(stored) {
2744
+ if (!stored) {
2745
+ return null;
3116
2746
  }
3117
- send(message) {
3118
- return this.transport(this.config, message);
2747
+ const key = resolveEncryptionKey();
2748
+ if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
2749
+ return stored;
3119
2750
  }
2751
+ return decryptField(stored, key);
3120
2752
  }
3121
2753
 
3122
- class Mailer {
3123
- driver;
3124
- constructor(driver) {
3125
- this.driver = driver;
3126
- }
3127
- send(message) {
3128
- return this.driver.send(message);
3129
- }
3130
- }
3131
- function createMailDriver() {
3132
- const driver = process.env.MAIL_DRIVER ?? "log";
3133
- if (driver === "smtp") {
3134
- return new SmtpMailDriver(resolveSmtpConfig());
3135
- }
3136
- return new LogMailDriver;
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);
3137
2759
  }
3138
- var appMailer = new Mailer(createMailDriver());
3139
- function mailer() {
3140
- return appMailer;
2760
+ function currentRequestMeta() {
2761
+ return requestMetaContext.getStore() ?? {
2762
+ ipAddress: null,
2763
+ userAgent: null
2764
+ };
3141
2765
  }
3142
2766
 
3143
- // ../../src/core/storage/storage.ts
3144
- import { mkdir, readFile, unlink, writeFile } from "fs/promises";
3145
- import { dirname, join as join3 } from "path";
3146
- var {S3Client } = globalThis.Bun;
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
+ }
3147
2781
 
3148
- class LocalStorageDriver {
3149
- rootDirectory;
3150
- constructor(rootDirectory) {
3151
- this.rootDirectory = rootDirectory;
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;
3152
2787
  }
3153
- resolveRootDirectory() {
3154
- return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
2788
+ const parsed = Number.parseInt(raw, 10);
2789
+ if (!Number.isInteger(parsed) || parsed <= 0) {
2790
+ return null;
3155
2791
  }
3156
- resolvePath(path) {
3157
- return join3(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
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.");
2805
+ }
2806
+ bits += value.toString(2).padStart(5, "0");
3158
2807
  }
3159
- async put(path, contents) {
3160
- const absolutePath = this.resolvePath(path);
3161
- await mkdir(dirname(absolutePath), { recursive: true });
3162
- await writeFile(absolutePath, contents);
3163
- return path;
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));
3164
2811
  }
3165
- async get(path) {
3166
- try {
3167
- return await readFile(this.resolvePath(path));
3168
- } catch {
3169
- return null;
3170
- }
2812
+ return Buffer.from(bytes);
2813
+ }
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;
3171
2832
  }
3172
- async delete(path) {
3173
- try {
3174
- await unlink(this.resolvePath(path));
2833
+ const timestep = Math.floor(Date.now() / 30000);
2834
+ for (let offset = -window;offset <= window; offset += 1) {
2835
+ if (generateTotp(secret, timestep + offset) === normalized) {
3175
2836
  return true;
3176
- } catch {
3177
- return false;
3178
2837
  }
3179
2838
  }
2839
+ return false;
3180
2840
  }
3181
2841
 
3182
- class S3StorageDriver {
3183
- client;
3184
- constructor(client) {
3185
- this.client = client;
3186
- }
3187
- async put(path, contents) {
3188
- await this.client.write(path.replace(/^\/+/, ""), contents);
3189
- return path;
3190
- }
3191
- async get(path) {
3192
- const normalizedPath = path.replace(/^\/+/, "");
3193
- const file = this.client.file(normalizedPath);
3194
- if (!await file.exists()) {
3195
- return null;
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;
2862
+ }
2863
+ }
2864
+
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;
2875
+ }
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.");
3196
2887
  }
3197
- return new Uint8Array(await file.arrayBuffer());
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
+ });
3198
2910
  }
3199
- async delete(path) {
3200
- try {
3201
- await this.client.unlink(path.replace(/^\/+/, ""));
3202
- return true;
3203
- } catch {
3204
- return false;
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.");
3205
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;
3206
2955
  }
3207
2956
  }
3208
2957
 
3209
- class StorageManager {
3210
- driver;
3211
- constructor(driver) {
3212
- this.driver = driver;
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];
3213
3001
  }
3214
- put(path, contents) {
3215
- return this.driver.put(path, contents);
3002
+ return [...MEMBER_ABILITIES];
3003
+ }
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
+ };
3216
3017
  }
3217
- get(path) {
3218
- return this.driver.get(path);
3018
+ }
3019
+
3020
+ class ApiTokenGuard {
3021
+ options;
3022
+ constructor(options) {
3023
+ this.options = options;
3219
3024
  }
3220
- delete(path) {
3221
- return this.driver.delete(path);
3025
+ resolve(request) {
3026
+ const authorization = request.headers.get("authorization");
3027
+ if (!authorization?.startsWith("Bearer ")) {
3028
+ return null;
3029
+ }
3030
+ const token = authorization.slice("Bearer ".length).trim();
3031
+ if (token !== this.options.token) {
3032
+ return null;
3033
+ }
3034
+ return this.options.user;
3222
3035
  }
3223
3036
  }
3224
- function resolveS3Config() {
3225
- const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
3226
- const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
3227
- const bucket = process.env.AWS_BUCKET?.trim();
3228
- if (!accessKeyId || !secretAccessKey || !bucket) {
3229
- throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
3037
+
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 ")) {
3046
+ return null;
3047
+ }
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);
3230
3057
  }
3231
- return {
3232
- accessKeyId,
3233
- secretAccessKey,
3234
- bucket,
3235
- ...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
3236
- ...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
3237
- };
3238
- }
3239
- function createS3Client(config = resolveS3Config()) {
3240
- return new S3Client({
3241
- accessKeyId: config.accessKeyId,
3242
- secretAccessKey: config.secretAccessKey,
3243
- bucket: config.bucket,
3244
- ...config.region ? { region: config.region } : {},
3245
- ...config.endpoint ? { endpoint: config.endpoint } : {}
3246
- });
3247
3058
  }
3248
- function createStorageDriver() {
3249
- const driver = process.env.STORAGE_DRIVER ?? "local";
3250
- if (driver === "s3") {
3251
- return new S3StorageDriver(createS3Client());
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
+ }
3071
+ }
3072
+ return null;
3252
3073
  }
3253
- return new LocalStorageDriver;
3254
3074
  }
3255
- var defaultStorage = { current: null };
3256
- function storage() {
3257
- if (!defaultStorage.current) {
3258
- defaultStorage.current = new StorageManager(createStorageDriver());
3075
+
3076
+ class AuthManager {
3077
+ guard;
3078
+ constructor(guard) {
3079
+ this.guard = guard;
3080
+ }
3081
+ async resolve(request) {
3082
+ if (request) {
3083
+ return await Promise.resolve(this.guard.resolve(request));
3084
+ }
3085
+ return currentAuthUser();
3086
+ }
3087
+ user(request) {
3088
+ return this.resolve(request);
3089
+ }
3090
+ async check(request) {
3091
+ return await this.user(request) !== null;
3092
+ }
3093
+ async requireUser(request) {
3094
+ const user = await this.user(request);
3095
+ if (!user) {
3096
+ throw new UnauthorizedError;
3097
+ }
3098
+ return user;
3259
3099
  }
3260
- return defaultStorage.current;
3261
- }
3262
- function resetDefaultStorage() {
3263
- defaultStorage.current = null;
3264
3100
  }
3101
+ // ../../src/core/auth/membershipContext.ts
3102
+ import { AsyncLocalStorage as AsyncLocalStorage5 } from "async_hooks";
3265
3103
 
3266
- // ../../src/core/facades/index.ts
3267
- function cache() {
3268
- return resolveApplicationCache();
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;
3115
+ }
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
+ }
3269
3152
  }
3270
- function auth() {
3271
- return resolveApplicationAuth();
3153
+ var memberRepository_default = OrganizationMemberRepository;
3154
+
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);
3272
3169
  }
3273
- function policyGate() {
3274
- return resolveApplicationPolicyGate();
3170
+ function currentOrgRole(organizationId) {
3171
+ return membershipContext.getStore()?.rolesByOrganizationId.get(organizationId) ?? null;
3275
3172
  }
3276
- function queue() {
3277
- return resolveApplicationQueue();
3173
+ function hasOrgMembership(organizationId) {
3174
+ return currentOrgRole(organizationId) !== null;
3278
3175
  }
3279
- function events() {
3280
- return eventBus;
3176
+ function currentOrganizationIds() {
3177
+ return membershipContext.getStore()?.organizationIds ?? [];
3281
3178
  }
3282
- function config(key) {
3283
- return resolveApplicationConfig().get(key);
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];
3284
3190
  }
3285
- function log() {
3286
- return resolveApplicationLogger();
3191
+ // ../../src/core/auth/membershipContextMiddleware.ts
3192
+ function createMembershipContextMiddleware() {
3193
+ return async (_request, next) => {
3194
+ return await runWithMembershipContext(async () => await next());
3195
+ };
3287
3196
  }
3288
- function mail() {
3289
- return mailer();
3197
+
3198
+ // ../../src/core/auth/membershipMiddleware.ts
3199
+ function createMembershipMiddleware() {
3200
+ return createMembershipContextMiddleware();
3290
3201
  }
3291
- function storageFacade() {
3292
- return storage();
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();
3293
3212
  }
3294
- // ../../src/core/http/bodySizeLimitMiddleware.ts
3295
- var DEFAULT_MAX_BODY_BYTES = 1048576;
3296
- function resolveMaxBodyBytes() {
3297
- const raw = process.env.MAX_REQUEST_BODY_BYTES?.trim();
3298
- if (!raw) {
3299
- return DEFAULT_MAX_BODY_BYTES;
3213
+ function scopedOrganizationIds(requestedOrganizationId) {
3214
+ const scope = resolveOrganizationScope();
3215
+ if (scope === null) {
3216
+ return requestedOrganizationId === undefined ? null : [requestedOrganizationId];
3300
3217
  }
3301
- const parsed = Number.parseInt(raw, 10);
3302
- if (!Number.isInteger(parsed) || parsed <= 0) {
3303
- return DEFAULT_MAX_BODY_BYTES;
3218
+ if (requestedOrganizationId !== undefined) {
3219
+ return scope.includes(requestedOrganizationId) ? [requestedOrganizationId] : [];
3304
3220
  }
3305
- return parsed;
3221
+ return scope;
3306
3222
  }
3307
- function createBodySizeLimitMiddleware(maxBytes = resolveMaxBodyBytes()) {
3308
- return async (request, next) => {
3309
- const contentLength = request.headers.get("content-length");
3310
- if (contentLength) {
3311
- const bytes = Number.parseInt(contentLength, 10);
3312
- if (Number.isInteger(bytes) && bytes > maxBytes) {
3313
- const error = new PayloadTooLargeError(`Request body exceeds the ${maxBytes} byte limit.`);
3314
- return Response.json({ error: error.message }, { status: error.status });
3315
- }
3316
- }
3317
- return await next();
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
3318
3237
  };
3319
3238
  }
3320
- // ../../src/core/http/cookies.ts
3321
- function readRequestCookie(request, name) {
3322
- const cookies = request.cookies;
3323
- if (cookies && typeof cookies.get === "function") {
3324
- const value = cookies.get(name);
3325
- if (value) {
3326
- return value;
3239
+ function appendProjectScope(where, accessibleProjectIds, requestedProjectId) {
3240
+ if (accessibleProjectIds === null) {
3241
+ if (requestedProjectId === undefined) {
3242
+ return where;
3327
3243
  }
3244
+ return {
3245
+ ...where,
3246
+ project_id: requestedProjectId
3247
+ };
3328
3248
  }
3329
- const header = request.headers.get("cookie");
3330
- if (!header) {
3331
- return null;
3249
+ if (accessibleProjectIds.length === 0) {
3250
+ return {
3251
+ ...where,
3252
+ project_id: [-1]
3253
+ };
3332
3254
  }
3333
- for (const part of header.split(";")) {
3334
- const idx = part.indexOf("=");
3335
- if (idx === -1)
3336
- continue;
3337
- const cookieName = part.slice(0, idx).trim();
3338
- if (cookieName !== name)
3339
- continue;
3340
- return decodeURIComponent(part.slice(idx + 1).trim());
3255
+ if (requestedProjectId !== undefined) {
3256
+ return {
3257
+ ...where,
3258
+ project_id: accessibleProjectIds.includes(requestedProjectId) ? requestedProjectId : -1
3259
+ };
3341
3260
  }
3342
- return null;
3261
+ return {
3262
+ ...where,
3263
+ project_id: accessibleProjectIds
3264
+ };
3343
3265
  }
3344
- function readBunRequestCookie(request, name) {
3345
- return request.cookies.get(name) ?? readRequestCookie(request, name);
3266
+ function emptyPaginateResult(page, perPage) {
3267
+ return {
3268
+ data: [],
3269
+ meta: {
3270
+ page,
3271
+ per_page: perPage,
3272
+ total: 0,
3273
+ last_page: 1
3274
+ }
3275
+ };
3346
3276
  }
3347
- // ../../src/config/cors.ts
3348
- var corsConfig = {
3349
- allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean),
3350
- allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
3351
- allowedHeaders: [
3352
- "Authorization",
3353
- "Content-Type",
3354
- "X-Request-Id",
3355
- "X-Tenant-Id",
3356
- "X-Authenticated-User-Id",
3357
- "X-Authenticated-User-Role",
3358
- "If-Match",
3359
- "If-None-Match"
3360
- ],
3361
- maxAgeSeconds: 86400
3362
- };
3363
-
3364
- // ../../src/core/http/corsMiddleware.ts
3365
- function createCorsMiddleware() {
3366
- return async (request, next) => {
3367
- if (request.method === "OPTIONS") {
3368
- return new Response(null, {
3369
- status: 204,
3370
- headers: buildCorsHeaders(request)
3371
- });
3277
+ function assertResourceInCurrentTenant(resourceTenantId, resourceLabel, resourceId) {
3278
+ if (resourceTenantId !== currentTenantId()) {
3279
+ throw new NotFoundError(`${resourceLabel} ${resourceId} not found.`);
3280
+ }
3281
+ }
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.`);
3290
+ }
3291
+ }
3292
+ // ../../src/core/auth/membershipService.ts
3293
+ class MembershipService {
3294
+ members;
3295
+ constructor(members = membershipRepository) {
3296
+ this.members = members;
3297
+ }
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.");
3372
3309
  }
3373
- const response = await next();
3374
- const headers = new Headers(response.headers);
3375
- for (const [key, value] of buildCorsHeaders(request)) {
3376
- headers.set(key, value);
3310
+ if (isGlobalAdmin(user)) {
3311
+ return "owner";
3377
3312
  }
3378
- return new Response(response.body, {
3379
- status: response.status,
3380
- statusText: response.statusText,
3381
- headers
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"
3382
3334
  });
3383
- };
3384
- }
3385
- function buildCorsHeaders(request) {
3386
- const headers = new Headers;
3387
- const origin = request.headers.get("origin");
3388
- const allowedOrigins = corsConfig.allowedOrigins;
3389
- const allowOrigin = allowedOrigins.includes("*") || origin && allowedOrigins.includes(origin) ? origin ?? "*" : allowedOrigins[0] ?? "*";
3390
- headers.set("Access-Control-Allow-Origin", allowOrigin);
3391
- headers.set("Access-Control-Allow-Methods", corsConfig.allowedMethods.join(", "));
3392
- headers.set("Access-Control-Allow-Headers", corsConfig.allowedHeaders.join(", "));
3393
- headers.set("Access-Control-Max-Age", String(corsConfig.maxAgeSeconds));
3394
- headers.set("Vary", "Origin");
3395
- return headers;
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);
3344
+ }
3396
3345
  }
3397
- // ../../src/core/http/csrfToken.ts
3398
- import { timingSafeEqual } from "crypto";
3399
-
3400
- // ../../src/core/http/requestMetaContext.ts
3401
- import { AsyncLocalStorage as AsyncLocalStorage4 } from "async_hooks";
3402
- var requestMetaContext = new AsyncLocalStorage4;
3403
- function runWithRequestMeta(meta, callback) {
3404
- return requestMetaContext.run(meta, callback);
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;
3405
3352
  }
3406
- function currentRequestMeta() {
3407
- return requestMetaContext.getStore() ?? {
3408
- ipAddress: null,
3409
- userAgent: null
3410
- };
3353
+ var membershipService_default = MembershipService;
3354
+ // ../../src/core/auth/policy.ts
3355
+ class Policy {
3356
+ constructor() {}
3357
+ view(_user, _resource) {
3358
+ return false;
3359
+ }
3360
+ create(_user) {
3361
+ return false;
3362
+ }
3363
+ update(_user, _resource) {
3364
+ return false;
3365
+ }
3366
+ delete(_user, _resource) {
3367
+ return false;
3368
+ }
3411
3369
  }
3370
+ var BLOCKED_POLICY_ACTIONS = new Set([
3371
+ "constructor",
3372
+ "toString",
3373
+ "valueOf",
3374
+ "hasOwnProperty",
3375
+ "isPrototypeOf",
3376
+ "propertyIsEnumerable",
3377
+ "__proto__"
3378
+ ]);
3412
3379
 
3413
- // ../../src/core/http/csrfToken.ts
3414
- var CSRF_COOKIE = "workhub_csrf";
3415
- var CSRF_TTL_MS = 60 * 60 * 1000;
3416
- function resolveCsrfSecret() {
3417
- return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
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;
3390
+ }
3391
+ if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
3392
+ return false;
3393
+ }
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
+ }
3418
3406
  }
3419
- function csrfVerifyOptions() {
3420
- return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
3407
+ // ../../src/core/database/bindConnection.ts
3408
+ function bindDatabaseConnection2(connection) {
3409
+ bindDatabaseConnection(connection);
3421
3410
  }
3422
- function tokensMatch(left, right) {
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) {
3423
3418
  const leftBuffer = Buffer.from(left);
3424
3419
  const rightBuffer = Buffer.from(right);
3425
3420
  if (leftBuffer.length !== rightBuffer.length) {
@@ -3427,1093 +3422,1220 @@ function tokensMatch(left, right) {
3427
3422
  }
3428
3423
  return timingSafeEqual(leftBuffer, rightBuffer);
3429
3424
  }
3430
- function createCsrfTokenCookie() {
3431
- const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
3432
- return {
3433
- token,
3434
- cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
3425
+
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;
3444
+ }
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;
3457
+ }
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;
3469
+ }
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
+ });
3488
+ }
3489
+ function isInsideTenantDatabaseScope(tenantId = currentTenant()?.id) {
3490
+ return hasActiveDatabaseConnection() && currentTenant()?.id === tenantId;
3491
+ }
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
+ });
3435
3517
  };
3436
3518
  }
3437
- function resolveCsrfToken(request) {
3438
- const cookieValue = readRequestCookie(request, CSRF_COOKIE);
3439
- if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
3440
- return { token: cookieValue };
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
+ });
3528
+ }
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;
3536
+ }
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);
3544
+ }
3545
+ }
3546
+ var taggedCache_default = TaggedCache;
3547
+
3548
+ // ../../src/core/cache/repository.ts
3549
+ class CacheRepository {
3550
+ store;
3551
+ constructor(store) {
3552
+ this.store = store;
3441
3553
  }
3442
- return createCsrfTokenCookie();
3443
- }
3444
- function readSubmittedCsrfToken(request) {
3445
- const headerToken = request.headers.get("x-csrf-token")?.trim();
3446
- if (headerToken) {
3447
- return headerToken;
3554
+ async get(key) {
3555
+ return this.store.get(key);
3448
3556
  }
3449
- return null;
3450
- }
3451
- async function readSubmittedCsrfTokenFromBody(request) {
3452
- const headerToken = readSubmittedCsrfToken(request);
3453
- if (headerToken) {
3454
- return headerToken;
3557
+ async remember(key, callback, ttlMs) {
3558
+ return this.store.getOrSet(key, callback, ttlMs);
3455
3559
  }
3456
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
3457
- if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
3458
- const formData = await request.clone().formData();
3459
- const field = formData.get("_token");
3460
- if (typeof field === "string" && field.trim().length > 0) {
3461
- return field.trim();
3462
- }
3463
- const legacyField = formData.get("_csrf");
3464
- if (typeof legacyField === "string" && legacyField.trim().length > 0) {
3465
- return legacyField.trim();
3466
- }
3560
+ async forget(key) {
3561
+ return this.store.invalidate(key);
3467
3562
  }
3468
- return null;
3469
- }
3470
- function verifyCsrfToken(request, submittedToken) {
3471
- if (!submittedToken) {
3472
- return false;
3563
+ async flush() {
3564
+ await this.store.clear();
3473
3565
  }
3474
- const cookieValue = readRequestCookie(request, CSRF_COOKIE);
3475
- if (!cookieValue) {
3476
- return false;
3566
+ tags(...names) {
3567
+ return new taggedCache_default(this.store, names);
3477
3568
  }
3478
- if (!tokensMatch(submittedToken, cookieValue)) {
3479
- return false;
3569
+ async getOrSet(key, loader, ttlMs) {
3570
+ return this.remember(key, loader, ttlMs);
3480
3571
  }
3481
- return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
3482
- }
3483
- function resolveCsrfTokenForRequest(request) {
3484
- const metaToken = currentRequestMeta().csrfToken;
3485
- if (metaToken) {
3486
- return metaToken;
3572
+ async invalidate(key) {
3573
+ return this.forget(key);
3574
+ }
3575
+ async invalidateByPrefix(prefix) {
3576
+ return this.store.invalidateByPrefix(prefix);
3577
+ }
3578
+ async clear() {
3579
+ await this.flush();
3580
+ }
3581
+ async size() {
3582
+ return this.store.size();
3487
3583
  }
3488
- return resolveCsrfToken(request).token;
3489
3584
  }
3490
-
3491
- // ../../src/core/http/csrfMiddleware.ts
3492
- var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
3493
- function appendSetCookie(response, cookie) {
3494
- const headers = new Headers(response.headers);
3495
- headers.append("set-cookie", cookie);
3496
- return new Response(response.body, {
3497
- status: response.status,
3498
- statusText: response.statusText,
3499
- headers
3500
- });
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]);
3603
+ }
3501
3604
  }
3502
- function createCsrfMiddleware() {
3503
- return async (request, next) => {
3504
- const method = request.method.toUpperCase();
3505
- if (!MUTATING_METHODS.has(method)) {
3506
- const csrf = resolveCsrfToken(request);
3507
- const meta = currentRequestMeta();
3508
- meta.csrfToken = csrf.token;
3509
- const response = await next();
3510
- if (!csrf.cookie) {
3511
- return response;
3512
- }
3513
- return appendSetCookie(response, csrf.cookie);
3514
- }
3515
- const submitted = await readSubmittedCsrfTokenFromBody(request);
3516
- if (!verifyCsrfToken(request, submitted)) {
3517
- throw new ForbiddenError("Invalid or missing CSRF token.");
3518
- }
3519
- return await next();
3520
- };
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
+ `);
3521
3618
  }
3522
- // ../../src/core/http/csrfProtection.ts
3523
- var DEFAULT_CSRF_TTL_MS = 60 * 60 * 1000;
3524
- function createCsrfProtection(secret, options = {}) {
3525
- const expiresIn = options.expiresIn ?? DEFAULT_CSRF_TTL_MS;
3526
- const maxAge = options.maxAge ?? expiresIn;
3527
- return {
3528
- generate(_sessionKey) {
3529
- return Bun.CSRF.generate(secret, { expiresIn });
3530
- },
3531
- verify(token, _sessionKey) {
3532
- if (!token) {
3533
- return false;
3534
- }
3535
- return Bun.CSRF.verify(token, { secret, maxAge });
3536
- },
3537
- secret
3538
- };
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
+ `);
3539
3626
  }
3540
- // ../../src/core/crypto/nonCryptographicHash.ts
3541
- function nonCryptographicDigest(input) {
3542
- return Bun.hash(input).toString(16);
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);
3543
3636
  }
3544
-
3545
- // ../../src/core/http/etag.ts
3546
- function isEtagEnabled() {
3547
- return (process.env.FEATURE_ETAG ?? "true") !== "false";
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
+ }));
3548
3645
  }
3549
- function formatWeakEtag(digest) {
3550
- return `W/"${digest}"`;
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
+ }
3658
+ }
3659
+ return pendingMigrations.length;
3551
3660
  }
3552
- function computeEtagFromJson(data) {
3553
- const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
3554
- return formatWeakEtag(digest);
3661
+ async function migrateDatabase(db2, migrations, options = {}) {
3662
+ const { advisoryLock = false, onMigration } = options;
3663
+ if (advisoryLock) {
3664
+ return withMigrationLock(db2, () => runPendingMigrations(db2, migrations, { onMigration }));
3665
+ }
3666
+ return runPendingMigrations(db2, migrations, { onMigration });
3555
3667
  }
3556
- function etagFromResource(resource) {
3557
- const version = resource.updated_at ?? resource.created_at ?? "";
3558
- const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
3559
- const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
3560
- return formatWeakEtag(digest);
3668
+ async function rollbackDatabase(db2, migrations, options = {}) {
3669
+ const applied = await getAppliedMigrations(db2);
3670
+ if (applied.length === 0) {
3671
+ return 0;
3672
+ }
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;
3684
+ }
3685
+ return rolledBack;
3561
3686
  }
3562
- function normalizeEtag(value) {
3563
- return value.trim();
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);
3703
+ return;
3704
+ }
3705
+ await runFresh();
3564
3706
  }
3565
- function etagValuesMatch(left, right) {
3566
- return normalizeEtag(left) === normalizeEtag(right);
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);
3567
3720
  }
3568
- function parseEtagList(header) {
3569
- if (!header) {
3570
- return [];
3721
+ async function runSeedersFromDirectory(directory, db2, options) {
3722
+ const seeders = await loadSeedersFromDirectory(directory);
3723
+ if (seeders.length === 0) {
3724
+ return 0;
3571
3725
  }
3572
- return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
3726
+ for (const seeder of seeders) {
3727
+ options?.onSeeder?.(seeder.name);
3728
+ await seeder.run(db2);
3729
+ }
3730
+ return seeders.length;
3573
3731
  }
3574
- function ifNoneMatchSatisfied(request, etag) {
3575
- const header = request.headers.get("if-none-match");
3576
- if (!header) {
3577
- return false;
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.');
3578
3737
  }
3579
- if (header.trim() === "*") {
3580
- return true;
3738
+ const from = process.env.MAIL_FROM?.trim();
3739
+ if (!from) {
3740
+ throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
3581
3741
  }
3582
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
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.');
3745
+ }
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
+ };
3583
3754
  }
3584
- function ifMatchSatisfied(request, etag) {
3585
- const header = request.headers.get("if-match");
3586
- if (!header) {
3587
- return false;
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);
3588
3771
  }
3589
- if (header.trim() === "*") {
3590
- return true;
3772
+ return { responses, remainder };
3773
+ }
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}`);
3591
3779
  }
3592
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
3780
+ return response;
3593
3781
  }
3594
- function assertIfMatch(request, etag, options = {}) {
3595
- const header = request.headers.get("if-match");
3596
- if (!header) {
3597
- if (options.required) {
3598
- throw new PreconditionFailedError("If-Match header is required.");
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;
3599
3791
  }
3600
- return;
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 };
3817
+ }
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();
3601
3853
  }
3602
- if (!ifMatchSatisfied(request, etag)) {
3603
- throw new PreconditionFailedError("Resource ETag does not match If-Match.");
3854
+ }
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
+ `);
3604
3879
  }
3880
+ headers.push("Content-Type: text/plain; charset=utf-8");
3881
+ return [...headers, "", message.body, ".", ""].join(`\r
3882
+ `);
3605
3883
  }
3606
- function applyEtagHeaders(headers, etag) {
3607
- const next = new Headers(headers);
3608
- next.set("ETag", etag);
3609
- next.set("Cache-Control", "private, must-revalidate");
3610
- next.append("Vary", "Authorization");
3611
- next.append("Vary", "X-Tenant-Id");
3612
- return next;
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
+ }));
3895
+ }
3613
3896
  }
3614
- function notModifiedResponse(etag) {
3615
- return new Response(null, {
3616
- status: 304,
3617
- headers: applyEtagHeaders(new Headers, etag)
3618
- });
3897
+
3898
+ class SmtpMailDriver {
3899
+ config;
3900
+ transport;
3901
+ constructor(config, transport = defaultSmtpTransport) {
3902
+ this.config = config;
3903
+ this.transport = transport;
3904
+ }
3905
+ send(message) {
3906
+ return this.transport(this.config, message);
3907
+ }
3619
3908
  }
3620
- function applyConditionalGet(request, response, etag) {
3621
- if (!isEtagEnabled()) {
3622
- return response;
3909
+
3910
+ class Mailer {
3911
+ driver;
3912
+ constructor(driver) {
3913
+ this.driver = driver;
3623
3914
  }
3624
- if (ifNoneMatchSatisfied(request, etag)) {
3625
- return notModifiedResponse(etag);
3915
+ send(message) {
3916
+ return this.driver.send(message);
3626
3917
  }
3627
- const headers = applyEtagHeaders(new Headers(response.headers), etag);
3628
- return new Response(response.body, {
3629
- status: response.status,
3630
- statusText: response.statusText,
3631
- headers
3632
- });
3633
3918
  }
3634
- // ../../src/core/http/flashSession.ts
3635
- import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
3636
- var FLASH_COOKIE = "workhub_flash";
3637
- var FLASH_TTL_MS = 60 * 1000;
3638
- function resolveFlashSecret() {
3639
- return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
3919
+ function createMailDriver() {
3920
+ const driver = process.env.MAIL_DRIVER ?? "log";
3921
+ if (driver === "smtp") {
3922
+ return new SmtpMailDriver(resolveSmtpConfig());
3923
+ }
3924
+ return new LogMailDriver;
3640
3925
  }
3641
- function signFlashPayload(payload, issuedAt) {
3642
- const signature = createHmac("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
3643
- return `${payload}.${issuedAt}.${signature}`;
3926
+ var appMailer = new Mailer(createMailDriver());
3927
+ function mailer() {
3928
+ return appMailer;
3644
3929
  }
3645
- function readFlashCookie(request) {
3646
- const cookieHeader = request.headers.get("cookie");
3647
- if (!cookieHeader) {
3648
- return null;
3930
+
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;
3935
+
3936
+ class LocalStorageDriver {
3937
+ rootDirectory;
3938
+ constructor(rootDirectory) {
3939
+ this.rootDirectory = rootDirectory;
3649
3940
  }
3650
- for (const part of cookieHeader.split(";")) {
3651
- const [name, ...rest] = part.trim().split("=");
3652
- if (name === FLASH_COOKIE) {
3653
- return decodeURIComponent(rest.join("="));
3654
- }
3941
+ resolveRootDirectory() {
3942
+ return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
3655
3943
  }
3656
- return null;
3657
- }
3658
- function parseFlashCookie(cookieValue) {
3659
- const parts = cookieValue.split(".");
3660
- if (parts.length < 3) {
3661
- return null;
3944
+ resolvePath(path) {
3945
+ return join3(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
3662
3946
  }
3663
- const signature = parts.pop();
3664
- const issuedAtRaw = parts.pop();
3665
- const payload = parts.join(".");
3666
- if (!signature || !issuedAtRaw || !payload) {
3667
- return null;
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;
3668
3952
  }
3669
- const issuedAt = Number.parseInt(issuedAtRaw, 10);
3670
- if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
3671
- return null;
3953
+ async get(path) {
3954
+ try {
3955
+ return await readFile(this.resolvePath(path));
3956
+ } catch {
3957
+ return null;
3958
+ }
3672
3959
  }
3673
- const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
3674
- if (!expectedSignature) {
3675
- return null;
3960
+ async delete(path) {
3961
+ try {
3962
+ await unlink(this.resolvePath(path));
3963
+ return true;
3964
+ } catch {
3965
+ return false;
3966
+ }
3676
3967
  }
3677
- const expectedBuffer = Buffer.from(expectedSignature);
3678
- const actualBuffer = Buffer.from(signature);
3679
- if (expectedBuffer.length !== actualBuffer.length) {
3680
- return null;
3968
+ }
3969
+
3970
+ class S3StorageDriver {
3971
+ client;
3972
+ constructor(client) {
3973
+ this.client = client;
3681
3974
  }
3682
- if (!timingSafeEqual2(expectedBuffer, actualBuffer)) {
3683
- return null;
3975
+ async put(path, contents) {
3976
+ await this.client.write(path.replace(/^\/+/, ""), contents);
3977
+ return path;
3684
3978
  }
3685
- try {
3686
- const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
3687
- if (!parsed?.message || typeof parsed.message !== "string") {
3979
+ async get(path) {
3980
+ const normalizedPath = path.replace(/^\/+/, "");
3981
+ const file = this.client.file(normalizedPath);
3982
+ if (!await file.exists()) {
3688
3983
  return null;
3689
3984
  }
3690
- if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
3691
- return null;
3985
+ return new Uint8Array(await file.arrayBuffer());
3986
+ }
3987
+ async delete(path) {
3988
+ try {
3989
+ await this.client.unlink(path.replace(/^\/+/, ""));
3990
+ return true;
3991
+ } catch {
3992
+ return false;
3692
3993
  }
3693
- return parsed;
3694
- } catch {
3695
- return null;
3696
3994
  }
3697
3995
  }
3698
- function createFlashCookie(message) {
3699
- const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
3700
- const issuedAt = Date.now();
3701
- const value = signFlashPayload(payload, issuedAt);
3702
- return `${FLASH_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
3703
- }
3704
- function clearFlashCookie() {
3705
- return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
3996
+
3997
+ class StorageManager {
3998
+ driver;
3999
+ constructor(driver) {
4000
+ this.driver = driver;
4001
+ }
4002
+ put(path, contents) {
4003
+ return this.driver.put(path, contents);
4004
+ }
4005
+ get(path) {
4006
+ return this.driver.get(path);
4007
+ }
4008
+ delete(path) {
4009
+ return this.driver.delete(path);
4010
+ }
3706
4011
  }
3707
- function pullFlash(request) {
3708
- const cookieValue = readFlashCookie(request);
3709
- if (!cookieValue) {
3710
- return null;
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.');
3711
4018
  }
3712
- return parseFlashCookie(cookieValue);
4019
+ return {
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() } : {}
4025
+ };
3713
4026
  }
3714
- function flashResponse(response, message) {
3715
- const headers = new Headers(response.headers);
3716
- headers.append("set-cookie", createFlashCookie(message));
3717
- return new Response(response.body, {
3718
- status: response.status,
3719
- statusText: response.statusText,
3720
- headers
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 } : {}
3721
4034
  });
3722
4035
  }
3723
- function withFlashClear(response) {
3724
- const headers = new Headers(response.headers);
3725
- headers.append("set-cookie", clearFlashCookie());
3726
- return new Response(response.body, {
3727
- status: response.status,
3728
- statusText: response.statusText,
3729
- headers
3730
- });
4036
+ function createStorageDriver() {
4037
+ const driver = process.env.STORAGE_DRIVER ?? "local";
4038
+ if (driver === "s3") {
4039
+ return new S3StorageDriver(createS3Client());
4040
+ }
4041
+ return new LocalStorageDriver;
4042
+ }
4043
+ var defaultStorage = { current: null };
4044
+ function storage() {
4045
+ if (!defaultStorage.current) {
4046
+ defaultStorage.current = new StorageManager(createStorageDriver());
4047
+ }
4048
+ return defaultStorage.current;
4049
+ }
4050
+ function resetDefaultStorage() {
4051
+ defaultStorage.current = null;
3731
4052
  }
3732
4053
 
3733
- // ../../src/core/http/flashMiddleware.ts
3734
- function createFlashMiddleware() {
3735
- return async (request, next) => {
3736
- const flash = pullFlash(request);
3737
- const meta = currentRequestMeta();
3738
- return await runWithRequestMeta({ ...meta, request, flash }, async () => {
3739
- const response = await next();
3740
- if (flash) {
3741
- return withFlashClear(response);
3742
- }
3743
- return response;
3744
- });
3745
- };
4054
+ // ../../src/core/facades/index.ts
4055
+ function cache() {
4056
+ return resolveApplicationCache();
3746
4057
  }
3747
- // ../../src/core/tenant/tenantContext.ts
3748
- import { AsyncLocalStorage as AsyncLocalStorage5 } from "async_hooks";
3749
- var tenantContext = new AsyncLocalStorage5;
3750
- function runWithTenant(tenant, callback) {
3751
- return tenantContext.run(tenant, callback);
4058
+ function auth() {
4059
+ return resolveApplicationAuth();
3752
4060
  }
3753
- function currentTenant() {
3754
- return tenantContext.getStore() ?? null;
4061
+ function policyGate() {
4062
+ return resolveApplicationPolicyGate();
3755
4063
  }
3756
- function currentTenantId() {
3757
- return currentTenant()?.id ?? 1;
4064
+ function queue() {
4065
+ return resolveApplicationQueue();
3758
4066
  }
3759
- function rateLimitMultiplierForPlan(plan) {
3760
- switch (plan) {
3761
- case "enterprise":
3762
- return 4;
3763
- case "pro":
3764
- return 2;
3765
- default:
3766
- return 1;
3767
- }
4067
+ function events() {
4068
+ return eventBus;
3768
4069
  }
3769
-
3770
- // ../../src/core/http/validation.ts
3771
- function buildRequestCacheKey(fallbackPath, request) {
3772
- if (!request) {
3773
- return fallbackPath;
3774
- }
3775
- const url = new URL(request.url);
3776
- const user = currentAuthUser();
3777
- const authScope = user ? `u:${user.id}` : "guest";
3778
- const tenantScope = `t:${currentTenantId()}`;
3779
- return `${authScope}|${tenantScope}|${url.pathname}${url.search}`;
4070
+ function config(key) {
4071
+ return resolveApplicationConfig().get(key);
3780
4072
  }
3781
- function getQueryParams(request) {
3782
- if (!request) {
3783
- return new URLSearchParams;
3784
- }
3785
- return new URL(request.url).searchParams;
4073
+ function log() {
4074
+ return resolveApplicationLogger();
3786
4075
  }
3787
- function parseOptionalPositiveIntQueryParam(params, name) {
3788
- const value = params.get(name);
3789
- if (value === null || value.trim() === "") {
3790
- return;
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;
3791
4088
  }
3792
- const parsed = Number.parseInt(value, 10);
4089
+ const parsed = Number.parseInt(raw, 10);
3793
4090
  if (!Number.isInteger(parsed) || parsed <= 0) {
3794
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a positive integer.`);
4091
+ return DEFAULT_MAX_BODY_BYTES;
3795
4092
  }
3796
4093
  return parsed;
3797
4094
  }
3798
- function parseOptionalBooleanQueryParam(params, name) {
3799
- const value = params.get(name);
3800
- if (value === null || value.trim() === "") {
3801
- return;
3802
- }
3803
- switch (value.toLowerCase()) {
3804
- case "true":
3805
- case "1":
3806
- return true;
3807
- case "false":
3808
- case "0":
3809
- return false;
3810
- default:
3811
- throw new BadRequestError(`Invalid query parameter "${name}". Expected a boolean.`);
3812
- }
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);
3813
4111
  }
3814
- function parseOptionalEnumQueryParam(params, name, allowedValues) {
3815
- const value = params.get(name);
3816
- if (value === null || value.trim() === "") {
3817
- return;
3818
- }
3819
- if (!allowedValues.includes(value)) {
3820
- throw new BadRequestError(`Invalid query parameter "${name}". Expected one of: ${allowedValues.join(", ")}.`);
3821
- }
3822
- return value;
4112
+
4113
+ // ../../src/core/http/etag.ts
4114
+ function isEtagEnabled() {
4115
+ return (process.env.FEATURE_ETAG ?? "true") !== "false";
3823
4116
  }
3824
- function expectObject(value, label = "request body") {
3825
- if (value === null || typeof value !== "object" || Array.isArray(value)) {
3826
- throw new BadRequestError(`${label} must be a JSON object.`);
3827
- }
3828
- return value;
4117
+ function formatWeakEtag(digest) {
4118
+ return `W/"${digest}"`;
3829
4119
  }
3830
- async function parseJsonBody(request, validator) {
3831
- let payload;
3832
- try {
3833
- payload = await request.json();
3834
- } catch {
3835
- throw new BadRequestError("Request body must be valid JSON.");
3836
- }
3837
- return validator(payload);
4120
+ function computeEtagFromJson(data) {
4121
+ const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
4122
+ return formatWeakEtag(digest);
3838
4123
  }
3839
- function readRequiredString(payload, field, options = {}) {
3840
- const value = payload[field];
3841
- if (typeof value !== "string" || value.trim() === "") {
3842
- throw new BadRequestError(`"${field}" is required and must be a string.`);
3843
- }
3844
- const trimmed = value.trim();
3845
- if (options.minLength !== undefined && trimmed.length < options.minLength) {
3846
- throw new BadRequestError(`"${field}" must be at least ${options.minLength} characters.`);
3847
- }
3848
- if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
3849
- throw new BadRequestError(`"${field}" must be at most ${options.maxLength} characters.`);
3850
- }
3851
- if (options.pattern && !options.pattern.test(trimmed)) {
3852
- throw new BadRequestError(`"${field}" has an invalid format.`);
3853
- }
3854
- return trimmed;
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);
3855
4129
  }
3856
- function readOptionalString(payload, field, options = {}) {
3857
- if (!(field in payload) || payload[field] === undefined) {
3858
- return;
3859
- }
3860
- return readRequiredString(payload, field, options);
4130
+ function normalizeEtag(value) {
4131
+ return value.trim();
3861
4132
  }
3862
- function readRequiredEnum(payload, field, allowedValues) {
3863
- const value = readRequiredString(payload, field);
3864
- if (!allowedValues.includes(value)) {
3865
- throw new BadRequestError(`"${field}" must be one of: ${allowedValues.join(", ")}.`);
3866
- }
3867
- return value;
4133
+ function etagValuesMatch(left, right) {
4134
+ return normalizeEtag(left) === normalizeEtag(right);
3868
4135
  }
3869
- function readOptionalEnum(payload, field, allowedValues) {
3870
- if (!(field in payload) || payload[field] === undefined) {
3871
- return;
4136
+ function parseEtagList(header) {
4137
+ if (!header) {
4138
+ return [];
3872
4139
  }
3873
- return readRequiredEnum(payload, field, allowedValues);
4140
+ return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
3874
4141
  }
3875
- function readRequiredPositiveInt(payload, field) {
3876
- const value = payload[field];
3877
- if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
3878
- throw new BadRequestError(`"${field}" is required and must be a positive integer.`);
4142
+ function ifNoneMatchSatisfied(request, etag) {
4143
+ const header = request.headers.get("if-none-match");
4144
+ if (!header) {
4145
+ return false;
3879
4146
  }
3880
- return value;
3881
- }
3882
- function readOptionalPositiveInt(payload, field) {
3883
- if (!(field in payload) || payload[field] === undefined) {
3884
- return;
4147
+ if (header.trim() === "*") {
4148
+ return true;
3885
4149
  }
3886
- return readRequiredPositiveInt(payload, field);
4150
+ return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
3887
4151
  }
3888
- function parsePositiveIntParam(value, name = "id") {
3889
- const parsed = Number.parseInt(value, 10);
3890
- if (!Number.isInteger(parsed) || parsed <= 0) {
3891
- throw new BadRequestError(`Invalid ${name}. Expected a positive integer.`);
4152
+ function ifMatchSatisfied(request, etag) {
4153
+ const header = request.headers.get("if-match");
4154
+ if (!header) {
4155
+ return false;
3892
4156
  }
3893
- return parsed;
3894
- }
3895
-
3896
- // ../../src/core/http/formRequest.ts
3897
- class FormRequest {
3898
- authorize(_request) {
4157
+ if (header.trim() === "*") {
3899
4158
  return true;
3900
4159
  }
3901
- async validate(request) {
3902
- if (!await this.authorize(request)) {
3903
- throw new ForbiddenError;
4160
+ return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
4161
+ }
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.");
3904
4167
  }
3905
- return await parseJsonBody(request, (payload) => this.parse(payload));
4168
+ return;
3906
4169
  }
3907
- }
3908
-
3909
- class QueryFormRequest {
3910
- validate(request) {
3911
- return this.parseQuery(request);
4170
+ if (!ifMatchSatisfied(request, etag)) {
4171
+ throw new PreconditionFailedError("Resource ETag does not match If-Match.");
3912
4172
  }
3913
4173
  }
3914
- // ../../src/config/frontend.ts
3915
- function readFrontendMode() {
3916
- const mode = (process.env.FRONTEND_MODE ?? "api").trim();
3917
- if (mode === "server-htmx") {
3918
- return "server-htmx";
3919
- }
3920
- if (mode === "spa-react") {
3921
- return "spa-react";
3922
- }
3923
- return "api";
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;
3924
4181
  }
3925
- function isViewsEnabled() {
3926
- return readFrontendMode() === "server-htmx";
4182
+ function notModifiedResponse(etag) {
4183
+ return new Response(null, {
4184
+ status: 304,
4185
+ headers: applyEtagHeaders(new Headers, etag)
4186
+ });
3927
4187
  }
3928
-
3929
- // ../../src/core/view/etaViewEngine.ts
3930
- import { join as join4 } from "path";
3931
- import { Eta } from "eta";
3932
- var DEFAULT_VIEWS_DIRECTORY = join4(process.cwd(), "resources/views");
3933
- var DEFAULT_LAYOUT = "layouts/app.eta";
3934
-
3935
- class EtaViewEngine {
3936
- eta;
3937
- resolveLayoutData;
3938
- constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
3939
- this.eta = new Eta({
3940
- views: viewsDirectory,
3941
- autoTrim: false
3942
- });
3943
- this.resolveLayoutData = resolveLayoutData;
4188
+ function applyConditionalGet(request, response, etag) {
4189
+ if (!isEtagEnabled()) {
4190
+ return response;
3944
4191
  }
3945
- async render(name, data = {}, options = {}) {
3946
- const template = name.endsWith(".eta") ? name : `${name}.eta`;
3947
- const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
3948
- const mergedData = { ...layoutData, ...data };
3949
- const body = await this.eta.renderAsync(template, mergedData);
3950
- const layout = options.layout ?? DEFAULT_LAYOUT;
3951
- if (layout === false) {
3952
- return body;
3953
- }
3954
- const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
3955
- return await this.eta.renderAsync(layoutTemplate, {
3956
- ...mergedData,
3957
- body
3958
- });
4192
+ if (ifNoneMatchSatisfied(request, etag)) {
4193
+ return notModifiedResponse(etag);
3959
4194
  }
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
+ });
3960
4201
  }
3961
- // ../../src/core/view/htmlResponse.ts
3962
- function htmlResponse(html, init = {}) {
3963
- return new Response(html, {
4202
+
4203
+ // ../../src/core/http/conditionalResponse.ts
4204
+ function jsonResponse(data, init = {}) {
4205
+ return Response.json(data, {
3964
4206
  status: init.status ?? 200,
3965
- statusText: init.statusText,
3966
- headers: {
3967
- "Content-Type": "text/html; charset=utf-8"
3968
- }
4207
+ headers: init.headers
3969
4208
  });
3970
4209
  }
3971
- function isHtmxRequest(request) {
3972
- return request.headers.get("HX-Request") === "true";
4210
+ function conditionalJsonResponse(request, data, init = {}) {
4211
+ if (!request || !isEtagEnabled()) {
4212
+ return jsonResponse(data, init);
4213
+ }
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
+ });
3973
4229
  }
3974
- // ../../src/core/auth/oauth/oidcProvider.ts
3975
- class OidcProvider {
3976
- options;
3977
- name;
3978
- constructor(options) {
3979
- this.options = options;
3980
- this.name = options.name;
3981
- }
3982
- getAuthorizationUrl(state) {
3983
- const params = new URLSearchParams({
3984
- client_id: this.options.clientId,
3985
- redirect_uri: this.options.redirectUri,
3986
- response_type: "code",
3987
- scope: (this.options.scopes ?? ["openid", "email", "profile"]).join(" "),
3988
- state
3989
- });
3990
- return `${this.options.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
3991
- }
3992
- async exchangeCode(code) {
3993
- const tokenResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/token`, {
3994
- method: "POST",
3995
- headers: { "content-type": "application/x-www-form-urlencoded" },
3996
- body: new URLSearchParams({
3997
- grant_type: "authorization_code",
3998
- code,
3999
- redirect_uri: this.options.redirectUri,
4000
- client_id: this.options.clientId,
4001
- client_secret: this.options.clientSecret
4002
- })
4003
- });
4004
- const tokenBody = await tokenResponse.json();
4005
- if (!tokenBody.access_token) {
4006
- throw new Error("OIDC token exchange failed.");
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;
4007
4237
  }
4008
- const profileResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/userinfo`, {
4009
- headers: { authorization: `Bearer ${tokenBody.access_token}` }
4010
- });
4011
- const profile = await profileResponse.json();
4012
- return {
4013
- providerUserId: profile.sub,
4014
- email: profile.email ?? `${profile.sub}@oidc.local`,
4015
- name: profile.name ?? profile.sub
4016
- };
4017
4238
  }
4239
+ const header = request.headers.get("cookie");
4240
+ if (!header) {
4241
+ return null;
4242
+ }
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());
4251
+ }
4252
+ return null;
4018
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
+ };
4019
4273
 
4020
- // ../../src/core/auth/oauth/providers.ts
4021
- class GitHubOAuthProvider {
4022
- options;
4023
- name = "github";
4024
- constructor(options) {
4025
- this.options = options;
4026
- }
4027
- getAuthorizationUrl(state) {
4028
- const params = new URLSearchParams({
4029
- client_id: this.options.clientId,
4030
- redirect_uri: this.options.redirectUri,
4031
- scope: "read:user user:email",
4032
- state
4033
- });
4034
- return `https://github.com/login/oauth/authorize?${params.toString()}`;
4035
- }
4036
- async exchangeCode(code) {
4037
- const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
4038
- method: "POST",
4039
- headers: {
4040
- accept: "application/json",
4041
- "content-type": "application/json"
4042
- },
4043
- body: JSON.stringify({
4044
- client_id: this.options.clientId,
4045
- client_secret: this.options.clientSecret,
4046
- code,
4047
- redirect_uri: this.options.redirectUri
4048
- })
4049
- });
4050
- const tokenBody = await tokenResponse.json();
4051
- if (!tokenBody.access_token) {
4052
- throw new Error("GitHub OAuth token exchange failed.");
4053
- }
4054
- const profileResponse = await fetch("https://api.github.com/user", {
4055
- headers: {
4056
- authorization: `Bearer ${tokenBody.access_token}`,
4057
- accept: "application/json",
4058
- "user-agent": "workhub"
4059
- }
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
+ });
4282
+ }
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);
4287
+ }
4288
+ return new Response(response.body, {
4289
+ status: response.status,
4290
+ statusText: response.statusText,
4291
+ headers
4060
4292
  });
4061
- const profile = await profileResponse.json();
4062
- return {
4063
- providerUserId: String(profile.id),
4064
- email: profile.email ?? `${profile.login}@users.noreply.github.com`,
4065
- name: profile.name ?? profile.login
4066
- };
4067
- }
4293
+ };
4068
4294
  }
4069
-
4070
- class MockOAuthProvider {
4071
- profile;
4072
- name = "mock";
4073
- constructor(profile) {
4074
- this.profile = profile;
4075
- }
4076
- getAuthorizationUrl(state) {
4077
- return `https://mock.oauth/authorize?state=${encodeURIComponent(state)}`;
4078
- }
4079
- async exchangeCode(code) {
4080
- if (code !== "valid-code") {
4081
- throw new Error("Invalid OAuth code.");
4082
- }
4083
- return this.profile;
4084
- }
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;
4085
4306
  }
4086
-
4087
- // ../../src/core/auth/oauth/samlProvider.ts
4088
- class SamlProvider {
4089
- loginUrl;
4090
- name = "saml";
4091
- constructor(loginUrl) {
4092
- this.loginUrl = loginUrl;
4093
- }
4094
- getAuthorizationUrl(state) {
4095
- return `${this.loginUrl}?state=${encodeURIComponent(state)}`;
4096
- }
4097
- async exchangeCode(code) {
4098
- if (!code.startsWith("saml:")) {
4099
- throw new Error("Invalid SAML assertion reference.");
4100
- }
4101
- const [, email, name] = code.split(":");
4102
- return {
4103
- providerUserId: email ?? "saml-user",
4104
- email: email ?? "saml-user@workhub.test",
4105
- name: name ?? "SAML User"
4106
- };
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;
4107
4322
  }
4323
+ return timingSafeEqual2(leftBuffer, rightBuffer);
4108
4324
  }
4109
-
4110
- // ../../src/config/features.ts
4111
- function readFeatureFlags() {
4325
+ function createCsrfTokenCookie() {
4326
+ const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
4112
4327
  return {
4113
- webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
4114
- fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
4115
- auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
4116
- oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
4117
- samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
4118
- scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
4119
- billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
4120
- siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
4121
- publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
4122
- emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
4123
- mfa: (process.env.FEATURE_MFA ?? "false") === "true"
4328
+ token,
4329
+ cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
4124
4330
  };
4125
4331
  }
4126
- var featureFlags = readFeatureFlags();
4127
- function isFeatureEnabled(feature) {
4128
- return readFeatureFlags()[feature];
4129
- }
4130
- // ../../src/modules/user/apiTokenTable.ts
4131
- var apiTokenTable = defineTable({
4132
- name: "api_token",
4133
- primaryKey: "id",
4134
- columns: [
4135
- "id",
4136
- "user_id",
4137
- "name",
4138
- "token_hash",
4139
- "abilities",
4140
- "last_used_at",
4141
- "expires_at",
4142
- "created_at"
4143
- ],
4144
- defaultOrderBy: { column: "id", direction: "ASC" }
4145
- });
4146
-
4147
- // ../../src/core/auth/password.ts
4148
- async function hashPassword(password) {
4149
- return await Bun.password.hash(password, {
4150
- algorithm: "bcrypt",
4151
- cost: 10
4152
- });
4153
- }
4154
- async function verifyPassword(password, passwordHash) {
4155
- return await Bun.password.verify(password, passwordHash);
4332
+ function resolveCsrfToken(request) {
4333
+ const cookieValue = readRequestCookie(request, CSRF_COOKIE);
4334
+ if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
4335
+ return { token: cookieValue };
4336
+ }
4337
+ return createCsrfTokenCookie();
4156
4338
  }
4157
-
4158
- // ../../src/core/crypto/fieldEncryption.ts
4159
- import { createCipheriv, createDecipheriv, createHmac as createHmac2, randomBytes } from "crypto";
4160
- var ENCRYPTION_PREFIX = "enc:v1:";
4161
- var IV_LENGTH = 12;
4162
- var TAG_LENGTH = 16;
4163
- function resolveEncryptionKey() {
4164
- const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
4165
- if (!raw) {
4166
- return null;
4339
+ function readSubmittedCsrfToken(request) {
4340
+ const headerToken = request.headers.get("x-csrf-token")?.trim();
4341
+ if (headerToken) {
4342
+ return headerToken;
4167
4343
  }
4168
- if (/^[0-9a-f]{64}$/i.test(raw)) {
4169
- return Buffer.from(raw, "hex");
4344
+ return null;
4345
+ }
4346
+ async function readSubmittedCsrfTokenFromBody(request) {
4347
+ const headerToken = readSubmittedCsrfToken(request);
4348
+ if (headerToken) {
4349
+ return headerToken;
4170
4350
  }
4171
- const decoded = Buffer.from(raw, "base64");
4172
- if (decoded.length === 32) {
4173
- return decoded;
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();
4361
+ }
4174
4362
  }
4175
- throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
4363
+ return null;
4176
4364
  }
4177
- function isFieldEncryptionEnabled() {
4178
- const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
4179
- if (featureFlag === "false") {
4365
+ function verifyCsrfToken(request, submittedToken) {
4366
+ if (!submittedToken) {
4180
4367
  return false;
4181
4368
  }
4182
- if (featureFlag === "true") {
4183
- return true;
4369
+ const cookieValue = readRequestCookie(request, CSRF_COOKIE);
4370
+ if (!cookieValue) {
4371
+ return false;
4184
4372
  }
4185
- return (process.env.APP_ENV ?? "local") === "production";
4186
- }
4187
- function encryptField(plaintext, key) {
4188
- const iv = randomBytes(IV_LENGTH);
4189
- const cipher = createCipheriv("aes-256-gcm", key, iv);
4190
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
4191
- const tag = cipher.getAuthTag();
4192
- const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
4193
- return `${ENCRYPTION_PREFIX}${payload}`;
4373
+ if (!tokensMatch(submittedToken, cookieValue)) {
4374
+ return false;
4375
+ }
4376
+ return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
4194
4377
  }
4195
- function decryptField(value, key) {
4196
- if (!value.startsWith(ENCRYPTION_PREFIX)) {
4197
- return value;
4378
+ function resolveCsrfTokenForRequest(request) {
4379
+ const metaToken = currentRequestMeta().csrfToken;
4380
+ if (metaToken) {
4381
+ return metaToken;
4198
4382
  }
4199
- const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
4200
- const iv = payload.subarray(0, IV_LENGTH);
4201
- const tag = payload.subarray(payload.length - TAG_LENGTH);
4202
- const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
4203
- const decipher = createDecipheriv("aes-256-gcm", key, iv);
4204
- decipher.setAuthTag(tag);
4205
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
4383
+ return resolveCsrfToken(request).token;
4206
4384
  }
4207
- function hashLookupValue(normalizedValue, key) {
4208
- return createHmac2("sha256", key).update(normalizedValue).digest("hex");
4385
+
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
+ });
4209
4396
  }
4210
- function normalizeEmail(email) {
4211
- return email.trim().toLowerCase();
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
+ };
4212
4416
  }
4213
- function protectEmail(email) {
4214
- const normalized = normalizeEmail(email);
4215
- const key = resolveEncryptionKey();
4216
- if (!key || !isFieldEncryptionEnabled()) {
4217
- return { storedEmail: normalized, emailLookup: normalized };
4218
- }
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;
4219
4422
  return {
4220
- storedEmail: encryptField(normalized, key),
4221
- emailLookup: hashLookupValue(normalized, key)
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
4222
4433
  };
4223
4434
  }
4224
- function revealEmail(storedEmail) {
4225
- const key = resolveEncryptionKey();
4226
- if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
4227
- return storedEmail;
4228
- }
4229
- return decryptField(storedEmail, key);
4230
- }
4231
- function emailLookupForQuery(email) {
4232
- const normalized = normalizeEmail(email);
4233
- const key = resolveEncryptionKey();
4234
- if (!key || !isFieldEncryptionEnabled()) {
4235
- return normalized;
4236
- }
4237
- return hashLookupValue(normalized, key);
4435
+ // ../../src/core/http/flashSession.ts
4436
+ import { createHmac as createHmac3, timingSafeEqual as timingSafeEqual3 } from "crypto";
4437
+ var FLASH_COOKIE = "workhub_flash";
4438
+ var FLASH_TTL_MS = 60 * 1000;
4439
+ function resolveFlashSecret() {
4440
+ return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
4238
4441
  }
4239
-
4240
- // ../../src/core/crypto/mfaSecret.ts
4241
- function protectMfaSecret(secret) {
4242
- const key = resolveEncryptionKey();
4243
- if (!isFieldEncryptionEnabled() || !key) {
4244
- return secret;
4245
- }
4246
- return encryptField(secret, key);
4442
+ function signFlashPayload(payload, issuedAt) {
4443
+ const signature = createHmac3("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
4444
+ return `${payload}.${issuedAt}.${signature}`;
4247
4445
  }
4248
- function revealMfaSecret(stored) {
4249
- if (!stored) {
4446
+ function readFlashCookie(request) {
4447
+ const cookieHeader = request.headers.get("cookie");
4448
+ if (!cookieHeader) {
4250
4449
  return null;
4251
4450
  }
4252
- const key = resolveEncryptionKey();
4253
- if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
4254
- return stored;
4255
- }
4256
- return decryptField(stored, key);
4257
- }
4258
-
4259
- // ../../src/core/security/securityEvents.ts
4260
- function logSecurityEvent(event, details = {}) {
4261
- const meta = currentRequestMeta();
4262
- const user = currentAuthUser();
4263
- console.log(JSON.stringify({
4264
- level: "security",
4265
- event,
4266
- timestamp: new Date().toISOString(),
4267
- ip_address: meta.ipAddress ?? null,
4268
- user_agent: meta.userAgent ?? null,
4269
- user_id: user?.id ?? null,
4270
- ...details
4271
- }));
4451
+ for (const part of cookieHeader.split(";")) {
4452
+ const [name, ...rest] = part.trim().split("=");
4453
+ if (name === FLASH_COOKIE) {
4454
+ return decodeURIComponent(rest.join("="));
4455
+ }
4456
+ }
4457
+ return null;
4272
4458
  }
4273
-
4274
- // ../../src/core/security/tokenExpiry.ts
4275
- function resolveDefaultTokenExpiryDays() {
4276
- const raw = process.env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim();
4277
- if (!raw) {
4459
+ function parseFlashCookie(cookieValue) {
4460
+ const parts = cookieValue.split(".");
4461
+ if (parts.length < 3) {
4278
4462
  return null;
4279
4463
  }
4280
- const parsed = Number.parseInt(raw, 10);
4281
- if (!Number.isInteger(parsed) || parsed <= 0) {
4464
+ const signature = parts.pop();
4465
+ const issuedAtRaw = parts.pop();
4466
+ const payload = parts.join(".");
4467
+ if (!signature || !issuedAtRaw || !payload) {
4282
4468
  return null;
4283
4469
  }
4284
- return parsed;
4285
- }
4286
-
4287
- // ../../src/core/security/totp.ts
4288
- import { createHmac as createHmac3 } from "crypto";
4289
- function decodeBase32(input) {
4290
- const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
4291
- const normalized = input.replace(/=+$/u, "").toUpperCase();
4292
- let bits = "";
4293
- for (const char of normalized) {
4294
- const value = alphabet.indexOf(char);
4295
- if (value === -1) {
4296
- throw new Error("Invalid base32 character in MFA secret.");
4297
- }
4298
- bits += value.toString(2).padStart(5, "0");
4470
+ const issuedAt = Number.parseInt(issuedAtRaw, 10);
4471
+ if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
4472
+ return null;
4299
4473
  }
4300
- const bytes = [];
4301
- for (let index = 0;index + 8 <= bits.length; index += 8) {
4302
- bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
4474
+ const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
4475
+ if (!expectedSignature) {
4476
+ return null;
4303
4477
  }
4304
- return Buffer.from(bytes);
4305
- }
4306
- function generateTotp(secret, counter, digits = 6) {
4307
- const key = decodeBase32(secret);
4308
- const buffer = Buffer.alloc(8);
4309
- buffer.writeBigUInt64BE(BigInt(counter));
4310
- const digest = createHmac3("sha1", key).update(buffer).digest();
4311
- const lastByte = digest[digest.length - 1] ?? 0;
4312
- const offset = lastByte & 15;
4313
- const b0 = digest[offset] ?? 0;
4314
- const b1 = digest[offset + 1] ?? 0;
4315
- const b2 = digest[offset + 2] ?? 0;
4316
- const b3 = digest[offset + 3] ?? 0;
4317
- const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
4318
- return String(code % 10 ** digits).padStart(digits, "0");
4319
- }
4320
- function verifyTotp(secret, token, window = 1) {
4321
- const normalized = token.trim();
4322
- if (!/^\d{6}$/u.test(normalized)) {
4323
- return false;
4478
+ const expectedBuffer = Buffer.from(expectedSignature);
4479
+ const actualBuffer = Buffer.from(signature);
4480
+ if (expectedBuffer.length !== actualBuffer.length) {
4481
+ return null;
4324
4482
  }
4325
- const timestep = Math.floor(Date.now() / 30000);
4326
- for (let offset = -window;offset <= window; offset += 1) {
4327
- if (generateTotp(secret, timestep + offset) === normalized) {
4328
- return true;
4483
+ if (!timingSafeEqual3(expectedBuffer, actualBuffer)) {
4484
+ return null;
4485
+ }
4486
+ try {
4487
+ const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
4488
+ if (!parsed?.message || typeof parsed.message !== "string") {
4489
+ return null;
4329
4490
  }
4491
+ if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
4492
+ return null;
4493
+ }
4494
+ return parsed;
4495
+ } catch {
4496
+ return null;
4330
4497
  }
4331
- return false;
4332
4498
  }
4333
-
4334
- // ../../src/domain/abilities.ts
4335
- var MEMBER_ABILITIES = [
4336
- "organizations:read",
4337
- "projects:read",
4338
- "projects:create",
4339
- "tasks:read",
4340
- "tasks:create",
4341
- "comments:read",
4342
- "comments:create",
4343
- "attachments:read",
4344
- "attachments:create",
4345
- "auth:tokens:read",
4346
- "auth:tokens:write"
4347
- ];
4348
- var ADMIN_ABILITIES = [
4349
- ...MEMBER_ABILITIES,
4350
- "organizations:create",
4351
- "organizations:update",
4352
- "organizations:delete",
4353
- "projects:update",
4354
- "projects:delete",
4355
- "tasks:update",
4356
- "tasks:delete",
4357
- "comments:update",
4358
- "comments:delete",
4359
- "attachments:delete",
4360
- "webhooks:read",
4361
- "webhooks:write",
4362
- "audit:read"
4363
- ];
4364
- var PLATFORM_ADMIN_ABILITIES = ["*"];
4365
- function resolveAbilitiesForRole(role) {
4366
- if (role === "admin") {
4367
- return [...PLATFORM_ADMIN_ABILITIES];
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;
4368
4506
  }
4369
- return [...MEMBER_ABILITIES];
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
+ });
4370
4517
  }
4371
4518
 
4372
- // ../../src/modules/user/authService.ts
4373
- class AuthService {
4374
- users;
4375
- tokens;
4376
- oauthIdentities;
4377
- oauthProviders = new Map;
4378
- constructor(users, tokens, oauthIdentities) {
4379
- this.users = users;
4380
- this.tokens = tokens;
4381
- this.oauthIdentities = oauthIdentities;
4382
- }
4383
- registerOAuthProvider(provider) {
4384
- this.oauthProviders.set(provider.name, provider);
4385
- }
4386
- getOAuthProvider(name) {
4387
- return this.oauthProviders.get(name);
4388
- }
4389
- async loginWithPassword(email, password, options = {}) {
4390
- const user = await this.users.findByEmail(email);
4391
- if (!user?.password_hash) {
4392
- logSecurityEvent("auth_login_failed", { reason: "unknown_user", email });
4393
- throw new UnauthorizedError("Invalid credentials.");
4394
- }
4395
- const valid = await verifyPassword(password, user.password_hash);
4396
- if (!valid) {
4397
- logSecurityEvent("auth_login_failed", { reason: "invalid_password", user_id: user.id });
4398
- throw new UnauthorizedError("Invalid credentials.");
4399
- }
4400
- if (isFeatureEnabled("emailVerification") && !user.email_verified_at) {
4401
- logSecurityEvent("auth_login_blocked", { reason: "email_unverified", user_id: user.id });
4402
- throw new UnauthorizedError("Email address is not verified.");
4403
- }
4404
- if (isFeatureEnabled("mfa") && user.mfa_enabled) {
4405
- const mfaSecret = revealMfaSecret(user.mfa_secret);
4406
- if (!mfaSecret || !options.mfaCode || !verifyTotp(mfaSecret, options.mfaCode)) {
4407
- logSecurityEvent("auth_login_failed", { reason: "invalid_mfa", user_id: user.id });
4408
- throw new UnauthorizedError("Invalid MFA code.");
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);
4409
4528
  }
4410
- }
4411
- logSecurityEvent("auth_login_success", { user_id: user.id, method: "password" });
4412
- return await this.tokens.createToken(user.id, {
4413
- name: "password-login",
4414
- abilities: resolveAbilitiesForRole(user.role),
4415
- expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
4529
+ return response;
4416
4530
  });
4531
+ };
4532
+ }
4533
+ // ../../src/core/http/validation.ts
4534
+ function buildRequestCacheKey(fallbackPath, request) {
4535
+ if (!request) {
4536
+ return fallbackPath;
4417
4537
  }
4418
- async loginWithOAuth(providerName, code) {
4419
- const provider = this.oauthProviders.get(providerName);
4420
- if (!provider) {
4421
- throw new UnauthorizedError("Unsupported OAuth provider.");
4422
- }
4423
- const profile = await provider.exchangeCode(code);
4424
- const user = await this.findOrCreateOAuthUser(providerName, profile);
4425
- logSecurityEvent("auth_login_success", { user_id: user.id, method: `oauth:${providerName}` });
4426
- return await this.tokens.createToken(user.id, {
4427
- name: `${providerName}-oauth`,
4428
- abilities: resolveAbilitiesForRole(user.role),
4429
- expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
4430
- });
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;
4431
4547
  }
4432
- buildOAuthAuthorizationUrl(providerName, state) {
4433
- const provider = this.oauthProviders.get(providerName);
4434
- if (!provider) {
4435
- throw new UnauthorizedError("Unsupported OAuth provider.");
4436
- }
4437
- return provider.getAuthorizationUrl(state);
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.");
4438
4556
  }
4439
- async findOrCreateOAuthUser(providerName, profile) {
4440
- const existingIdentity = await this.oauthIdentities.findByProviderUser(providerName, profile.providerUserId);
4441
- if (existingIdentity) {
4442
- return await this.users.findByIdOrThrow(existingIdentity.user_id);
4443
- }
4444
- const existingUser = await this.users.findByEmail(profile.email);
4445
- const user = existingUser ?? await this.users.create({
4446
- name: profile.name,
4447
- email: profile.email,
4448
- role: "member",
4449
- tenant_id: currentTenantId(),
4450
- email_verified_at: new Date,
4451
- created_at: new Date,
4452
- updated_at: new Date
4453
- });
4454
- await this.oauthIdentities.create({
4455
- user_id: user.id,
4456
- provider: providerName,
4457
- provider_user_id: profile.providerUserId,
4458
- email: profile.email,
4459
- created_at: new Date
4460
- });
4461
- return user;
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.`);
4462
4563
  }
4564
+ return parsed;
4463
4565
  }
4464
4566
 
4465
- // ../../src/modules/user/notificationTable.ts
4466
- var notificationTable = defineTable({
4467
- name: "notification",
4468
- primaryKey: "id",
4469
- columns: ["id", "user_id", "tenant_id", "type", "title", "body", "data", "read_at", "created_at"],
4470
- defaultOrderBy: { column: "created_at", direction: "DESC" }
4471
- });
4472
-
4473
- // ../../src/modules/user/oauthIdentityRepository.ts
4474
- var oauthIdentityTable = defineTable({
4475
- name: "oauth_identity",
4476
- primaryKey: "id",
4477
- columns: ["id", "user_id", "provider", "provider_user_id", "email", "created_at"]
4478
- });
4479
-
4480
- // ../../src/modules/user/table.ts
4481
- var userTable = defineTable({
4482
- name: "users",
4483
- primaryKey: "id",
4484
- columns: [
4485
- "id",
4486
- "name",
4487
- "email",
4488
- "email_lookup",
4489
- "role",
4490
- "tenant_id",
4491
- "password_hash",
4492
- "email_verified_at",
4493
- "mfa_secret",
4494
- "mfa_enabled",
4495
- "created_at",
4496
- "updated_at"
4497
- ],
4498
- defaultOrderBy: { column: "id", direction: "ASC" }
4499
- });
4500
-
4501
- // ../../src/core/auth/tokenHash.ts
4502
- import { createHash, createHmac as createHmac4 } from "crypto";
4503
- function resolveTokenPepper() {
4504
- return process.env.TOKEN_HASH_PEPPER?.trim() ?? "workhub-dev-token-pepper";
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
+ }
4505
4578
  }
4506
- function hashApiToken(token) {
4507
- const pepper = resolveTokenPepper();
4508
- if (pepper && pepper !== "workhub-dev-token-pepper") {
4509
- return createHmac4("sha256", pepper).update(token).digest("hex");
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";
4510
4587
  }
4511
- return createHash("sha256").update(token).digest("hex");
4588
+ return "api";
4589
+ }
4590
+ function isViewsEnabled() {
4591
+ return readFrontendMode() === "server-htmx";
4512
4592
  }
4513
4593
 
4514
- // ../../src/modules/user/provider.ts
4515
- var tokenServiceToken = CORE_TOKEN_SERVICE_TOKEN;
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";
4516
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
+ });
4624
+ }
4625
+ }
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
+ }
4634
+ });
4635
+ }
4636
+ function isHtmxRequest(request) {
4637
+ return request.headers.get("HX-Request") === "true";
4638
+ }
4517
4639
  // ../../src/core/view/webLayoutData.ts
4518
4640
  async function resolveWebLayoutData(container, request) {
4519
4641
  const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
@@ -4634,7 +4756,7 @@ function createAuthMiddleware(auth2) {
4634
4756
  // ../../src/core/http/authorizeMiddleware.ts
4635
4757
  function createAuthorizeMiddleware(gate, auth2, resource, action) {
4636
4758
  return async (request, next) => {
4637
- const user = await auth2.resolve(request);
4759
+ const user = currentAuthUser() ?? await auth2.resolve(request);
4638
4760
  if (!gate.allows(resource, action, user)) {
4639
4761
  const error = new ForbiddenError;
4640
4762
  return Response.json({ error: error.message }, { status: error.status });
@@ -4642,33 +4764,6 @@ function createAuthorizeMiddleware(gate, auth2, resource, action) {
4642
4764
  return await next();
4643
4765
  };
4644
4766
  }
4645
- // ../../src/core/http/conditionalResponse.ts
4646
- function jsonResponse(data, init = {}) {
4647
- return Response.json(data, {
4648
- status: init.status ?? 200,
4649
- headers: init.headers
4650
- });
4651
- }
4652
- function conditionalJsonResponse(request, data, init = {}) {
4653
- if (!request || !isEtagEnabled()) {
4654
- return jsonResponse(data, init);
4655
- }
4656
- const etag = computeEtagFromJson(data);
4657
- if (ifNoneMatchSatisfied(request, etag)) {
4658
- return notModifiedResponse(etag);
4659
- }
4660
- const response = jsonResponse(data, init);
4661
- const headers = new Headers(response.headers);
4662
- headers.set("ETag", etag);
4663
- headers.set("Cache-Control", "private, must-revalidate");
4664
- headers.append("Vary", "Authorization");
4665
- headers.append("Vary", "X-Tenant-Id");
4666
- return new Response(response.body, {
4667
- status: response.status,
4668
- statusText: response.statusText,
4669
- headers
4670
- });
4671
- }
4672
4767
  // ../../src/core/http/middleware.ts
4673
4768
  function isRouteHandler(value) {
4674
4769
  return typeof value === "function";
@@ -4809,7 +4904,8 @@ function securedBindRouteModel(param, resolver, authorization, handler) {
4809
4904
  const model = await resolver(id, request);
4810
4905
  const gate = resolveApplicationPolicyGate();
4811
4906
  const auth2 = resolveApplicationAuth();
4812
- gate.authorize(authorization.resource, authorization.action, await auth2.resolve(request), model);
4907
+ const user = currentAuthUser() ?? await auth2.resolve(request);
4908
+ gate.authorize(authorization.resource, authorization.action, user, model);
4813
4909
  if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
4814
4910
  assertIfMatch(request, etagFromResource(model), {
4815
4911
  required: authorization.requireIfMatch ?? true
@@ -4831,7 +4927,8 @@ function securedBindRouteModelByKey(param, resolver, authorization, handler) {
4831
4927
  const model = await resolver(key, request);
4832
4928
  const gate = resolveApplicationPolicyGate();
4833
4929
  const auth2 = resolveApplicationAuth();
4834
- gate.authorize(authorization.resource, authorization.action, await auth2.resolve(request), model);
4930
+ const user = currentAuthUser() ?? await auth2.resolve(request);
4931
+ gate.authorize(authorization.resource, authorization.action, user, model);
4835
4932
  if (isEtagEnabled() && isMutatingPolicyAction(authorization.action)) {
4836
4933
  assertIfMatch(request, etagFromResource(model), {
4837
4934
  required: authorization.requireIfMatch ?? true
@@ -4917,10 +5014,6 @@ async function parseMultipartUpload(request, fieldName = "file") {
4917
5014
  contents: new Uint8Array(await value.arrayBuffer())
4918
5015
  };
4919
5016
  }
4920
- // ../../src/core/http/route.ts
4921
- function getRouteParams(request) {
4922
- return request.params;
4923
- }
4924
5017
 
4925
5018
  // ../../src/core/http/index.ts
4926
5019
  function jsonResponse2(data, init = {}) {
@@ -5155,6 +5248,25 @@ function createRequireWebAuthMiddleware(auth2) {
5155
5248
  return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
5156
5249
  };
5157
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
+ }
5158
5270
  // ../../src/config/app.ts
5159
5271
  var appConfig = {
5160
5272
  name: "WorkHub",
@@ -5229,7 +5341,7 @@ function createSecurityHeadersMiddleware() {
5229
5341
  };
5230
5342
  }
5231
5343
  // ../../src/core/http/throttleMiddleware.ts
5232
- var {RedisClient: RedisClient2 } = globalThis.Bun;
5344
+ var {RedisClient: RedisClient3 } = globalThis.Bun;
5233
5345
  function resolveThrottleIdentity(request) {
5234
5346
  const user = currentAuthUser();
5235
5347
  if (user?.tokenId !== undefined) {
@@ -5241,7 +5353,7 @@ function resolveThrottleIdentity(request) {
5241
5353
  return request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
5242
5354
  }
5243
5355
  function createThrottleMiddleware(options) {
5244
- const client = new RedisClient2(options.redisUrl);
5356
+ const client = new RedisClient3(options.redisUrl);
5245
5357
  const prefix = options.keyPrefix ?? "workhub:throttle:";
5246
5358
  return async (request, next) => {
5247
5359
  const identity = resolveThrottleIdentity(request);
@@ -5342,11 +5454,6 @@ function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
5342
5454
  });
5343
5455
  }
5344
5456
  }
5345
- function resetGracefulShutdownForTests() {
5346
- shutdownHandlers.clear();
5347
- shutdownInstalled = false;
5348
- shuttingDown = false;
5349
- }
5350
5457
  // ../../src/core/logging/requestLoggingMiddleware.ts
5351
5458
  function createRequestLoggingMiddleware() {
5352
5459
  return async (request, next) => {
@@ -5665,7 +5772,7 @@ async function runQueueJob(envelope, failedJobs) {
5665
5772
  }
5666
5773
 
5667
5774
  // ../../src/core/queue/redisQueue.ts
5668
- var {RedisClient: RedisClient3 } = globalThis.Bun;
5775
+ var {RedisClient: RedisClient4 } = globalThis.Bun;
5669
5776
  var QUEUE_LIST_KEY = "workhub:queue:default";
5670
5777
  var QUEUE_HIGH_KEY = "workhub:queue:high";
5671
5778
  var QUEUE_LOW_KEY = "workhub:queue:low";
@@ -5715,7 +5822,7 @@ function parseQueueJobEnvelope(rawPayload) {
5715
5822
  class RedisQueue {
5716
5823
  client;
5717
5824
  constructor(redisUrl) {
5718
- this.client = new RedisClient3(redisUrl);
5825
+ this.client = new RedisClient4(redisUrl);
5719
5826
  }
5720
5827
  async dispatch(job, payload) {
5721
5828
  const name = jobRegistry.resolveName(job);
@@ -5741,7 +5848,7 @@ class QueueWorker {
5741
5848
  constructor(redisUrl, failedJobs, timeoutSeconds = 5) {
5742
5849
  this.failedJobs = failedJobs;
5743
5850
  this.timeoutSeconds = timeoutSeconds;
5744
- this.client = new RedisClient3(redisUrl);
5851
+ this.client = new RedisClient4(redisUrl);
5745
5852
  }
5746
5853
  requestStop() {
5747
5854
  this.stopping = true;
@@ -5838,6 +5945,68 @@ function createProductionQueue(driver, options = {}) {
5838
5945
  function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
5839
5946
  return new QueueWorker(redisUrl, failedJobs);
5840
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
+ }
5841
6010
  // ../../src/core/scheduler/schedule.ts
5842
6011
  class Schedule {
5843
6012
  tasks = [];
@@ -5878,44 +6047,18 @@ function guestCanViewResource() {
5878
6047
  return isPublicReadsEnabled();
5879
6048
  }
5880
6049
  // ../../src/core/tenant/tenantMiddleware.ts
5881
- import { createHash as createHash2 } from "crypto";
6050
+ import { createHash } from "crypto";
5882
6051
 
5883
6052
  // ../../src/core/tenant/databaseTenantContext.ts
5884
6053
  async function runWithMigrationBypass(callback) {
5885
- await connection_default`SELECT set_config('app.bypass_rls', 'true', false)`;
6054
+ await repositoryConnection`SELECT set_config('app.bypass_rls', 'true', false)`;
5886
6055
  try {
5887
6056
  return await callback();
5888
6057
  } finally {
5889
- await connection_default`SELECT set_config('app.bypass_rls', 'false', false)`;
6058
+ await repositoryConnection`SELECT set_config('app.bypass_rls', 'false', false)`;
5890
6059
  }
5891
6060
  }
5892
6061
 
5893
- // ../../src/core/tenant/resolveTenant.ts
5894
- async function resolveTenant(tenantId) {
5895
- const rows = await connection_default`
5896
- SELECT id, slug, plan, region
5897
- FROM tenant
5898
- WHERE id = ${tenantId}
5899
- LIMIT 1
5900
- `;
5901
- const row = rows[0];
5902
- return row ? { id: row.id, slug: row.slug, plan: row.plan, region: row.region ?? "eu" } : null;
5903
- }
5904
-
5905
- // ../../src/core/tenant/tenantDatabaseScope.ts
5906
- async function applyTenantContextToTransaction(transaction, tenantId) {
5907
- await transaction.unsafe(`SELECT set_config('app.tenant_id', $1, true)`, [String(tenantId)]);
5908
- await transaction.unsafe(`SELECT set_config('app.bypass_rls', $1, true)`, ["false"]);
5909
- }
5910
- async function runWithTenantDatabase(tenant, callback) {
5911
- return await getDatabase().begin(async (transaction) => {
5912
- await applyTenantContextToTransaction(transaction, tenant.id);
5913
- return await runWithDatabaseConnection(transaction, async () => {
5914
- return await runWithTenant(tenant, callback);
5915
- });
5916
- });
5917
- }
5918
-
5919
6062
  // ../../src/core/tenant/tenantMiddleware.ts
5920
6063
  var DEFAULT_TENANT = {
5921
6064
  id: 1,
@@ -5925,7 +6068,7 @@ var DEFAULT_TENANT = {
5925
6068
  };
5926
6069
  async function resolveUserTenantId(userId) {
5927
6070
  return await runWithMigrationBypass(async () => {
5928
- const rows = await connection_default`
6071
+ const rows = await repositoryConnection`
5929
6072
  SELECT tenant_id
5930
6073
  FROM users
5931
6074
  WHERE id = ${userId}
@@ -5935,7 +6078,7 @@ async function resolveUserTenantId(userId) {
5935
6078
  });
5936
6079
  }
5937
6080
  function auditChecksum(payload) {
5938
- return createHash2("sha256").update(JSON.stringify(payload)).digest("hex");
6081
+ return createHash("sha256").update(JSON.stringify(payload)).digest("hex");
5939
6082
  }
5940
6083
  async function resolveTenantForRequest(request) {
5941
6084
  const user = currentAuthUser();
@@ -5962,6 +6105,10 @@ async function resolveTenantForRequest(request) {
5962
6105
  }
5963
6106
  function createTenantMiddleware() {
5964
6107
  return async (request, next) => {
6108
+ const pathname = new URL(request.url).pathname;
6109
+ if (pathname.startsWith("/scim/")) {
6110
+ return await next();
6111
+ }
5965
6112
  try {
5966
6113
  const tenant = await resolveTenantForRequest(request);
5967
6114
  return await runWithTenantDatabase(tenant, async () => {
@@ -5983,6 +6130,15 @@ function createTenantMiddleware() {
5983
6130
  }
5984
6131
  };
5985
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
+ }
5986
6142
  // ../../src/core/tracing/otel.ts
5987
6143
  import { randomBytes as randomBytes2 } from "crypto";
5988
6144
  function randomHex(bytes) {
@@ -6026,16 +6182,6 @@ async function exportOtelSpan(span) {
6026
6182
  });
6027
6183
  }
6028
6184
 
6029
- // ../../src/core/tracing/traceContext.ts
6030
- import { AsyncLocalStorage as AsyncLocalStorage6 } from "async_hooks";
6031
- var traceContextStorage = new AsyncLocalStorage6;
6032
- function runWithTraceContext(context, callback) {
6033
- return traceContextStorage.run(context, callback);
6034
- }
6035
- function currentTraceId() {
6036
- return traceContextStorage.getStore()?.traceId ?? null;
6037
- }
6038
-
6039
6185
  // ../../src/core/tracing/tracingMiddleware.ts
6040
6186
  function createTracingMiddleware() {
6041
6187
  return async (request, next) => {
@@ -6114,45 +6260,6 @@ function maxLength(maximum) {
6114
6260
  return;
6115
6261
  };
6116
6262
  }
6117
- function pattern(expression) {
6118
- return (field, value) => {
6119
- if (typeof value !== "string") {
6120
- return;
6121
- }
6122
- if (!expression.test(value.trim())) {
6123
- return `"${field}" has an invalid format.`;
6124
- }
6125
- return;
6126
- };
6127
- }
6128
- function enumRule(allowedValues) {
6129
- return (field, value) => {
6130
- if (typeof value !== "string") {
6131
- return;
6132
- }
6133
- if (!allowedValues.includes(value)) {
6134
- return `"${field}" must be one of: ${allowedValues.join(", ")}.`;
6135
- }
6136
- return;
6137
- };
6138
- }
6139
- function optional() {
6140
- return () => {
6141
- return;
6142
- };
6143
- }
6144
- function integerRule() {
6145
- return (field, value) => {
6146
- if (value === undefined || value === null || value === "") {
6147
- return;
6148
- }
6149
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
6150
- if (!Number.isInteger(parsed)) {
6151
- return `"${field}" must be an integer.`;
6152
- }
6153
- return;
6154
- };
6155
- }
6156
6263
  function emailRule() {
6157
6264
  return (field, value) => {
6158
6265
  if (typeof value !== "string") {
@@ -6165,40 +6272,6 @@ function emailRule() {
6165
6272
  return;
6166
6273
  };
6167
6274
  }
6168
- function confirmed(fieldName) {
6169
- return (field, value, payload) => {
6170
- const confirmationKey = `${fieldName}_confirmation`;
6171
- const confirmation = payload[confirmationKey];
6172
- if (value !== confirmation) {
6173
- return `"${field}" confirmation does not match.`;
6174
- }
6175
- return;
6176
- };
6177
- }
6178
- function positiveIntegerRule() {
6179
- return (field, value) => {
6180
- if (value === undefined || value === null || value === "") {
6181
- return;
6182
- }
6183
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
6184
- if (!Number.isInteger(parsed) || parsed <= 0) {
6185
- return `"${field}" must be a positive integer.`;
6186
- }
6187
- return;
6188
- };
6189
- }
6190
- function integerRange(minimum, maximum) {
6191
- return (field, value) => {
6192
- if (value === undefined || value === null || value === "") {
6193
- return;
6194
- }
6195
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
6196
- if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
6197
- return `"${field}" must be an integer between ${minimum} and ${maximum}.`;
6198
- }
6199
- return;
6200
- };
6201
- }
6202
6275
  function validateObject(payload, schema) {
6203
6276
  if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
6204
6277
  throw new ValidationError("Request body must be a JSON object.");
@@ -6223,6 +6296,7 @@ function validateObject(payload, schema) {
6223
6296
  return output;
6224
6297
  }
6225
6298
  export {
6299
+ wrapRouteHandler,
6226
6300
  wrapMarkdownMailLayout,
6227
6301
  withMigrationLock,
6228
6302
  withMiddleware,
@@ -6239,6 +6313,11 @@ export {
6239
6313
  sendMarkdownMail,
6240
6314
  securedBindRouteModelByKey,
6241
6315
  securedBindRouteModel,
6316
+ scopedOrganizationIds,
6317
+ runWithTraceContext,
6318
+ runWithTenantDatabase,
6319
+ runWithTenant,
6320
+ runWithRequestMeta,
6242
6321
  runWithDatabaseConnection,
6243
6322
  runWithAuthUser,
6244
6323
  runSeedersFromDirectory,
@@ -6248,7 +6327,12 @@ export {
6248
6327
  runDueScheduledTasks,
6249
6328
  rollbackDatabase,
6250
6329
  resolveWebLayoutData,
6330
+ resolveUserTenantId,
6331
+ resolveUserId,
6251
6332
  resolveService,
6333
+ resolveRepositoryConnection,
6334
+ resolveOrganizationScope,
6335
+ resolveMembershipService,
6252
6336
  resolveDatabaseDriver,
6253
6337
  resolveCsrfTokenForRequest,
6254
6338
  resolveCsrfToken,
@@ -6262,9 +6346,11 @@ export {
6262
6346
  resetDefaultStorage,
6263
6347
  required,
6264
6348
  requestIdMiddleware,
6349
+ repositoryConnection,
6265
6350
  renderMarkdownMail,
6266
6351
  registerShutdownHandler,
6267
6352
  registerModelRepository,
6353
+ registerDefaultDatabasePool,
6268
6354
  readSubmittedCsrfTokenFromBody,
6269
6355
  readSubmittedCsrfToken,
6270
6356
  readRequestCookie,
@@ -6272,7 +6358,9 @@ export {
6272
6358
  queue,
6273
6359
  prometheusRegistry,
6274
6360
  policyGate,
6361
+ parsePositiveIntParam,
6275
6362
  parsePaginationQuery,
6363
+ parseMultipartUpload,
6276
6364
  paginatedResponse,
6277
6365
  normalizeMetricPath,
6278
6366
  noContentResponse,
@@ -6285,13 +6373,16 @@ export {
6285
6373
  markdownToHtml,
6286
6374
  mailer,
6287
6375
  mail,
6376
+ logSecurityEvent,
6288
6377
  log,
6289
6378
  loadSeedersFromDirectory,
6290
6379
  loadMigrationsFromDirectory,
6291
6380
  jsonResponse2 as jsonResponse,
6292
6381
  jobRegistry,
6293
6382
  isPublicReadsEnabled,
6383
+ isInsideTenantDatabaseScope,
6294
6384
  isHtmxRequest,
6385
+ isGlobalAdmin,
6295
6386
  isEtagEnabled,
6296
6387
  installGracefulShutdownSignals,
6297
6388
  inferReferencedTable,
@@ -6304,26 +6395,41 @@ export {
6304
6395
  indexBelongsToManyRelation,
6305
6396
  hydrateValue,
6306
6397
  htmlResponse,
6398
+ hasOrgMembership,
6307
6399
  hasOne,
6400
+ hasMinimumOrgRole2 as hasMinimumOrgRole,
6308
6401
  hasMany,
6402
+ guestCanViewResource,
6309
6403
  grammarForDriver,
6310
6404
  getMigrationStatus,
6405
+ getDefaultDatabaseQuery,
6406
+ getDefaultDatabasePool,
6311
6407
  getActiveDatabaseConnection,
6312
6408
  freshDatabase,
6313
6409
  formatAdminValue,
6314
6410
  filterMassAssignable,
6315
6411
  events,
6316
6412
  etagFromResource,
6413
+ emptyPaginateResult,
6317
6414
  emailRule,
6318
6415
  dehydrateValue,
6319
6416
  defineTable,
6417
+ currentTraceId,
6418
+ currentTenantId,
6419
+ currentTenant,
6420
+ currentRequestMeta,
6421
+ currentOrganizationIds,
6422
+ currentOrgRole,
6320
6423
  currentAuthUser,
6321
6424
  createdResponse,
6322
6425
  createTrackedJob,
6323
6426
  createTracingMiddleware,
6324
6427
  createThrottleMiddleware,
6325
6428
  createTenantMiddleware,
6429
+ createStorageDriver,
6326
6430
  createSecurityHeadersMiddleware,
6431
+ createScimThrottleMiddleware,
6432
+ createScimAuthMiddleware,
6327
6433
  createSchemaBuilder,
6328
6434
  createRequireWebAuthMiddleware,
6329
6435
  createRequireGlobalAdminMiddleware,
@@ -6340,7 +6446,8 @@ export {
6340
6446
  createLoginThrottleMiddleware,
6341
6447
  createFlashMiddleware,
6342
6448
  createFailedJobService,
6343
- createDatabaseConnection2 as createDatabaseConnection,
6449
+ createDatabaseQueryProxy,
6450
+ createDatabaseConnection,
6344
6451
  createCsrfTokenCookie,
6345
6452
  createCsrfProtection,
6346
6453
  createCsrfMiddleware,
@@ -6349,18 +6456,29 @@ export {
6349
6456
  createAuthorizeMiddleware,
6350
6457
  createAuthMiddleware,
6351
6458
  config,
6459
+ conditionalJsonResponse,
6352
6460
  composeMiddleware,
6353
6461
  compileBlueprint,
6462
+ collectQueueMetrics,
6354
6463
  cache,
6355
6464
  buildSmtpPayload,
6465
+ buildRequestCacheKey,
6356
6466
  buildMarkdownMailMessage,
6467
+ bindRouteModel,
6357
6468
  bindDatabaseConnection2 as bindDatabaseConnection,
6358
6469
  belongsToMany,
6359
6470
  belongsTo,
6471
+ authContext,
6360
6472
  auth,
6473
+ auditChecksum,
6474
+ assertResourceInCurrentTenant,
6475
+ assertOrganizationReadable,
6361
6476
  assertIfMatch,
6362
6477
  applyMiddlewareToRoutes,
6478
+ applyConditionalGet,
6363
6479
  applyCasts,
6480
+ appendProjectScope,
6481
+ appendOrganizationScope,
6364
6482
  appSchedule,
6365
6483
  WhereBuilder,
6366
6484
  WebFormRequest,
@@ -6388,10 +6506,12 @@ export {
6388
6506
  NotFoundError,
6389
6507
  MySqlGrammar,
6390
6508
  Model,
6509
+ membershipService_default as MembershipService,
6391
6510
  Mailer,
6392
6511
  LogMailDriver,
6393
6512
  LocalStorageDriver,
6394
6513
  Job,
6514
+ GuestGuard,
6395
6515
  FormRequest,
6396
6516
  ForeignIdColumnDefinition,
6397
6517
  ForbiddenError,
@@ -6399,15 +6519,20 @@ export {
6399
6519
  failedJobRepository_default as FailedJobRepository,
6400
6520
  EventBus,
6401
6521
  EtaViewEngine,
6522
+ DatabaseTokenGuard,
6402
6523
  DEFAULT_VIEWS_DIRECTORY,
6524
+ DEFAULT_TENANT,
6403
6525
  ConflictError,
6404
6526
  ConfigStore,
6527
+ CompositeGuard,
6405
6528
  ColumnDefinition,
6406
- repository_default as CacheRepository,
6529
+ repository_default2 as CacheRepository,
6407
6530
  CACHE_TAGS,
6408
6531
  Blueprint,
6409
6532
  baseRepository_default as BaseRepository,
6410
6533
  BadRequestError,
6534
+ AuthManager,
6411
6535
  AsyncQueue,
6536
+ ApiTokenGuard,
6412
6537
  AdminResourceRegistry
6413
6538
  };