@getstrata/bootstrap 0.2.25 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/bootstrap/public-api.ts
3
- import { appSchedule as appSchedule2, runDueScheduledTasks as runDueScheduledTasks2, Schedule as Schedule2 } from "@getstrata/core/scheduler/schedule";
3
+ import { appSchedule as appSchedule3, runDueScheduledTasks as runDueScheduledTasks2, Schedule as Schedule2 } from "@getstrata/core/scheduler/schedule";
4
4
 
5
5
  // ../../src/core/scheduler/schedule.ts
6
6
  class Schedule {
@@ -35,452 +35,37 @@ async function runDueScheduledTasks(schedule = appSchedule, now = new Date) {
35
35
  return due.length;
36
36
  }
37
37
 
38
- // ../../src/config/features.ts
39
- function readFeatureFlags() {
40
- return {
41
- webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
42
- fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
43
- auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
44
- oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
45
- samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
46
- scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
47
- billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
48
- siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
49
- publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
50
- emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
51
- mfa: (process.env.FEATURE_MFA ?? "false") === "true"
52
- };
53
- }
54
- var featureFlags = readFeatureFlags();
55
- function isFeatureEnabled(feature) {
56
- return readFeatureFlags()[feature];
57
- }
58
-
59
- // ../../src/config/app.ts
60
- var appConfig = {
61
- name: "WorkHub",
62
- env: process.env.APP_ENV ?? "local",
63
- debug: (process.env.APP_DEBUG ?? "true") !== "false",
64
- url: process.env.APP_URL ?? "http://localhost:3000",
65
- apiPrefix: process.env.API_PREFIX ?? "/api/v1"
66
- };
67
-
68
- // ../../src/core/database/boundConnection.ts
69
- var boundConnectionHolder = {
70
- connection: null
71
- };
72
- function getBoundDatabaseConnection() {
73
- return boundConnectionHolder.connection;
74
- }
75
-
76
- // ../../src/core/runtime/asyncContextStore.ts
77
- import { AsyncLocalStorage } from "async_hooks";
78
- function createAsyncContextStore(key) {
79
- const symbol = Symbol.for(key);
80
- const globalRecord = globalThis;
81
- const existing = globalRecord[symbol];
82
- if (existing) {
83
- return existing;
84
- }
85
- const store = new AsyncLocalStorage;
86
- globalRecord[symbol] = store;
87
- return store;
88
- }
89
-
90
- // ../../src/core/database/connectionContext.ts
91
- var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
92
- function getActiveDatabaseConnection(fallback) {
93
- return activeConnection.getStore() ?? fallback;
94
- }
95
-
96
- // ../../src/core/database/queryProxy.ts
97
- var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
98
- function createDatabaseQueryProxy(pool) {
99
- function resolveDatabase() {
100
- return getActiveDatabaseConnection(pool);
101
- }
102
- function resolveDatabaseForProperty(property) {
103
- if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
104
- return pool;
105
- }
106
- return resolveDatabase();
107
- }
108
- return new Proxy(function database() {}, {
109
- apply(_target, _thisArg, args) {
110
- return resolveDatabase()(...args);
111
- },
112
- get(_target, property) {
113
- const connection = resolveDatabaseForProperty(property);
114
- const value = connection[property];
115
- return typeof value === "function" ? value.bind(connection) : value;
116
- }
117
- });
118
- }
119
-
120
- // ../../src/core/database/defaultConnection.ts
121
- var defaultPool = {
122
- connection: null
123
- };
124
- var defaultQuery = {
125
- connection: null
126
- };
127
- function registerDefaultDatabasePool(connection) {
128
- defaultPool.connection = connection;
129
- defaultQuery.connection = createDatabaseQueryProxy(connection);
130
- }
131
- function getDefaultDatabaseQuery() {
132
- if (!defaultQuery.connection) {
133
- throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
134
- }
135
- return defaultQuery.connection;
136
- }
137
-
138
- // ../../src/core/database/repositoryConnection.ts
139
- function resolveRepositoryConnection() {
140
- return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
141
- }
142
- var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
143
- apply(_target, _thisArg, args) {
144
- return resolveRepositoryConnection()(...args);
145
- },
146
- get(_target, property) {
147
- const connection = resolveRepositoryConnection();
148
- const value = connection[property];
149
- return typeof value === "function" ? value.bind(connection) : value;
150
- }
151
- });
152
-
153
- // ../../src/core/security/safeUrl.ts
154
- import { lookup as dnsLookupImpl } from "dns/promises";
155
-
156
- // ../../src/core/errors/http.ts
157
- class HttpError extends Error {
158
- status;
159
- details;
160
- constructor(status, message, details) {
161
- super(message);
162
- this.name = new.target.name;
163
- this.status = status;
164
- this.details = details;
165
- }
166
- }
167
-
168
- class BadRequestError extends HttpError {
169
- constructor(message = "Bad Request", details) {
170
- super(400, message, details);
171
- }
172
- }
173
- class ConflictError extends HttpError {
174
- constructor(message = "Conflict", details) {
175
- super(409, message, details);
176
- }
177
- }
178
-
179
- class UnprocessableEntityError extends HttpError {
180
- constructor(message = "Unprocessable Entity", details) {
181
- super(422, message, details);
182
- }
183
- }
184
- class ForbiddenError extends HttpError {
185
- constructor(message = "Forbidden", details) {
186
- super(403, message, details);
187
- }
188
- }
189
-
190
- class UnauthorizedError extends HttpError {
191
- constructor(message = "Unauthorized", details) {
192
- super(401, message, details);
193
- }
194
- }
195
- class PreconditionFailedError extends HttpError {
196
- constructor(message = "Precondition Failed", details) {
197
- super(412, message, details);
198
- }
199
- }
200
-
201
- // ../../src/core/security/safeUrl.ts
202
- var dnsLookup = dnsLookupImpl;
203
- var BLOCKED_HOSTNAMES = new Set([
204
- "localhost",
205
- "127.0.0.1",
206
- "0.0.0.0",
207
- "::1",
208
- "metadata.google.internal"
209
- ]);
210
- function isPrivateIpv4(hostname) {
211
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
212
- if (!match) {
213
- return false;
214
- }
215
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
216
- if (octets.some((octet) => octet < 0 || octet > 255)) {
217
- return true;
218
- }
219
- const [a = 0, b = 0] = octets;
220
- if (a === 10) {
221
- return true;
222
- }
223
- if (a === 127) {
224
- return true;
225
- }
226
- if (a === 0) {
227
- return true;
228
- }
229
- if (a === 169 && b === 254) {
230
- return true;
231
- }
232
- if (a === 172 && b >= 16 && b <= 31) {
233
- return true;
234
- }
235
- if (a === 192 && b === 168) {
236
- return true;
237
- }
238
- return false;
239
- }
240
- function isBlockedHostname(hostname) {
241
- const normalized = hostname.trim().toLowerCase();
242
- if (normalized.length === 0) {
243
- return true;
244
- }
245
- if (BLOCKED_HOSTNAMES.has(normalized)) {
246
- return true;
247
- }
248
- if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
249
- return true;
250
- }
251
- if (normalized.includes(":")) {
252
- return true;
253
- }
254
- return isPrivateIpv4(normalized);
255
- }
256
- function assertSafeOutboundUrl(rawUrl, options = {}) {
257
- let parsed;
258
- try {
259
- parsed = new URL(rawUrl);
260
- } catch {
261
- throw new BadRequestError("Webhook URL is invalid.");
262
- }
263
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
264
- throw new BadRequestError("Webhook URL must use HTTPS.");
265
- }
266
- if (parsed.username || parsed.password) {
267
- throw new BadRequestError("Webhook URL must not include credentials.");
268
- }
269
- if (isBlockedHostname(parsed.hostname)) {
270
- throw new BadRequestError("Webhook URL targets a blocked host.");
271
- }
272
- return parsed;
273
- }
274
- function isBlockedIpAddress(address) {
275
- return isBlockedHostname(address.trim().toLowerCase());
276
- }
277
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
278
- const parsed = assertSafeOutboundUrl(rawUrl, options);
279
- if (options.resolveDns === false) {
280
- return parsed;
281
- }
282
- const hostname = parsed.hostname.trim().toLowerCase();
283
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
284
- if (results.some((result) => isBlockedIpAddress(result.address))) {
285
- throw new BadRequestError("Webhook URL targets a blocked host.");
286
- }
287
- return parsed;
288
- }
289
-
290
- // ../../src/core/security/safeFetch.ts
291
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
292
- async function safeFetch(input, init = {}, options = {}) {
293
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
294
- const maxRedirects = options.maxRedirects ?? 0;
295
- const resolveDns = options.resolveDns ?? appConfig.env === "production";
296
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
297
- const controller = new AbortController;
298
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
299
- try {
300
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
301
- let redirectCount = 0;
302
- while (true) {
303
- const response = await fetch(currentUrl, {
304
- ...init,
305
- signal: controller.signal,
306
- redirect: "manual"
307
- });
308
- if (response.status >= 300 && response.status < 400) {
309
- const location = response.headers.get("location");
310
- if (!location || redirectCount >= maxRedirects) {
311
- return response;
312
- }
313
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
314
- redirectCount += 1;
315
- continue;
316
- }
317
- return response;
318
- }
319
- } finally {
320
- clearTimeout(timeout);
321
- }
322
- }
323
-
324
- // ../../src/core/tenant/databaseTenantContext.ts
325
- async function runWithMigrationBypass(callback) {
326
- await repositoryConnection`SELECT set_config('app.bypass_rls', 'true', false)`;
327
- try {
328
- return await callback();
329
- } finally {
330
- await repositoryConnection`SELECT set_config('app.bypass_rls', 'false', false)`;
331
- }
332
- }
333
-
334
- // ../../src/core/audit/siemFormatter.ts
335
- function formatSiemAuditEvent(input) {
336
- return {
337
- timestamp: input.created_at.toISOString(),
338
- event_type: "workhub.audit",
339
- actor_user_id: input.user_id,
340
- tenant_id: input.tenant_id ?? null,
341
- trace_id: input.trace_id ?? null,
342
- action: input.action,
343
- subject_type: input.subject_type,
344
- subject_id: input.subject_id,
345
- ip_address: input.ip_address ?? null,
346
- user_agent: input.user_agent ?? null,
347
- checksum: input.checksum ?? null,
348
- payload: input.payload ?? {}
349
- };
350
- }
351
- function formatCefLine(event) {
352
- const extension = [
353
- `rt=${event.timestamp}`,
354
- `suid=${event.actor_user_id ?? "unknown"}`,
355
- `cs1=${event.action}`,
356
- `cs1Label=Action`,
357
- `cs2=${event.subject_type}`,
358
- `cs2Label=SubjectType`,
359
- `cs3=${event.subject_id ?? ""}`,
360
- `cs3Label=SubjectId`,
361
- `src=${event.ip_address ?? ""}`,
362
- `request=${event.trace_id ?? ""}`
363
- ].join(" ");
364
- return `CEF:0|WorkHub|API|1.0|${event.action}|${event.subject_type}|5|${extension}`;
365
- }
366
-
367
- // ../../src/core/audit/exportAuditLogs.ts
368
- function resolveAuditExportConfig() {
369
- const endpoint = process.env.SIEM_EXPORT_URL?.trim();
370
- if (!endpoint) {
371
- return null;
372
- }
373
- assertSafeOutboundUrl(endpoint, { allowHttp: appConfig.env !== "production" });
374
- const batchSize = Number(process.env.SIEM_EXPORT_BATCH_SIZE ?? "100");
375
- return {
376
- endpoint,
377
- format: process.env.SIEM_EXPORT_FORMAT === "cef" ? "cef" : "json",
378
- batchSize: Number.isFinite(batchSize) ? batchSize : 100
379
- };
380
- }
381
- async function exportPendingAuditLogs() {
382
- const config = resolveAuditExportConfig();
383
- if (!config) {
384
- return 0;
385
- }
386
- return await runWithMigrationBypass(async () => {
387
- const rows = await repositoryConnection`
388
- SELECT
389
- id,
390
- user_id,
391
- action,
392
- subject_type,
393
- subject_id,
394
- payload,
395
- ip_address,
396
- user_agent,
397
- checksum,
398
- tenant_id,
399
- trace_id,
400
- created_at
401
- FROM audit_log
402
- WHERE exported_at IS NULL
403
- ORDER BY id
404
- LIMIT ${config.batchSize}
405
- `;
406
- if (rows.length === 0) {
407
- return 0;
408
- }
409
- const events = rows.map((row) => formatSiemAuditEvent({
410
- action: row.action,
411
- subject_type: row.subject_type,
412
- subject_id: row.subject_id,
413
- user_id: row.user_id,
414
- tenant_id: row.tenant_id,
415
- trace_id: row.trace_id,
416
- ip_address: row.ip_address,
417
- user_agent: row.user_agent,
418
- checksum: row.checksum,
419
- payload: row.payload,
420
- created_at: row.created_at
421
- }));
422
- const body = config.format === "cef" ? events.map((event) => formatCefLine(event)).join(`
423
- `) : JSON.stringify({ events });
424
- const response = await safeFetch(config.endpoint, {
425
- method: "POST",
426
- headers: {
427
- "content-type": config.format === "cef" ? "text/plain" : "application/json",
428
- ...process.env.SIEM_EXPORT_TOKEN ? { authorization: `Bearer ${process.env.SIEM_EXPORT_TOKEN}` } : {}
429
- },
430
- body
431
- }, { allowHttp: appConfig.env !== "production" });
432
- if (!response.ok) {
433
- throw new Error(`SIEM export failed with status ${response.status}.`);
434
- }
435
- const ids = rows.map((row) => row.id);
436
- for (const id of ids) {
437
- await repositoryConnection`UPDATE audit_log SET exported_at = NOW() WHERE id = ${id}`;
438
- }
439
- return rows.length;
440
- });
441
- }
442
-
443
- // ../../src/core/logging/logger.ts
444
- class Logger {
445
- channel;
446
- constructor(channel = "app") {
447
- this.channel = channel;
448
- }
449
- write(level, message, context = {}) {
450
- const entry = {
451
- level,
452
- channel: this.channel,
453
- message,
454
- timestamp: new Date().toISOString(),
455
- ...context
456
- };
457
- const line = JSON.stringify(entry);
458
- if (level === "error") {
459
- console.error(line);
460
- return;
461
- }
462
- console.log(line);
463
- }
464
- debug(message, context) {
465
- this.write("debug", message, context);
466
- }
467
- info(message, context) {
468
- this.write("info", message, context);
469
- }
470
- warn(message, context) {
471
- this.write("warn", message, context);
472
- }
473
- error(message, context) {
474
- this.write("error", message, context);
475
- }
38
+ // ../../src/bootstrap/schedule.ts
39
+ import { exportPendingAuditLogs } from "@getstrata/core/audit/exportAuditLogs";
40
+ import { appLogger } from "@getstrata/core/logging/logger";
41
+ import { appSchedule as appSchedule2 } from "@getstrata/core/scheduler/schedule";
42
+
43
+ // ../../src/config/features.ts
44
+ function readFeatureFlags() {
45
+ return {
46
+ webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
47
+ fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
48
+ auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
49
+ oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
50
+ samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
51
+ scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
52
+ billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
53
+ siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
54
+ publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
55
+ emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
56
+ mfa: (process.env.FEATURE_MFA ?? "false") === "true"
57
+ };
58
+ }
59
+ var featureFlags = readFeatureFlags();
60
+ function isFeatureEnabled(feature) {
61
+ return readFeatureFlags()[feature];
476
62
  }
477
- var appLogger = new Logger("app");
478
63
 
479
64
  // ../../src/bootstrap/schedule.ts
480
- appSchedule.command("* * * * *", "heartbeat", () => {
65
+ appSchedule2.command("* * * * *", "heartbeat", () => {
481
66
  appLogger.debug("Scheduler heartbeat");
482
67
  });
483
- appSchedule.command("* * * * *", "audit-export", async () => {
68
+ appSchedule2.command("* * * * *", "audit-export", async () => {
484
69
  if (!isFeatureEnabled("siemExport")) {
485
70
  return;
486
71
  }
@@ -517,7 +102,7 @@ import {
517
102
  resolveApplicationPolicyGate,
518
103
  resolveApplicationQueue,
519
104
  setActiveApplicationContext
520
- } from "@getstrata/core/runtime/applicationRegistry.ts";
105
+ } from "@getstrata/core/runtime/applicationRegistry";
521
106
  // ../../src/bootstrap/buildModuleRoutes.ts
522
107
  import { conditionalJsonResponse } from "@getstrata/core/http";
523
108
  import { applyMiddlewareToRoutes } from "@getstrata/core/http/middleware";
@@ -606,7 +191,7 @@ import {
606
191
  CORE_POLICY_GATE_TOKEN,
607
192
  CORE_QUEUE_TOKEN,
608
193
  CORE_TOKEN_SERVICE_TOKEN
609
- } from "@getstrata/core/contracts/serviceTokens.ts";
194
+ } from "@getstrata/core/contracts/serviceTokens";
610
195
  var APP_PORT_CONFIG_KEY = "app.port";
611
196
  var CACHE_TTL_MS_CONFIG_KEY = "cache.ttlMs";
612
197
  var CACHE_MAX_ENTRIES_CONFIG_KEY = "cache.maxEntries";
@@ -1032,6 +617,68 @@ var databaseConfig = {
1032
617
  connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
1033
618
  };
1034
619
 
620
+ // ../../src/core/runtime/asyncContextStore.ts
621
+ import { AsyncLocalStorage } from "async_hooks";
622
+ function createAsyncContextStore(key) {
623
+ const symbol = Symbol.for(key);
624
+ const globalRecord = globalThis;
625
+ const existing = globalRecord[symbol];
626
+ if (existing) {
627
+ return existing;
628
+ }
629
+ const store = new AsyncLocalStorage;
630
+ globalRecord[symbol] = store;
631
+ return store;
632
+ }
633
+
634
+ // ../../src/core/database/connectionContext.ts
635
+ var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
636
+ function getActiveDatabaseConnection(fallback) {
637
+ return activeConnection.getStore() ?? fallback;
638
+ }
639
+
640
+ // ../../src/core/database/queryProxy.ts
641
+ var POOL_CONNECTION_METHODS = new Set(["begin", "close", "connect"]);
642
+ function createDatabaseQueryProxy(pool) {
643
+ function resolveDatabase() {
644
+ return getActiveDatabaseConnection(pool);
645
+ }
646
+ function resolveDatabaseForProperty(property) {
647
+ if (typeof property === "string" && POOL_CONNECTION_METHODS.has(property)) {
648
+ return pool;
649
+ }
650
+ return resolveDatabase();
651
+ }
652
+ return new Proxy(function database() {}, {
653
+ apply(_target, _thisArg, args) {
654
+ return resolveDatabase()(...args);
655
+ },
656
+ get(_target, property) {
657
+ const connection = resolveDatabaseForProperty(property);
658
+ const value = connection[property];
659
+ return typeof value === "function" ? value.bind(connection) : value;
660
+ }
661
+ });
662
+ }
663
+
664
+ // ../../src/core/database/defaultConnection.ts
665
+ var defaultPool = {
666
+ connection: null
667
+ };
668
+ var defaultQuery = {
669
+ connection: null
670
+ };
671
+ function registerDefaultDatabasePool(connection) {
672
+ defaultPool.connection = connection;
673
+ defaultQuery.connection = createDatabaseQueryProxy(connection);
674
+ }
675
+ function getDefaultDatabaseQuery() {
676
+ if (!defaultQuery.connection) {
677
+ throw new Error("Default database query handle is not registered. Call registerDefaultDatabasePool() during app bootstrap.");
678
+ }
679
+ return defaultQuery.connection;
680
+ }
681
+
1035
682
  // ../../src/db/connection/createConnection.ts
1036
683
  var {SQL } = globalThis.Bun;
1037
684
  function createDatabaseConnection(config) {
@@ -1094,7 +741,7 @@ var apiTokenTable = defineTable({
1094
741
  // ../../src/modules/user/authService.ts
1095
742
  import { verifyPassword } from "@getstrata/core/auth/password";
1096
743
  import { revealMfaSecret } from "@getstrata/core/crypto/mfaSecret";
1097
- import { UnauthorizedError as UnauthorizedError2 } from "@getstrata/core/errors/http";
744
+ import { UnauthorizedError } from "@getstrata/core/errors/http";
1098
745
  import { logSecurityEvent } from "@getstrata/core/security/securityEvents";
1099
746
  import { resolveDefaultTokenExpiryDays } from "@getstrata/core/security/tokenExpiry";
1100
747
  import { verifyTotp } from "@getstrata/core/security/totp";
@@ -1119,22 +766,22 @@ class AuthService {
1119
766
  const user = await this.users.findByEmail(email);
1120
767
  if (!user?.password_hash) {
1121
768
  logSecurityEvent("auth_login_failed", { reason: "unknown_user", email });
1122
- throw new UnauthorizedError2("Invalid credentials.");
769
+ throw new UnauthorizedError("Invalid credentials.");
1123
770
  }
1124
771
  const valid = await verifyPassword(password, user.password_hash);
1125
772
  if (!valid) {
1126
773
  logSecurityEvent("auth_login_failed", { reason: "invalid_password", user_id: user.id });
1127
- throw new UnauthorizedError2("Invalid credentials.");
774
+ throw new UnauthorizedError("Invalid credentials.");
1128
775
  }
1129
776
  if (isFeatureEnabled("emailVerification") && !user.email_verified_at) {
1130
777
  logSecurityEvent("auth_login_blocked", { reason: "email_unverified", user_id: user.id });
1131
- throw new UnauthorizedError2("Email address is not verified.");
778
+ throw new UnauthorizedError("Email address is not verified.");
1132
779
  }
1133
780
  if (isFeatureEnabled("mfa") && user.mfa_enabled) {
1134
781
  const mfaSecret = revealMfaSecret(user.mfa_secret);
1135
782
  if (!mfaSecret || !options.mfaCode || !verifyTotp(mfaSecret, options.mfaCode)) {
1136
783
  logSecurityEvent("auth_login_failed", { reason: "invalid_mfa", user_id: user.id });
1137
- throw new UnauthorizedError2("Invalid MFA code.");
784
+ throw new UnauthorizedError("Invalid MFA code.");
1138
785
  }
1139
786
  }
1140
787
  logSecurityEvent("auth_login_success", { user_id: user.id, method: "password" });
@@ -1147,7 +794,7 @@ class AuthService {
1147
794
  async loginWithOAuth(providerName, code) {
1148
795
  const provider = this.oauthProviders.get(providerName);
1149
796
  if (!provider) {
1150
- throw new UnauthorizedError2("Unsupported OAuth provider.");
797
+ throw new UnauthorizedError("Unsupported OAuth provider.");
1151
798
  }
1152
799
  const profile = await provider.exchangeCode(code);
1153
800
  const user = await this.findOrCreateOAuthUser(providerName, profile);
@@ -1161,7 +808,7 @@ class AuthService {
1161
808
  buildOAuthAuthorizationUrl(providerName, state) {
1162
809
  const provider = this.oauthProviders.get(providerName);
1163
810
  if (!provider) {
1164
- throw new UnauthorizedError2("Unsupported OAuth provider.");
811
+ throw new UnauthorizedError("Unsupported OAuth provider.");
1165
812
  }
1166
813
  return provider.getAuthorizationUrl(state);
1167
814
  }
@@ -1248,12 +895,57 @@ var userTable = defineTable4({
1248
895
 
1249
896
  // ../../src/modules/user/tokenService.ts
1250
897
  import { hashApiToken } from "@getstrata/core/auth/tokenHash";
1251
- import { ForbiddenError as ForbiddenError2, NotFoundError as NotFoundError2 } from "@getstrata/core/errors/http";
898
+ import { ForbiddenError, NotFoundError as NotFoundError2 } from "@getstrata/core/errors/http";
1252
899
  import { resolveDefaultTokenExpiryDays as resolveDefaultTokenExpiryDays2 } from "@getstrata/core/security/tokenExpiry";
1253
900
 
1254
901
  // ../../src/modules/user/provider.ts
1255
902
  var tokenServiceToken = CORE_TOKEN_SERVICE_TOKEN;
1256
903
 
904
+ // ../../src/core/errors/http.ts
905
+ class HttpError extends Error {
906
+ status;
907
+ details;
908
+ constructor(status, message, details) {
909
+ super(message);
910
+ this.name = new.target.name;
911
+ this.status = status;
912
+ this.details = details;
913
+ }
914
+ }
915
+
916
+ class BadRequestError extends HttpError {
917
+ constructor(message = "Bad Request", details) {
918
+ super(400, message, details);
919
+ }
920
+ }
921
+ class ConflictError extends HttpError {
922
+ constructor(message = "Conflict", details) {
923
+ super(409, message, details);
924
+ }
925
+ }
926
+
927
+ class UnprocessableEntityError extends HttpError {
928
+ constructor(message = "Unprocessable Entity", details) {
929
+ super(422, message, details);
930
+ }
931
+ }
932
+ class ForbiddenError2 extends HttpError {
933
+ constructor(message = "Forbidden", details) {
934
+ super(403, message, details);
935
+ }
936
+ }
937
+
938
+ class UnauthorizedError2 extends HttpError {
939
+ constructor(message = "Unauthorized", details) {
940
+ super(401, message, details);
941
+ }
942
+ }
943
+ class PreconditionFailedError extends HttpError {
944
+ constructor(message = "Precondition Failed", details) {
945
+ super(412, message, details);
946
+ }
947
+ }
948
+
1257
949
  // ../../src/core/auth/authContext.ts
1258
950
  var authContext = createAsyncContextStore("@getstrata/authContext");
1259
951
  function currentAuthUser() {
@@ -1340,7 +1032,7 @@ class AuthManager {
1340
1032
  async requireUser(request) {
1341
1033
  const user = await this.user(request);
1342
1034
  if (!user) {
1343
- throw new UnauthorizedError;
1035
+ throw new UnauthorizedError2;
1344
1036
  }
1345
1037
  return user;
1346
1038
  }
@@ -1895,6 +1587,15 @@ var cacheProvider = {
1895
1587
  };
1896
1588
  var cache_default = cacheProvider;
1897
1589
 
1590
+ // ../../src/config/app.ts
1591
+ var appConfig = {
1592
+ name: "WorkHub",
1593
+ env: process.env.APP_ENV ?? "local",
1594
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
1595
+ url: process.env.APP_URL ?? "http://localhost:3000",
1596
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
1597
+ };
1598
+
1898
1599
  // ../../src/config/queue.ts
1899
1600
  var queueConfig = {
1900
1601
  driver: process.env.QUEUE_DRIVER === "redis" || process.env.QUEUE_DRIVER === "async" || process.env.QUEUE_DRIVER === "sync" ? process.env.QUEUE_DRIVER : DEFAULT_QUEUE_DRIVER,
@@ -2117,25 +1818,103 @@ function discoverListeners() {
2117
1818
  return appListeners;
2118
1819
  }
2119
1820
 
2120
- // ../../src/core/queue/index.ts
2121
- class Job {
2122
- maxAttempts;
2123
- backoffMs;
2124
- priority;
1821
+ // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
1822
+ import { eventBus as eventBus2, modelEventName as modelEventName2 } from "@getstrata/core/events";
1823
+ import InvalidateCacheTagsJob from "@getstrata/core/jobs/invalidateCacheTagsJob";
1824
+ import { createTrackedJob } from "@getstrata/core/queue/createAppQueue";
1825
+ var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
1826
+ function registerInvalidateCacheOnModelWriteListeners(bus = eventBus2) {
1827
+ for (const tableName of discoverModelTableNames()) {
1828
+ for (const action of MODEL_WRITE_ACTIONS) {
1829
+ bus.listen(modelEventName2(tableName, action), async () => {
1830
+ const tags = cacheTagsForModelWrite(tableName, action);
1831
+ if (tags.length === 0) {
1832
+ return;
1833
+ }
1834
+ let cache;
1835
+ let queue;
1836
+ try {
1837
+ cache = resolveApplicationCache();
1838
+ queue = resolveApplicationQueue();
1839
+ } catch {
1840
+ return;
1841
+ }
1842
+ const job = createTrackedJob("cache.invalidate-tags", new InvalidateCacheTagsJob(cache));
1843
+ await queue.dispatch(job, { tags });
1844
+ });
1845
+ }
1846
+ }
2125
1847
  }
2126
1848
 
2127
- // ../../src/core/jobs/invalidateCacheTagsJob.ts
2128
- class InvalidateCacheTagsJob extends Job {
2129
- cache;
2130
- constructor(cache) {
2131
- super();
2132
- this.cache = cache;
1849
+ // ../../src/bootstrap/providers/listeners.ts
1850
+ var registeredListenerGroups = new Set;
1851
+ function registerListenerGroup(name, register) {
1852
+ if (registeredListenerGroups.has(name)) {
1853
+ return;
2133
1854
  }
2134
- async handle(payload) {
2135
- await this.cache.tags(...payload.tags).flush();
1855
+ registeredListenerGroups.add(name);
1856
+ register();
1857
+ }
1858
+ var listenersProvider = {
1859
+ name: "core.listeners",
1860
+ boot() {
1861
+ registerListenerGroup("cache.invalidate-on-model-write", () => {
1862
+ registerInvalidateCacheOnModelWriteListeners();
1863
+ });
1864
+ for (const [index, registerListener] of discoverListeners().entries()) {
1865
+ registerListenerGroup(`app.listener.${index}`, registerListener);
1866
+ }
1867
+ }
1868
+ };
1869
+ var listeners_default = listenersProvider;
1870
+
1871
+ // ../../src/core/auth/policy.ts
1872
+ var BLOCKED_POLICY_ACTIONS = new Set([
1873
+ "constructor",
1874
+ "toString",
1875
+ "valueOf",
1876
+ "hasOwnProperty",
1877
+ "isPrototypeOf",
1878
+ "propertyIsEnumerable",
1879
+ "__proto__"
1880
+ ]);
1881
+
1882
+ class PolicyGate {
1883
+ constructor() {}
1884
+ policies = new Map;
1885
+ register(resource, policy) {
1886
+ this.policies.set(resource, policy);
1887
+ }
1888
+ allows(resource, action, user, model) {
1889
+ const policy = this.policies.get(resource);
1890
+ if (!policy) {
1891
+ return false;
1892
+ }
1893
+ if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
1894
+ return false;
1895
+ }
1896
+ const handler = policy[action];
1897
+ if (typeof handler !== "function") {
1898
+ return false;
1899
+ }
1900
+ const resolvedUser = user === undefined ? currentAuthUser() : user;
1901
+ return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
1902
+ }
1903
+ authorize(resource, action, user, model) {
1904
+ if (!this.allows(resource, action, user, model)) {
1905
+ throw new ForbiddenError2;
1906
+ }
2136
1907
  }
2137
1908
  }
2138
- var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
1909
+
1910
+ // ../../src/bootstrap/providers/policy.ts
1911
+ var policyProvider = {
1912
+ name: "core.policy",
1913
+ register({ container }) {
1914
+ container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate);
1915
+ }
1916
+ };
1917
+ var policy_default = policyProvider;
2139
1918
 
2140
1919
  // ../../src/core/pagination/index.ts
2141
1920
  function buildPaginationMeta(input) {
@@ -2673,8 +2452,31 @@ function indexMorphToRelation(children, parentsByType, relation) {
2673
2452
  result.set(child[relation.morphIdKey], parent);
2674
2453
  }
2675
2454
  }
2676
- return result;
2677
- }
2455
+ return result;
2456
+ }
2457
+
2458
+ // ../../src/core/database/boundConnection.ts
2459
+ var boundConnectionHolder = {
2460
+ connection: null
2461
+ };
2462
+ function getBoundDatabaseConnection() {
2463
+ return boundConnectionHolder.connection;
2464
+ }
2465
+
2466
+ // ../../src/core/database/repositoryConnection.ts
2467
+ function resolveRepositoryConnection() {
2468
+ return getBoundDatabaseConnection() ?? getDefaultDatabaseQuery();
2469
+ }
2470
+ var repositoryConnection = new Proxy(function repositoryConnection2() {}, {
2471
+ apply(_target, _thisArg, args) {
2472
+ return resolveRepositoryConnection()(...args);
2473
+ },
2474
+ get(_target, property) {
2475
+ const connection = resolveRepositoryConnection();
2476
+ const value = connection[property];
2477
+ return typeof value === "function" ? value.bind(connection) : value;
2478
+ }
2479
+ });
2678
2480
 
2679
2481
  // ../../src/core/database/whereBuilder.ts
2680
2482
  class WhereBuilder {
@@ -4000,9 +3802,6 @@ class ResilientQueue {
4000
3802
  function createFailedJobService() {
4001
3803
  return new failedJobService_default(new failedJobRepository_default);
4002
3804
  }
4003
- function createTrackedJob(name, job) {
4004
- return jobRegistry.track(name, job);
4005
- }
4006
3805
  function createProductionQueue(driver, options = {}) {
4007
3806
  options.registerJobs?.();
4008
3807
  const failedJobs = options.failedJobs ?? createFailedJobService();
@@ -4025,103 +3824,141 @@ function createAppQueue(driver, redisUrl, failedJobs = createFailedJobService(),
4025
3824
  });
4026
3825
  }
4027
3826
 
4028
- // ../../src/bootstrap/listeners/invalidateCacheOnModelWrite.ts
4029
- var MODEL_WRITE_ACTIONS = ["created", "updated", "deleted", "restored", "force-deleted"];
4030
- function registerInvalidateCacheOnModelWriteListeners(bus = eventBus) {
4031
- for (const tableName of discoverModelTableNames()) {
4032
- for (const action of MODEL_WRITE_ACTIONS) {
4033
- bus.listen(modelEventName(tableName, action), async () => {
4034
- const tags = cacheTagsForModelWrite(tableName, action);
4035
- if (tags.length === 0) {
4036
- return;
4037
- }
4038
- let cache;
4039
- let queue;
4040
- try {
4041
- cache = resolveApplicationCache();
4042
- queue = resolveApplicationQueue();
4043
- } catch {
4044
- return;
4045
- }
4046
- const job = createTrackedJob("cache.invalidate-tags", new invalidateCacheTagsJob_default(cache));
4047
- await queue.dispatch(job, { tags });
4048
- });
4049
- }
4050
- }
3827
+ // ../../src/core/jobs/dispatchWebhookJob.ts
3828
+ import { createHmac as createHmac2 } from "crypto";
3829
+
3830
+ // ../../src/core/queue/index.ts
3831
+ class Job {
3832
+ maxAttempts;
3833
+ backoffMs;
3834
+ priority;
4051
3835
  }
4052
3836
 
4053
- // ../../src/bootstrap/providers/listeners.ts
4054
- var registeredListenerGroups = new Set;
4055
- function registerListenerGroup(name, register) {
4056
- if (registeredListenerGroups.has(name)) {
4057
- return;
3837
+ // ../../src/core/security/safeUrl.ts
3838
+ import { lookup as dnsLookupImpl } from "dns/promises";
3839
+ var dnsLookup = dnsLookupImpl;
3840
+ var BLOCKED_HOSTNAMES = new Set([
3841
+ "localhost",
3842
+ "127.0.0.1",
3843
+ "0.0.0.0",
3844
+ "::1",
3845
+ "metadata.google.internal"
3846
+ ]);
3847
+ function isPrivateIpv4(hostname) {
3848
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
3849
+ if (!match) {
3850
+ return false;
4058
3851
  }
4059
- registeredListenerGroups.add(name);
4060
- register();
3852
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
3853
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
3854
+ return true;
3855
+ }
3856
+ const [a = 0, b = 0] = octets;
3857
+ if (a === 10) {
3858
+ return true;
3859
+ }
3860
+ if (a === 127) {
3861
+ return true;
3862
+ }
3863
+ if (a === 0) {
3864
+ return true;
3865
+ }
3866
+ if (a === 169 && b === 254) {
3867
+ return true;
3868
+ }
3869
+ if (a === 172 && b >= 16 && b <= 31) {
3870
+ return true;
3871
+ }
3872
+ if (a === 192 && b === 168) {
3873
+ return true;
3874
+ }
3875
+ return false;
4061
3876
  }
4062
- var listenersProvider = {
4063
- name: "core.listeners",
4064
- boot() {
4065
- registerListenerGroup("cache.invalidate-on-model-write", () => {
4066
- registerInvalidateCacheOnModelWriteListeners();
4067
- });
4068
- for (const [index, registerListener] of discoverListeners().entries()) {
4069
- registerListenerGroup(`app.listener.${index}`, registerListener);
4070
- }
3877
+ function isBlockedHostname(hostname) {
3878
+ const normalized = hostname.trim().toLowerCase();
3879
+ if (normalized.length === 0) {
3880
+ return true;
4071
3881
  }
4072
- };
4073
- var listeners_default = listenersProvider;
4074
-
4075
- // ../../src/core/auth/policy.ts
4076
- var BLOCKED_POLICY_ACTIONS = new Set([
4077
- "constructor",
4078
- "toString",
4079
- "valueOf",
4080
- "hasOwnProperty",
4081
- "isPrototypeOf",
4082
- "propertyIsEnumerable",
4083
- "__proto__"
4084
- ]);
4085
-
4086
- class PolicyGate {
4087
- constructor() {}
4088
- policies = new Map;
4089
- register(resource, policy) {
4090
- this.policies.set(resource, policy);
3882
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
3883
+ return true;
4091
3884
  }
4092
- allows(resource, action, user, model) {
4093
- const policy = this.policies.get(resource);
4094
- if (!policy) {
4095
- return false;
4096
- }
4097
- if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
4098
- return false;
4099
- }
4100
- const handler = policy[action];
4101
- if (typeof handler !== "function") {
4102
- return false;
4103
- }
4104
- const resolvedUser = user === undefined ? currentAuthUser() : user;
4105
- return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
3885
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
3886
+ return true;
4106
3887
  }
4107
- authorize(resource, action, user, model) {
4108
- if (!this.allows(resource, action, user, model)) {
4109
- throw new ForbiddenError;
4110
- }
3888
+ if (normalized.includes(":")) {
3889
+ return true;
3890
+ }
3891
+ return isPrivateIpv4(normalized);
3892
+ }
3893
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
3894
+ let parsed;
3895
+ try {
3896
+ parsed = new URL(rawUrl);
3897
+ } catch {
3898
+ throw new BadRequestError("Webhook URL is invalid.");
3899
+ }
3900
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
3901
+ throw new BadRequestError("Webhook URL must use HTTPS.");
3902
+ }
3903
+ if (parsed.username || parsed.password) {
3904
+ throw new BadRequestError("Webhook URL must not include credentials.");
3905
+ }
3906
+ if (isBlockedHostname(parsed.hostname)) {
3907
+ throw new BadRequestError("Webhook URL targets a blocked host.");
3908
+ }
3909
+ return parsed;
3910
+ }
3911
+ function isBlockedIpAddress(address) {
3912
+ return isBlockedHostname(address.trim().toLowerCase());
3913
+ }
3914
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
3915
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
3916
+ if (options.resolveDns === false) {
3917
+ return parsed;
3918
+ }
3919
+ const hostname = parsed.hostname.trim().toLowerCase();
3920
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
3921
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
3922
+ throw new BadRequestError("Webhook URL targets a blocked host.");
4111
3923
  }
3924
+ return parsed;
4112
3925
  }
4113
3926
 
4114
- // ../../src/bootstrap/providers/policy.ts
4115
- var policyProvider = {
4116
- name: "core.policy",
4117
- register({ container }) {
4118
- container.set(CORE_POLICY_GATE_TOKEN, new PolicyGate);
3927
+ // ../../src/core/security/safeFetch.ts
3928
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
3929
+ async function safeFetch(input, init = {}, options = {}) {
3930
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3931
+ const maxRedirects = options.maxRedirects ?? 0;
3932
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
3933
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
3934
+ const controller = new AbortController;
3935
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
3936
+ try {
3937
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
3938
+ let redirectCount = 0;
3939
+ while (true) {
3940
+ const response = await fetch(currentUrl, {
3941
+ ...init,
3942
+ signal: controller.signal,
3943
+ redirect: "manual"
3944
+ });
3945
+ if (response.status >= 300 && response.status < 400) {
3946
+ const location = response.headers.get("location");
3947
+ if (!location || redirectCount >= maxRedirects) {
3948
+ return response;
3949
+ }
3950
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
3951
+ redirectCount += 1;
3952
+ continue;
3953
+ }
3954
+ return response;
3955
+ }
3956
+ } finally {
3957
+ clearTimeout(timeout);
4119
3958
  }
4120
- };
4121
- var policy_default = policyProvider;
3959
+ }
4122
3960
 
4123
3961
  // ../../src/core/jobs/dispatchWebhookJob.ts
4124
- import { createHmac as createHmac2 } from "crypto";
4125
3962
  class DispatchWebhookJob extends Job {
4126
3963
  maxAttempts = 3;
4127
3964
  backoffMs = 2000;
@@ -4181,6 +4018,19 @@ class DispatchWebhookJob extends Job {
4181
4018
  }
4182
4019
  var dispatchWebhookJob_default = DispatchWebhookJob;
4183
4020
 
4021
+ // ../../src/core/jobs/invalidateCacheTagsJob.ts
4022
+ class InvalidateCacheTagsJob2 extends Job {
4023
+ cache;
4024
+ constructor(cache) {
4025
+ super();
4026
+ this.cache = cache;
4027
+ }
4028
+ async handle(payload) {
4029
+ await this.cache.tags(...payload.tags).flush();
4030
+ }
4031
+ }
4032
+ var invalidateCacheTagsJob_default = InvalidateCacheTagsJob2;
4033
+
4184
4034
  // ../../src/core/contracts/di.ts
4185
4035
  function getRequiredDependency2(dependencies, key) {
4186
4036
  const dependency = dependencies[key];
@@ -4194,6 +4044,42 @@ function getRequiredDependency2(dependencies, key) {
4194
4044
  var CORE_POLICY_GATE_TOKEN2 = "core.policyGate";
4195
4045
  var CORE_AUTH_TOKEN2 = "core.auth";
4196
4046
 
4047
+ // ../../src/core/logging/logger.ts
4048
+ class Logger {
4049
+ channel;
4050
+ constructor(channel = "app") {
4051
+ this.channel = channel;
4052
+ }
4053
+ write(level, message, context = {}) {
4054
+ const entry = {
4055
+ level,
4056
+ channel: this.channel,
4057
+ message,
4058
+ timestamp: new Date().toISOString(),
4059
+ ...context
4060
+ };
4061
+ const line = JSON.stringify(entry);
4062
+ if (level === "error") {
4063
+ console.error(line);
4064
+ return;
4065
+ }
4066
+ console.log(line);
4067
+ }
4068
+ debug(message, context) {
4069
+ this.write("debug", message, context);
4070
+ }
4071
+ info(message, context) {
4072
+ this.write("info", message, context);
4073
+ }
4074
+ warn(message, context) {
4075
+ this.write("warn", message, context);
4076
+ }
4077
+ error(message, context) {
4078
+ this.write("error", message, context);
4079
+ }
4080
+ }
4081
+ var appLogger2 = new Logger("app");
4082
+
4197
4083
  // ../../src/core/runtime/applicationRegistry.ts
4198
4084
  var APPLICATION_CONTEXT_KEY = Symbol.for("@getstrata/applicationContext");
4199
4085
  var activeContext;
@@ -5151,7 +5037,7 @@ export {
5151
5037
  buildModuleRoutes,
5152
5038
  assertProductionSecrets,
5153
5039
  assertAppDependenciesComplete,
5154
- appSchedule2 as appSchedule,
5040
+ appSchedule3 as appSchedule,
5155
5041
  ServiceContainer,
5156
5042
  Schedule2 as Schedule,
5157
5043
  RouteRegistry,