@getstrata/core 0.5.18 → 0.5.21

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.
@@ -1937,8 +1937,9 @@ class JobRegistry {
1937
1937
  }
1938
1938
  var jobRegistry = new JobRegistry;
1939
1939
 
1940
- // ../../src/bootstrap/config.ts
1940
+ // ../../src/core/contracts/serviceTokens.ts
1941
1941
  var CORE_TOKEN_SERVICE_TOKEN = "core.tokenService";
1942
+ // ../../src/bootstrap/config.ts
1942
1943
  var DEFAULT_QUEUE_DRIVER = "sync";
1943
1944
 
1944
1945
  // ../../src/config/queue.ts
@@ -2153,6 +2154,237 @@ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
2153
2154
  return new QueueWorker(redisUrl, failedJobs);
2154
2155
  }
2155
2156
 
2157
+ // ../../src/core/jobs/dispatchWebhookJob.ts
2158
+ import { createHmac } from "crypto";
2159
+
2160
+ // ../../src/config/app.ts
2161
+ var appConfig = {
2162
+ name: "WorkHub",
2163
+ env: process.env.APP_ENV ?? "local",
2164
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
2165
+ url: process.env.APP_URL ?? "http://localhost:3000",
2166
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
2167
+ };
2168
+
2169
+ // ../../src/core/queue/index.ts
2170
+ class Job {
2171
+ maxAttempts;
2172
+ backoffMs;
2173
+ priority;
2174
+ }
2175
+
2176
+ // ../../src/core/security/safeUrl.ts
2177
+ import { lookup as dnsLookupImpl } from "dns/promises";
2178
+ var dnsLookup = dnsLookupImpl;
2179
+ var BLOCKED_HOSTNAMES = new Set([
2180
+ "localhost",
2181
+ "127.0.0.1",
2182
+ "0.0.0.0",
2183
+ "::1",
2184
+ "metadata.google.internal"
2185
+ ]);
2186
+ function isPrivateIpv4(hostname) {
2187
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
2188
+ if (!match) {
2189
+ return false;
2190
+ }
2191
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
2192
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
2193
+ return true;
2194
+ }
2195
+ const [a = 0, b = 0] = octets;
2196
+ if (a === 10) {
2197
+ return true;
2198
+ }
2199
+ if (a === 127) {
2200
+ return true;
2201
+ }
2202
+ if (a === 0) {
2203
+ return true;
2204
+ }
2205
+ if (a === 169 && b === 254) {
2206
+ return true;
2207
+ }
2208
+ if (a === 172 && b >= 16 && b <= 31) {
2209
+ return true;
2210
+ }
2211
+ if (a === 192 && b === 168) {
2212
+ return true;
2213
+ }
2214
+ return false;
2215
+ }
2216
+ function isBlockedHostname(hostname) {
2217
+ const normalized = hostname.trim().toLowerCase();
2218
+ if (normalized.length === 0) {
2219
+ return true;
2220
+ }
2221
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
2222
+ return true;
2223
+ }
2224
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
2225
+ return true;
2226
+ }
2227
+ if (normalized.includes(":")) {
2228
+ return true;
2229
+ }
2230
+ return isPrivateIpv4(normalized);
2231
+ }
2232
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
2233
+ let parsed;
2234
+ try {
2235
+ parsed = new URL(rawUrl);
2236
+ } catch {
2237
+ throw new BadRequestError("Webhook URL is invalid.");
2238
+ }
2239
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
2240
+ throw new BadRequestError("Webhook URL must use HTTPS.");
2241
+ }
2242
+ if (parsed.username || parsed.password) {
2243
+ throw new BadRequestError("Webhook URL must not include credentials.");
2244
+ }
2245
+ if (isBlockedHostname(parsed.hostname)) {
2246
+ throw new BadRequestError("Webhook URL targets a blocked host.");
2247
+ }
2248
+ return parsed;
2249
+ }
2250
+ function isBlockedIpAddress(address) {
2251
+ return isBlockedHostname(address.trim().toLowerCase());
2252
+ }
2253
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
2254
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
2255
+ if (options.resolveDns === false) {
2256
+ return parsed;
2257
+ }
2258
+ const hostname = parsed.hostname.trim().toLowerCase();
2259
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
2260
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
2261
+ throw new BadRequestError("Webhook URL targets a blocked host.");
2262
+ }
2263
+ return parsed;
2264
+ }
2265
+ function setDnsLookupForTests(lookupFn) {
2266
+ dnsLookup = lookupFn;
2267
+ }
2268
+ function resetDnsLookupForTests() {
2269
+ dnsLookup = dnsLookupImpl;
2270
+ }
2271
+
2272
+ // ../../src/core/security/safeFetch.ts
2273
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
2274
+ async function safeFetch(input, init = {}, options = {}) {
2275
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
2276
+ const maxRedirects = options.maxRedirects ?? 0;
2277
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
2278
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
2279
+ const controller = new AbortController;
2280
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
2281
+ try {
2282
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
2283
+ let redirectCount = 0;
2284
+ while (true) {
2285
+ const response = await fetch(currentUrl, {
2286
+ ...init,
2287
+ signal: controller.signal,
2288
+ redirect: "manual"
2289
+ });
2290
+ if (response.status >= 300 && response.status < 400) {
2291
+ const location = response.headers.get("location");
2292
+ if (!location || redirectCount >= maxRedirects) {
2293
+ return response;
2294
+ }
2295
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
2296
+ redirectCount += 1;
2297
+ continue;
2298
+ }
2299
+ return response;
2300
+ }
2301
+ } finally {
2302
+ clearTimeout(timeout);
2303
+ }
2304
+ }
2305
+
2306
+ // ../../src/core/jobs/dispatchWebhookJob.ts
2307
+ class DispatchWebhookJob extends Job {
2308
+ maxAttempts = 3;
2309
+ backoffMs = 2000;
2310
+ async handle(payload) {
2311
+ const rows = await repositoryConnection`
2312
+ SELECT id, url, secret
2313
+ FROM webhook
2314
+ WHERE id = ${payload.webhookId} AND active = TRUE
2315
+ LIMIT 1
2316
+ `;
2317
+ const webhook = rows[0];
2318
+ if (!webhook) {
2319
+ return;
2320
+ }
2321
+ const body = JSON.stringify({ event: payload.event, payload: payload.payload });
2322
+ const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
2323
+ assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
2324
+ let responseStatus = null;
2325
+ let errorMessage = null;
2326
+ try {
2327
+ const response = await safeFetch(webhook.url, {
2328
+ method: "POST",
2329
+ headers: {
2330
+ "content-type": "application/json",
2331
+ "x-workhub-signature": signature
2332
+ },
2333
+ body
2334
+ }, { allowHttp: appConfig.env !== "production" });
2335
+ responseStatus = response.status;
2336
+ if (!response.ok) {
2337
+ throw new Error(`Webhook delivery failed with status ${response.status}.`);
2338
+ }
2339
+ } catch (error) {
2340
+ errorMessage = error instanceof Error ? error.message : String(error);
2341
+ await repositoryConnection`
2342
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
2343
+ VALUES (
2344
+ ${webhook.id},
2345
+ ${payload.event},
2346
+ ${JSON.stringify(payload.payload)}::jsonb,
2347
+ ${responseStatus},
2348
+ ${errorMessage}
2349
+ )
2350
+ `;
2351
+ throw error instanceof Error ? error : new Error(errorMessage);
2352
+ }
2353
+ await repositoryConnection`
2354
+ INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
2355
+ VALUES (
2356
+ ${webhook.id},
2357
+ ${payload.event},
2358
+ ${JSON.stringify(payload.payload)}::jsonb,
2359
+ ${responseStatus}
2360
+ )
2361
+ `;
2362
+ }
2363
+ }
2364
+ var dispatchWebhookJob_default = DispatchWebhookJob;
2365
+
2366
+ // ../../src/core/jobs/invalidateCacheTagsJob.ts
2367
+ class InvalidateCacheTagsJob extends Job {
2368
+ cache;
2369
+ constructor(cache) {
2370
+ super();
2371
+ this.cache = cache;
2372
+ }
2373
+ async handle(payload) {
2374
+ await this.cache.tags(...payload.tags).flush();
2375
+ }
2376
+ }
2377
+ var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
2378
+
2379
+ // ../../src/core/contracts/applicationContext.ts
2380
+ function getRequiredDependency(dependencies, key) {
2381
+ const dependency = dependencies[key];
2382
+ if (dependency === undefined) {
2383
+ throw new Error(`Required dependency "${String(key)}" is not registered.`);
2384
+ }
2385
+ return dependency;
2386
+ }
2387
+
2156
2388
  // ../../src/core/logging/logger.ts
2157
2389
  class Logger {
2158
2390
  channel;
@@ -2189,79 +2421,7 @@ class Logger {
2189
2421
  }
2190
2422
  var appLogger = new Logger("app");
2191
2423
 
2192
- // ../../src/bootstrap/contracts.ts
2193
- class ServiceContainer {
2194
- services = new Map;
2195
- singletonFactories = new Map;
2196
- bindings = new Map;
2197
- set(key, value) {
2198
- this.singletonFactories.delete(key);
2199
- this.bindings.delete(key);
2200
- this.services.set(key, value);
2201
- return value;
2202
- }
2203
- singleton(key, factory) {
2204
- this.bindings.delete(key);
2205
- this.services.delete(key);
2206
- this.singletonFactories.set(key, factory);
2207
- }
2208
- bind(key, factory) {
2209
- this.singletonFactories.delete(key);
2210
- this.services.delete(key);
2211
- this.bindings.set(key, factory);
2212
- }
2213
- get(key) {
2214
- if (this.services.has(key)) {
2215
- return this.services.get(key);
2216
- }
2217
- const singletonFactory = this.singletonFactories.get(key);
2218
- if (singletonFactory) {
2219
- const value = singletonFactory(this);
2220
- this.services.set(key, value);
2221
- return value;
2222
- }
2223
- const binding = this.bindings.get(key);
2224
- if (binding) {
2225
- return binding(this);
2226
- }
2227
- throw new Error(`Service "${key}" is not registered.`);
2228
- }
2229
- resolve(key) {
2230
- return this.get(key);
2231
- }
2232
- has(key) {
2233
- return this.services.has(key) || this.singletonFactories.has(key) || this.bindings.has(key);
2234
- }
2235
- }
2236
-
2237
- class ConfigStore {
2238
- values = new Map;
2239
- set(key, value) {
2240
- this.values.set(key, value);
2241
- return value;
2242
- }
2243
- get(key) {
2244
- return this.values.get(key);
2245
- }
2246
- require(key) {
2247
- if (!this.values.has(key)) {
2248
- throw new Error(`Config key "${key}" is not defined.`);
2249
- }
2250
- return this.values.get(key);
2251
- }
2252
- has(key) {
2253
- return this.values.has(key);
2254
- }
2255
- }
2256
- function getRequiredDependency(dependencies, key) {
2257
- const dependency = dependencies[key];
2258
- if (dependency === undefined) {
2259
- throw new Error(`Required dependency "${key}" is not registered.`);
2260
- }
2261
- return dependency;
2262
- }
2263
-
2264
- // ../../src/bootstrap/applicationRegistry.ts
2424
+ // ../../src/core/runtime/applicationRegistry.ts
2265
2425
  var APPLICATION_CONTEXT_KEY = Symbol.for("@getstrata/applicationContext");
2266
2426
  var activeContext;
2267
2427
  function readStoredApplicationContext() {
@@ -2284,2587 +2444,6 @@ function requireActiveApplicationContext() {
2284
2444
  function resolveApplicationCache() {
2285
2445
  return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
2286
2446
  }
2287
- // ../../src/core/admin/registry.ts
2288
- class AdminResourceRegistry {
2289
- resources = new Map;
2290
- constructor() {}
2291
- register(resource) {
2292
- if (this.resources.has(resource.name)) {
2293
- throw new Error(`Admin resource "${resource.name}" is already registered.`);
2294
- }
2295
- this.resources.set(resource.name, resource);
2296
- }
2297
- get(name) {
2298
- return this.resources.get(name);
2299
- }
2300
- list() {
2301
- const definitions = [];
2302
- for (const resource of this.resources.values()) {
2303
- const { handlers: _handlers, ...definition } = resource;
2304
- definitions.push(definition);
2305
- }
2306
- return definitions;
2307
- }
2308
- all() {
2309
- return [...this.resources.values()];
2310
- }
2311
- clear() {
2312
- this.resources.clear();
2313
- }
2314
- }
2315
- // ../../src/core/auth/authContext.ts
2316
- var authContext = createAsyncContextStore("@getstrata/authContext");
2317
- function currentAuthUser() {
2318
- return authContext.getStore() ?? null;
2319
- }
2320
- // ../../src/domain/abilities.ts
2321
- var MEMBER_ABILITIES = [
2322
- "organizations:read",
2323
- "projects:read",
2324
- "projects:create",
2325
- "tasks:read",
2326
- "tasks:create",
2327
- "comments:read",
2328
- "comments:create",
2329
- "attachments:read",
2330
- "attachments:create",
2331
- "auth:tokens:read",
2332
- "auth:tokens:write"
2333
- ];
2334
- var ADMIN_ABILITIES = [
2335
- ...MEMBER_ABILITIES,
2336
- "organizations:create",
2337
- "organizations:update",
2338
- "organizations:delete",
2339
- "projects:update",
2340
- "projects:delete",
2341
- "tasks:update",
2342
- "tasks:delete",
2343
- "comments:update",
2344
- "comments:delete",
2345
- "attachments:delete",
2346
- "webhooks:read",
2347
- "webhooks:write",
2348
- "audit:read"
2349
- ];
2350
- var PLATFORM_ADMIN_ABILITIES = ["*"];
2351
- function resolveAbilitiesForRole(role) {
2352
- if (role === "admin") {
2353
- return [...PLATFORM_ADMIN_ABILITIES];
2354
- }
2355
- return [...MEMBER_ABILITIES];
2356
- }
2357
-
2358
- // ../../src/core/auth/oauth/oidcProvider.ts
2359
- class OidcProvider {
2360
- options;
2361
- name;
2362
- constructor(options) {
2363
- this.options = options;
2364
- this.name = options.name;
2365
- }
2366
- getAuthorizationUrl(state) {
2367
- const params = new URLSearchParams({
2368
- client_id: this.options.clientId,
2369
- redirect_uri: this.options.redirectUri,
2370
- response_type: "code",
2371
- scope: (this.options.scopes ?? ["openid", "email", "profile"]).join(" "),
2372
- state
2373
- });
2374
- return `${this.options.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
2375
- }
2376
- async exchangeCode(code) {
2377
- const tokenResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/token`, {
2378
- method: "POST",
2379
- headers: { "content-type": "application/x-www-form-urlencoded" },
2380
- body: new URLSearchParams({
2381
- grant_type: "authorization_code",
2382
- code,
2383
- redirect_uri: this.options.redirectUri,
2384
- client_id: this.options.clientId,
2385
- client_secret: this.options.clientSecret
2386
- })
2387
- });
2388
- const tokenBody = await tokenResponse.json();
2389
- if (!tokenBody.access_token) {
2390
- throw new Error("OIDC token exchange failed.");
2391
- }
2392
- const profileResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/userinfo`, {
2393
- headers: { authorization: `Bearer ${tokenBody.access_token}` }
2394
- });
2395
- const profile = await profileResponse.json();
2396
- return {
2397
- providerUserId: profile.sub,
2398
- email: profile.email ?? `${profile.sub}@oidc.local`,
2399
- name: profile.name ?? profile.sub
2400
- };
2401
- }
2402
- }
2403
-
2404
- // ../../src/core/auth/oauth/providers.ts
2405
- class GitHubOAuthProvider {
2406
- options;
2407
- name = "github";
2408
- constructor(options) {
2409
- this.options = options;
2410
- }
2411
- getAuthorizationUrl(state) {
2412
- const params = new URLSearchParams({
2413
- client_id: this.options.clientId,
2414
- redirect_uri: this.options.redirectUri,
2415
- scope: "read:user user:email",
2416
- state
2417
- });
2418
- return `https://github.com/login/oauth/authorize?${params.toString()}`;
2419
- }
2420
- async exchangeCode(code) {
2421
- const tokenResponse = await fetch("https://github.com/login/oauth/access_token", {
2422
- method: "POST",
2423
- headers: {
2424
- accept: "application/json",
2425
- "content-type": "application/json"
2426
- },
2427
- body: JSON.stringify({
2428
- client_id: this.options.clientId,
2429
- client_secret: this.options.clientSecret,
2430
- code,
2431
- redirect_uri: this.options.redirectUri
2432
- })
2433
- });
2434
- const tokenBody = await tokenResponse.json();
2435
- if (!tokenBody.access_token) {
2436
- throw new Error("GitHub OAuth token exchange failed.");
2437
- }
2438
- const profileResponse = await fetch("https://api.github.com/user", {
2439
- headers: {
2440
- authorization: `Bearer ${tokenBody.access_token}`,
2441
- accept: "application/json",
2442
- "user-agent": "workhub"
2443
- }
2444
- });
2445
- const profile = await profileResponse.json();
2446
- return {
2447
- providerUserId: String(profile.id),
2448
- email: profile.email ?? `${profile.login}@users.noreply.github.com`,
2449
- name: profile.name ?? profile.login
2450
- };
2451
- }
2452
- }
2453
-
2454
- class MockOAuthProvider {
2455
- profile;
2456
- name = "mock";
2457
- constructor(profile) {
2458
- this.profile = profile;
2459
- }
2460
- getAuthorizationUrl(state) {
2461
- return `https://mock.oauth/authorize?state=${encodeURIComponent(state)}`;
2462
- }
2463
- async exchangeCode(code) {
2464
- if (code !== "valid-code") {
2465
- throw new Error("Invalid OAuth code.");
2466
- }
2467
- return this.profile;
2468
- }
2469
- }
2470
-
2471
- // ../../src/core/auth/oauth/samlProvider.ts
2472
- class SamlProvider {
2473
- loginUrl;
2474
- name = "saml";
2475
- constructor(loginUrl) {
2476
- this.loginUrl = loginUrl;
2477
- }
2478
- getAuthorizationUrl(state) {
2479
- return `${this.loginUrl}?state=${encodeURIComponent(state)}`;
2480
- }
2481
- async exchangeCode(code) {
2482
- if (!code.startsWith("saml:")) {
2483
- throw new Error("Invalid SAML assertion reference.");
2484
- }
2485
- const [, email, name] = code.split(":");
2486
- return {
2487
- providerUserId: email ?? "saml-user",
2488
- email: email ?? "saml-user@workhub.test",
2489
- name: name ?? "SAML User"
2490
- };
2491
- }
2492
- }
2493
-
2494
- // ../../src/config/features.ts
2495
- function readFeatureFlags() {
2496
- return {
2497
- webhooks: (process.env.FEATURE_WEBHOOKS ?? "true") !== "false",
2498
- fullTextSearch: (process.env.FEATURE_SEARCH ?? "true") !== "false",
2499
- auditLog: (process.env.FEATURE_AUDIT_LOG ?? "true") !== "false",
2500
- oauthLogin: (process.env.FEATURE_OAUTH ?? "true") !== "false",
2501
- samlLogin: (process.env.FEATURE_SAML ?? "false") === "true",
2502
- scim: (process.env.FEATURE_SCIM ?? "true") !== "false",
2503
- billing: (process.env.FEATURE_BILLING ?? "true") !== "false",
2504
- siemExport: (process.env.FEATURE_SIEM_EXPORT ?? "true") !== "false",
2505
- publicReads: (process.env.FEATURE_PUBLIC_READS ?? "true") !== "false",
2506
- emailVerification: (process.env.FEATURE_EMAIL_VERIFICATION ?? "false") === "true",
2507
- mfa: (process.env.FEATURE_MFA ?? "false") === "true"
2508
- };
2509
- }
2510
- var featureFlags = readFeatureFlags();
2511
- function isFeatureEnabled(feature) {
2512
- return readFeatureFlags()[feature];
2513
- }
2514
-
2515
- // ../../src/config/database.ts
2516
- function readInteger(name, fallback) {
2517
- const parsed = Number.parseInt(process.env[name] ?? String(fallback), 10);
2518
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback;
2519
- }
2520
- var databaseConfig = {
2521
- url: process.env.DATABASE_URL ?? "",
2522
- poolMax: readInteger("DB_POOL_MAX", 10),
2523
- idleTimeoutSeconds: readInteger("DB_POOL_IDLE_TIMEOUT", 30),
2524
- maxLifetimeSeconds: readInteger("DB_POOL_MAX_LIFETIME", 3600),
2525
- connectionTimeoutSeconds: readInteger("DB_CONNECTION_TIMEOUT", 10)
2526
- };
2527
-
2528
- // ../../src/db/connection/createConnection.ts
2529
- var {SQL } = globalThis.Bun;
2530
- function createDatabaseConnection2(config) {
2531
- if (!config.url) {
2532
- throw new Error("DATABASE_URL is not configured. Set DATABASE_URL before starting the app or running integration tests.");
2533
- }
2534
- return new SQL({
2535
- url: config.url,
2536
- max: config.poolMax,
2537
- idleTimeout: config.idleTimeoutSeconds,
2538
- maxLifetime: config.maxLifetimeSeconds,
2539
- connectionTimeout: config.connectionTimeoutSeconds
2540
- });
2541
- }
2542
-
2543
- // ../../src/db/connection/index.ts
2544
- var connectionHolder = {
2545
- connection: null
2546
- };
2547
- function getDatabase() {
2548
- if (!connectionHolder.connection) {
2549
- connectionHolder.connection = createDatabaseConnection2(databaseConfig);
2550
- registerDefaultDatabasePool(connectionHolder.connection);
2551
- }
2552
- return connectionHolder.connection;
2553
- }
2554
- function getDb() {
2555
- getDatabase();
2556
- return getDefaultDatabaseQuery();
2557
- }
2558
- var db = new Proxy(function database() {}, {
2559
- apply(_target, _thisArg, args) {
2560
- return getDb()(...args);
2561
- },
2562
- get(_target, property) {
2563
- const connection = getDb();
2564
- const value = connection[property];
2565
- return typeof value === "function" ? value.bind(connection) : value;
2566
- }
2567
- });
2568
- var connection_default = db;
2569
-
2570
- // ../../src/modules/user/apiTokenTable.ts
2571
- var apiTokenTable = defineTable({
2572
- name: "api_token",
2573
- primaryKey: "id",
2574
- columns: [
2575
- "id",
2576
- "user_id",
2577
- "name",
2578
- "token_hash",
2579
- "abilities",
2580
- "last_used_at",
2581
- "expires_at",
2582
- "created_at"
2583
- ],
2584
- defaultOrderBy: { column: "id", direction: "ASC" }
2585
- });
2586
-
2587
- // ../../src/core/auth/password.ts
2588
- async function hashPassword(password) {
2589
- return await Bun.password.hash(password, {
2590
- algorithm: "bcrypt",
2591
- cost: 10
2592
- });
2593
- }
2594
- async function verifyPassword(password, passwordHash) {
2595
- return await Bun.password.verify(password, passwordHash);
2596
- }
2597
-
2598
- // ../../src/core/crypto/fieldEncryption.ts
2599
- import { createCipheriv, createDecipheriv, createHmac, randomBytes } from "crypto";
2600
- var ENCRYPTION_PREFIX = "enc:v1:";
2601
- var IV_LENGTH = 12;
2602
- var TAG_LENGTH = 16;
2603
- function resolveEncryptionKey() {
2604
- const raw = process.env.KMS_ENCRYPTION_KEY?.trim();
2605
- if (!raw) {
2606
- return null;
2607
- }
2608
- if (/^[0-9a-f]{64}$/i.test(raw)) {
2609
- return Buffer.from(raw, "hex");
2610
- }
2611
- const decoded = Buffer.from(raw, "base64");
2612
- if (decoded.length === 32) {
2613
- return decoded;
2614
- }
2615
- throw new Error("KMS_ENCRYPTION_KEY must be 32 bytes (hex or base64).");
2616
- }
2617
- function isFieldEncryptionEnabled() {
2618
- const featureFlag = process.env.FEATURE_FIELD_ENCRYPTION;
2619
- if (featureFlag === "false") {
2620
- return false;
2621
- }
2622
- if (featureFlag === "true") {
2623
- return true;
2624
- }
2625
- return (process.env.APP_ENV ?? "local") === "production";
2626
- }
2627
- function encryptField(plaintext, key) {
2628
- const iv = randomBytes(IV_LENGTH);
2629
- const cipher = createCipheriv("aes-256-gcm", key, iv);
2630
- const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
2631
- const tag = cipher.getAuthTag();
2632
- const payload = Buffer.concat([iv, encrypted, tag]).toString("base64");
2633
- return `${ENCRYPTION_PREFIX}${payload}`;
2634
- }
2635
- function decryptField(value, key) {
2636
- if (!value.startsWith(ENCRYPTION_PREFIX)) {
2637
- return value;
2638
- }
2639
- const payload = Buffer.from(value.slice(ENCRYPTION_PREFIX.length), "base64");
2640
- const iv = payload.subarray(0, IV_LENGTH);
2641
- const tag = payload.subarray(payload.length - TAG_LENGTH);
2642
- const ciphertext = payload.subarray(IV_LENGTH, payload.length - TAG_LENGTH);
2643
- const decipher = createDecipheriv("aes-256-gcm", key, iv);
2644
- decipher.setAuthTag(tag);
2645
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
2646
- }
2647
- function hashLookupValue(normalizedValue, key) {
2648
- return createHmac("sha256", key).update(normalizedValue).digest("hex");
2649
- }
2650
- function normalizeEmail(email) {
2651
- return email.trim().toLowerCase();
2652
- }
2653
- function protectEmail(email) {
2654
- const normalized = normalizeEmail(email);
2655
- const key = resolveEncryptionKey();
2656
- if (!key || !isFieldEncryptionEnabled()) {
2657
- return { storedEmail: normalized, emailLookup: normalized };
2658
- }
2659
- return {
2660
- storedEmail: encryptField(normalized, key),
2661
- emailLookup: hashLookupValue(normalized, key)
2662
- };
2663
- }
2664
- function revealEmail(storedEmail) {
2665
- const key = resolveEncryptionKey();
2666
- if (!key || !storedEmail.startsWith(ENCRYPTION_PREFIX)) {
2667
- return storedEmail;
2668
- }
2669
- return decryptField(storedEmail, key);
2670
- }
2671
- function emailLookupForQuery(email) {
2672
- const normalized = normalizeEmail(email);
2673
- const key = resolveEncryptionKey();
2674
- if (!key || !isFieldEncryptionEnabled()) {
2675
- return normalized;
2676
- }
2677
- return hashLookupValue(normalized, key);
2678
- }
2679
-
2680
- // ../../src/core/crypto/mfaSecret.ts
2681
- function protectMfaSecret(secret) {
2682
- const key = resolveEncryptionKey();
2683
- if (!isFieldEncryptionEnabled() || !key) {
2684
- return secret;
2685
- }
2686
- return encryptField(secret, key);
2687
- }
2688
- function revealMfaSecret(stored) {
2689
- if (!stored) {
2690
- return null;
2691
- }
2692
- const key = resolveEncryptionKey();
2693
- if (!isFieldEncryptionEnabled() || !key || !stored.startsWith("enc:v1:")) {
2694
- return stored;
2695
- }
2696
- return decryptField(stored, key);
2697
- }
2698
-
2699
- // ../../src/core/http/requestMetaContext.ts
2700
- var requestMetaContext = createAsyncContextStore("@getstrata/requestMetaContext");
2701
- function currentRequestMeta() {
2702
- return requestMetaContext.getStore() ?? {
2703
- ipAddress: null,
2704
- userAgent: null
2705
- };
2706
- }
2707
-
2708
- // ../../src/core/security/securityEvents.ts
2709
- function logSecurityEvent(event, details = {}) {
2710
- const meta = currentRequestMeta();
2711
- const user = currentAuthUser();
2712
- console.log(JSON.stringify({
2713
- level: "security",
2714
- event,
2715
- timestamp: new Date().toISOString(),
2716
- ip_address: meta.ipAddress ?? null,
2717
- user_agent: meta.userAgent ?? null,
2718
- user_id: user?.id ?? null,
2719
- ...details
2720
- }));
2721
- }
2722
-
2723
- // ../../src/core/security/tokenExpiry.ts
2724
- function resolveDefaultTokenExpiryDays() {
2725
- const raw = process.env.API_TOKEN_DEFAULT_EXPIRY_DAYS?.trim();
2726
- if (!raw) {
2727
- return null;
2728
- }
2729
- const parsed = Number.parseInt(raw, 10);
2730
- if (!Number.isInteger(parsed) || parsed <= 0) {
2731
- return null;
2732
- }
2733
- return parsed;
2734
- }
2735
-
2736
- // ../../src/core/security/totp.ts
2737
- import { createHmac as createHmac2 } from "crypto";
2738
- function decodeBase32(input) {
2739
- const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
2740
- const normalized = input.replace(/=+$/u, "").toUpperCase();
2741
- let bits = "";
2742
- for (const char of normalized) {
2743
- const value = alphabet.indexOf(char);
2744
- if (value === -1) {
2745
- throw new Error("Invalid base32 character in MFA secret.");
2746
- }
2747
- bits += value.toString(2).padStart(5, "0");
2748
- }
2749
- const bytes = [];
2750
- for (let index = 0;index + 8 <= bits.length; index += 8) {
2751
- bytes.push(Number.parseInt(bits.slice(index, index + 8), 2));
2752
- }
2753
- return Buffer.from(bytes);
2754
- }
2755
- function generateTotp(secret, counter, digits = 6) {
2756
- const key = decodeBase32(secret);
2757
- const buffer = Buffer.alloc(8);
2758
- buffer.writeBigUInt64BE(BigInt(counter));
2759
- const digest = createHmac2("sha1", key).update(buffer).digest();
2760
- const lastByte = digest[digest.length - 1] ?? 0;
2761
- const offset = lastByte & 15;
2762
- const b0 = digest[offset] ?? 0;
2763
- const b1 = digest[offset + 1] ?? 0;
2764
- const b2 = digest[offset + 2] ?? 0;
2765
- const b3 = digest[offset + 3] ?? 0;
2766
- const code = (b0 & 127) << 24 | (b1 & 255) << 16 | (b2 & 255) << 8 | b3 & 255;
2767
- return String(code % 10 ** digits).padStart(digits, "0");
2768
- }
2769
- function verifyTotp(secret, token, window = 1) {
2770
- const normalized = token.trim();
2771
- if (!/^\d{6}$/u.test(normalized)) {
2772
- return false;
2773
- }
2774
- const timestep = Math.floor(Date.now() / 30000);
2775
- for (let offset = -window;offset <= window; offset += 1) {
2776
- if (generateTotp(secret, timestep + offset) === normalized) {
2777
- return true;
2778
- }
2779
- }
2780
- return false;
2781
- }
2782
-
2783
- // ../../src/core/tenant/tenantContext.ts
2784
- var tenantContext = createAsyncContextStore("@getstrata/tenantContext");
2785
- function currentTenant() {
2786
- return tenantContext.getStore() ?? null;
2787
- }
2788
- function currentTenantId() {
2789
- return currentTenant()?.id ?? 1;
2790
- }
2791
-
2792
- // ../../src/modules/user/authService.ts
2793
- class AuthService {
2794
- users;
2795
- tokens;
2796
- oauthIdentities;
2797
- oauthProviders = new Map;
2798
- constructor(users, tokens, oauthIdentities) {
2799
- this.users = users;
2800
- this.tokens = tokens;
2801
- this.oauthIdentities = oauthIdentities;
2802
- }
2803
- registerOAuthProvider(provider) {
2804
- this.oauthProviders.set(provider.name, provider);
2805
- }
2806
- getOAuthProvider(name) {
2807
- return this.oauthProviders.get(name);
2808
- }
2809
- async loginWithPassword(email, password, options = {}) {
2810
- const user = await this.users.findByEmail(email);
2811
- if (!user?.password_hash) {
2812
- logSecurityEvent("auth_login_failed", { reason: "unknown_user", email });
2813
- throw new UnauthorizedError("Invalid credentials.");
2814
- }
2815
- const valid = await verifyPassword(password, user.password_hash);
2816
- if (!valid) {
2817
- logSecurityEvent("auth_login_failed", { reason: "invalid_password", user_id: user.id });
2818
- throw new UnauthorizedError("Invalid credentials.");
2819
- }
2820
- if (isFeatureEnabled("emailVerification") && !user.email_verified_at) {
2821
- logSecurityEvent("auth_login_blocked", { reason: "email_unverified", user_id: user.id });
2822
- throw new UnauthorizedError("Email address is not verified.");
2823
- }
2824
- if (isFeatureEnabled("mfa") && user.mfa_enabled) {
2825
- const mfaSecret = revealMfaSecret(user.mfa_secret);
2826
- if (!mfaSecret || !options.mfaCode || !verifyTotp(mfaSecret, options.mfaCode)) {
2827
- logSecurityEvent("auth_login_failed", { reason: "invalid_mfa", user_id: user.id });
2828
- throw new UnauthorizedError("Invalid MFA code.");
2829
- }
2830
- }
2831
- logSecurityEvent("auth_login_success", { user_id: user.id, method: "password" });
2832
- return await this.tokens.createToken(user.id, {
2833
- name: "password-login",
2834
- abilities: resolveAbilitiesForRole(user.role),
2835
- expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
2836
- });
2837
- }
2838
- async loginWithOAuth(providerName, code) {
2839
- const provider = this.oauthProviders.get(providerName);
2840
- if (!provider) {
2841
- throw new UnauthorizedError("Unsupported OAuth provider.");
2842
- }
2843
- const profile = await provider.exchangeCode(code);
2844
- const user = await this.findOrCreateOAuthUser(providerName, profile);
2845
- logSecurityEvent("auth_login_success", { user_id: user.id, method: `oauth:${providerName}` });
2846
- return await this.tokens.createToken(user.id, {
2847
- name: `${providerName}-oauth`,
2848
- abilities: resolveAbilitiesForRole(user.role),
2849
- expiresInDays: resolveDefaultTokenExpiryDays() ?? undefined
2850
- });
2851
- }
2852
- buildOAuthAuthorizationUrl(providerName, state) {
2853
- const provider = this.oauthProviders.get(providerName);
2854
- if (!provider) {
2855
- throw new UnauthorizedError("Unsupported OAuth provider.");
2856
- }
2857
- return provider.getAuthorizationUrl(state);
2858
- }
2859
- async findOrCreateOAuthUser(providerName, profile) {
2860
- const existingIdentity = await this.oauthIdentities.findByProviderUser(providerName, profile.providerUserId);
2861
- if (existingIdentity) {
2862
- return await this.users.findByIdOrThrow(existingIdentity.user_id);
2863
- }
2864
- const existingUser = await this.users.findByEmail(profile.email);
2865
- const user = existingUser ?? await this.users.create({
2866
- name: profile.name,
2867
- email: profile.email,
2868
- role: "member",
2869
- tenant_id: currentTenantId(),
2870
- email_verified_at: new Date,
2871
- created_at: new Date,
2872
- updated_at: new Date
2873
- });
2874
- await this.oauthIdentities.create({
2875
- user_id: user.id,
2876
- provider: providerName,
2877
- provider_user_id: profile.providerUserId,
2878
- email: profile.email,
2879
- created_at: new Date
2880
- });
2881
- return user;
2882
- }
2883
- }
2884
-
2885
- // ../../src/modules/user/notificationTable.ts
2886
- var notificationTable = defineTable({
2887
- name: "notification",
2888
- primaryKey: "id",
2889
- columns: ["id", "user_id", "tenant_id", "type", "title", "body", "data", "read_at", "created_at"],
2890
- defaultOrderBy: { column: "created_at", direction: "DESC" }
2891
- });
2892
-
2893
- // ../../src/modules/user/oauthIdentityRepository.ts
2894
- var oauthIdentityTable = defineTable({
2895
- name: "oauth_identity",
2896
- primaryKey: "id",
2897
- columns: ["id", "user_id", "provider", "provider_user_id", "email", "created_at"]
2898
- });
2899
-
2900
- // ../../src/modules/user/table.ts
2901
- var userTable = defineTable({
2902
- name: "users",
2903
- primaryKey: "id",
2904
- columns: [
2905
- "id",
2906
- "name",
2907
- "email",
2908
- "email_lookup",
2909
- "role",
2910
- "tenant_id",
2911
- "password_hash",
2912
- "email_verified_at",
2913
- "mfa_secret",
2914
- "mfa_enabled",
2915
- "created_at",
2916
- "updated_at"
2917
- ],
2918
- defaultOrderBy: { column: "id", direction: "ASC" }
2919
- });
2920
-
2921
- // ../../src/core/auth/tokenHash.ts
2922
- import { createHash, createHmac as createHmac3 } from "crypto";
2923
- function resolveTokenPepper() {
2924
- return process.env.TOKEN_HASH_PEPPER?.trim() ?? "workhub-dev-token-pepper";
2925
- }
2926
- function hashApiToken(token) {
2927
- const pepper = resolveTokenPepper();
2928
- if (pepper && pepper !== "workhub-dev-token-pepper") {
2929
- return createHmac3("sha256", pepper).update(token).digest("hex");
2930
- }
2931
- return createHash("sha256").update(token).digest("hex");
2932
- }
2933
-
2934
- // ../../src/modules/user/provider.ts
2935
- var tokenServiceToken = CORE_TOKEN_SERVICE_TOKEN;
2936
- // ../../src/modules/organization/memberRepository.ts
2937
- class OrganizationMemberRepository {
2938
- constructor() {}
2939
- async findMembership(userId, organizationId) {
2940
- const rows = await connection_default`
2941
- SELECT id, organization_id, user_id, role, created_at
2942
- FROM organization_member
2943
- WHERE user_id = ${userId} AND organization_id = ${organizationId}
2944
- LIMIT 1
2945
- `;
2946
- return rows[0] ?? null;
2947
- }
2948
- async listForUser(userId) {
2949
- return await connection_default`
2950
- SELECT id, organization_id, user_id, role, created_at
2951
- FROM organization_member
2952
- WHERE user_id = ${userId}
2953
- ORDER BY organization_id
2954
- `;
2955
- }
2956
- async listForOrganization(organizationId) {
2957
- return await connection_default`
2958
- SELECT id, organization_id, user_id, role, created_at
2959
- FROM organization_member
2960
- WHERE organization_id = ${organizationId}
2961
- ORDER BY id
2962
- `;
2963
- }
2964
- async addMember(input) {
2965
- const rows = await connection_default`
2966
- INSERT INTO organization_member (organization_id, user_id, role)
2967
- VALUES (${input.organizationId}, ${input.userId}, ${input.role ?? "member"})
2968
- RETURNING id, organization_id, user_id, role, created_at
2969
- `;
2970
- const row = rows[0];
2971
- if (!row) {
2972
- throw new Error("Organization member insert did not return a row.");
2973
- }
2974
- return row;
2975
- }
2976
- async removeMember(organizationId, userId) {
2977
- const rows = await connection_default`
2978
- DELETE FROM organization_member
2979
- WHERE organization_id = ${organizationId} AND user_id = ${userId}
2980
- RETURNING id
2981
- `;
2982
- return rows.length > 0;
2983
- }
2984
- }
2985
- var memberRepository_default = OrganizationMemberRepository;
2986
-
2987
- // ../../src/core/auth/membershipContext.ts
2988
- var membershipContext = createAsyncContextStore("@getstrata/membershipContext");
2989
- var membershipRepository = new memberRepository_default;
2990
- // ../../src/core/auth/policy.ts
2991
- var BLOCKED_POLICY_ACTIONS = new Set([
2992
- "constructor",
2993
- "toString",
2994
- "valueOf",
2995
- "hasOwnProperty",
2996
- "isPrototypeOf",
2997
- "propertyIsEnumerable",
2998
- "__proto__"
2999
- ]);
3000
-
3001
- class PolicyGate {
3002
- constructor() {}
3003
- policies = new Map;
3004
- register(resource, policy) {
3005
- this.policies.set(resource, policy);
3006
- }
3007
- allows(resource, action, user, model) {
3008
- const policy = this.policies.get(resource);
3009
- if (!policy) {
3010
- return false;
3011
- }
3012
- if (BLOCKED_POLICY_ACTIONS.has(action) || !(action in policy)) {
3013
- return false;
3014
- }
3015
- const handler = policy[action];
3016
- if (typeof handler !== "function") {
3017
- return false;
3018
- }
3019
- const resolvedUser = user === undefined ? currentAuthUser() : user;
3020
- return model === undefined ? handler.call(policy, resolvedUser) : handler.call(policy, resolvedUser, model);
3021
- }
3022
- authorize(resource, action, user, model) {
3023
- if (!this.allows(resource, action, user, model)) {
3024
- throw new ForbiddenError;
3025
- }
3026
- }
3027
- }
3028
- // ../../src/core/cache/redisCacheStore.ts
3029
- var {RedisClient: RedisClient2 } = globalThis.Bun;
3030
- var KEY_PREFIX = "workhub:cache:";
3031
- var TAG_PREFIX = "workhub:cache:tag:";
3032
-
3033
- class RedisCacheStore {
3034
- ttlMs;
3035
- maxEntries;
3036
- client;
3037
- inflight = new Map;
3038
- keyTags = new Map;
3039
- constructor(redisUrl, ttlMs, maxEntries) {
3040
- this.ttlMs = ttlMs;
3041
- this.maxEntries = maxEntries;
3042
- this.client = new RedisClient2(redisUrl);
3043
- }
3044
- async get(key) {
3045
- const raw = await this.client.get(this.storageKey(key));
3046
- if (raw === null) {
3047
- return;
3048
- }
3049
- return JSON.parse(raw);
3050
- }
3051
- async set(key, value, ttlMs) {
3052
- const resolvedTtlMs = ttlMs ?? this.ttlMs;
3053
- const payload = JSON.stringify(value);
3054
- if (resolvedTtlMs > 0) {
3055
- await this.client.psetex(this.storageKey(key), resolvedTtlMs, payload);
3056
- } else {
3057
- await this.client.set(this.storageKey(key), payload);
3058
- }
3059
- await this.enforceMaxEntries();
3060
- }
3061
- async getOrSet(key, loader, ttlMs) {
3062
- const cached = await this.get(key);
3063
- if (cached !== undefined) {
3064
- return cached;
3065
- }
3066
- const inflightRequest = this.inflight.get(key);
3067
- if (inflightRequest) {
3068
- return inflightRequest;
3069
- }
3070
- const pendingRequest = loader().then(async (value) => {
3071
- await this.set(key, value, ttlMs);
3072
- return value;
3073
- }).finally(() => {
3074
- this.inflight.delete(key);
3075
- });
3076
- this.inflight.set(key, pendingRequest);
3077
- return pendingRequest;
3078
- }
3079
- async attachTags(key, tags) {
3080
- if (tags.length === 0) {
3081
- return;
3082
- }
3083
- let tagsForKey = this.keyTags.get(key);
3084
- if (!tagsForKey) {
3085
- tagsForKey = new Set;
3086
- this.keyTags.set(key, tagsForKey);
3087
- }
3088
- for (const tag of tags) {
3089
- tagsForKey.add(tag);
3090
- await this.client.sadd(this.tagKey(tag), key);
3091
- }
3092
- }
3093
- async flushTags(tags) {
3094
- const keysToRemove = new Set;
3095
- for (const tag of tags) {
3096
- const members = await this.client.smembers(this.tagKey(tag));
3097
- for (const member of members) {
3098
- keysToRemove.add(member);
3099
- }
3100
- }
3101
- let removed = 0;
3102
- for (const key of keysToRemove) {
3103
- if (await this.invalidate(key)) {
3104
- removed += 1;
3105
- }
3106
- }
3107
- for (const tag of tags) {
3108
- await this.client.del(this.tagKey(tag));
3109
- }
3110
- return removed;
3111
- }
3112
- async invalidate(key) {
3113
- const deleted = await this.client.del(this.storageKey(key));
3114
- await this.detachKeyFromTags(key);
3115
- return deleted > 0;
3116
- }
3117
- async invalidateByPrefix(prefix) {
3118
- const keys = await this.client.keys(`${KEY_PREFIX}*`);
3119
- let removed = 0;
3120
- for (const storageKey of keys) {
3121
- const key = storageKey.slice(KEY_PREFIX.length);
3122
- if (key === prefix || key.startsWith(`${prefix}?`)) {
3123
- if (await this.invalidate(key)) {
3124
- removed += 1;
3125
- }
3126
- }
3127
- }
3128
- return removed;
3129
- }
3130
- async clear() {
3131
- const keys = await this.client.keys(`${KEY_PREFIX}*`);
3132
- if (keys.length > 0) {
3133
- await this.client.del(...keys);
3134
- }
3135
- const tagKeys = await this.client.keys(`${TAG_PREFIX}*`);
3136
- if (tagKeys.length > 0) {
3137
- await this.client.del(...tagKeys);
3138
- }
3139
- this.inflight.clear();
3140
- this.keyTags.clear();
3141
- }
3142
- async size() {
3143
- const keys = await this.client.keys(`${KEY_PREFIX}*`);
3144
- return keys.length;
3145
- }
3146
- storageKey(key) {
3147
- return `${KEY_PREFIX}${key}`;
3148
- }
3149
- tagKey(tag) {
3150
- return `${TAG_PREFIX}${tag}`;
3151
- }
3152
- async detachKeyFromTags(key) {
3153
- const tags = this.keyTags.get(key);
3154
- if (!tags) {
3155
- return;
3156
- }
3157
- for (const tag of tags) {
3158
- await this.client.srem(this.tagKey(tag), key);
3159
- }
3160
- this.keyTags.delete(key);
3161
- }
3162
- async enforceMaxEntries() {
3163
- const keys = await this.client.keys(`${KEY_PREFIX}*`);
3164
- if (keys.length <= this.maxEntries) {
3165
- return;
3166
- }
3167
- const overflow = keys.length - this.maxEntries;
3168
- const keysToRemove = keys.slice(0, overflow);
3169
- if (keysToRemove.length > 0) {
3170
- await this.client.del(...keysToRemove);
3171
- }
3172
- }
3173
- }
3174
- var redisCacheStore_default = RedisCacheStore;
3175
-
3176
- // ../../src/core/cache/simpleCache.ts
3177
- class SimpleCache {
3178
- ttlMs;
3179
- maxEntries;
3180
- cache = new Map;
3181
- inflight = new Map;
3182
- tagIndex = new Map;
3183
- keyTags = new Map;
3184
- constructor(ttlMs = 3600000, maxEntries = 100) {
3185
- this.ttlMs = ttlMs;
3186
- this.maxEntries = maxEntries;
3187
- if (!Number.isFinite(ttlMs) || ttlMs < 0) {
3188
- throw new RangeError("ttlMs must be a non-negative number.");
3189
- }
3190
- if (!Number.isInteger(maxEntries) || maxEntries < 1) {
3191
- throw new RangeError("maxEntries must be a positive integer.");
3192
- }
3193
- }
3194
- get(key) {
3195
- return this.getFreshEntry(key)?.value;
3196
- }
3197
- set(key, value, ttlMs) {
3198
- const now = Date.now();
3199
- const resolvedTtlMs = ttlMs ?? this.ttlMs;
3200
- this.cache.set(key, {
3201
- value,
3202
- expiresAt: now + resolvedTtlMs,
3203
- lastAccessedAt: now
3204
- });
3205
- this.evictOverflow();
3206
- }
3207
- async getOrSet(key, loader, ttlMs) {
3208
- this.pruneExpired();
3209
- const cachedEntry = this.getFreshEntry(key);
3210
- if (cachedEntry) {
3211
- return cachedEntry.value;
3212
- }
3213
- const inflightRequest = this.inflight.get(key);
3214
- if (inflightRequest) {
3215
- return inflightRequest;
3216
- }
3217
- const pendingRequest = loader().then((value) => {
3218
- this.set(key, value, ttlMs);
3219
- return value;
3220
- }).finally(() => {
3221
- this.inflight.delete(key);
3222
- });
3223
- this.inflight.set(key, pendingRequest);
3224
- return pendingRequest;
3225
- }
3226
- attachTags(key, tags) {
3227
- if (tags.length === 0) {
3228
- return;
3229
- }
3230
- let tagsForKey = this.keyTags.get(key);
3231
- if (!tagsForKey) {
3232
- tagsForKey = new Set;
3233
- this.keyTags.set(key, tagsForKey);
3234
- }
3235
- for (const tag of tags) {
3236
- tagsForKey.add(tag);
3237
- let keysForTag = this.tagIndex.get(tag);
3238
- if (!keysForTag) {
3239
- keysForTag = new Set;
3240
- this.tagIndex.set(tag, keysForTag);
3241
- }
3242
- keysForTag.add(key);
3243
- }
3244
- }
3245
- flushTags(tags) {
3246
- const keysToRemove = new Set;
3247
- for (const tag of tags) {
3248
- const keys = this.tagIndex.get(tag);
3249
- if (!keys) {
3250
- continue;
3251
- }
3252
- for (const key of keys) {
3253
- keysToRemove.add(key);
3254
- }
3255
- }
3256
- let removed = 0;
3257
- for (const key of keysToRemove) {
3258
- if (this.invalidate(key)) {
3259
- removed += 1;
3260
- }
3261
- }
3262
- for (const tag of tags) {
3263
- this.tagIndex.delete(tag);
3264
- }
3265
- return removed;
3266
- }
3267
- invalidate(key) {
3268
- const removed = this.cache.delete(key);
3269
- if (removed) {
3270
- this.detachKeyFromTags(key);
3271
- }
3272
- return removed;
3273
- }
3274
- invalidateByPrefix(prefix) {
3275
- let removed = 0;
3276
- for (const key of [...this.cache.keys()]) {
3277
- if (key === prefix || key.startsWith(`${prefix}?`)) {
3278
- if (this.invalidate(key)) {
3279
- removed += 1;
3280
- }
3281
- }
3282
- }
3283
- return removed;
3284
- }
3285
- clear() {
3286
- this.cache.clear();
3287
- this.inflight.clear();
3288
- this.tagIndex.clear();
3289
- this.keyTags.clear();
3290
- }
3291
- size() {
3292
- this.pruneExpired();
3293
- return this.cache.size;
3294
- }
3295
- detachKeyFromTags(key) {
3296
- const tags = this.keyTags.get(key);
3297
- if (!tags) {
3298
- return;
3299
- }
3300
- for (const tag of tags) {
3301
- const keys = this.tagIndex.get(tag);
3302
- if (!keys) {
3303
- continue;
3304
- }
3305
- keys.delete(key);
3306
- if (keys.size === 0) {
3307
- this.tagIndex.delete(tag);
3308
- }
3309
- }
3310
- this.keyTags.delete(key);
3311
- }
3312
- getFreshEntry(key) {
3313
- const entry = this.cache.get(key);
3314
- if (!entry) {
3315
- return;
3316
- }
3317
- if (entry.expiresAt <= Date.now()) {
3318
- this.invalidate(key);
3319
- return;
3320
- }
3321
- entry.lastAccessedAt = Date.now();
3322
- return entry;
3323
- }
3324
- pruneExpired() {
3325
- const now = Date.now();
3326
- for (const [key, entry] of this.cache.entries()) {
3327
- if (entry.expiresAt <= now) {
3328
- this.invalidate(key);
3329
- }
3330
- }
3331
- }
3332
- evictOverflow() {
3333
- while (this.cache.size > this.maxEntries) {
3334
- let oldestKey;
3335
- let oldestAccessTime = Number.POSITIVE_INFINITY;
3336
- for (const [key, entry] of this.cache.entries()) {
3337
- if (entry.lastAccessedAt < oldestAccessTime) {
3338
- oldestAccessTime = entry.lastAccessedAt;
3339
- oldestKey = key;
3340
- }
3341
- }
3342
- if (!oldestKey) {
3343
- return;
3344
- }
3345
- this.invalidate(oldestKey);
3346
- }
3347
- }
3348
- }
3349
- var simpleCache_default = SimpleCache;
3350
-
3351
- // ../../src/core/cache/simpleCacheStore.ts
3352
- class SimpleCacheStore {
3353
- cache;
3354
- constructor(cache) {
3355
- this.cache = cache;
3356
- }
3357
- get(key) {
3358
- return Promise.resolve(this.cache.get(key));
3359
- }
3360
- set(key, value, ttlMs) {
3361
- this.cache.set(key, value, ttlMs);
3362
- return Promise.resolve();
3363
- }
3364
- getOrSet(key, loader, ttlMs) {
3365
- return this.cache.getOrSet(key, loader, ttlMs);
3366
- }
3367
- attachTags(key, tags) {
3368
- this.cache.attachTags(key, tags);
3369
- return Promise.resolve();
3370
- }
3371
- flushTags(tags) {
3372
- return Promise.resolve(this.cache.flushTags(tags));
3373
- }
3374
- invalidate(key) {
3375
- return Promise.resolve(this.cache.invalidate(key));
3376
- }
3377
- invalidateByPrefix(prefix) {
3378
- return Promise.resolve(this.cache.invalidateByPrefix(prefix));
3379
- }
3380
- clear() {
3381
- this.cache.clear();
3382
- return Promise.resolve();
3383
- }
3384
- size() {
3385
- return Promise.resolve(this.cache.size());
3386
- }
3387
- }
3388
- var simpleCacheStore_default = SimpleCacheStore;
3389
-
3390
- // ../../src/core/cache/createCacheStore.ts
3391
- function createCacheStore(options) {
3392
- if (options.driver === "redis") {
3393
- if (!options.redisUrl) {
3394
- throw new Error('CACHE_DRIVER="redis" requires REDIS_URL to be set.');
3395
- }
3396
- return new redisCacheStore_default(options.redisUrl, options.ttlMs, options.maxEntries);
3397
- }
3398
- return new simpleCacheStore_default(new simpleCache_default(options.ttlMs, options.maxEntries));
3399
- }
3400
- // ../../src/core/cache/tags.ts
3401
- var CACHE_TAGS = {
3402
- organizations: "organizations",
3403
- projects: "projects",
3404
- tasks: "tasks",
3405
- comments: "comments",
3406
- attachments: "attachments",
3407
- reports: "reports"
3408
- };
3409
- // ../../src/core/database/seeders/runner.ts
3410
- import { readdir } from "fs/promises";
3411
- import { join } from "path";
3412
- import { pathToFileURL } from "url";
3413
- async function loadSeedersFromDirectory(directory) {
3414
- const entries = await readdir(directory);
3415
- const seederFiles = entries.filter((entry) => (entry.endsWith(".ts") || entry.endsWith(".js")) && entry !== "types.ts" && entry !== "index.ts" && entry !== "runner.ts").sort();
3416
- const loadedSeeders = await Promise.all(seederFiles.map(async (fileName) => {
3417
- const moduleUrl = pathToFileURL(join(directory, fileName)).href;
3418
- const module = await import(moduleUrl);
3419
- return module.default;
3420
- }));
3421
- return loadedSeeders.filter((seeder) => seeder?.name !== undefined);
3422
- }
3423
- async function runSeedersFromDirectory(directory, db2, options) {
3424
- const seeders = await loadSeedersFromDirectory(directory);
3425
- if (seeders.length === 0) {
3426
- return 0;
3427
- }
3428
- for (const seeder of seeders) {
3429
- options?.onSeeder?.(seeder.name);
3430
- await seeder.run(db2);
3431
- }
3432
- return seeders.length;
3433
- }
3434
- // ../../src/core/mail/mailer.ts
3435
- function resolveSmtpConfig() {
3436
- const host = process.env.MAIL_HOST?.trim();
3437
- if (!host) {
3438
- throw new Error('MAIL_DRIVER="smtp" requires MAIL_HOST to be set.');
3439
- }
3440
- const from = process.env.MAIL_FROM?.trim();
3441
- if (!from) {
3442
- throw new Error('MAIL_DRIVER="smtp" requires MAIL_FROM to be set.');
3443
- }
3444
- const port = Number.parseInt(process.env.MAIL_PORT ?? "587", 10);
3445
- if (!Number.isInteger(port) || port <= 0) {
3446
- throw new Error('Environment variable "MAIL_PORT" must be a positive integer.');
3447
- }
3448
- return {
3449
- host,
3450
- port,
3451
- from,
3452
- secure: (process.env.MAIL_SECURE ?? "false") === "true",
3453
- ...process.env.MAIL_USERNAME?.trim() ? { username: process.env.MAIL_USERNAME.trim() } : {},
3454
- ...process.env.MAIL_PASSWORD?.trim() ? { password: process.env.MAIL_PASSWORD.trim() } : {}
3455
- };
3456
- }
3457
- function encodeBase64(value) {
3458
- return Buffer.from(value, "utf8").toString("base64");
3459
- }
3460
- function parseSmtpResponses(buffer) {
3461
- const responses = [];
3462
- let remainder = buffer;
3463
- while (remainder.includes(`\r
3464
- `)) {
3465
- const index = remainder.indexOf(`\r
3466
- `);
3467
- const line = remainder.slice(0, index);
3468
- remainder = remainder.slice(index + 2);
3469
- if (line.length >= 4 && line[3] === "-") {
3470
- continue;
3471
- }
3472
- responses.push(line);
3473
- }
3474
- return { responses, remainder };
3475
- }
3476
- async function waitForSmtpResponse(readResponse, expectedCodes) {
3477
- const response = await readResponse();
3478
- const code = response.slice(0, 3);
3479
- if (!expectedCodes.includes(code)) {
3480
- throw new Error(`Unexpected SMTP response: ${response}`);
3481
- }
3482
- return response;
3483
- }
3484
- async function openSmtpConnection(config) {
3485
- let buffer = "";
3486
- const waiters = [];
3487
- const readResponse = () => new Promise((resolve, reject) => {
3488
- const parsed = parseSmtpResponses(buffer);
3489
- if (parsed.responses.length > 0) {
3490
- buffer = parsed.remainder;
3491
- resolve(parsed.responses.shift());
3492
- return;
3493
- }
3494
- waiters.push({ resolve, reject });
3495
- });
3496
- const socket = await Bun.connect({
3497
- hostname: config.host,
3498
- port: config.port,
3499
- socket: {
3500
- open() {},
3501
- data(_socket, chunk) {
3502
- buffer += Buffer.from(chunk).toString("utf8");
3503
- const parsed = parseSmtpResponses(buffer);
3504
- buffer = parsed.remainder;
3505
- while (parsed.responses.length > 0 && waiters.length > 0) {
3506
- const response = parsed.responses.shift();
3507
- waiters.shift()?.resolve(response);
3508
- }
3509
- },
3510
- error(_socket, error) {
3511
- const pending = waiters.splice(0);
3512
- for (const waiter of pending) {
3513
- waiter.reject(error instanceof Error ? error : new Error(String(error)));
3514
- }
3515
- }
3516
- }
3517
- });
3518
- return { socket, readResponse };
3519
- }
3520
- async function defaultSmtpTransport(config, message) {
3521
- const { socket, readResponse } = await openSmtpConnection(config);
3522
- try {
3523
- await waitForSmtpResponse(readResponse, ["220"]);
3524
- await socket.write(`EHLO workhub.local\r
3525
- `);
3526
- await waitForSmtpResponse(readResponse, ["250"]);
3527
- if (config.username && config.password) {
3528
- await socket.write(`AUTH LOGIN\r
3529
- `);
3530
- await waitForSmtpResponse(readResponse, ["334"]);
3531
- await socket.write(`${encodeBase64(config.username)}\r
3532
- `);
3533
- await waitForSmtpResponse(readResponse, ["334"]);
3534
- await socket.write(`${encodeBase64(config.password)}\r
3535
- `);
3536
- await waitForSmtpResponse(readResponse, ["235"]);
3537
- }
3538
- await socket.write(`MAIL FROM:<${config.from}>\r
3539
- `);
3540
- await waitForSmtpResponse(readResponse, ["250"]);
3541
- await socket.write(`RCPT TO:<${message.to}>\r
3542
- `);
3543
- await waitForSmtpResponse(readResponse, ["250", "251"]);
3544
- await socket.write(`DATA\r
3545
- `);
3546
- await waitForSmtpResponse(readResponse, ["354"]);
3547
- const payload = buildSmtpPayload(config.from, message);
3548
- await socket.write(payload);
3549
- await waitForSmtpResponse(readResponse, ["250"]);
3550
- await socket.write(`QUIT\r
3551
- `);
3552
- await waitForSmtpResponse(readResponse, ["221"]);
3553
- } finally {
3554
- socket.end();
3555
- }
3556
- }
3557
- function buildSmtpPayload(from, message) {
3558
- const headers = [
3559
- `From: ${from}`,
3560
- `To: ${message.to}`,
3561
- `Subject: ${message.subject}`,
3562
- "MIME-Version: 1.0"
3563
- ];
3564
- if (message.html) {
3565
- const boundary = `strata-${Date.now().toString(36)}`;
3566
- headers.push(`Content-Type: multipart/alternative; boundary="${boundary}"`);
3567
- const parts = [
3568
- `--${boundary}`,
3569
- "Content-Type: text/plain; charset=utf-8",
3570
- "",
3571
- message.body,
3572
- `--${boundary}`,
3573
- "Content-Type: text/html; charset=utf-8",
3574
- "",
3575
- message.html,
3576
- `--${boundary}--`,
3577
- ""
3578
- ];
3579
- return [...headers, "", ...parts, ".", ""].join(`\r
3580
- `);
3581
- }
3582
- headers.push("Content-Type: text/plain; charset=utf-8");
3583
- return [...headers, "", message.body, ".", ""].join(`\r
3584
- `);
3585
- }
3586
-
3587
- class LogMailDriver {
3588
- async send(message) {
3589
- console.log(JSON.stringify({
3590
- level: "info",
3591
- channel: "mail",
3592
- to: message.to,
3593
- subject: message.subject,
3594
- body: message.body,
3595
- ...message.html ? { html: message.html } : {}
3596
- }));
3597
- }
3598
- }
3599
-
3600
- class SmtpMailDriver {
3601
- config;
3602
- transport;
3603
- constructor(config, transport = defaultSmtpTransport) {
3604
- this.config = config;
3605
- this.transport = transport;
3606
- }
3607
- send(message) {
3608
- return this.transport(this.config, message);
3609
- }
3610
- }
3611
-
3612
- class Mailer {
3613
- driver;
3614
- constructor(driver) {
3615
- this.driver = driver;
3616
- }
3617
- send(message) {
3618
- return this.driver.send(message);
3619
- }
3620
- }
3621
- function createMailDriver() {
3622
- const driver = process.env.MAIL_DRIVER ?? "log";
3623
- if (driver === "smtp") {
3624
- return new SmtpMailDriver(resolveSmtpConfig());
3625
- }
3626
- return new LogMailDriver;
3627
- }
3628
- var appMailer = new Mailer(createMailDriver());
3629
-
3630
- // ../../src/core/storage/storage.ts
3631
- import { mkdir, readFile, unlink, writeFile } from "fs/promises";
3632
- import { dirname, join as join2 } from "path";
3633
- var {S3Client } = globalThis.Bun;
3634
-
3635
- class LocalStorageDriver {
3636
- rootDirectory;
3637
- constructor(rootDirectory) {
3638
- this.rootDirectory = rootDirectory;
3639
- }
3640
- resolveRootDirectory() {
3641
- return this.rootDirectory ?? process.env.STORAGE_PATH ?? "storage";
3642
- }
3643
- resolvePath(path) {
3644
- return join2(this.resolveRootDirectory(), path.replace(/^\/+/, ""));
3645
- }
3646
- async put(path, contents) {
3647
- const absolutePath = this.resolvePath(path);
3648
- await mkdir(dirname(absolutePath), { recursive: true });
3649
- await writeFile(absolutePath, contents);
3650
- return path;
3651
- }
3652
- async get(path) {
3653
- try {
3654
- return await readFile(this.resolvePath(path));
3655
- } catch {
3656
- return null;
3657
- }
3658
- }
3659
- async delete(path) {
3660
- try {
3661
- await unlink(this.resolvePath(path));
3662
- return true;
3663
- } catch {
3664
- return false;
3665
- }
3666
- }
3667
- }
3668
-
3669
- class S3StorageDriver {
3670
- client;
3671
- constructor(client) {
3672
- this.client = client;
3673
- }
3674
- async put(path, contents) {
3675
- await this.client.write(path.replace(/^\/+/, ""), contents);
3676
- return path;
3677
- }
3678
- async get(path) {
3679
- const normalizedPath = path.replace(/^\/+/, "");
3680
- const file = this.client.file(normalizedPath);
3681
- if (!await file.exists()) {
3682
- return null;
3683
- }
3684
- return new Uint8Array(await file.arrayBuffer());
3685
- }
3686
- async delete(path) {
3687
- try {
3688
- await this.client.unlink(path.replace(/^\/+/, ""));
3689
- return true;
3690
- } catch {
3691
- return false;
3692
- }
3693
- }
3694
- }
3695
-
3696
- class StorageManager {
3697
- driver;
3698
- constructor(driver) {
3699
- this.driver = driver;
3700
- }
3701
- put(path, contents) {
3702
- return this.driver.put(path, contents);
3703
- }
3704
- get(path) {
3705
- return this.driver.get(path);
3706
- }
3707
- delete(path) {
3708
- return this.driver.delete(path);
3709
- }
3710
- }
3711
- function resolveS3Config() {
3712
- const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
3713
- const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();
3714
- const bucket = process.env.AWS_BUCKET?.trim();
3715
- if (!accessKeyId || !secretAccessKey || !bucket) {
3716
- throw new Error('STORAGE_DRIVER="s3" requires AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET.');
3717
- }
3718
- return {
3719
- accessKeyId,
3720
- secretAccessKey,
3721
- bucket,
3722
- ...process.env.AWS_REGION?.trim() ? { region: process.env.AWS_REGION.trim() } : {},
3723
- ...process.env.AWS_ENDPOINT?.trim() ? { endpoint: process.env.AWS_ENDPOINT.trim() } : {}
3724
- };
3725
- }
3726
- function createS3Client(config = resolveS3Config()) {
3727
- return new S3Client({
3728
- accessKeyId: config.accessKeyId,
3729
- secretAccessKey: config.secretAccessKey,
3730
- bucket: config.bucket,
3731
- ...config.region ? { region: config.region } : {},
3732
- ...config.endpoint ? { endpoint: config.endpoint } : {}
3733
- });
3734
- }
3735
- function createStorageDriver() {
3736
- const driver = process.env.STORAGE_DRIVER ?? "local";
3737
- if (driver === "s3") {
3738
- return new S3StorageDriver(createS3Client());
3739
- }
3740
- return new LocalStorageDriver;
3741
- }
3742
- var defaultStorage = { current: null };
3743
- function storage() {
3744
- if (!defaultStorage.current) {
3745
- defaultStorage.current = new StorageManager(createStorageDriver());
3746
- }
3747
- return defaultStorage.current;
3748
- }
3749
- function resetDefaultStorage() {
3750
- defaultStorage.current = null;
3751
- }
3752
- // ../../src/core/crypto/nonCryptographicHash.ts
3753
- function nonCryptographicDigest(input) {
3754
- return Bun.hash(input).toString(16);
3755
- }
3756
-
3757
- // ../../src/core/http/etag.ts
3758
- function isEtagEnabled() {
3759
- return (process.env.FEATURE_ETAG ?? "true") !== "false";
3760
- }
3761
- function formatWeakEtag(digest) {
3762
- return `W/"${digest}"`;
3763
- }
3764
- function computeEtagFromJson(data) {
3765
- const digest = nonCryptographicDigest(JSON.stringify(data)).slice(0, 32);
3766
- return formatWeakEtag(digest);
3767
- }
3768
- function etagFromResource(resource) {
3769
- const version = resource.updated_at ?? resource.created_at ?? "";
3770
- const versionText = version instanceof Date ? version.toISOString() : version ? String(version) : "0";
3771
- const digest = nonCryptographicDigest(`${String(resource.id ?? "0")}:${versionText}`).slice(0, 32);
3772
- return formatWeakEtag(digest);
3773
- }
3774
- function normalizeEtag(value) {
3775
- return value.trim();
3776
- }
3777
- function etagValuesMatch(left, right) {
3778
- return normalizeEtag(left) === normalizeEtag(right);
3779
- }
3780
- function parseEtagList(header) {
3781
- if (!header) {
3782
- return [];
3783
- }
3784
- return header.split(",").map((value) => normalizeEtag(value)).filter(Boolean);
3785
- }
3786
- function ifNoneMatchSatisfied(request, etag) {
3787
- const header = request.headers.get("if-none-match");
3788
- if (!header) {
3789
- return false;
3790
- }
3791
- if (header.trim() === "*") {
3792
- return true;
3793
- }
3794
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
3795
- }
3796
- function ifMatchSatisfied(request, etag) {
3797
- const header = request.headers.get("if-match");
3798
- if (!header) {
3799
- return false;
3800
- }
3801
- if (header.trim() === "*") {
3802
- return true;
3803
- }
3804
- return parseEtagList(header).some((candidate) => etagValuesMatch(candidate, etag));
3805
- }
3806
- function assertIfMatch(request, etag, options = {}) {
3807
- const header = request.headers.get("if-match");
3808
- if (!header) {
3809
- if (options.required) {
3810
- throw new PreconditionFailedError("If-Match header is required.");
3811
- }
3812
- return;
3813
- }
3814
- if (!ifMatchSatisfied(request, etag)) {
3815
- throw new PreconditionFailedError("Resource ETag does not match If-Match.");
3816
- }
3817
- }
3818
- function applyEtagHeaders(headers, etag) {
3819
- const next = new Headers(headers);
3820
- next.set("ETag", etag);
3821
- next.set("Cache-Control", "private, must-revalidate");
3822
- next.append("Vary", "Authorization");
3823
- next.append("Vary", "X-Tenant-Id");
3824
- return next;
3825
- }
3826
- function notModifiedResponse(etag) {
3827
- return new Response(null, {
3828
- status: 304,
3829
- headers: applyEtagHeaders(new Headers, etag)
3830
- });
3831
- }
3832
- function applyConditionalGet(request, response, etag) {
3833
- if (!isEtagEnabled()) {
3834
- return response;
3835
- }
3836
- if (ifNoneMatchSatisfied(request, etag)) {
3837
- return notModifiedResponse(etag);
3838
- }
3839
- const headers = applyEtagHeaders(new Headers(response.headers), etag);
3840
- return new Response(response.body, {
3841
- status: response.status,
3842
- statusText: response.statusText,
3843
- headers
3844
- });
3845
- }
3846
- // ../../src/core/http/cookies.ts
3847
- function readRequestCookie(request, name) {
3848
- const cookies = request.cookies;
3849
- if (cookies && typeof cookies.get === "function") {
3850
- const value = cookies.get(name);
3851
- if (value) {
3852
- return value;
3853
- }
3854
- }
3855
- const header = request.headers.get("cookie");
3856
- if (!header) {
3857
- return null;
3858
- }
3859
- for (const part of header.split(";")) {
3860
- const idx = part.indexOf("=");
3861
- if (idx === -1)
3862
- continue;
3863
- const cookieName = part.slice(0, idx).trim();
3864
- if (cookieName !== name)
3865
- continue;
3866
- return decodeURIComponent(part.slice(idx + 1).trim());
3867
- }
3868
- return null;
3869
- }
3870
- // ../../src/config/cors.ts
3871
- var corsConfig = {
3872
- allowedOrigins: (process.env.CORS_ALLOWED_ORIGINS ?? "*").split(",").map((origin) => origin.trim()).filter(Boolean),
3873
- allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"],
3874
- allowedHeaders: [
3875
- "Authorization",
3876
- "Content-Type",
3877
- "X-Request-Id",
3878
- "X-Tenant-Id",
3879
- "X-Authenticated-User-Id",
3880
- "X-Authenticated-User-Role",
3881
- "If-Match",
3882
- "If-None-Match"
3883
- ],
3884
- maxAgeSeconds: 86400
3885
- };
3886
- // ../../src/core/http/csrfToken.ts
3887
- import { timingSafeEqual } from "crypto";
3888
- var CSRF_COOKIE = "workhub_csrf";
3889
- var CSRF_TTL_MS = 60 * 60 * 1000;
3890
- function resolveCsrfSecret() {
3891
- return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || process.env.ADMIN_API_TOKEN?.trim() || "workhub-dev-csrf-secret";
3892
- }
3893
- function csrfVerifyOptions() {
3894
- return { secret: resolveCsrfSecret(), maxAge: CSRF_TTL_MS };
3895
- }
3896
- function tokensMatch(left, right) {
3897
- const leftBuffer = Buffer.from(left);
3898
- const rightBuffer = Buffer.from(right);
3899
- if (leftBuffer.length !== rightBuffer.length) {
3900
- return false;
3901
- }
3902
- return timingSafeEqual(leftBuffer, rightBuffer);
3903
- }
3904
- function createCsrfTokenCookie() {
3905
- const token = Bun.CSRF.generate(resolveCsrfSecret(), { expiresIn: CSRF_TTL_MS });
3906
- return {
3907
- token,
3908
- cookie: `${CSRF_COOKIE}=${encodeURIComponent(token)}; Path=/; SameSite=Lax; Max-Age=${Math.floor(CSRF_TTL_MS / 1000)}`
3909
- };
3910
- }
3911
- function resolveCsrfToken(request) {
3912
- const cookieValue = readRequestCookie(request, CSRF_COOKIE);
3913
- if (cookieValue && Bun.CSRF.verify(cookieValue, csrfVerifyOptions())) {
3914
- return { token: cookieValue };
3915
- }
3916
- return createCsrfTokenCookie();
3917
- }
3918
- function readSubmittedCsrfToken(request) {
3919
- const headerToken = request.headers.get("x-csrf-token")?.trim();
3920
- if (headerToken) {
3921
- return headerToken;
3922
- }
3923
- return null;
3924
- }
3925
- async function readSubmittedCsrfTokenFromBody(request) {
3926
- const headerToken = readSubmittedCsrfToken(request);
3927
- if (headerToken) {
3928
- return headerToken;
3929
- }
3930
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
3931
- if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
3932
- const formData = await request.clone().formData();
3933
- const field = formData.get("_token");
3934
- if (typeof field === "string" && field.trim().length > 0) {
3935
- return field.trim();
3936
- }
3937
- const legacyField = formData.get("_csrf");
3938
- if (typeof legacyField === "string" && legacyField.trim().length > 0) {
3939
- return legacyField.trim();
3940
- }
3941
- }
3942
- return null;
3943
- }
3944
- function verifyCsrfToken(request, submittedToken) {
3945
- if (!submittedToken) {
3946
- return false;
3947
- }
3948
- const cookieValue = readRequestCookie(request, CSRF_COOKIE);
3949
- if (!cookieValue) {
3950
- return false;
3951
- }
3952
- if (!tokensMatch(submittedToken, cookieValue)) {
3953
- return false;
3954
- }
3955
- return Bun.CSRF.verify(submittedToken, csrfVerifyOptions());
3956
- }
3957
- function resolveCsrfTokenForRequest(request) {
3958
- const metaToken = currentRequestMeta().csrfToken;
3959
- if (metaToken) {
3960
- return metaToken;
3961
- }
3962
- return resolveCsrfToken(request).token;
3963
- }
3964
-
3965
- // ../../src/core/http/csrfMiddleware.ts
3966
- var MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
3967
- // ../../src/core/http/csrfProtection.ts
3968
- var DEFAULT_CSRF_TTL_MS = 60 * 60 * 1000;
3969
- // ../../src/core/http/flashSession.ts
3970
- import { createHmac as createHmac4, timingSafeEqual as timingSafeEqual2 } from "crypto";
3971
- var FLASH_COOKIE = "workhub_flash";
3972
- var FLASH_TTL_MS = 60 * 1000;
3973
- function resolveFlashSecret() {
3974
- return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
3975
- }
3976
- function signFlashPayload(payload, issuedAt) {
3977
- const signature = createHmac4("sha256", resolveFlashSecret()).update(`${payload}.${issuedAt}`).digest("hex");
3978
- return `${payload}.${issuedAt}.${signature}`;
3979
- }
3980
- function readFlashCookie(request) {
3981
- const cookieHeader = request.headers.get("cookie");
3982
- if (!cookieHeader) {
3983
- return null;
3984
- }
3985
- for (const part of cookieHeader.split(";")) {
3986
- const [name, ...rest] = part.trim().split("=");
3987
- if (name === FLASH_COOKIE) {
3988
- return decodeURIComponent(rest.join("="));
3989
- }
3990
- }
3991
- return null;
3992
- }
3993
- function parseFlashCookie(cookieValue) {
3994
- const parts = cookieValue.split(".");
3995
- if (parts.length < 3) {
3996
- return null;
3997
- }
3998
- const signature = parts.pop();
3999
- const issuedAtRaw = parts.pop();
4000
- const payload = parts.join(".");
4001
- if (!signature || !issuedAtRaw || !payload) {
4002
- return null;
4003
- }
4004
- const issuedAt = Number.parseInt(issuedAtRaw, 10);
4005
- if (!Number.isFinite(issuedAt) || Date.now() - issuedAt > FLASH_TTL_MS) {
4006
- return null;
4007
- }
4008
- const expectedSignature = signFlashPayload(payload, issuedAt).split(".").pop();
4009
- if (!expectedSignature) {
4010
- return null;
4011
- }
4012
- const expectedBuffer = Buffer.from(expectedSignature);
4013
- const actualBuffer = Buffer.from(signature);
4014
- if (expectedBuffer.length !== actualBuffer.length) {
4015
- return null;
4016
- }
4017
- if (!timingSafeEqual2(expectedBuffer, actualBuffer)) {
4018
- return null;
4019
- }
4020
- try {
4021
- const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
4022
- if (!parsed?.message || typeof parsed.message !== "string") {
4023
- return null;
4024
- }
4025
- if (parsed.level !== "success" && parsed.level !== "error" && parsed.level !== "info") {
4026
- return null;
4027
- }
4028
- return parsed;
4029
- } catch {
4030
- return null;
4031
- }
4032
- }
4033
- function createFlashCookie(message) {
4034
- const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
4035
- const issuedAt = Date.now();
4036
- const value = signFlashPayload(payload, issuedAt);
4037
- return `${FLASH_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
4038
- }
4039
- function clearFlashCookie() {
4040
- return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
4041
- }
4042
- function pullFlash(request) {
4043
- const cookieValue = readFlashCookie(request);
4044
- if (!cookieValue) {
4045
- return null;
4046
- }
4047
- return parseFlashCookie(cookieValue);
4048
- }
4049
- function flashResponse(response, message) {
4050
- const headers = new Headers(response.headers);
4051
- headers.append("set-cookie", createFlashCookie(message));
4052
- return new Response(response.body, {
4053
- status: response.status,
4054
- statusText: response.statusText,
4055
- headers
4056
- });
4057
- }
4058
- function withFlashClear(response) {
4059
- const headers = new Headers(response.headers);
4060
- headers.append("set-cookie", clearFlashCookie());
4061
- return new Response(response.body, {
4062
- status: response.status,
4063
- statusText: response.statusText,
4064
- headers
4065
- });
4066
- }
4067
- // ../../src/core/http/validation.ts
4068
- async function parseJsonBody(request, validator) {
4069
- let payload;
4070
- try {
4071
- payload = await request.json();
4072
- } catch {
4073
- throw new BadRequestError("Request body must be valid JSON.");
4074
- }
4075
- return validator(payload);
4076
- }
4077
- // ../../src/config/frontend.ts
4078
- function readFrontendMode() {
4079
- const mode = (process.env.FRONTEND_MODE ?? "api").trim();
4080
- if (mode === "server-htmx") {
4081
- return "server-htmx";
4082
- }
4083
- if (mode === "spa-react") {
4084
- return "spa-react";
4085
- }
4086
- return "api";
4087
- }
4088
- function isViewsEnabled() {
4089
- return readFrontendMode() === "server-htmx";
4090
- }
4091
-
4092
- // ../../src/core/view/etaViewEngine.ts
4093
- import { join as join3 } from "path";
4094
- import { Eta } from "eta";
4095
- var DEFAULT_VIEWS_DIRECTORY = join3(process.cwd(), "resources/views");
4096
- var DEFAULT_LAYOUT = "layouts/app.eta";
4097
-
4098
- class EtaViewEngine {
4099
- eta;
4100
- resolveLayoutData;
4101
- constructor(viewsDirectory = DEFAULT_VIEWS_DIRECTORY, resolveLayoutData) {
4102
- this.eta = new Eta({
4103
- views: viewsDirectory,
4104
- autoTrim: false
4105
- });
4106
- this.resolveLayoutData = resolveLayoutData;
4107
- }
4108
- async render(name, data = {}, options = {}) {
4109
- const template = name.endsWith(".eta") ? name : `${name}.eta`;
4110
- const layoutData = this.resolveLayoutData ? await this.resolveLayoutData() : {};
4111
- const mergedData = { ...layoutData, ...data };
4112
- const body = await this.eta.renderAsync(template, mergedData);
4113
- const layout = options.layout ?? DEFAULT_LAYOUT;
4114
- if (layout === false) {
4115
- return body;
4116
- }
4117
- const layoutTemplate = layout.endsWith(".eta") ? layout : `${layout}.eta`;
4118
- return await this.eta.renderAsync(layoutTemplate, {
4119
- ...mergedData,
4120
- body
4121
- });
4122
- }
4123
- }
4124
- // ../../src/core/view/htmlResponse.ts
4125
- function htmlResponse(html, init = {}) {
4126
- return new Response(html, {
4127
- status: init.status ?? 200,
4128
- statusText: init.statusText,
4129
- headers: {
4130
- "Content-Type": "text/html; charset=utf-8"
4131
- }
4132
- });
4133
- }
4134
- function isHtmxRequest(request) {
4135
- return request.headers.get("HX-Request") === "true";
4136
- }
4137
- // ../../src/core/view/webLayoutData.ts
4138
- async function resolveWebLayoutData(container, request) {
4139
- const csrfToken = request ? resolveCsrfTokenForRequest(request) : "";
4140
- const flash = request ? currentRequestMeta().flash ?? pullFlash(request) : null;
4141
- const authUser = currentAuthUser();
4142
- if (!authUser) {
4143
- return { authUser: null, csrfToken, flash };
4144
- }
4145
- const userId = Number(authUser.id);
4146
- if (!Number.isInteger(userId) || userId <= 0) {
4147
- return { authUser: null, csrfToken, flash };
4148
- }
4149
- if (!container.has(tokenServiceToken)) {
4150
- return {
4151
- authUser: {
4152
- id: userId,
4153
- email: "",
4154
- role: authUser.role ?? "member"
4155
- },
4156
- csrfToken,
4157
- flash
4158
- };
4159
- }
4160
- const tokenService = container.resolve(tokenServiceToken);
4161
- try {
4162
- const user = await tokenService.findByIdOrThrow(userId);
4163
- return {
4164
- authUser: {
4165
- id: userId,
4166
- email: user.email ?? "",
4167
- role: authUser.role ?? user.role ?? "member"
4168
- },
4169
- csrfToken,
4170
- flash
4171
- };
4172
- } catch {
4173
- return { authUser: null, csrfToken, flash };
4174
- }
4175
- }
4176
- // ../../src/core/http/contentNegotiation.ts
4177
- function requestPrefersJson(request) {
4178
- if (!request) {
4179
- return true;
4180
- }
4181
- if (request.headers.get("HX-Request") === "true") {
4182
- return false;
4183
- }
4184
- const accept = request.headers.get("accept")?.toLowerCase() ?? "";
4185
- if (accept.includes("text/html")) {
4186
- return false;
4187
- }
4188
- if (accept.includes("application/json")) {
4189
- return true;
4190
- }
4191
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
4192
- if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
4193
- return false;
4194
- }
4195
- const pathname = new URL(request.url).pathname;
4196
- return pathname.startsWith("/api/");
4197
- }
4198
-
4199
- // ../../src/core/http/webErrorResponse.ts
4200
- function normalizeFieldErrors(details) {
4201
- if (!details || typeof details !== "object" || Array.isArray(details)) {
4202
- return {};
4203
- }
4204
- const errors = {};
4205
- for (const [field, messages] of Object.entries(details)) {
4206
- if (Array.isArray(messages)) {
4207
- errors[field] = messages.map(String);
4208
- continue;
4209
- }
4210
- if (typeof messages === "string") {
4211
- errors[field] = [messages];
4212
- }
4213
- }
4214
- return errors;
4215
- }
4216
- function webErrorResponse(error, request) {
4217
- if (!request || !isViewsEnabled() || requestPrefersJson(request)) {
4218
- return null;
4219
- }
4220
- const mappedError = error instanceof HttpError ? error : mapDatabaseError(error);
4221
- if (mappedError instanceof UnauthorizedError) {
4222
- const redirectTarget = encodeURIComponent(new URL(request.url).pathname);
4223
- return Response.redirect(`/login?redirect=${redirectTarget}`, 302);
4224
- }
4225
- if (mappedError instanceof ValidationError) {
4226
- const errors = normalizeFieldErrors(mappedError.details);
4227
- const fieldSummary = Object.entries(errors).flatMap(([field, messages]) => messages.map((message) => `${field}: ${message}`)).join(`
4228
- `);
4229
- return htmlResponse(`<section class="page-header"><h1>Validation failed</h1><pre>${fieldSummary || mappedError.message}</pre><p><a href="javascript:history.back()">Go back</a></p></section>`, { status: mappedError.status });
4230
- }
4231
- return htmlResponse(`<section class="page-header"><h1>${mappedError.message}</h1></section>`, {
4232
- status: mappedError.status
4233
- });
4234
- }
4235
- // ../../src/core/http/resources.ts
4236
- function serializeDate(value) {
4237
- return value instanceof Date ? value.toISOString() : value;
4238
- }
4239
- function toResourceCollection(items, transformer) {
4240
- return items.map(transformer);
4241
- }
4242
- function toPaginatedResourceCollection(items, meta, transformer) {
4243
- return {
4244
- data: toResourceCollection(items, transformer),
4245
- meta
4246
- };
4247
- }
4248
- // ../../src/config/uploads.ts
4249
- var DEFAULT_MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
4250
- var ALLOWED_UPLOAD_MIME_TYPES = new Set([
4251
- "application/pdf",
4252
- "application/json",
4253
- "application/zip",
4254
- "application/x-zip-compressed",
4255
- "image/jpeg",
4256
- "image/png",
4257
- "image/gif",
4258
- "image/webp",
4259
- "text/plain",
4260
- "text/csv"
4261
- ]);
4262
- // ../../src/core/http/memoryThrottleMiddleware.ts
4263
- var buckets = new Map;
4264
- // ../../src/core/metrics/prometheus.ts
4265
- class PrometheusRegistry {
4266
- httpRequestsTotal = new Map;
4267
- httpRequestDurationMs = new Map;
4268
- incrementHttpRequest(labels) {
4269
- const key = this.metricKey(labels);
4270
- this.httpRequestsTotal.set(key, (this.httpRequestsTotal.get(key) ?? 0) + 1);
4271
- }
4272
- observeHttpDuration(labels, durationMs) {
4273
- const key = this.metricKey(labels);
4274
- const samples = this.httpRequestDurationMs.get(key) ?? [];
4275
- samples.push(durationMs);
4276
- this.httpRequestDurationMs.set(key, samples);
4277
- }
4278
- renderMetrics() {
4279
- const lines = [
4280
- "# HELP http_requests_total Total HTTP requests processed.",
4281
- "# TYPE http_requests_total counter"
4282
- ];
4283
- for (const [key, value] of this.httpRequestsTotal) {
4284
- lines.push(`http_requests_total{${key}} ${value}`);
4285
- }
4286
- lines.push("# HELP http_request_duration_ms_sum Sum of HTTP request durations in milliseconds.", "# TYPE http_request_duration_ms_sum counter");
4287
- for (const [key, samples] of this.httpRequestDurationMs) {
4288
- const sum = samples.reduce((total, sample) => total + sample, 0);
4289
- lines.push(`http_request_duration_ms_sum{${key}} ${sum}`);
4290
- }
4291
- return `${lines.join(`
4292
- `)}
4293
- `;
4294
- }
4295
- resetForTests() {
4296
- this.httpRequestsTotal.clear();
4297
- this.httpRequestDurationMs.clear();
4298
- }
4299
- getHttpRequestSummary() {
4300
- const byStatus = {};
4301
- const pathCounts = new Map;
4302
- let totalRequests = 0;
4303
- for (const [key, count] of this.httpRequestsTotal) {
4304
- totalRequests += count;
4305
- const method = key.match(/method="([^"]+)"/)?.[1] ?? "GET";
4306
- const path = key.match(/path="([^"]+)"/)?.[1] ?? "/";
4307
- const status = key.match(/status="([^"]+)"/)?.[1] ?? "200";
4308
- byStatus[status] = (byStatus[status] ?? 0) + count;
4309
- const pathKey = `${method} ${path}`;
4310
- const existing = pathCounts.get(pathKey);
4311
- if (existing) {
4312
- existing.count += count;
4313
- } else {
4314
- pathCounts.set(pathKey, { method, path, count });
4315
- }
4316
- }
4317
- const topPaths = Array.from(pathCounts.values()).sort((left, right) => right.count - left.count).slice(0, 10);
4318
- return {
4319
- totalRequests,
4320
- byStatus,
4321
- topPaths
4322
- };
4323
- }
4324
- metricKey(labels) {
4325
- return `method="${labels.method}",path="${labels.path}",status="${labels.status}"`;
4326
- }
4327
- }
4328
- var prometheusRegistry = new PrometheusRegistry;
4329
- // ../../src/config/app.ts
4330
- var appConfig = {
4331
- name: "WorkHub",
4332
- env: process.env.APP_ENV ?? "local",
4333
- debug: (process.env.APP_DEBUG ?? "true") !== "false",
4334
- url: process.env.APP_URL ?? "http://localhost:3000",
4335
- apiPrefix: process.env.API_PREFIX ?? "/api/v1"
4336
- };
4337
- // ../../src/core/http/parseFormBody.ts
4338
- async function parseFormBody(request) {
4339
- const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
4340
- if (!contentType.includes("application/x-www-form-urlencoded") && !contentType.includes("multipart/form-data")) {
4341
- throw new BadRequestError("Expected a form submission.");
4342
- }
4343
- return formDataToRecord(await request.formData());
4344
- }
4345
- function formDataToRecord(formData) {
4346
- const values = {};
4347
- for (const [key, value] of formData.entries()) {
4348
- if (typeof value === "string") {
4349
- values[key] = value;
4350
- }
4351
- }
4352
- return values;
4353
- }
4354
-
4355
- // ../../src/core/http/webFormRequest.ts
4356
- class WebFormRequest {
4357
- authorize(_request) {
4358
- return true;
4359
- }
4360
- validatePayload(payload) {
4361
- return this.parse(payload);
4362
- }
4363
- async validate(request) {
4364
- if (!await this.authorize(request)) {
4365
- throw new ForbiddenError;
4366
- }
4367
- const payload = requestPrefersJson(request) ? await parseJsonBody(request, (body) => body) : await parseFormBody(request);
4368
- try {
4369
- return this.validatePayload(payload);
4370
- } catch (error) {
4371
- if (error instanceof ValidationError) {
4372
- throw error;
4373
- }
4374
- throw error;
4375
- }
4376
- }
4377
- }
4378
- // ../../src/core/lifecycle/gracefulShutdown.ts
4379
- var shutdownHandlers = new Map;
4380
- var shutdownInstalled = false;
4381
- var shuttingDown = false;
4382
- function registerShutdownHandler(name, handler) {
4383
- shutdownHandlers.set(name, handler);
4384
- return () => {
4385
- shutdownHandlers.delete(name);
4386
- };
4387
- }
4388
- async function runGracefulShutdown(signal) {
4389
- if (shuttingDown) {
4390
- return;
4391
- }
4392
- shuttingDown = true;
4393
- console.log(`[shutdown] Received ${signal}, draining ${shutdownHandlers.size} handler(s)...`);
4394
- for (const [name, handler] of shutdownHandlers) {
4395
- try {
4396
- await handler();
4397
- console.log(`[shutdown] Completed ${name}`);
4398
- } catch (error) {
4399
- console.error(`[shutdown] Failed ${name}:`, error);
4400
- }
4401
- }
4402
- }
4403
- function installGracefulShutdownSignals(signals = ["SIGINT", "SIGTERM"]) {
4404
- if (shutdownInstalled) {
4405
- return;
4406
- }
4407
- shutdownInstalled = true;
4408
- for (const signal of signals) {
4409
- process.on(signal, () => {
4410
- runGracefulShutdown(signal).finally(() => {
4411
- process.exit(0);
4412
- });
4413
- });
4414
- }
4415
- }
4416
- function resetGracefulShutdownForTests() {
4417
- shutdownHandlers.clear();
4418
- shutdownInstalled = false;
4419
- shuttingDown = false;
4420
- }
4421
- // ../../src/core/queue/index.ts
4422
- class Job {
4423
- maxAttempts;
4424
- backoffMs;
4425
- priority;
4426
- }
4427
- // ../../src/core/queue/queueMetrics.ts
4428
- var {RedisClient: RedisClient3 } = globalThis.Bun;
4429
- async function readRedisQueueDepth(redisUrl) {
4430
- const client = new RedisClient3(redisUrl);
4431
- const [high, defaultQueue, low] = await Promise.all([
4432
- client.llen(QUEUE_HIGH_KEY),
4433
- client.llen(QUEUE_LIST_KEY),
4434
- client.llen(QUEUE_LOW_KEY)
4435
- ]);
4436
- return {
4437
- high: Number(high ?? 0),
4438
- default: Number(defaultQueue ?? 0),
4439
- low: Number(low ?? 0),
4440
- total: Number(high ?? 0) + Number(defaultQueue ?? 0) + Number(low ?? 0)
4441
- };
4442
- }
4443
- async function collectQueueMetrics() {
4444
- const driver = queueConfig.driver;
4445
- const failedJobs = createFailedJobService();
4446
- const failedCount = (await failedJobs.listRecent(1000)).length;
4447
- if (driver !== "redis") {
4448
- return {
4449
- driver,
4450
- pending: {
4451
- high: 0,
4452
- default: 0,
4453
- low: 0,
4454
- total: 0
4455
- },
4456
- failedCount
4457
- };
4458
- }
4459
- const redisUrl = process.env.REDIS_URL;
4460
- if (!redisUrl) {
4461
- return {
4462
- driver,
4463
- pending: {
4464
- high: 0,
4465
- default: 0,
4466
- low: 0,
4467
- total: 0
4468
- },
4469
- failedCount
4470
- };
4471
- }
4472
- return {
4473
- driver,
4474
- pending: await readRedisQueueDepth(redisUrl),
4475
- failedCount
4476
- };
4477
- }
4478
- // ../../src/core/scheduler/schedule.ts
4479
- class Schedule {
4480
- tasks = [];
4481
- command(expression, name, run) {
4482
- this.tasks.push({ expression, name, run });
4483
- return this;
4484
- }
4485
- dueTasks(now = new Date) {
4486
- const minute = now.getMinutes();
4487
- return this.tasks.filter((task) => {
4488
- if (task.expression === "* * * * *") {
4489
- return true;
4490
- }
4491
- if (task.expression.startsWith("*/")) {
4492
- const interval = Number.parseInt(task.expression.slice(2), 10);
4493
- return Number.isInteger(interval) && interval > 0 && minute % interval === 0;
4494
- }
4495
- return false;
4496
- });
4497
- }
4498
- tasksList() {
4499
- return [...this.tasks];
4500
- }
4501
- }
4502
- var appSchedule = new Schedule;
4503
- // ../../src/core/security/publicReads.ts
4504
- function isPublicReadsEnabled() {
4505
- return isFeatureEnabled("publicReads");
4506
- }
4507
- function guestCanViewResource() {
4508
- return isPublicReadsEnabled();
4509
- }
4510
- // ../../src/core/tracing/traceContext.ts
4511
- var traceContextStorage = createAsyncContextStore("@getstrata/traceContext");
4512
- // ../../src/core/validation/rules.ts
4513
- function required() {
4514
- return (field, value) => {
4515
- if (value === undefined || value === null || typeof value === "string" && value.trim() === "") {
4516
- return `"${field}" is required.`;
4517
- }
4518
- return;
4519
- };
4520
- }
4521
- function stringRule() {
4522
- return (field, value) => {
4523
- if (value === undefined || value === null) {
4524
- return;
4525
- }
4526
- if (typeof value !== "string") {
4527
- return `"${field}" must be a string.`;
4528
- }
4529
- return;
4530
- };
4531
- }
4532
- function minLength(minimum) {
4533
- return (field, value) => {
4534
- if (typeof value !== "string") {
4535
- return;
4536
- }
4537
- if (value.trim().length < minimum) {
4538
- return `"${field}" must be at least ${minimum} characters.`;
4539
- }
4540
- return;
4541
- };
4542
- }
4543
- function maxLength(maximum) {
4544
- return (field, value) => {
4545
- if (typeof value !== "string") {
4546
- return;
4547
- }
4548
- if (value.trim().length > maximum) {
4549
- return `"${field}" must be at most ${maximum} characters.`;
4550
- }
4551
- return;
4552
- };
4553
- }
4554
- function pattern(expression) {
4555
- return (field, value) => {
4556
- if (typeof value !== "string") {
4557
- return;
4558
- }
4559
- if (!expression.test(value.trim())) {
4560
- return `"${field}" has an invalid format.`;
4561
- }
4562
- return;
4563
- };
4564
- }
4565
- function enumRule(allowedValues) {
4566
- return (field, value) => {
4567
- if (typeof value !== "string") {
4568
- return;
4569
- }
4570
- if (!allowedValues.includes(value)) {
4571
- return `"${field}" must be one of: ${allowedValues.join(", ")}.`;
4572
- }
4573
- return;
4574
- };
4575
- }
4576
- function optional() {
4577
- return () => {
4578
- return;
4579
- };
4580
- }
4581
- function integerRule() {
4582
- return (field, value) => {
4583
- if (value === undefined || value === null || value === "") {
4584
- return;
4585
- }
4586
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
4587
- if (!Number.isInteger(parsed)) {
4588
- return `"${field}" must be an integer.`;
4589
- }
4590
- return;
4591
- };
4592
- }
4593
- function emailRule() {
4594
- return (field, value) => {
4595
- if (typeof value !== "string") {
4596
- return;
4597
- }
4598
- const normalized = value.trim();
4599
- if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
4600
- return `"${field}" must be a valid email address.`;
4601
- }
4602
- return;
4603
- };
4604
- }
4605
- function confirmed(fieldName) {
4606
- return (field, value, payload) => {
4607
- const confirmationKey = `${fieldName}_confirmation`;
4608
- const confirmation = payload[confirmationKey];
4609
- if (value !== confirmation) {
4610
- return `"${field}" confirmation does not match.`;
4611
- }
4612
- return;
4613
- };
4614
- }
4615
- function positiveIntegerRule() {
4616
- return (field, value) => {
4617
- if (value === undefined || value === null || value === "") {
4618
- return;
4619
- }
4620
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
4621
- if (!Number.isInteger(parsed) || parsed <= 0) {
4622
- return `"${field}" must be a positive integer.`;
4623
- }
4624
- return;
4625
- };
4626
- }
4627
- function integerRange(minimum, maximum) {
4628
- return (field, value) => {
4629
- if (value === undefined || value === null || value === "") {
4630
- return;
4631
- }
4632
- const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
4633
- if (!Number.isInteger(parsed) || parsed < minimum || parsed > maximum) {
4634
- return `"${field}" must be an integer between ${minimum} and ${maximum}.`;
4635
- }
4636
- return;
4637
- };
4638
- }
4639
- function validateObject(payload, schema) {
4640
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
4641
- throw new ValidationError("Request body must be a JSON object.");
4642
- }
4643
- const body = payload;
4644
- const errors = {};
4645
- const output = {};
4646
- for (const [field, rules] of Object.entries(schema)) {
4647
- const messages = rules.map((rule) => rule(field, body[field], body)).filter((message) => message !== undefined);
4648
- if (messages.length > 0) {
4649
- errors[field] = messages;
4650
- continue;
4651
- }
4652
- if (field in body && body[field] !== undefined) {
4653
- const rawValue = body[field];
4654
- output[field] = typeof rawValue === "string" ? rawValue.trim() : rawValue;
4655
- }
4656
- }
4657
- if (Object.keys(errors).length > 0) {
4658
- throw new ValidationError("The given data was invalid.", errors);
4659
- }
4660
- return output;
4661
- }
4662
- // ../../src/core/jobs/dispatchWebhookJob.ts
4663
- import { createHmac as createHmac5 } from "crypto";
4664
-
4665
- // ../../src/core/security/safeUrl.ts
4666
- import { lookup as dnsLookupImpl } from "dns/promises";
4667
- var dnsLookup = dnsLookupImpl;
4668
- var BLOCKED_HOSTNAMES = new Set([
4669
- "localhost",
4670
- "127.0.0.1",
4671
- "0.0.0.0",
4672
- "::1",
4673
- "metadata.google.internal"
4674
- ]);
4675
- function isPrivateIpv4(hostname) {
4676
- const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
4677
- if (!match) {
4678
- return false;
4679
- }
4680
- const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
4681
- if (octets.some((octet) => octet < 0 || octet > 255)) {
4682
- return true;
4683
- }
4684
- const [a = 0, b = 0] = octets;
4685
- if (a === 10) {
4686
- return true;
4687
- }
4688
- if (a === 127) {
4689
- return true;
4690
- }
4691
- if (a === 0) {
4692
- return true;
4693
- }
4694
- if (a === 169 && b === 254) {
4695
- return true;
4696
- }
4697
- if (a === 172 && b >= 16 && b <= 31) {
4698
- return true;
4699
- }
4700
- if (a === 192 && b === 168) {
4701
- return true;
4702
- }
4703
- return false;
4704
- }
4705
- function isBlockedHostname(hostname) {
4706
- const normalized = hostname.trim().toLowerCase();
4707
- if (normalized.length === 0) {
4708
- return true;
4709
- }
4710
- if (BLOCKED_HOSTNAMES.has(normalized)) {
4711
- return true;
4712
- }
4713
- if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
4714
- return true;
4715
- }
4716
- if (normalized.includes(":")) {
4717
- return true;
4718
- }
4719
- return isPrivateIpv4(normalized);
4720
- }
4721
- function assertSafeOutboundUrl(rawUrl, options = {}) {
4722
- let parsed;
4723
- try {
4724
- parsed = new URL(rawUrl);
4725
- } catch {
4726
- throw new BadRequestError("Webhook URL is invalid.");
4727
- }
4728
- if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
4729
- throw new BadRequestError("Webhook URL must use HTTPS.");
4730
- }
4731
- if (parsed.username || parsed.password) {
4732
- throw new BadRequestError("Webhook URL must not include credentials.");
4733
- }
4734
- if (isBlockedHostname(parsed.hostname)) {
4735
- throw new BadRequestError("Webhook URL targets a blocked host.");
4736
- }
4737
- return parsed;
4738
- }
4739
- function isBlockedIpAddress(address) {
4740
- return isBlockedHostname(address.trim().toLowerCase());
4741
- }
4742
- async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
4743
- const parsed = assertSafeOutboundUrl(rawUrl, options);
4744
- if (options.resolveDns === false) {
4745
- return parsed;
4746
- }
4747
- const hostname = parsed.hostname.trim().toLowerCase();
4748
- const results = await dnsLookup(hostname, { all: true, verbatim: true });
4749
- if (results.some((result) => isBlockedIpAddress(result.address))) {
4750
- throw new BadRequestError("Webhook URL targets a blocked host.");
4751
- }
4752
- return parsed;
4753
- }
4754
- function setDnsLookupForTests(lookupFn) {
4755
- dnsLookup = lookupFn;
4756
- }
4757
- function resetDnsLookupForTests() {
4758
- dnsLookup = dnsLookupImpl;
4759
- }
4760
-
4761
- // ../../src/core/security/safeFetch.ts
4762
- var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
4763
- async function safeFetch(input, init = {}, options = {}) {
4764
- const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
4765
- const maxRedirects = options.maxRedirects ?? 0;
4766
- const resolveDns = options.resolveDns ?? appConfig.env === "production";
4767
- const urlOptions = { allowHttp: options.allowHttp, resolveDns };
4768
- const controller = new AbortController;
4769
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
4770
- try {
4771
- let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
4772
- let redirectCount = 0;
4773
- while (true) {
4774
- const response = await fetch(currentUrl, {
4775
- ...init,
4776
- signal: controller.signal,
4777
- redirect: "manual"
4778
- });
4779
- if (response.status >= 300 && response.status < 400) {
4780
- const location = response.headers.get("location");
4781
- if (!location || redirectCount >= maxRedirects) {
4782
- return response;
4783
- }
4784
- currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
4785
- redirectCount += 1;
4786
- continue;
4787
- }
4788
- return response;
4789
- }
4790
- } finally {
4791
- clearTimeout(timeout);
4792
- }
4793
- }
4794
-
4795
- // ../../src/core/jobs/dispatchWebhookJob.ts
4796
- class DispatchWebhookJob extends Job {
4797
- maxAttempts = 3;
4798
- backoffMs = 2000;
4799
- async handle(payload) {
4800
- const rows = await repositoryConnection`
4801
- SELECT id, url, secret
4802
- FROM webhook
4803
- WHERE id = ${payload.webhookId} AND active = TRUE
4804
- LIMIT 1
4805
- `;
4806
- const webhook = rows[0];
4807
- if (!webhook) {
4808
- return;
4809
- }
4810
- const body = JSON.stringify({ event: payload.event, payload: payload.payload });
4811
- const signature = createHmac5("sha256", webhook.secret).update(body).digest("hex");
4812
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
4813
- let responseStatus = null;
4814
- let errorMessage = null;
4815
- try {
4816
- const response = await safeFetch(webhook.url, {
4817
- method: "POST",
4818
- headers: {
4819
- "content-type": "application/json",
4820
- "x-workhub-signature": signature
4821
- },
4822
- body
4823
- }, { allowHttp: appConfig.env !== "production" });
4824
- responseStatus = response.status;
4825
- if (!response.ok) {
4826
- throw new Error(`Webhook delivery failed with status ${response.status}.`);
4827
- }
4828
- } catch (error) {
4829
- errorMessage = error instanceof Error ? error.message : String(error);
4830
- await repositoryConnection`
4831
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
4832
- VALUES (
4833
- ${webhook.id},
4834
- ${payload.event},
4835
- ${JSON.stringify(payload.payload)}::jsonb,
4836
- ${responseStatus},
4837
- ${errorMessage}
4838
- )
4839
- `;
4840
- throw error instanceof Error ? error : new Error(errorMessage);
4841
- }
4842
- await repositoryConnection`
4843
- INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
4844
- VALUES (
4845
- ${webhook.id},
4846
- ${payload.event},
4847
- ${JSON.stringify(payload.payload)}::jsonb,
4848
- ${responseStatus}
4849
- )
4850
- `;
4851
- }
4852
- }
4853
- var dispatchWebhookJob_default = DispatchWebhookJob;
4854
-
4855
- // ../../src/core/jobs/invalidateCacheTagsJob.ts
4856
- class InvalidateCacheTagsJob extends Job {
4857
- cache;
4858
- constructor(cache2) {
4859
- super();
4860
- this.cache = cache2;
4861
- }
4862
- async handle(payload) {
4863
- await this.cache.tags(...payload.tags).flush();
4864
- }
4865
- }
4866
- var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
4867
-
4868
2447
  // ../../src/bootstrap/queue/defaultJobs.ts
4869
2448
  function registerDefaultJobs() {
4870
2449
  jobRegistry.register("cache.invalidate-tags", () => {