/**
* @module routes/system
* @description System info, activities, data management, and URL reachability. Mounted at `/api/v1` (INF-005).
*
* All queries are scoped to the authenticated user's workspace (ACL-001).
*
* ### Endpoints
* | Method | Path | Description | Min Role |
* |----------|-------------------------------|--------------------------------------------|-----------|
* | `GET` | `/api/v1/activities` | Activity log (filterable by type, project) | viewer |
* | `POST` | `/api/v1/test-connection` | Verify a URL is reachable (SSRF-protected) | qa_lead |
* | `GET` | `/api/v1/system` | Uptime, Node/Playwright versions, DB counts | viewer |
* | `POST` | `/api/v1/system/client-error` | Log a frontend crash report | viewer |
* | `DELETE` | `/api/v1/data/runs` | Clear all run history (incl. soft-deleted) | admin |
* | `DELETE` | `/api/v1/data/activities` | Clear activity log | admin |
* | `DELETE` | `/api/v1/data/healing` | Clear self-healing history | admin |
*/
import { Router } from "express";
import { rateLimit } from "express-rate-limit";
import * as projectRepo from "../database/repositories/projectRepo.js";
import * as testRepo from "../database/repositories/testRepo.js";
import * as runRepo from "../database/repositories/runRepo.js";
import * as activityRepo from "../database/repositories/activityRepo.js";
import * as auditDlqRepo from "../database/repositories/auditDlqRepo.js";
import * as workspaceSiemConfigRepo from "../database/repositories/workspaceSiemConfigRepo.js";
import * as healingRepo from "../database/repositories/healingRepo.js";
import { validateUrl } from "../utils/ssrfGuard.js";
import { logActivity } from "../utils/activityLogger.js";
import { actor } from "../utils/actor.js";
import { formatLogLine } from "../utils/logFormatter.js";
import { activeTaskCount } from "../scheduler.js";
import { requireRole } from "../middleware/requireRole.js";
import { SYSTEM_WORKSPACE_ID } from "../constants/systemWorkspace.js";
const router = Router();
/**
* SEC-007: anti-exfiltration rate limiter for bulk audit-log exports.
*
* Browsing the JSON paginated view is cheap; CSV/NDJSON exports return the
* entire current page in one shot and a determined exfiltrator can
* script the cursor loop to pull the whole log in seconds. Cap export
* calls at 10 per 15-min window per (workspace × admin) — generous for
* legitimate evidence requests (SOC 2 control-walk, customer DSAR), tight
* enough that a script tripping the limit shows up as a clear signal in
* both the rate-limit logs and the meta-audit `audit.export` rows.
*
* The keyGenerator includes `req.workspaceId + sub` so a compromised admin
* cookie can't burn the budget of an unrelated workspace.
*/
const auditExportLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: parseInt(process.env.AUDIT_EXPORT_RATE_LIMIT ?? "", 10) || 10,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `${req.workspaceId || "no-ws"}::${req.authUser?.sub || req.ip || "anon"}`,
// Only count *export* calls toward the budget — JSON browsing is exempt.
// `skip` is evaluated BEFORE `keyGenerator` in express-rate-limit, so a
// skipped JSON read never touches the rate-limit store at all. Anti-
// exfiltration coverage for JSON paginated reads is provided by the
// server-side `audit.read` meta-audit row emitted on every call (PCI-DSS
// 10.2.6) — a scripted full-table walk leaves a one-for-one trail in
// the same compliance log it's trying to exfiltrate, which is the
// documented & accepted residual risk.
skip: (req) => req.query.format !== "csv" && req.query.format !== "ndjson",
message: {
error: "Too many audit-log exports. Try again later.",
code: "AUDIT_EXPORT_RATE_LIMITED",
},
});
// ─── Activities ───────────────────────────────────────────────────────────────
router.get("/activities", (req, res) => {
// Cap `limit` at 1000 so a malformed client can't pull the entire table
// in one request — the AUTO-003b approvals timeline reads 200 at a time
// and pages via `offset`. The default stays 200 to keep existing callers
// (Settings → System activity widget, ReviewQueue auto-tray) unchanged.
const rawLimit = parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(1000, rawLimit)) : 200;
const rawOffset = parseInt(req.query.offset, 10);
const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? rawOffset : undefined;
// `after` / `before` accept ISO-8601 strings; the repo does a lexicographic
// string comparison against `createdAt` (also ISO). Pass through as-is —
// any malformed value would simply match no rows rather than throw, and
// the repo doesn't trust the input for SQL (parameterised).
const activities = activityRepo.getFiltered({
type: req.query.type || undefined,
projectId: req.query.projectId || undefined,
workspaceId: req.workspaceId,
after: req.query.after || undefined,
before: req.query.before || undefined,
limit,
offset,
});
res.json(activities);
});
/**
* SEC-007: verify the audit-log hash chain for the caller's workspace.
*
* No-op when `AUDIT_HASH_CHAIN` is unset (returns `chainDisabled: true`) —
* the chain feature flag is off by default because chained writes
* serialise INSERTs under contention.
*
* @route GET /api/v1/audit/verify
*/
router.get("/audit/verify", requireRole("admin"), (req, res) => {
try {
if (process.env.AUDIT_HASH_CHAIN !== "true") {
return res.json({ verified: true, chainDisabled: true });
}
return res.json(activityRepo.verifyAuditChain(req.workspaceId));
} catch (err) {
// AGENT.md: 5xx never leaks internal details. Log server-side, return
// a stable error code the frontend can branch on.
console.error(formatLogLine("error", null, `[audit/verify] ${err.message}`));
return res.status(500).json({ error: "Audit chain verification unavailable.", code: "AUDIT_VERIFY_FAILED" });
}
});
/**
* SEC-007: list system-scoped security events (workspaceId = SYSTEM_WORKSPACE_ID).
*
* These are audit rows that fire BEFORE a tenant can be resolved — chiefly
* `auth.login.failed` against unknown emails (credential-stuffing probes
* against the deployment as a whole). Routing them to the system sentinel
* workspace keeps them queryable without leaking any tenant's existence
* via the side-channel of "which workspace did the row land in?".
*
* Cross-tenant by design — only deployment-level admins should see this
* surface. We gate on `requireRole("admin")` AND require that the caller's
* own workspace is non-system (a defence-in-depth check; no real workspace
* uses the sentinel ID, but listing this route on it would be confusing).
*
* Reuses `getFiltered` rather than `getWorkspaceAuditLog` because the
* latter's cursor pagination and meta-audit emission make sense for
* tenant audit logs, not the deployment-wide security-events stream.
*
* @route GET /api/v1/system/security-events
*/
router.get("/system/security-events", requireRole("admin"), (req, res) => {
try {
// Defence-in-depth: the system sentinel is never a real workspace, so
// a request whose own scope IS the sentinel is malformed (or an attempt
// to spoof access). Reject explicitly.
if (req.workspaceId === SYSTEM_WORKSPACE_ID) {
return res.status(403).json({
error: "Caller workspace cannot be the system sentinel.",
code: "SYSTEM_SCOPE_MISUSE",
});
}
const rawLimit = Number.parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(1000, rawLimit)) : 200;
const rawOffset = Number.parseInt(req.query.offset, 10);
const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? rawOffset : undefined;
const rows = activityRepo.getFiltered({
workspaceId: SYSTEM_WORKSPACE_ID,
type: req.query.type || undefined,
after: req.query.after || undefined,
before: req.query.before || undefined,
limit,
offset,
});
return res.json({ rows, count: rows.length });
} catch (err) {
console.error(formatLogLine("error", null, `[system/security-events] ${err.message}`));
return res.status(500).json({
error: "System security events unavailable.",
code: "SYSTEM_SECURITY_EVENTS_FAILED",
});
}
});
/**
* SEC-007: workspace-scoped audit log. Admin-gated. Returns rows in newest-
* first order with opaque cursor pagination (`nextCursor`). Supports
* `?format=csv` and `?format=ndjson` exports of the current page.
*
* ### Authorisation contract
* The `:workspaceId` URL param is validated against `req.workspaceId` (the
* authenticated workspace injected by `workspaceScope` middleware). A 403 is
* returned on mismatch — without this check an admin in workspace A could
* read workspace B's audit log by changing the URL.
*
* ### Query params
* | Param | Type | Notes |
* |------------|----------|------------------------------------------------|
* | userId | string | Filter by acting user. |
* | type | string|string[] | Repeat to filter by multiple types. |
* | dateFrom | ISO date | Inclusive lower bound. |
* | dateTo | ISO date | Inclusive upper bound. |
* | ipAddress | string | Exact-match filter. |
* | cursor | ISO date | Opaque cursor from previous page's nextCursor. |
* | limit | int | 1..1000, default 200. |
* | format | csv\|ndjson | Exports the current page in that format. |
*
* @route GET /api/v1/workspaces/:workspaceId/audit-log
*/
router.get("/workspaces/:workspaceId/audit-log", requireRole("admin"), auditExportLimiter, (req, res) => {
try {
// ── Authorisation: URL param must match the authenticated workspace ──
// Without this, the URL param overrides the middleware-injected scope
// and an admin in workspace A can read workspace B's audit log just by
// editing the URL. Return 403 (not 404) so we don't leak whether the
// target workspace exists.
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "Audit log is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
// ── Parse + validate query params ──
const types = Array.isArray(req.query.type)
? req.query.type
: (req.query.type ? [req.query.type] : []);
// Same hard cap as the existing /activities route (line 41) so an
// attacker cannot pull the entire table with ?limit=999999.
const rawLimit = Number.parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(1000, rawLimit)) : 200;
const cursor = typeof req.query.cursor === "string" && req.query.cursor.length > 0
? req.query.cursor
: undefined;
// ── Fetch ──
const { rows, nextCursor } = activityRepo.getWorkspaceAuditLog(req.workspaceId, {
userId: req.query.userId || undefined,
projectId: req.query.projectId || undefined,
types,
dateFrom: req.query.dateFrom || undefined,
dateTo: req.query.dateTo || undefined,
ipAddress: req.query.ipAddress || undefined,
cursor,
limit,
});
// ── Meta-audit: log every audit-log access ──
// PCI-DSS 10.2.6 and SOC 2 CC7.2 require that *reads of the audit log
// are themselves audited*. Without this, an insider with admin access
// could exfiltrate the entire compliance trail with zero trace.
//
// The actual filter shape goes into `meta` so a reviewer can later
// answer "who read the audit log, for which window, and how?".
// Exports use a distinct `audit.export` type so anti-exfiltration
// dashboards can alert on bulk export bursts separately from
// routine UI browsing.
const isExport = req.query.format === "csv" || req.query.format === "ndjson";
logActivity({
type: isExport ? "audit.export" : "audit.read",
req,
userId: req.authUser?.sub || null,
userName: req.authUser?.name || req.authUser?.email || null,
workspaceId: req.workspaceId,
meta: {
format: req.query.format || "json",
rowCount: rows.length,
filters: {
userId: req.query.userId || null,
projectId: req.query.projectId || null,
types: types.length ? types : null,
dateFrom: req.query.dateFrom || null,
dateTo: req.query.dateTo || null,
ipAddress: req.query.ipAddress || null,
cursor: cursor || null,
limit,
},
},
});
// ── Export formats ──
if (req.query.format === "ndjson") {
res.setHeader("Content-Type", "application/x-ndjson");
// Disposition hint for browsers that open the response directly.
res.setHeader("Content-Disposition", `attachment; filename="sentri-audit-log-${new Date().toISOString().slice(0, 10)}.ndjson"`);
return res.send(rows.map((r) => JSON.stringify(r)).join("\n") + (rows.length ? "\n" : ""));
}
if (req.query.format === "csv") {
// RFC 4180: wrap every field in quotes, double internal quotes.
// First column header is `createdAt` to match the NDJSON/JSON field
// name — SIEM importers joining CSV and NDJSON exports otherwise
// need a mapping table for the `timestamp` ↔ `createdAt` rename.
const header = "createdAt,userId,userName,type,meta,ipAddress,userAgent,workspaceId";
const esc = (v) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const body = rows.map((r) =>
[r.createdAt, r.userId, r.userName, r.type, JSON.stringify(r.meta ?? null), r.ipAddress, r.userAgent, r.workspaceId]
.map(esc).join(","),
).join("\n");
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", `attachment; filename="sentri-audit-log-${new Date().toISOString().slice(0, 10)}.csv"`);
return res.send(`${header}\n${body}${body ? "\n" : ""}`);
}
return res.json({ rows, nextCursor });
} catch (err) {
// AGENT.md: 5xx errors never leak internal details. Log server-side,
// return a stable code so the UI can render a clean "try again" state.
console.error(formatLogLine("error", null, `[audit-log] ${err.message}`));
return res.status(500).json({ error: "Audit log unavailable.", code: "AUDIT_READ_FAILED" });
}
});
/**
* SEC-007: list SIEM dead-letter queue entries for the caller's workspace.
*
* The DLQ stores audit events that the SIEM forwarder (Part C) failed to
* deliver after exhausting its retry budget. Admins use this list +
* `POST .../replay` to recover from transient outages at the SIEM target.
*
* Workspace-scope is enforced by comparing the URL `:workspaceId` against
* `req.workspaceId` — same pattern as the audit-log route above.
*
* @route GET /api/v1/workspaces/:workspaceId/audit-log/dlq
*/
router.get("/workspaces/:workspaceId/audit-log/dlq", requireRole("admin"), (req, res) => {
try {
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "Audit DLQ is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
const rawLimit = Number.parseInt(req.query.limit, 10);
const limit = Number.isFinite(rawLimit) ? Math.max(1, Math.min(1000, rawLimit)) : 200;
const rows = auditDlqRepo.listByWorkspace(req.workspaceId, { limit });
return res.json({ rows, count: rows.length });
} catch (err) {
console.error(formatLogLine("error", null, `[audit-log/dlq] ${err.message}`));
return res.status(500).json({ error: "Audit DLQ unavailable.", code: "AUDIT_DLQ_READ_FAILED" });
}
});
/**
* SEC-007: replay a single DLQ entry against the SIEM forwarder.
*
* Re-dispatches the stored snapshot. On success the DLQ row is removed.
* On failure the attempt counter increments and the latest error is
* recorded so the next replay attempt sees the freshest context.
*
* Pre-Part-C state: the SIEM forwarder (`dispatchSiemEvent`) does not yet
* exist, so this route returns `503 SIEM_NOT_CONFIGURED` to signal that
* the configured SIEM target is missing. The handler is shaped so that
* dropping in the forwarder later (C2) is a one-line change.
*
* Workspace-scope: both the URL param AND the row's stored `workspaceId`
* are checked so even if the DLQ row's path was guessed, an admin in a
* different workspace can't replay it.
*
* @route POST /api/v1/workspaces/:workspaceId/audit-log/dlq/:dlqId/replay
*/
router.post("/workspaces/:workspaceId/audit-log/dlq/:dlqId/replay", requireRole("admin"), async (req, res) => {
try {
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "Audit DLQ is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
const row = auditDlqRepo.getById(req.params.dlqId);
if (!row) {
return res.status(404).json({ error: "DLQ entry not found.", code: "AUDIT_DLQ_NOT_FOUND" });
}
// Defence-in-depth: even if a future bug let the URL param drift from
// the row's workspace, the row's stored workspaceId is the source of
// truth for cross-tenant isolation.
if (row.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "DLQ entry belongs to a different workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
// ── Re-dispatch via the SIEM forwarder ──
// The forwarder lives in `utils/notifications.js` (Part C). Until that
// lands, fail loudly with a stable code instead of silently succeeding
// — operators must know the replay was a no-op.
let dispatchSiemEvent = null;
try {
const mod = await import("../utils/notifications.js");
dispatchSiemEvent = mod.dispatchSiemEvent || null;
} catch { /* module not present in tests — treated as not-configured */ }
if (typeof dispatchSiemEvent !== "function") {
return res.status(503).json({
error: "SIEM forwarder is not configured on this server.",
code: "SIEM_NOT_CONFIGURED",
});
}
// `dispatchSiemEvent` is fire-and-forget: it NEVER throws and returns
// `{ ok, attempts?, lastError? }` instead. The catch is kept as a
// defence-in-depth guard for unexpected synchronous errors (e.g. the
// dynamic import resolving to a broken module).
//
// `skipDlqOnFailure: true` — the row we're replaying ALREADY lives in
// the DLQ. Without this flag, a failed re-dispatch would enqueue a
// *second* DLQ row for the same event, accumulating duplicates on
// every retry attempt. We bump the original row's `attempts` counter
// below instead.
let result;
try {
result = await dispatchSiemEvent(row.workspaceId, row.rowSnapshot, { skipDlqOnFailure: true });
} catch (dispatchErr) {
const attempts = auditDlqRepo.incrementAttempts(row.id, dispatchErr.message || String(dispatchErr));
return res.status(502).json({
error: "Replay failed at the SIEM target.",
code: "SIEM_DISPATCH_FAILED",
attempts,
});
}
// Branch on the documented return contract.
if (!result || result.ok !== true) {
// No SIEM config / disabled → surface as 503 SIEM_NOT_CONFIGURED so
// the UI distinguishes a config gap from a transport failure.
if (result?.lastError === "siem-not-configured") {
return res.status(503).json({
error: "SIEM forwarder is not configured for this workspace.",
code: "SIEM_NOT_CONFIGURED",
});
}
// Real dispatch failure — record the attempt and surface a 502.
// The forwarder itself already enqueued a fresh DLQ row; bump the
// *original* row's attempts so the inspector reflects the retry.
const attempts = auditDlqRepo.incrementAttempts(row.id, result?.lastError || "unknown");
return res.status(502).json({
error: "Replay failed at the SIEM target.",
code: "SIEM_DISPATCH_FAILED",
attempts,
});
}
// Success — remove the DLQ row so it doesn't reappear in the inspector.
auditDlqRepo.remove(row.id);
return res.json({ ok: true, id: row.id, replayedAt: new Date().toISOString() });
} catch (err) {
console.error(formatLogLine("error", null, `[audit-log/dlq/replay] ${err.message}`));
return res.status(500).json({ error: "Audit DLQ replay unavailable.", code: "AUDIT_DLQ_REPLAY_FAILED" });
}
});
/**
* SEC-007 Part C: GET per-workspace SIEM forwarder configuration.
*
* Returns the masked config — `hmacSecret` is replaced with
* `••••••••<last4>` so admins can confirm which secret is configured
* without exposing the value. Returns `{ config: null }` when no
* config exists.
*
* @route GET /api/v1/workspaces/:workspaceId/siem-config
*/
router.get("/workspaces/:workspaceId/siem-config", requireRole("admin"), (req, res) => {
try {
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "SIEM config is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
const config = workspaceSiemConfigRepo.getMasked(req.workspaceId);
return res.json({ config });
} catch (err) {
console.error(formatLogLine("error", null, `[siem-config] ${err.message}`));
return res.status(500).json({ error: "SIEM config unavailable.", code: "SIEM_CONFIG_READ_FAILED" });
}
});
/**
* SEC-007 Part C: PUT (upsert) per-workspace SIEM forwarder configuration.
*
* The HMAC secret is encrypted at rest via `credentialEncryption.js`.
* The target URL is validated against the SSRF guard so cloud-metadata
* endpoints / RFC 1918 ranges / link-local / etc. are rejected at write
* time. The `headers` object is bounded at 4096 chars JSON-serialised to
* stop a malicious admin from blowing out the DB.
*
* The route emits a `settings.update` activity (workspace-scoped) on
* success so the change is audit-trailed alongside other admin
* configuration changes.
*
* @route PUT /api/v1/workspaces/:workspaceId/siem-config
*/
router.put("/workspaces/:workspaceId/siem-config", requireRole("admin"), async (req, res) => {
try {
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "SIEM config is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
const { targetUrl, hmacSecret, headers, enabled } = req.body || {};
// Required-field validation up front — clearer errors than the SSRF
// check tripping on undefined.
if (typeof targetUrl !== "string" || !targetUrl.trim()) {
return res.status(400).json({ error: "targetUrl is required.", code: "SIEM_CONFIG_INVALID" });
}
// SEC-007: `hmacSecret` is conditionally required.
// - On INSERT (no existing row) → required, ≥ 32 chars.
// - On UPDATE (row exists) → optional. Omit or pass empty string
// to keep the existing encrypted secret; pass ≥ 32 chars to rotate.
//
// The 32-char floor follows NIST SP 800-107 (HMAC key length should be
// ≥ output length; SHA-256 → 32 bytes). We accept arbitrary printable
// ASCII (operators may paste base64 / hex / passphrase form), so 32
// chars guarantees ≥ 192 bits of entropy even at the lowercase-only
// worst case.
const existingConfig = workspaceSiemConfigRepo.getMasked(req.workspaceId);
const hasNewSecret = typeof hmacSecret === "string" && hmacSecret.length > 0;
if (!existingConfig && !hasNewSecret) {
return res.status(400).json({
error: "hmacSecret is required when creating a new SIEM config.",
code: "SIEM_CONFIG_INVALID",
});
}
if (hasNewSecret && hmacSecret.length < 32) {
return res.status(400).json({
error: "hmacSecret must be at least 32 characters.",
code: "SIEM_CONFIG_INVALID",
});
}
// SSRF: block cloud metadata + private ranges + link-local. Same
// validator the notification webhook config uses. ALLOW_PRIVATE_URLS
// escape hatch is honoured for dev/CI per the existing pattern.
if (process.env.ALLOW_PRIVATE_URLS !== "true") {
const ssrfErr = await validateUrl(targetUrl);
if (ssrfErr) {
return res.status(400).json({ error: ssrfErr, code: "SIEM_CONFIG_INVALID_URL" });
}
}
// Bound `headers` size — an admin pasting 100KB of headers would
// (a) bloat the DB row and (b) make every dispatched HTTP request
// larger than necessary. 4096 chars JSON is generous for legitimate
// SIEM-vendor token headers (Splunk HEC tokens are ~36 chars).
if (headers !== undefined && headers !== null) {
if (typeof headers !== "object" || Array.isArray(headers)) {
return res.status(400).json({ error: "headers must be an object.", code: "SIEM_CONFIG_INVALID" });
}
if (JSON.stringify(headers).length > 4096) {
return res.status(400).json({ error: "headers exceeds 4096 chars.", code: "SIEM_CONFIG_INVALID" });
}
// SEC-007: defence-in-depth against header overwrite. The forwarder
// (`backend/src/utils/notifications.js`) already spreads custom headers
// first so system-controlled integrity headers cannot be clobbered, but
// we also reject the names at write time so a misconfigured admin sees
// a clear 400 instead of a silently-ignored value. Case-insensitive
// match: HTTP header names are case-insensitive per RFC 7230.
const reserved = new Set(["content-type", "x-sentri-audit-signature"]);
for (const key of Object.keys(headers)) {
if (reserved.has(key.toLowerCase())) {
return res.status(400).json({
error: `Header "${key}" is reserved and cannot be overridden.`,
code: "SIEM_CONFIG_RESERVED_HEADER",
});
}
}
}
const persisted = workspaceSiemConfigRepo.upsert(req.workspaceId, {
targetUrl: targetUrl.trim(),
hmacSecret,
headers: headers || null,
enabled: enabled !== false, // default true
});
// Audit-trail the config change. The hmacSecret is NOT in the meta —
// only the targetUrl + enabled state, so the activity row is safe to
// expose to any admin viewing the workspace audit log.
logActivity({
...actor(req),
type: "settings.update",
req,
workspaceId: req.workspaceId,
detail: `SIEM forwarder ${enabled === false ? "disabled" : "configured"} (${targetUrl})`,
meta: { surface: "siem-config", targetUrl: targetUrl.trim(), enabled: enabled !== false },
});
return res.json({ config: persisted });
} catch (err) {
console.error(formatLogLine("error", null, `[siem-config/upsert] ${err.message}`));
return res.status(500).json({ error: "SIEM config save unavailable.", code: "SIEM_CONFIG_WRITE_FAILED" });
}
});
/**
* SEC-007 Part C: DELETE the per-workspace SIEM forwarder configuration.
*
* Idempotent — returns `{ ok: true, removed: false }` when no config
* existed. Emits a `settings.update` activity for audit trail.
*
* @route DELETE /api/v1/workspaces/:workspaceId/siem-config
*/
router.delete("/workspaces/:workspaceId/siem-config", requireRole("admin"), (req, res) => {
try {
if (req.params.workspaceId !== req.workspaceId) {
return res.status(403).json({
error: "SIEM config is scoped to your authenticated workspace.",
code: "AUDIT_WORKSPACE_MISMATCH",
});
}
const removed = workspaceSiemConfigRepo.remove(req.workspaceId);
if (removed) {
logActivity({
...actor(req),
type: "settings.update",
req,
workspaceId: req.workspaceId,
detail: "SIEM forwarder configuration removed",
meta: { surface: "siem-config", action: "delete" },
});
}
return res.json({ ok: true, removed });
} catch (err) {
console.error(formatLogLine("error", null, `[siem-config/delete] ${err.message}`));
return res.status(500).json({ error: "SIEM config delete unavailable.", code: "SIEM_CONFIG_DELETE_FAILED" });
}
});
// ─── URL reachability test ────────────────────────────────────────────────────
router.post("/test-connection", requireRole("qa_lead"), async (req, res) => {
const { url } = req.body;
if (!url) return res.status(400).json({ error: "url is required" });
let parsed;
try {
parsed = new URL(url);
} catch {
return res.status(400).json({ error: "Invalid URL format" });
}
if (!["http:", "https:"].includes(parsed.protocol)) {
return res.status(400).json({ error: "URL must use http or https protocol" });
}
// SSRF protection: block loopback, link-local, and private IP ranges.
//
// Dev escape hatch — when `ALLOW_PRIVATE_URLS=true`, skip the SSRF check so
// developers can Test against `http://localhost:3000`, Dockerised stacks, or
// internal staging hostnames (mirrors the `SKIP_EMAIL_VERIFICATION` pattern).
// Never set this in production — it permits SSRF to cloud metadata endpoints
// (169.254.169.254), databases on the local network, etc.
if (process.env.ALLOW_PRIVATE_URLS === "true") {
try {
const response = await fetch(url, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(10000) });
return res.json({ ok: true, status: response.status });
} catch (err) {
return res.status(502).json({ ok: false, error: err.message });
}
}
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
function extractMappedIPv4(host) {
const dottedMatch = host.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
if (dottedMatch) return dottedMatch[1];
const hexMatch = host.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i);
if (hexMatch) {
const hi = parseInt(hexMatch[1], 16);
const lo = parseInt(hexMatch[2], 16);
return `${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`;
}
return null;
}
function isPrivateIPv4(ip) {
return (
/^127\.\d+\.\d+\.\d+$/.test(ip) ||
/^10\.\d+\.\d+\.\d+$/.test(ip) ||
/^172\.(1[6-9]|2\d|3[01])\.\d+\.\d+$/.test(ip) ||
/^192\.168\.\d+\.\d+$/.test(ip) ||
ip === "0.0.0.0" ||
ip === "169.254.169.254"
);
}
const mappedIPv4 = extractMappedIPv4(hostname);
const blocked =
hostname === "localhost" ||
hostname.endsWith(".localhost") ||
isPrivateIPv4(hostname) ||
(mappedIPv4 && isPrivateIPv4(mappedIPv4)) ||
hostname === "0.0.0.0" ||
hostname === "::" ||
hostname === "::1" ||
(/^::ffff:/i.test(hostname) && mappedIPv4 === null) ||
hostname === "169.254.169.254" ||
hostname === "metadata.google.internal" ||
hostname.endsWith(".internal") ||
/^fe80:/i.test(hostname) ||
/^fd[0-9a-f]{2}:/i.test(hostname) ||
/^fc[0-9a-f]{2}:/i.test(hostname);
if (blocked) {
return res.status(400).json({ error: "URL must not point to localhost, private, or internal addresses" });
}
try {
const response = await fetch(url, { method: "HEAD", redirect: "manual", signal: AbortSignal.timeout(10000) });
res.json({ ok: true, status: response.status });
} catch (err) {
res.status(502).json({ ok: false, error: err.message });
}
});
// ─── System Info ──────────────────────────────────────────────────────────────
router.get("/system", async (req, res) => {
let playwrightVersion = null;
try {
const pwPkg = await import("playwright/package.json", { with: { type: "json" } }).catch(() => null);
playwrightVersion = pwPkg?.default?.version || null;
} catch { /* ignore */ }
if (!playwrightVersion) {
try {
const { createRequire } = await import("module");
const require = createRequire(import.meta.url);
const pwPkg = require("playwright/package.json");
playwrightVersion = pwPkg.version;
} catch { /* ignore */ }
}
const projects = projectRepo.getAll(req.workspaceId);
const projectIds = projects.map((p) => p.id);
// Use SQL-level counts instead of loading all test rows into memory.
const testCount = testRepo.countByProjectIds(projectIds);
const approvedTests = testRepo.countApprovedByProjectIds(projectIds);
const draftTests = testRepo.countDraftByProjectIds(projectIds);
// Healing counts need test IDs — use the lightweight ID-only query.
const testIds = testRepo.getAllIdsByProjectIdsIncludeDeleted(projectIds);
const projectCount = projects.length;
const runCount = runRepo.countByProjectIds(projectIds);
const activityCount = activityRepo.countFiltered({ workspaceId: req.workspaceId });
const healingEntries = healingRepo.countByTestIds(testIds);
res.json({
projects: projectCount,
tests: testCount,
runs: runCount,
activities: activityCount,
healingEntries,
approvedTests,
draftTests,
uptime: Math.floor(process.uptime()),
nodeVersion: process.version,
playwrightVersion,
memoryMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
activeSchedules: activeTaskCount(),
});
});
// ─── Client error reporting ───────────────────────────────────────────────────
// Receives crash reports from the frontend ErrorBoundary (componentDidCatch).
// Logs the error server-side so crashes are visible in backend logs even when
// the user doesn't report them. The endpoint intentionally does minimal work
// and always returns 200 — it must never throw back to the already-crashed UI.
router.post("/system/client-error", (req, res) => {
const { message, stack, componentStack, url } = req.body || {};
console.error(formatLogLine("error", null,
`[client-error] ${message || "Unknown error"} at ${url || "unknown URL"}` +
(stack ? `\n${stack}` : "") +
(componentStack ? `\nComponent stack:${componentStack}` : ""),
));
res.json({ ok: true });
});
// ─── Data Management ──────────────────────────────────────────────────────────
router.delete("/data/runs", requireRole("admin"), (req, res) => {
const projects = projectRepo.getAllIncludeDeleted(req.workspaceId);
const count = projects.reduce((sum, p) => sum + runRepo.hardDeleteByProjectId(p.id).length, 0);
logActivity({ ...actor(req), type: "settings.update", detail: `Cleared ${count} run(s)` });
res.json({ ok: true, cleared: count });
});
router.delete("/data/activities", requireRole("admin"), (req, res) => {
if (process.env.DANGER_ALLOW_AUDIT_PURGE !== "true") {
return res.status(403).json({ error: "Audit purge is disabled.", code: "AUDIT_PURGE_DISABLED" });
}
const count = activityRepo.clearByWorkspaceId(req.workspaceId);
// SEC-007: emit the meta-audit row AFTER the truncate so the act of
// purging the audit log is itself recorded — and survives in the DB
// instead of being deleted by the very `clearByWorkspaceId` that
// wiped the workspace's rows. The previous ordering (log → clear)
// dropped the row immediately, leaving zero in-DB evidence of the
// most security-sensitive admin action in the system, undermining
// the PCI-DSS 10.2.6 / SOC 2 CC7.2 trail this row exists for.
//
// `meta.cleared` captures the number of rows the truncate removed
// so reviewers can see the blast radius. The row is also pushed to
// the SIEM forwarder via the standard `logActivity` setImmediate
// dispatch, so external evidence survives even if a future bug or
// operator action wipes this surviving row too.
logActivity({
...actor(req),
type: "audit.purge",
req,
workspaceId: req.workspaceId,
detail: `Audit log truncated via DELETE /data/activities (DANGER_ALLOW_AUDIT_PURGE=true). Cleared ${count} row(s).`,
meta: { surface: "data-management", envFlag: "DANGER_ALLOW_AUDIT_PURGE", cleared: count },
});
res.json({ ok: true, cleared: count });
});
router.delete("/data/healing", requireRole("admin"), (req, res) => {
const projectIds = projectRepo.getAllIncludeDeleted(req.workspaceId).map((p) => p.id);
const testIds = testRepo.getAllIdsByProjectIdsIncludeDeleted(projectIds);
const count = healingRepo.countByTestIds(testIds);
healingRepo.deleteByTestIds(testIds);
logActivity({ ...actor(req), type: "settings.update", detail: `Cleared ${count} healing history entries` });
res.json({ ok: true, cleared: count });
});
export default router;