@getstrata/core 0.5.61 → 0.5.62

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 0.5.62
4
+
5
+ Laravel URL signing, Bun-native markdown mail, and session-auth cleanup.
6
+
7
+ - `temporarySignedUrl` / `signedUrl` / `hasValidSignature` / `assertValidSignature` on `@getstrata/core/http/signedUrl`. HMAC uses `SIGNED_URL_SECRET` or `SESSION_SECRET`. Paths must be same-origin (`/` only; reject `//` and `://`).
8
+ - `markdownToHtml()` uses `Bun.markdown.html()` plus `sanitizeMailHtml` (allowlist). Scripts, `javascript:` links, and unknown tags are stripped.
9
+ - `ExportAuditLogsJob` (`@getstrata/core/jobs/exportAuditLogsJob`) wraps SIEM export so the scheduler can dispatch a real job.
10
+ - `DispatchWebhookJob` reads `APP_ENV` and `WEBHOOK_SIGNATURE_HEADER` instead of WorkHub `appConfig`.
11
+ - `createRequireWebAuthMiddleware` runs the handler inside `runWithAuthUser` so `currentAuthUser()` works on HTMX session routes.
12
+ - `generateTotpSecret()` / `buildOtpauthUrl()` on `@getstrata/core/security/totp`.
13
+ - `createRequireAbilityMiddleware` rethrows `ForbiddenError` for HTML views so HTMX routes render a styled 403 instead of JSON.
14
+
3
15
  ## 0.5.61
4
16
 
5
17
  HTMX HTML kernel gaps that sibling apps could not work around without weakening CSP or duplicating the framework.
@@ -0,0 +1,11 @@
1
+ interface TemporarySignedUrlOptions {
2
+ expiresInSeconds?: number;
3
+ query?: Record<string, string | number>;
4
+ }
5
+ declare function signedUrl(path: string, query?: Record<string, string | number>): string;
6
+ declare function temporarySignedUrl(path: string, expiresInSeconds: number, query?: Record<string, string | number>): string;
7
+ declare function absoluteTemporarySignedUrl(path: string, expiresInSeconds: number, query?: Record<string, string | number>, origin?: string): string;
8
+ declare function hasValidSignature(input: Request | URL | string): boolean;
9
+ declare function assertValidSignature(input: Request | URL | string): void;
10
+ export type { TemporarySignedUrlOptions };
11
+ export { absoluteTemporarySignedUrl, assertValidSignature, hasValidSignature, signedUrl, temporarySignedUrl, };
@@ -0,0 +1,12 @@
1
+ import { Job } from "../queue";
2
+ interface ExportAuditLogsPayload {
3
+ reason?: string;
4
+ }
5
+ declare class ExportAuditLogsJob extends Job<ExportAuditLogsPayload> {
6
+ readonly maxAttempts = 2;
7
+ readonly backoffMs = 5000;
8
+ handle(_payload?: ExportAuditLogsPayload): Promise<void>;
9
+ }
10
+ export { ExportAuditLogsJob };
11
+ export default ExportAuditLogsJob;
12
+ export type { ExportAuditLogsPayload };
@@ -0,0 +1,2 @@
1
+ declare function sanitizeMailHtml(html: string): string;
2
+ export { sanitizeMailHtml };
@@ -1,3 +1,9 @@
1
+ declare function generateTotpSecret(byteLength?: number): string;
2
+ declare function buildOtpauthUrl(options: {
3
+ secret: string;
4
+ account: string;
5
+ issuer?: string;
6
+ }): string;
1
7
  declare function generateTotp(secret: string, counter: number, digits?: number): string;
2
8
  declare function verifyTotp(secret: string, token: string, window?: number): boolean;
3
- export { generateTotp, verifyTotp };
9
+ export { buildOtpauthUrl, generateTotp, generateTotpSecret, verifyTotp };
@@ -4,7 +4,7 @@ import { repositoryConnection as db2 } from "@getstrata/core/database/repository
4
4
 
5
5
  // ../../src/config/app.ts
6
6
  var appConfig = {
7
- name: "WorkHub",
7
+ name: process.env.APP_NAME?.trim() || "WorkHub",
8
8
  env: process.env.APP_ENV ?? "local",
9
9
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
10
10
  url: process.env.APP_URL ?? "http://localhost:3000",
@@ -2,14 +2,57 @@
2
2
  // ../../src/core/http/requireAbilityMiddleware.ts
3
3
  import { currentAuthUser } from "@getstrata/core/auth/authContext";
4
4
  import { ForbiddenError } from "@getstrata/core/errors/http";
5
+
6
+ // ../../src/config/frontend.ts
7
+ function readFrontendMode() {
8
+ const mode = (process.env.FRONTEND_MODE ?? "api").trim();
9
+ if (mode === "server-htmx") {
10
+ return "server-htmx";
11
+ }
12
+ if (mode === "spa-react") {
13
+ return "spa-react";
14
+ }
15
+ return "api";
16
+ }
17
+ function isViewsEnabled() {
18
+ return readFrontendMode() === "server-htmx";
19
+ }
20
+
21
+ // ../../src/core/http/contentNegotiation.ts
22
+ function requestPrefersJson(request) {
23
+ if (!request) {
24
+ return true;
25
+ }
26
+ if (request.headers.get("HX-Request") === "true") {
27
+ return false;
28
+ }
29
+ const accept = request.headers.get("accept")?.toLowerCase() ?? "";
30
+ if (accept.includes("text/html")) {
31
+ return false;
32
+ }
33
+ if (accept.includes("application/json")) {
34
+ return true;
35
+ }
36
+ const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
37
+ if (contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data")) {
38
+ return false;
39
+ }
40
+ const pathname = new URL(request.url).pathname;
41
+ return pathname.startsWith("/api/");
42
+ }
43
+
44
+ // ../../src/core/http/requireAbilityMiddleware.ts
5
45
  function createRequireAbilityMiddleware(abilityChecker) {
6
46
  return (ability) => {
7
- return async (_request, next) => {
47
+ return async (request, next) => {
8
48
  const user = currentAuthUser();
9
49
  try {
10
50
  abilityChecker.requireAbility(user, ability);
11
51
  } catch (error) {
12
52
  if (error instanceof ForbiddenError) {
53
+ if (isViewsEnabled() && !requestPrefersJson(request)) {
54
+ throw error;
55
+ }
13
56
  return Response.json({ error: error.message }, { status: error.status });
14
57
  }
15
58
  throw error;
@@ -1,5 +1,6 @@
1
1
  // @bun
2
2
  // ../../src/core/http/requireWebAuthMiddleware.ts
3
+ import { runWithAuthUser } from "@getstrata/core/auth/authContext";
3
4
  import { UnauthorizedError } from "@getstrata/core/errors/http";
4
5
 
5
6
  // ../../src/core/http/contentNegotiation.ts
@@ -63,7 +64,7 @@ function createRequireWebAuthMiddleware(auth) {
63
64
  return async (request, next) => {
64
65
  const user = await auth.resolve(request);
65
66
  if (user) {
66
- return await next();
67
+ return await runWithAuthUser(user, () => next());
67
68
  }
68
69
  if (requestPrefersJson(request)) {
69
70
  throw new UnauthorizedError;
@@ -9,7 +9,7 @@ import { currentRequestMeta, runWithRequestMeta } from "@getstrata/core/http/req
9
9
 
10
10
  // ../../src/config/app.ts
11
11
  var appConfig = {
12
- name: "WorkHub",
12
+ name: process.env.APP_NAME?.trim() || "WorkHub",
13
13
  env: process.env.APP_ENV ?? "local",
14
14
  debug: (process.env.APP_DEBUG ?? "true") !== "false",
15
15
  url: process.env.APP_URL ?? "http://localhost:3000",
@@ -0,0 +1,97 @@
1
+ // @bun
2
+ // ../../src/core/http/signedUrl.ts
3
+ import { createHmac } from "crypto";
4
+ import { ForbiddenError } from "@getstrata/core/errors/http";
5
+ import { timingSafeCompareString } from "@getstrata/core/security/timingSafeCompare";
6
+ function resolveSignedUrlSecret() {
7
+ return process.env.SIGNED_URL_SECRET?.trim() || process.env.SESSION_SECRET?.trim() || process.env.OAUTH_STATE_SECRET?.trim() || "workhub-dev-signed-url-secret";
8
+ }
9
+ function resolveSignedUrlOrigin() {
10
+ return (process.env.APP_URL ?? "http://localhost:3000").replace(/\/$/, "");
11
+ }
12
+ function normalizeSignedPath(path) {
13
+ const trimmed = path.trim();
14
+ if (!trimmed.startsWith("/") || trimmed.startsWith("//") || trimmed.includes("\\")) {
15
+ throw new Error("Signed URLs must use a same-origin absolute path.");
16
+ }
17
+ if (trimmed.includes("://")) {
18
+ throw new Error("Signed URLs must use a same-origin absolute path.");
19
+ }
20
+ return trimmed;
21
+ }
22
+ function sortedQueryString(params) {
23
+ const entries = [...params.entries()].filter(([key]) => key !== "signature").sort(([left], [right]) => left.localeCompare(right));
24
+ return new URLSearchParams(entries).toString();
25
+ }
26
+ function signCanonicalPayload(path, query) {
27
+ return createHmac("sha256", resolveSignedUrlSecret()).update(`${path}
28
+ ${query}`).digest("hex");
29
+ }
30
+ function buildSignedSearchParams(path, query = {}, expiresAt) {
31
+ const params = new URLSearchParams;
32
+ for (const [key, value] of Object.entries(query)) {
33
+ if (key === "signature" || key === "expires") {
34
+ continue;
35
+ }
36
+ params.set(key, String(value));
37
+ }
38
+ if (expiresAt !== undefined) {
39
+ params.set("expires", String(expiresAt));
40
+ }
41
+ params.set("signature", signCanonicalPayload(path, sortedQueryString(params)));
42
+ return params;
43
+ }
44
+ function signedUrl(path, query = {}) {
45
+ const normalizedPath = normalizeSignedPath(path);
46
+ const params = buildSignedSearchParams(normalizedPath, query);
47
+ return `${normalizedPath}?${params.toString()}`;
48
+ }
49
+ function temporarySignedUrl(path, expiresInSeconds, query = {}) {
50
+ if (!Number.isInteger(expiresInSeconds) || expiresInSeconds <= 0) {
51
+ throw new Error("Signed URL expiry must be a positive integer number of seconds.");
52
+ }
53
+ const normalizedPath = normalizeSignedPath(path);
54
+ const expiresAt = Math.floor(Date.now() / 1000) + expiresInSeconds;
55
+ const params = buildSignedSearchParams(normalizedPath, query, expiresAt);
56
+ return `${normalizedPath}?${params.toString()}`;
57
+ }
58
+ function absoluteTemporarySignedUrl(path, expiresInSeconds, query = {}, origin = resolveSignedUrlOrigin()) {
59
+ return `${origin.replace(/\/$/, "")}${temporarySignedUrl(path, expiresInSeconds, query)}`;
60
+ }
61
+ function readSignedRequestUrl(input) {
62
+ if (input instanceof URL) {
63
+ return input;
64
+ }
65
+ if (typeof input === "string") {
66
+ return new URL(input, resolveSignedUrlOrigin());
67
+ }
68
+ return new URL(input.url);
69
+ }
70
+ function hasValidSignature(input) {
71
+ const url = readSignedRequestUrl(input);
72
+ const signature = url.searchParams.get("signature");
73
+ if (!signature) {
74
+ return false;
75
+ }
76
+ const expires = url.searchParams.get("expires");
77
+ if (expires) {
78
+ const expiresAt = Number.parseInt(expires, 10);
79
+ if (!Number.isInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
80
+ return false;
81
+ }
82
+ }
83
+ const expected = signCanonicalPayload(url.pathname, sortedQueryString(url.searchParams));
84
+ return timingSafeCompareString(signature, expected);
85
+ }
86
+ function assertValidSignature(input) {
87
+ if (!hasValidSignature(input)) {
88
+ throw new ForbiddenError("Invalid or expired signed URL.");
89
+ }
90
+ }
91
+ export {
92
+ absoluteTemporarySignedUrl,
93
+ assertValidSignature,
94
+ hasValidSignature,
95
+ signedUrl,
96
+ temporarySignedUrl
97
+ };
@@ -3,15 +3,6 @@
3
3
  import { createHmac } from "crypto";
4
4
  import { repositoryConnection as db2 } from "@getstrata/core/database/repositoryConnection";
5
5
 
6
- // ../../src/config/app.ts
7
- var appConfig = {
8
- name: "WorkHub",
9
- env: process.env.APP_ENV ?? "local",
10
- debug: (process.env.APP_DEBUG ?? "true") !== "false",
11
- url: process.env.APP_URL ?? "http://localhost:3000",
12
- apiPrefix: process.env.API_PREFIX ?? "/api/v1"
13
- };
14
-
15
6
  // ../../src/core/queue/index.ts
16
7
  class Job {
17
8
  maxAttempts;
@@ -38,6 +29,15 @@ function createQueue(driver) {
38
29
  return driver === "async" ? new AsyncQueue : new SyncQueue;
39
30
  }
40
31
 
32
+ // ../../src/config/app.ts
33
+ var appConfig = {
34
+ name: process.env.APP_NAME?.trim() || "WorkHub",
35
+ env: process.env.APP_ENV ?? "local",
36
+ debug: (process.env.APP_DEBUG ?? "true") !== "false",
37
+ url: process.env.APP_URL ?? "http://localhost:3000",
38
+ apiPrefix: process.env.API_PREFIX ?? "/api/v1"
39
+ };
40
+
41
41
  // ../../src/core/security/safeUrl.ts
42
42
  import { lookup as dnsLookupImpl } from "dns/promises";
43
43
  import { BadRequestError } from "@getstrata/core/errors/http";
@@ -292,7 +292,9 @@ class DispatchWebhookJob extends Job {
292
292
  }
293
293
  const body = JSON.stringify({ event: payload.event, payload: payload.payload });
294
294
  const signature = createHmac("sha256", webhook.secret).update(body).digest("hex");
295
- assertSafeOutboundUrl(webhook.url, { allowHttp: appConfig.env !== "production" });
295
+ const allowHttp = (process.env.APP_ENV ?? "local") !== "production";
296
+ const signatureHeader = process.env.WEBHOOK_SIGNATURE_HEADER?.trim() || "x-workhub-signature";
297
+ assertSafeOutboundUrl(webhook.url, { allowHttp });
296
298
  let responseStatus = null;
297
299
  let errorMessage = null;
298
300
  try {
@@ -300,10 +302,10 @@ class DispatchWebhookJob extends Job {
300
302
  method: "POST",
301
303
  headers: {
302
304
  "content-type": "application/json",
303
- "x-workhub-signature": signature
305
+ [signatureHeader]: signature
304
306
  },
305
307
  body
306
- }, { allowHttp: appConfig.env !== "production" });
308
+ }, { allowHttp });
307
309
  responseStatus = response.status;
308
310
  if (!response.ok) {
309
311
  throw new Error(`Webhook delivery failed with status ${response.status}.`);
@@ -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
+ };