@getstrata/core 0.5.61 → 0.5.63

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.
@@ -0,0 +1,340 @@
1
+ // @bun
2
+ // ../../src/core/audit/exportAuditLogs.ts
3
+ import { repositoryConnection as db2 } from "@getstrata/core/database/repositoryConnection";
4
+
5
+ // ../../src/config/app.ts
6
+ var appConfig = {
7
+ name: process.env.APP_NAME?.trim() || "WorkHub",
8
+ env: process.env.APP_ENV ?? "local",
9
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
10
+ url: process.env.APP_URL ?? "http://localhost:3000",
11
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
12
+ };
13
+
14
+ // ../../src/core/security/safeUrl.ts
15
+ import { lookup as dnsLookupImpl } from "dns/promises";
16
+ import { BadRequestError } from "@getstrata/core/errors/http";
17
+ var dnsLookup = dnsLookupImpl;
18
+ var BLOCKED_HOSTNAMES = new Set([
19
+ "localhost",
20
+ "127.0.0.1",
21
+ "0.0.0.0",
22
+ "::1",
23
+ "metadata.google.internal"
24
+ ]);
25
+ function isPrivateIpv4(hostname) {
26
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(hostname);
27
+ if (!match) {
28
+ return false;
29
+ }
30
+ const octets = match.slice(1, 5).map((part) => Number.parseInt(part, 10));
31
+ if (octets.some((octet) => octet < 0 || octet > 255)) {
32
+ return true;
33
+ }
34
+ const [a = 0, b = 0] = octets;
35
+ if (a === 10) {
36
+ return true;
37
+ }
38
+ if (a === 127) {
39
+ return true;
40
+ }
41
+ if (a === 0) {
42
+ return true;
43
+ }
44
+ if (a === 169 && b === 254) {
45
+ return true;
46
+ }
47
+ if (a === 172 && b >= 16 && b <= 31) {
48
+ return true;
49
+ }
50
+ if (a === 192 && b === 168) {
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ function isBlockedHostname(hostname) {
56
+ const normalized = hostname.trim().toLowerCase();
57
+ if (normalized.length === 0) {
58
+ return true;
59
+ }
60
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
61
+ return true;
62
+ }
63
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
64
+ return true;
65
+ }
66
+ if (normalized.includes(":")) {
67
+ return true;
68
+ }
69
+ return isPrivateIpv4(normalized);
70
+ }
71
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
72
+ let parsed;
73
+ try {
74
+ parsed = new URL(rawUrl);
75
+ } catch {
76
+ throw new BadRequestError("Webhook URL is invalid.");
77
+ }
78
+ if (parsed.protocol !== "https:" && !(options.allowHttp && parsed.protocol === "http:")) {
79
+ throw new BadRequestError("Webhook URL must use HTTPS.");
80
+ }
81
+ if (parsed.username || parsed.password) {
82
+ throw new BadRequestError("Webhook URL must not include credentials.");
83
+ }
84
+ if (isBlockedHostname(parsed.hostname)) {
85
+ throw new BadRequestError("Webhook URL targets a blocked host.");
86
+ }
87
+ return parsed;
88
+ }
89
+ function isBlockedIpAddress(address) {
90
+ return isBlockedHostname(address.trim().toLowerCase());
91
+ }
92
+ async function assertSafeOutboundUrlResolved(rawUrl, options = {}) {
93
+ const parsed = assertSafeOutboundUrl(rawUrl, options);
94
+ if (options.resolveDns === false) {
95
+ return parsed;
96
+ }
97
+ const hostname = parsed.hostname.trim().toLowerCase();
98
+ const results = await dnsLookup(hostname, { all: true, verbatim: true });
99
+ if (results.some((result) => isBlockedIpAddress(result.address))) {
100
+ throw new BadRequestError("Webhook URL targets a blocked host.");
101
+ }
102
+ return parsed;
103
+ }
104
+ function setDnsLookupForTests(lookupFn) {
105
+ dnsLookup = lookupFn;
106
+ }
107
+ function resetDnsLookupForTests() {
108
+ dnsLookup = dnsLookupImpl;
109
+ }
110
+
111
+ // ../../src/core/security/safeFetch.ts
112
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
113
+ async function safeFetch(input, init = {}, options = {}) {
114
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
115
+ const maxRedirects = options.maxRedirects ?? 0;
116
+ const resolveDns = options.resolveDns ?? appConfig.env === "production";
117
+ const urlOptions = { allowHttp: options.allowHttp, resolveDns };
118
+ const controller = new AbortController;
119
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
120
+ try {
121
+ let currentUrl = (await assertSafeOutboundUrlResolved(input, urlOptions)).toString();
122
+ let redirectCount = 0;
123
+ while (true) {
124
+ const response = await fetch(currentUrl, {
125
+ ...init,
126
+ signal: controller.signal,
127
+ redirect: "manual"
128
+ });
129
+ if (response.status >= 300 && response.status < 400) {
130
+ const location = response.headers.get("location");
131
+ if (!location || redirectCount >= maxRedirects) {
132
+ return response;
133
+ }
134
+ currentUrl = (await assertSafeOutboundUrlResolved(new URL(location, currentUrl).toString(), urlOptions)).toString();
135
+ redirectCount += 1;
136
+ continue;
137
+ }
138
+ return response;
139
+ }
140
+ } finally {
141
+ clearTimeout(timeout);
142
+ }
143
+ }
144
+
145
+ // ../../src/core/tenant/databaseTenantContext.ts
146
+ import { repositoryConnection as db } from "@getstrata/core/database/repositoryConnection";
147
+ async function runWithMigrationBypass(callback) {
148
+ await db`SELECT set_config('app.bypass_rls', 'true', false)`;
149
+ try {
150
+ return await callback();
151
+ } finally {
152
+ await db`SELECT set_config('app.bypass_rls', 'false', false)`;
153
+ }
154
+ }
155
+
156
+ // ../../src/core/audit/siemFormatter.ts
157
+ function formatSiemAuditEvent(input) {
158
+ return {
159
+ timestamp: input.created_at.toISOString(),
160
+ event_type: "workhub.audit",
161
+ actor_user_id: input.user_id,
162
+ tenant_id: input.tenant_id ?? null,
163
+ trace_id: input.trace_id ?? null,
164
+ action: input.action,
165
+ subject_type: input.subject_type,
166
+ subject_id: input.subject_id,
167
+ ip_address: input.ip_address ?? null,
168
+ user_agent: input.user_agent ?? null,
169
+ checksum: input.checksum ?? null,
170
+ payload: input.payload ?? {}
171
+ };
172
+ }
173
+ function formatCefLine(event) {
174
+ const extension = [
175
+ `rt=${event.timestamp}`,
176
+ `suid=${event.actor_user_id ?? "unknown"}`,
177
+ `cs1=${event.action}`,
178
+ `cs1Label=Action`,
179
+ `cs2=${event.subject_type}`,
180
+ `cs2Label=SubjectType`,
181
+ `cs3=${event.subject_id ?? ""}`,
182
+ `cs3Label=SubjectId`,
183
+ `src=${event.ip_address ?? ""}`,
184
+ `request=${event.trace_id ?? ""}`
185
+ ].join(" ");
186
+ return `CEF:0|WorkHub|API|1.0|${event.action}|${event.subject_type}|5|${extension}`;
187
+ }
188
+
189
+ // ../../src/core/audit/exportAuditLogs.ts
190
+ function resolveAuditExportConfig() {
191
+ const endpoint = process.env.SIEM_EXPORT_URL?.trim();
192
+ if (!endpoint) {
193
+ return null;
194
+ }
195
+ assertSafeOutboundUrl(endpoint, { allowHttp: appConfig.env !== "production" });
196
+ const batchSize = Number(process.env.SIEM_EXPORT_BATCH_SIZE ?? "100");
197
+ return {
198
+ endpoint,
199
+ format: process.env.SIEM_EXPORT_FORMAT === "cef" ? "cef" : "json",
200
+ batchSize: Number.isFinite(batchSize) ? batchSize : 100
201
+ };
202
+ }
203
+ async function exportPendingAuditLogs() {
204
+ const config = resolveAuditExportConfig();
205
+ if (!config) {
206
+ return 0;
207
+ }
208
+ return await runWithMigrationBypass(async () => {
209
+ const rows = await db2`
210
+ SELECT
211
+ id,
212
+ user_id,
213
+ action,
214
+ subject_type,
215
+ subject_id,
216
+ payload,
217
+ ip_address,
218
+ user_agent,
219
+ checksum,
220
+ tenant_id,
221
+ trace_id,
222
+ created_at
223
+ FROM audit_log
224
+ WHERE exported_at IS NULL
225
+ ORDER BY id
226
+ LIMIT ${config.batchSize}
227
+ `;
228
+ if (rows.length === 0) {
229
+ return 0;
230
+ }
231
+ const events = rows.map((row) => formatSiemAuditEvent({
232
+ action: row.action,
233
+ subject_type: row.subject_type,
234
+ subject_id: row.subject_id,
235
+ user_id: row.user_id,
236
+ tenant_id: row.tenant_id,
237
+ trace_id: row.trace_id,
238
+ ip_address: row.ip_address,
239
+ user_agent: row.user_agent,
240
+ checksum: row.checksum,
241
+ payload: row.payload,
242
+ created_at: row.created_at
243
+ }));
244
+ const body = config.format === "cef" ? events.map((event) => formatCefLine(event)).join(`
245
+ `) : JSON.stringify({ events });
246
+ const response = await safeFetch(config.endpoint, {
247
+ method: "POST",
248
+ headers: {
249
+ "content-type": config.format === "cef" ? "text/plain" : "application/json",
250
+ ...process.env.SIEM_EXPORT_TOKEN ? { authorization: `Bearer ${process.env.SIEM_EXPORT_TOKEN}` } : {}
251
+ },
252
+ body
253
+ }, { allowHttp: appConfig.env !== "production" });
254
+ if (!response.ok) {
255
+ throw new Error(`SIEM export failed with status ${response.status}.`);
256
+ }
257
+ const ids = rows.map((row) => row.id);
258
+ for (const id of ids) {
259
+ await db2`UPDATE audit_log SET exported_at = NOW() WHERE id = ${id}`;
260
+ }
261
+ return rows.length;
262
+ });
263
+ }
264
+
265
+ // ../../src/core/logging/logger.ts
266
+ class Logger {
267
+ channel;
268
+ constructor(channel = "app") {
269
+ this.channel = channel;
270
+ }
271
+ write(level, message, context = {}) {
272
+ const entry = {
273
+ level,
274
+ channel: this.channel,
275
+ message,
276
+ timestamp: new Date().toISOString(),
277
+ ...context
278
+ };
279
+ const line = JSON.stringify(entry);
280
+ if (level === "error") {
281
+ console.error(line);
282
+ return;
283
+ }
284
+ console.log(line);
285
+ }
286
+ debug(message, context) {
287
+ this.write("debug", message, context);
288
+ }
289
+ info(message, context) {
290
+ this.write("info", message, context);
291
+ }
292
+ warn(message, context) {
293
+ this.write("warn", message, context);
294
+ }
295
+ error(message, context) {
296
+ this.write("error", message, context);
297
+ }
298
+ }
299
+ var appLogger = new Logger("app");
300
+
301
+ // ../../src/core/queue/index.ts
302
+ class Job {
303
+ maxAttempts;
304
+ backoffMs;
305
+ priority;
306
+ }
307
+
308
+ class SyncQueue {
309
+ async dispatch(job, payload) {
310
+ await job.handle(payload);
311
+ }
312
+ }
313
+
314
+ class AsyncQueue {
315
+ async dispatch(job, payload) {
316
+ setTimeout(() => {
317
+ job.handle(payload).catch((error) => {
318
+ console.error("[AsyncQueue] Job failed:", error);
319
+ });
320
+ }, 0);
321
+ }
322
+ }
323
+ function createQueue(driver) {
324
+ return driver === "async" ? new AsyncQueue : new SyncQueue;
325
+ }
326
+
327
+ // ../../src/core/jobs/exportAuditLogsJob.ts
328
+ class ExportAuditLogsJob extends Job {
329
+ maxAttempts = 2;
330
+ backoffMs = 5000;
331
+ async handle(_payload = {}) {
332
+ const exported = await exportPendingAuditLogs();
333
+ if (exported > 0) {
334
+ appLogger.info(`Exported ${exported} audit log entries to SIEM.`);
335
+ }
336
+ }
337
+ }
338
+ export {
339
+ ExportAuditLogsJob
340
+ };
@@ -1,27 +1,77 @@
1
1
  // @bun
2
- // ../../src/core/mail/markdownMail.ts
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
3
23
  function escapeHtml(value) {
4
24
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5
25
  }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+
59
+ // ../../src/core/mail/markdownMail.ts
60
+ function escapeHtml2(value) {
61
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
62
+ }
6
63
  function stripMarkdown(markdown) {
7
64
  return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
8
65
 
9
66
  `).trim();
10
67
  }
11
68
  function markdownToHtml(markdown) {
12
- const escaped = markdown.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
- return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
14
- if (block.startsWith("<")) {
15
- return block;
16
- }
17
- return `<p>${block.replace(/\n/g, " ")}</p>`;
18
- }).join(`
19
- `);
69
+ return sanitizeMailHtml(Bun.markdown.html(markdown));
20
70
  }
21
71
  function wrapMarkdownMailLayout(bodyHtml, options = {}) {
22
- const title = escapeHtml(options.title ?? "GetStrata");
23
- const preview = escapeHtml(options.preview ?? "");
24
- const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
72
+ const title = escapeHtml2(options.title ?? "GetStrata");
73
+ const preview = escapeHtml2(options.preview ?? "");
74
+ const footer = escapeHtml2(options.footer ?? "Sent by GetStrata");
25
75
  return `<!DOCTYPE html>
26
76
  <html lang="en">
27
77
  <head>
@@ -1,27 +1,77 @@
1
1
  // @bun
2
- // ../../src/core/mail/markdownMail.ts
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
3
23
  function escapeHtml(value) {
4
24
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5
25
  }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+
59
+ // ../../src/core/mail/markdownMail.ts
60
+ function escapeHtml2(value) {
61
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
62
+ }
6
63
  function stripMarkdown(markdown) {
7
64
  return markdown.replace(/^#{1,6}\s+/gm, "").replace(/\*\*([^*]+)\*\*/g, "$1").replace(/\*([^*]+)\*/g, "$1").replace(/`([^`]+)`/g, "$1").replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1 ($2)").replace(/^[-*]\s+/gm, "\u2022 ").replace(/```[\s\S]*?```/g, "").replace(/\n{3,}/g, `
8
65
 
9
66
  `).trim();
10
67
  }
11
68
  function markdownToHtml(markdown) {
12
- const escaped = markdown.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
13
- return escaped.replace(/^### (.+)$/gm, "<h3>$1</h3>").replace(/^## (.+)$/gm, "<h2>$1</h2>").replace(/^# (.+)$/gm, "<h1>$1</h1>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>').replace(/^[-*]\s+(.+)$/gm, "<li>$1</li>").replace(/(<li>[\s\S]*?<\/li>\n?)+/g, (block) => `<ul>${block}</ul>`).replace(/```(\w+)?\n([\s\S]*?)```/g, "<pre><code>$2</code></pre>").split(/\n\n+/).map((block) => {
14
- if (block.startsWith("<")) {
15
- return block;
16
- }
17
- return `<p>${block.replace(/\n/g, " ")}</p>`;
18
- }).join(`
19
- `);
69
+ return sanitizeMailHtml(Bun.markdown.html(markdown));
20
70
  }
21
71
  function wrapMarkdownMailLayout(bodyHtml, options = {}) {
22
- const title = escapeHtml(options.title ?? "GetStrata");
23
- const preview = escapeHtml(options.preview ?? "");
24
- const footer = escapeHtml(options.footer ?? "Sent by GetStrata");
72
+ const title = escapeHtml2(options.title ?? "GetStrata");
73
+ const preview = escapeHtml2(options.preview ?? "");
74
+ const footer = escapeHtml2(options.footer ?? "Sent by GetStrata");
25
75
  return `<!DOCTYPE html>
26
76
  <html lang="en">
27
77
  <head>
@@ -0,0 +1,60 @@
1
+ // @bun
2
+ // ../../src/core/mail/sanitizeMailHtml.ts
3
+ var ALLOWED_TAGS = new Set([
4
+ "a",
5
+ "blockquote",
6
+ "br",
7
+ "code",
8
+ "em",
9
+ "h1",
10
+ "h2",
11
+ "h3",
12
+ "h4",
13
+ "h5",
14
+ "h6",
15
+ "hr",
16
+ "li",
17
+ "ol",
18
+ "p",
19
+ "pre",
20
+ "strong",
21
+ "ul"
22
+ ]);
23
+ function escapeHtml(value) {
24
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25
+ }
26
+ function decodeBasicEntities(value) {
27
+ return value.replace(/&amp;/g, "&").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&lt;/g, "<").replace(/&gt;/g, ">");
28
+ }
29
+ function isSafeHref(value) {
30
+ return /^(https?:|mailto:)/i.test(value.trim());
31
+ }
32
+ function sanitizeAttributes(tagName, rawAttributes) {
33
+ if (tagName !== "a") {
34
+ return "";
35
+ }
36
+ const hrefMatch = rawAttributes.match(/\bhref\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/i);
37
+ const href = decodeBasicEntities(hrefMatch?.[2] ?? hrefMatch?.[3] ?? hrefMatch?.[4] ?? "");
38
+ if (!href || !isSafeHref(href)) {
39
+ return "";
40
+ }
41
+ return ` href="${escapeHtml(href)}"`;
42
+ }
43
+ function sanitizeMailHtml(html) {
44
+ return html.replace(/<\/?([a-zA-Z][a-zA-Z0-9]*)\b([^>]*)>/g, (match, tagName, rawAttributes) => {
45
+ const name = tagName.toLowerCase();
46
+ if (!ALLOWED_TAGS.has(name)) {
47
+ return "";
48
+ }
49
+ if (match.startsWith("</")) {
50
+ return `</${name}>`;
51
+ }
52
+ if (match.endsWith("/>") || name === "br" || name === "hr") {
53
+ return `<${name}>`;
54
+ }
55
+ return `<${name}${sanitizeAttributes(name, rawAttributes)}>`;
56
+ });
57
+ }
58
+ export {
59
+ sanitizeMailHtml
60
+ };
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/config/app.ts
3
3
  var appConfig = {
4
- name: "WorkHub",
4
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5
5
  env: process.env.APP_ENV ?? "local",
6
6
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
7
7
  url: process.env.APP_URL ?? "http://localhost:3000",
@@ -1,7 +1,7 @@
1
1
  // @bun
2
2
  // ../../src/config/app.ts
3
3
  var appConfig = {
4
- name: "WorkHub",
4
+ name: process.env.APP_NAME?.trim() || "WorkHub",
5
5
  env: process.env.APP_ENV ?? "local",
6
6
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
7
7
  url: process.env.APP_URL ?? "http://localhost:3000",
@@ -1,6 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/security/totp.ts
3
- import { createHmac } from "crypto";
3
+ import { createHmac, randomBytes } from "crypto";
4
4
  function decodeBase32(input) {
5
5
  const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
6
6
  const normalized = input.replace(/=+$/u, "").toUpperCase();
@@ -18,6 +18,34 @@ function decodeBase32(input) {
18
18
  }
19
19
  return Buffer.from(bytes);
20
20
  }
21
+ function encodeBase32(bytes) {
22
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
23
+ let bits = "";
24
+ for (const byte of bytes) {
25
+ bits += byte.toString(2).padStart(8, "0");
26
+ }
27
+ let output = "";
28
+ for (let index = 0;index < bits.length; index += 5) {
29
+ const chunk = bits.slice(index, index + 5).padEnd(5, "0");
30
+ output += alphabet[Number.parseInt(chunk, 2)] ?? "";
31
+ }
32
+ return output;
33
+ }
34
+ function generateTotpSecret(byteLength = 20) {
35
+ return encodeBase32(randomBytes(byteLength));
36
+ }
37
+ function buildOtpauthUrl(options) {
38
+ const issuer = options.issuer?.trim() || process.env.APP_NAME?.trim() || "WorkHub";
39
+ const label = `${issuer}:${options.account}`;
40
+ const params = new URLSearchParams({
41
+ secret: options.secret,
42
+ issuer,
43
+ algorithm: "SHA1",
44
+ digits: "6",
45
+ period: "30"
46
+ });
47
+ return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
48
+ }
21
49
  function generateTotp(secret, counter, digits = 6) {
22
50
  const key = decodeBase32(secret);
23
51
  const buffer = Buffer.alloc(8);
@@ -46,6 +74,8 @@ function verifyTotp(secret, token, window = 1) {
46
74
  return false;
47
75
  }
48
76
  export {
77
+ buildOtpauthUrl,
49
78
  generateTotp,
79
+ generateTotpSecret,
50
80
  verifyTotp
51
81
  };
@@ -524,6 +524,9 @@ function resolveCsrfTokenForRequest(request) {
524
524
  import { createHmac, timingSafeEqual as timingSafeEqual2 } from "crypto";
525
525
  var FLASH_COOKIE = "workhub_flash";
526
526
  var FLASH_TTL_MS = 60 * 1000;
527
+ function flashCookieName() {
528
+ return process.env.FLASH_COOKIE_NAME?.trim() || FLASH_COOKIE;
529
+ }
527
530
  function resolveFlashSecret() {
528
531
  return process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-flash-secret";
529
532
  }
@@ -538,7 +541,7 @@ function readFlashCookie(request) {
538
541
  }
539
542
  for (const part of cookieHeader.split(";")) {
540
543
  const [name, ...rest] = part.trim().split("=");
541
- if (name === FLASH_COOKIE) {
544
+ if (name === flashCookieName()) {
542
545
  return decodeURIComponent(rest.join("="));
543
546
  }
544
547
  }
@@ -588,10 +591,10 @@ function createFlashCookie(message) {
588
591
  const payload = Buffer.from(JSON.stringify(message), "utf8").toString("base64url");
589
592
  const issuedAt = Date.now();
590
593
  const value = signFlashPayload(payload, issuedAt);
591
- return `${FLASH_COOKIE}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
594
+ return `${flashCookieName()}=${encodeURIComponent(value)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60`;
592
595
  }
593
596
  function clearFlashCookie() {
594
- return `${FLASH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
597
+ return `${flashCookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
595
598
  }
596
599
  function pullFlash(request) {
597
600
  const cookieValue = readFlashCookie(request);