@getstrata/core 0.5.17 → 0.5.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/auth/authContext.d.ts +1 -2
- package/dist/core/auth/membershipContext.d.ts +1 -2
- package/dist/core/http/requestMetaContext.d.ts +1 -2
- package/dist/core/runtime/asyncContextStore.d.ts +3 -0
- package/dist/core/tenant/tenantContext.d.ts +1 -2
- package/dist/core/tracing/traceContext.d.ts +1 -2
- package/dist/entries/http/csrfToken.js +15 -2
- package/dist/entries/http/webErrorResponse.js +19 -8
- package/dist/entries/http/webFormRequest.js +16 -4
- package/dist/entries/jobs/dispatchWebhookJob.js +15 -2
- package/dist/entries/queue/createAppQueue.js +2609 -226
- package/dist/entries/queue/publicQueue.js +15 -2
- package/dist/entries/queue/queueMetrics.js +2560 -228
- package/dist/entries/view.js +19 -8
- package/dist/index.js +35 -16
- package/package.json +1 -1
|
@@ -644,9 +644,22 @@ function getBoundDatabaseConnection() {
|
|
|
644
644
|
return boundConnectionHolder.connection;
|
|
645
645
|
}
|
|
646
646
|
|
|
647
|
-
// ../../src/core/
|
|
647
|
+
// ../../src/core/runtime/asyncContextStore.ts
|
|
648
648
|
import { AsyncLocalStorage } from "async_hooks";
|
|
649
|
-
|
|
649
|
+
function createAsyncContextStore(key) {
|
|
650
|
+
const symbol = Symbol.for(key);
|
|
651
|
+
const globalRecord = globalThis;
|
|
652
|
+
const existing = globalRecord[symbol];
|
|
653
|
+
if (existing) {
|
|
654
|
+
return existing;
|
|
655
|
+
}
|
|
656
|
+
const store = new AsyncLocalStorage;
|
|
657
|
+
globalRecord[symbol] = store;
|
|
658
|
+
return store;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ../../src/core/database/connectionContext.ts
|
|
662
|
+
var activeConnection = createAsyncContextStore("@getstrata/databaseConnectionContext");
|
|
650
663
|
function getActiveDatabaseConnection(fallback) {
|
|
651
664
|
return activeConnection.getStore() ?? fallback;
|
|
652
665
|
}
|
|
@@ -2140,228 +2153,6 @@ function createQueueWorker(redisUrl, failedJobs = createFailedJobService()) {
|
|
|
2140
2153
|
return new QueueWorker(redisUrl, failedJobs);
|
|
2141
2154
|
}
|
|
2142
2155
|
|
|
2143
|
-
// ../../src/core/jobs/dispatchWebhookJob.ts
|
|
2144
|
-
import { createHmac } from "crypto";
|
|
2145
|
-
|
|
2146
|
-
// ../../src/config/app.ts
|
|
2147
|
-
var appConfig = {
|
|
2148
|
-
name: "WorkHub",
|
|
2149
|
-
env: process.env.APP_ENV ?? "local",
|
|
2150
|
-
debug: (process.env.APP_DEBUG ?? "true") !== "false",
|
|
2151
|
-
url: process.env.APP_URL ?? "http://localhost:3000",
|
|
2152
|
-
apiPrefix: process.env.API_PREFIX ?? "/api/v1"
|
|
2153
|
-
};
|
|
2154
|
-
|
|
2155
|
-
// ../../src/core/queue/index.ts
|
|
2156
|
-
class Job {
|
|
2157
|
-
maxAttempts;
|
|
2158
|
-
backoffMs;
|
|
2159
|
-
priority;
|
|
2160
|
-
}
|
|
2161
|
-
|
|
2162
|
-
// ../../src/core/security/safeUrl.ts
|
|
2163
|
-
import { lookup as dnsLookupImpl } from "dns/promises";
|
|
2164
|
-
var dnsLookup = dnsLookupImpl;
|
|
2165
|
-
var BLOCKED_HOSTNAMES = new Set([
|
|
2166
|
-
"localhost",
|
|
2167
|
-
"127.0.0.1",
|
|
2168
|
-
"0.0.0.0",
|
|
2169
|
-
"::1",
|
|
2170
|
-
"metadata.google.internal"
|
|
2171
|
-
]);
|
|
2172
|
-
function isPrivateIpv4(hostname) {
|
|
2173
|
-
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
|
|
2174
|
-
if (!match) {
|
|
2175
|
-
return false;
|
|
2176
|
-
}
|
|
2177
|
-
const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
|
|
2178
|
-
if (octets.some((octet) => octet < 0 || octet > 255)) {
|
|
2179
|
-
return true;
|
|
2180
|
-
}
|
|
2181
|
-
const [a = 0, b = 0] = octets;
|
|
2182
|
-
if (a === 10) {
|
|
2183
|
-
return true;
|
|
2184
|
-
}
|
|
2185
|
-
if (a === 127) {
|
|
2186
|
-
return true;
|
|
2187
|
-
}
|
|
2188
|
-
if (a === 0) {
|
|
2189
|
-
return true;
|
|
2190
|
-
}
|
|
2191
|
-
if (a === 169 && b === 254) {
|
|
2192
|
-
return true;
|
|
2193
|
-
}
|
|
2194
|
-
if (a === 172 && b >= 16 && b <= 31) {
|
|
2195
|
-
return true;
|
|
2196
|
-
}
|
|
2197
|
-
if (a === 192 && b === 168) {
|
|
2198
|
-
return true;
|
|
2199
|
-
}
|
|
2200
|
-
return false;
|
|
2201
|
-
}
|
|
2202
|
-
function isBlockedHostname(hostname) {
|
|
2203
|
-
const normalized = hostname.trim().toLowerCase();
|
|
2204
|
-
if (normalized.length === 0) {
|
|
2205
|
-
return true;
|
|
2206
|
-
}
|
|
2207
|
-
if (BLOCKED_HOSTNAMES.has(normalized)) {
|
|
2208
|
-
return true;
|
|
2209
|
-
}
|
|
2210
|
-
if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
|
|
2211
|
-
return true;
|
|
2212
|
-
}
|
|
2213
|
-
if (normalized.includes(":")) {
|
|
2214
|
-
return true;
|
|
2215
|
-
}
|
|
2216
|
-
return isPrivateIpv4(normalized);
|
|
2217
|
-
}
|
|
2218
|
-
function assertSafeOutboundUrl(rawUrl, options = {}) {
|
|
2219
|
-
let parsed;
|
|
2220
|
-
try {
|
|
2221
|
-
parsed = new URL(rawUrl);
|
|
2222
|
-
} catch {
|
|
2223
|
-
throw new BadRequestError("Webhook URL is invalid.");
|
|
2224
|
-
}
|
|
2225
|
-
if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
|
|
2226
|
-
throw new BadRequestError("Webhook URL must use HTTPS.");
|
|
2227
|
-
}
|
|
2228
|
-
if (parsed.username || parsed.password) {
|
|
2229
|
-
throw new BadRequestError("Webhook URL must not include credentials.");
|
|
2230
|
-
}
|
|
2231
|
-
if (isBlockedHostname(parsed.hostname)) {
|
|
2232
|
-
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
2233
|
-
}
|
|
2234
|
-
return parsed;
|
|
2235
|
-
}
|
|
2236
|
-
function isBlockedIpAddress(address) {
|
|
2237
|
-
return isBlockedHostname(address.trim().toLowerCase());
|
|
2238
|
-
}
|
|
2239
|
-
async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
|
|
2240
|
-
const parsed = assertSafeOutboundUrl(rawUrl, options);
|
|
2241
|
-
if (options.resolveDns === false) {
|
|
2242
|
-
return parsed;
|
|
2243
|
-
}
|
|
2244
|
-
const hostname = parsed.hostname.trim().toLowerCase();
|
|
2245
|
-
const results = await dnsLookup(hostname, { all: true, verbatim: true });
|
|
2246
|
-
if (results.some((result) => isBlockedIpAddress(result.address))) {
|
|
2247
|
-
throw new BadRequestError("Webhook URL targets a blocked host.");
|
|
2248
|
-
}
|
|
2249
|
-
return parsed;
|
|
2250
|
-
}
|
|
2251
|
-
function setDnsLookupForTests(lookupFn) {
|
|
2252
|
-
dnsLookup = lookupFn;
|
|
2253
|
-
}
|
|
2254
|
-
function resetDnsLookupForTests() {
|
|
2255
|
-
dnsLookup = dnsLookupImpl;
|
|
2256
|
-
}
|
|
2257
|
-
|
|
2258
|
-
// ../../src/core/security/safeFetch.ts
|
|
2259
|
-
var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
|
|
2260
|
-
async function safeFetch(input, init = {}, options = {}) {
|
|
2261
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
2262
|
-
const maxRedirects = options.maxRedirects ?? 0;
|
|
2263
|
-
const resolveDns = options.resolveDns ?? appConfig.env === "production";
|
|
2264
|
-
const urlOptions = { allowHttp: options.allowHttp, resolveDns };
|
|
2265
|
-
const controller = new AbortController;
|
|
2266
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
2267
|
-
try {
|
|
2268
|
-
let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
|
|
2269
|
-
let redirectCount = 0;
|
|
2270
|
-
while (true) {
|
|
2271
|
-
const response = await fetch(currentUrl, {
|
|
2272
|
-
...init,
|
|
2273
|
-
signal: controller.signal,
|
|
2274
|
-
redirect: "manual"
|
|
2275
|
-
});
|
|
2276
|
-
if (response.status >= 300 && response.status < 400) {
|
|
2277
|
-
const location = response.headers.get("location");
|
|
2278
|
-
if (!location || redirectCount >= maxRedirects) {
|
|
2279
|
-
return response;
|
|
2280
|
-
}
|
|
2281
|
-
currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
|
|
2282
|
-
redirectCount += 1;
|
|
2283
|
-
continue;
|
|
2284
|
-
}
|
|
2285
|
-
return response;
|
|
2286
|
-
}
|
|
2287
|
-
} finally {
|
|
2288
|
-
clearTimeout(timeout);
|
|
2289
|
-
}
|
|
2290
|
-
}
|
|
2291
|
-
|
|
2292
|
-
// ../../src/core/jobs/dispatchWebhookJob.ts
|
|
2293
|
-
class DispatchWebhookJob extends Job {
|
|
2294
|
-
maxAttempts = 3;
|
|
2295
|
-
backoffMs = 2000;
|
|
2296
|
-
async handle(payload) {
|
|
2297
|
-
const rows = await repositoryConnection`
|
|
2298
|
-
SELECT id, url, secret
|
|
2299
|
-
FROM webhook
|
|
2300
|
-
WHERE id = ${payload.webhookId} AND active = TRUE
|
|
2301
|
-
LIMIT 1
|
|
2302
|
-
`;
|
|
2303
|
-
const webhook = rows[0];
|
|
2304
|
-
if (!webhook) {
|
|
2305
|
-
return;
|
|
2306
|
-
}
|
|
2307
|
-
const body = JSON.stringify({ event: payload.event, payload: payload.payload });
|
|
2308
|
-
const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
|
|
2309
|
-
assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
|
|
2310
|
-
let responseStatus = null;
|
|
2311
|
-
let errorMessage = null;
|
|
2312
|
-
try {
|
|
2313
|
-
const response = await safeFetch(webhook.url, {
|
|
2314
|
-
method: "POST",
|
|
2315
|
-
headers: {
|
|
2316
|
-
"content-type": "application/json",
|
|
2317
|
-
"x-workhub-signature": signature
|
|
2318
|
-
},
|
|
2319
|
-
body
|
|
2320
|
-
}, { allowHttp: appConfig.env !== "production" });
|
|
2321
|
-
responseStatus = response.status;
|
|
2322
|
-
if (!response.ok) {
|
|
2323
|
-
throw new Error(`Webhook delivery failed with status ${response.status}.`);
|
|
2324
|
-
}
|
|
2325
|
-
} catch (error) {
|
|
2326
|
-
errorMessage = error instanceof Error ? error.message : String(error);
|
|
2327
|
-
await repositoryConnection`
|
|
2328
|
-
INSERT INTO webhook_delivery (webhook_id, event, payload, response_status, error)
|
|
2329
|
-
VALUES (
|
|
2330
|
-
${webhook.id},
|
|
2331
|
-
${payload.event},
|
|
2332
|
-
${JSON.stringify(payload.payload)}::jsonb,
|
|
2333
|
-
${responseStatus},
|
|
2334
|
-
${errorMessage}
|
|
2335
|
-
)
|
|
2336
|
-
`;
|
|
2337
|
-
throw error instanceof Error ? error : new Error(errorMessage);
|
|
2338
|
-
}
|
|
2339
|
-
await repositoryConnection`
|
|
2340
|
-
INSERT INTO webhook_delivery (webhook_id, event, payload, response_status)
|
|
2341
|
-
VALUES (
|
|
2342
|
-
${webhook.id},
|
|
2343
|
-
${payload.event},
|
|
2344
|
-
${JSON.stringify(payload.payload)}::jsonb,
|
|
2345
|
-
${responseStatus}
|
|
2346
|
-
)
|
|
2347
|
-
`;
|
|
2348
|
-
}
|
|
2349
|
-
}
|
|
2350
|
-
var dispatchWebhookJob_default = DispatchWebhookJob;
|
|
2351
|
-
|
|
2352
|
-
// ../../src/core/jobs/invalidateCacheTagsJob.ts
|
|
2353
|
-
class InvalidateCacheTagsJob extends Job {
|
|
2354
|
-
cache;
|
|
2355
|
-
constructor(cache) {
|
|
2356
|
-
super();
|
|
2357
|
-
this.cache = cache;
|
|
2358
|
-
}
|
|
2359
|
-
async handle(payload) {
|
|
2360
|
-
await this.cache.tags(...payload.tags).flush();
|
|
2361
|
-
}
|
|
2362
|
-
}
|
|
2363
|
-
var invalidateCacheTagsJob_default = InvalidateCacheTagsJob;
|
|
2364
|
-
|
|
2365
2156
|
// ../../src/core/logging/logger.ts
|
|
2366
2157
|
class Logger {
|
|
2367
2158
|
channel;
|
|
@@ -2471,16 +2262,2608 @@ function getRequiredDependency(dependencies, key) {
|
|
|
2471
2262
|
}
|
|
2472
2263
|
|
|
2473
2264
|
// ../../src/bootstrap/applicationRegistry.ts
|
|
2265
|
+
var APPLICATION_CONTEXT_KEY = Symbol.for("@getstrata/applicationContext");
|
|
2474
2266
|
var activeContext;
|
|
2267
|
+
function readStoredApplicationContext() {
|
|
2268
|
+
if (activeContext) {
|
|
2269
|
+
return activeContext;
|
|
2270
|
+
}
|
|
2271
|
+
const globalContext = globalThis[APPLICATION_CONTEXT_KEY];
|
|
2272
|
+
if (globalContext) {
|
|
2273
|
+
activeContext = globalContext;
|
|
2274
|
+
}
|
|
2275
|
+
return activeContext;
|
|
2276
|
+
}
|
|
2475
2277
|
function requireActiveApplicationContext() {
|
|
2476
|
-
|
|
2278
|
+
const context = readStoredApplicationContext();
|
|
2279
|
+
if (!context) {
|
|
2477
2280
|
throw new Error("The application context has not been bootstrapped.");
|
|
2478
2281
|
}
|
|
2479
|
-
return
|
|
2282
|
+
return context;
|
|
2480
2283
|
}
|
|
2481
2284
|
function resolveApplicationCache() {
|
|
2482
2285
|
return getRequiredDependency(requireActiveApplicationContext().dependencies, "cache");
|
|
2483
2286
|
}
|
|
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;
|
|
2484
4867
|
|
|
2485
4868
|
// ../../src/bootstrap/queue/defaultJobs.ts
|
|
2486
4869
|
function registerDefaultJobs() {
|