Files
sencho/backend/src/services/DatabaseService.ts
T
Anso 3b650523c1 Audit-hardening pass for secret and misconfiguration scanning (#977)
* fix(security): dedupe concurrent compose-stack scans

Track stack scans in scanningImages keyed stack:<nodeId>:<stackName>.
The /scan/stack route returns 409 when an in-flight scan exists, and
the service-side check is the real correctness barrier (the route
pre-check is a fast-path optimization that mirrors scanImage). The
dedup key release lives in a try/finally so failed scans free the
slot for retry.

Why: scanComposeStack had no equivalent of scanImage's scanningImages
guard, so two simultaneous calls for the same stack would both run
trivy config, both insert a vulnerability_scans row, and double-
process the result.

* feat(security): acknowledge misconfig findings

Adds a parallel acknowledgement system for Trivy misconfig findings
that mirrors cve_suppressions: a new misconfig_acknowledgements table,
read-time enrichment via the new misconfig-ack-filter utility, REST
CRUD endpoints, fleet-sync replication from control to replicas, a
Settings panel, and an Acknowledge button on the Misconfigs tab.

Schema and behavior parity with cve_suppressions:
  - UNIQUE(rule_id, COALESCE(stack_pattern, '')) so fleet-wide acks
    collide as expected
  - blockIfReplica on every write
  - Audit-log entries name the scope (rule_id, stack_pattern) but
    never the reason text
  - replicated_from_control flag controls UI delete affordance and
    drives clearReplicatedRows on demote/reanchor
  - Validators reused: validateStackPatternForRedos for glob safety,
    sanitizeForLog for log fragments

SARIF export emits an external/accepted suppression entry per
acknowledged misconfig, matching the CVE pattern.

Per-row Acknowledge dialog prefills stack_pattern with the scan's
stack_context so the default scope is "rule + this stack only" and an
operator must broaden explicitly.

Tests: misconfig-ack-filter (15) and misconfig-ack-routes (23)
including the duplicate-409 case for both pinned and fleet-wide acks.

* fix(security): reap orphaned trivy tmp dirs at startup

When the buildEnv path writes a per-scan DOCKER_CONFIG dir under
os.tmpdir() and the process crashes before the finally block runs,
the dir leaks. Mirrors GitSourceService.sweepStaleTempDirs:
exported sweepStaleTrivyTempDirs is fire-and-forget at boot,
removes prefix-matching dirs older than 1 hour, swallows
permission/race failures, logs a single line if any were reaped.

* perf(security): emit per-batch summary for scanAllNodeImages

Adds one diag() line at the end of scanAllNodeImages summarising
unique image count, scanned, skipped, failed, violation count, and
elapsed time. Per-image diag inside scanImage stays useful for
debugging individual scans; the summary gives operators a single
fleet-level checkpoint when developer_mode is on.

* perf(security): cap SARIF export at 5000 findings per type

Replace the unbounded fetchAllPages walk on /scans/:id/sarif with a
hard limit of 5000 findings per type. When any type trips the cap,
emit run-level properties.truncated=true plus row_limit and per-type
totals so downstream tooling can flag the export as partial.
Console-warns for ops visibility.

A scan with 50k vulns previously streamed every row into memory
before serialising; the cap bounds memory and serialisation time at
the cost of completeness on pathological scans.

* docs(env): document TRIVY_BIN host-binary override

The env var is honored by TrivyService.detectTrivy as a fallback when
no managed install is present, but it was undocumented in
.env.example. Adds the var with a comment explaining precedence
(managed > TRIVY_BIN > PATH).

* test(security): cover scanComposeStack failure modes

Two new cases drive the existing try/catch through real failure
paths:
  - Malformed Trivy stdout: row flips to status='failed' with the
    parser error preserved on `error`.
  - execFile rejection: row flips to status='failed' with a string
    error message.

Pairs with the existing dedup tests so the failure path now also
verifies the scan row state, not just the thrown exception.

* test(e2e): security scanner + misconfig acknowledgement flow

Seven Playwright tests covering the scanner UI and the new
acknowledgement system end-to-end:
  - Trivy availability gate (skips suite when binary absent so CI
    without Trivy can opt out via E2E_SKIP_TRIVY=1)
  - Stack config scan completes and records misconfig findings
  - Concurrent stack scan returns 409 from the dedup gate
  - Misconfig ack POST creates and lists on Settings
  - Duplicate (rule_id, stack_pattern) returns 409
  - Malformed rule_id (shell metacharacters) returns 400
  - Misconfigs tab renders against a real stack scan

Tests drive the API for behaviour assertions and the UI only for
shell-rendering checks; the visual snapshot suite owns screenshots.

* docs(features): add misconfig acknowledgement workflow and SARIF cap

Refreshes vulnerability-scanning.mdx with:
  - Misconfig acknowledgements section covering the per-row dialog,
    Settings panel, scope/matching rules, and SARIF emission
  - Tier table row for the new feature
  - SARIF section note on the 5000 row-per-type cap and the
    properties.truncated marker for partial exports
  - Troubleshooting entries: SARIF cap, hidden Acknowledge button,
    findings resurfacing after delete, Trivy DB phone-home, and
    409 on concurrent compose-stack scans

* fix(ci): clear backend lint and CodeQL alerts

- Remove the dead fetchAllPages helper in routes/security.ts. It lost
  its callers when the SARIF endpoint switched to direct paged reads
  for the truncation cap. ESLint flagged it as unused.
- Switch the trivy-tmp-cleanup test helper to fs.mkdtempSync. Building
  paths under os.tmpdir() with predictable names tripped CodeQL's
  js/insecure-temporary-file rule (high severity), which warns about
  symlink-pre-creation attacks even in test code. mkdtempSync appends
  a process-random suffix and creates the dir atomically; the
  sencho-trivy- prefix is preserved so the production sweep still
  matches the test fixtures.
2026-05-07 19:23:11 -04:00

4515 lines
182 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { CryptoService } from './CryptoService';
import { isSeverityAtLeast } from '../utils/severity';
export interface Agent {
id?: number;
type: 'discord' | 'slack' | 'webhook';
url: string;
enabled: boolean;
}
export interface GlobalSetting {
key: string;
value: string;
}
export interface StackAlert {
id?: number;
stack_name: string;
metric: string;
operator: string;
threshold: number;
duration_mins: number;
cooldown_mins: number;
last_fired_at?: number;
}
export type NodeMode = 'proxy' | 'pilot_agent';
export interface AutoHealPolicy {
id?: number;
stack_name: string;
service_name: string | null;
unhealthy_duration_mins: number;
cooldown_mins: number;
max_restarts_per_hour: number;
auto_disable_after_failures: number;
enabled: number;
consecutive_failures: number;
last_fired_at: number;
created_at: number;
updated_at: number;
}
export interface AutoHealHistoryEntry {
id?: number;
policy_id: number;
stack_name: string;
service_name: string | null;
container_name: string;
container_id: string;
action: 'restarted' | 'skipped_user_action' | 'skipped_cooldown' | 'skipped_rate_limit' | 'failed' | 'policy_auto_disabled';
reason: string;
success: number;
error: string | null;
timestamp: number;
}
export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
mode: NodeMode;
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
api_url?: string;
api_token?: string;
pilot_last_seen?: number | null;
pilot_agent_version?: string | null;
last_successful_contact?: number | null;
cordoned: boolean;
cordoned_at: number | null;
cordoned_reason: string | null;
}
export interface StackRestartSummary {
stackName: string;
crash: number;
autoheal: number;
manual: number;
total: number;
}
export interface PilotEnrollment {
node_id: number;
token_hash: string;
expires_at: number;
used_at: number | null;
}
export interface Label {
id: number;
node_id: number;
name: string;
color: string;
}
export type WebhookAction = 'deploy' | 'restart' | 'stop' | 'start' | 'pull' | 'git-pull';
export interface Webhook {
id?: number;
name: string;
stack_name: string;
action: WebhookAction;
secret: string;
enabled: boolean;
created_at: number;
updated_at: number;
}
export type GitSourceAuthType = 'none' | 'token';
export interface StackGitSource {
id?: number;
stack_name: string;
repo_url: string;
branch: string;
compose_path: string;
sync_env: boolean;
env_path: string | null;
auth_type: GitSourceAuthType;
encrypted_token: string | null;
auto_apply_on_webhook: boolean;
auto_deploy_on_apply: boolean;
last_applied_commit_sha: string | null;
last_applied_content_hash: string | null;
pending_commit_sha: string | null;
pending_compose_content: string | null;
pending_env_content: string | null;
pending_fetched_at: number | null;
last_debounce_at: number | null;
created_at: number;
updated_at: number;
}
export interface WebhookExecution {
id?: number;
webhook_id: number;
action: string;
status: 'success' | 'failure';
trigger_source: string | null;
duration_ms: number | null;
error: string | null;
executed_at: number;
}
export type AuthProvider = 'local' | 'ldap' | 'oidc_google' | 'oidc_github' | 'oidc_okta' | 'oidc_custom';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type ResourceType = 'stack' | 'node';
export interface User {
id: number;
username: string;
password_hash: string;
role: UserRole;
auth_provider: AuthProvider;
provider_id: string | null;
email: string | null;
token_version: number;
created_at: number;
updated_at: number;
}
export interface UserMfa {
user_id: number;
enabled: number;
totp_secret_encrypted: string | null;
backup_codes_json: string | null;
sso_enforce_mfa: number;
failed_attempts: number;
locked_until: number | null;
created_at: number;
updated_at: number;
}
export type UserMfaUpdate = Partial<{
enabled: boolean;
totp_secret_encrypted: string | null;
backup_codes_json: string | null;
sso_enforce_mfa: boolean;
failed_attempts: number;
locked_until: number | null;
}>;
export interface RoleAssignment {
id: number;
user_id: number;
role: UserRole;
resource_type: ResourceType;
resource_id: string;
created_at: number;
}
export interface SSOConfig {
id: number;
provider: string;
enabled: number;
config_json: string;
created_at: number;
updated_at: number;
}
export interface NotificationHistory {
id?: number;
level: 'info' | 'warning' | 'error';
category?: string;
message: string;
timestamp: number;
is_read: boolean;
dispatch_error?: string;
stack_name?: string;
container_name?: string;
actor_username?: string | null;
}
export interface FleetSnapshot {
id: number;
description: string;
created_by: string;
node_count: number;
stack_count: number;
skipped_nodes: string;
created_at: number;
}
export interface FleetSnapshotFile {
id: number;
snapshot_id: number;
node_id: number;
node_name: string;
stack_name: string;
filename: string;
content: string;
}
export type DriftMode = 'observe' | 'suggest' | 'enforce';
export type BlueprintClassification = 'stateless' | 'stateful' | 'unknown';
export type BlueprintDeploymentStatus =
| 'pending'
| 'pending_state_review'
| 'deploying'
| 'active'
| 'drifted'
| 'correcting'
| 'failed'
| 'withdrawing'
| 'withdrawn'
| 'evict_blocked'
| 'name_conflict';
export type BlueprintSelector =
| { type: 'labels'; any: string[]; all: string[] }
| { type: 'nodes'; ids: number[] };
export interface NodeLabelRow {
id: number;
node_id: number;
label: string;
created_at: number;
}
export interface Blueprint {
id: number;
name: string;
description: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode: DriftMode;
classification: BlueprintClassification;
classification_reasons: string[];
enabled: boolean;
revision: number;
created_at: number;
updated_at: number;
created_by: string | null;
pinned_node_id: number | null;
}
export interface BlueprintDeployment {
id: number;
blueprint_id: number;
node_id: number;
status: BlueprintDeploymentStatus;
applied_revision: number | null;
last_deployed_at: number | null;
last_checked_at: number | null;
last_drift_at: number | null;
drift_summary: string | null;
last_error: string | null;
}
export interface AuditLogEntry {
id: number;
timestamp: number;
username: string;
method: string;
path: string;
status_code: number;
node_id: number | null;
ip_address: string;
summary: string;
}
export interface SecretRow {
id: number;
name: string;
description: string;
current_version: number;
created_at: number;
created_by: string;
updated_at: number;
}
export interface SecretVersionRow {
id: number;
secret_id: number;
version: number;
encrypted_payload: string;
key_count: number;
created_at: number;
created_by: string;
note: string;
}
export type SecretPushStatus = 'ok' | 'failed' | 'skipped';
export interface SecretPushRow {
id: number;
secret_id: number;
version: number;
push_id: string;
node_id: number;
stack_name: string;
env_file_basename: string;
status: SecretPushStatus;
error: string;
added_count: number;
changed_count: number;
unchanged_count: number;
pushed_by: string;
pushed_at: number;
}
export type ApiTokenScope = 'read-only' | 'deploy-only' | 'full-admin';
/** Map an API token's scope to the synthesized user role used during request authorization. */
export const API_TOKEN_SCOPE_TO_ROLE: Record<ApiTokenScope, UserRole> = {
'read-only': 'viewer',
'deploy-only': 'deployer',
'full-admin': 'admin',
};
export interface ApiToken {
id: number;
token_hash: string;
name: string;
scope: ApiTokenScope;
user_id: number;
created_at: number;
last_used_at: number | null;
expires_at: number | null;
revoked_at: number | null;
}
export interface ScheduledTask {
id: number;
name: string;
target_type: 'stack' | 'fleet' | 'system';
target_id: string | null;
node_id: number | null;
action: 'restart' | 'snapshot' | 'prune' | 'update' | 'scan' | 'auto_backup' | 'auto_stop' | 'auto_down' | 'auto_start';
cron_expression: string;
enabled: number;
created_by: string;
created_at: number;
updated_at: number;
last_run_at: number | null;
next_run_at: number | null;
last_status: string | null;
last_error: string | null;
prune_targets: string | null;
target_services: string | null;
prune_label_filter: string | null;
delete_after_run?: number;
}
export interface ScheduledTaskRun {
id: number;
task_id: number;
started_at: number;
completed_at: number | null;
status: 'running' | 'success' | 'failure';
output: string | null;
error: string | null;
triggered_by: 'scheduler' | 'manual';
}
export type RegistryType = 'dockerhub' | 'ghcr' | 'ecr' | 'custom';
export interface Registry {
id: number;
name: string;
url: string;
type: RegistryType;
username: string;
secret: string;
aws_region: string | null;
created_at: number;
updated_at: number;
}
export interface NotificationRoute {
id: number;
name: string;
node_id: number | null;
stack_patterns: string[];
label_ids: number[] | null;
categories: string[] | null;
channel_type: 'discord' | 'slack' | 'webhook';
channel_url: string;
priority: number;
enabled: boolean;
created_at: number;
updated_at: number;
}
export type VulnSeverity = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' | 'UNKNOWN';
export type VulnScanStatus = 'in_progress' | 'completed' | 'failed';
export type VulnScanTrigger = 'manual' | 'scheduled' | 'deploy' | 'deploy-preflight';
/**
* Decision recorded when a scan is evaluated against the matching policy.
* Persisted as JSON on `vulnerability_scans.policy_evaluation` so the UI
* can surface a banner on the scan details sheet without re-running the
* match. `violated=false` rows exist too (informational), which is why
* presence of the field does not mean "blocked".
*/
export interface PolicyEvaluation {
policyId: number;
policyName: string;
maxSeverity: VulnSeverity;
violated: boolean;
evaluatedAt: number;
}
export interface VulnerabilityScan {
id: number;
node_id: number;
image_ref: string;
image_digest: string | null;
scanned_at: number;
total_vulnerabilities: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
unknown_count: number;
fixable_count: number;
secret_count: number;
misconfig_count: number;
scanners_used: string;
highest_severity: VulnSeverity | null;
os_info: string | null;
trivy_version: string | null;
scan_duration_ms: number | null;
triggered_by: VulnScanTrigger;
status: VulnScanStatus;
error: string | null;
stack_context: string | null;
// JSON-encoded PolicyEvaluation; null if never evaluated.
policy_evaluation: string | null;
}
export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as PolicyEvaluation;
if (typeof parsed.policyId !== 'number' || typeof parsed.policyName !== 'string') {
return null;
}
return parsed;
} catch {
return null;
}
}
export interface VulnerabilityDetail {
id: number;
scan_id: number;
vulnerability_id: string;
pkg_name: string;
installed_version: string;
fixed_version: string | null;
severity: VulnSeverity;
title: string | null;
description: string | null;
primary_url: string | null;
}
export interface SecretFinding {
id: number;
scan_id: number;
rule_id: string;
category: string | null;
severity: VulnSeverity;
title: string | null;
target: string;
start_line: number | null;
end_line: number | null;
match_excerpt: string | null;
}
export interface MisconfigFinding {
id: number;
scan_id: number;
rule_id: string;
check_id: string | null;
severity: VulnSeverity;
title: string | null;
message: string | null;
resolution: string | null;
target: string;
primary_url: string | null;
}
export interface ScanPolicy {
id: number;
name: string;
node_id: number | null;
node_identity: string;
stack_pattern: string | null;
max_severity: VulnSeverity;
block_on_deploy: number;
enabled: number;
replicated_from_control: number;
created_at: number;
updated_at: number;
}
export interface FleetSyncStatus {
node_id: number;
resource: string;
last_success_at: number | null;
last_failure_at: number | null;
last_error: string | null;
}
export interface CveSuppression {
id: number;
cve_id: string;
pkg_name: string | null;
image_pattern: string | null;
reason: string;
created_by: string;
created_at: number;
expires_at: number | null;
replicated_from_control: number;
}
/**
* Operator-acknowledged misconfiguration finding. Acknowledgements match by
* rule_id and an optional stack_pattern glob, are applied at read time, and
* never modify the persisted finding row. Mirrors `cve_suppressions` shape.
*/
export interface MisconfigAcknowledgement {
id: number;
rule_id: string;
stack_pattern: string | null;
reason: string;
created_by: string;
created_at: number;
expires_at: number | null;
replicated_from_control: number;
}
export interface ScanSummary {
image_ref: string;
highest_severity: VulnSeverity | null;
total: number;
critical: number;
high: number;
medium: number;
low: number;
unknown: number;
fixable: number;
scanned_at: number;
scan_id: number;
}
// Audit log buffering: writes are batched into a transaction either after
// AUDIT_LOG_FLUSH_INTERVAL_MS or once the buffer hits AUDIT_LOG_FLUSH_THRESHOLD,
// whichever comes first. Without this, every mutating /api/* request runs an
// individual INSERT and serializes against other writers under burst load
// (SQLite's single-writer model). All read paths (getAuditLogs,
// getAuditLogsInRange, cleanupOldAuditLogs) drain the buffer first so callers
// always observe a consistent view.
const AUDIT_LOG_FLUSH_INTERVAL_MS = 1_000;
const AUDIT_LOG_FLUSH_THRESHOLD = 100;
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
// Cache of the global_settings table, populated on first read and
// invalidated by updateGlobalSetting(). Hot paths (auth middleware,
// WS upgrade, the audit-log debug gate) read this on every request,
// so the round-trip to SQLite is worth eliminating. Assumes this
// process is the sole writer to global_settings; sidecar tools that
// edit the row directly will not invalidate the cache.
private cachedGlobalSettings: Readonly<Record<string, string>> | null = null;
private auditLogBuffer: Array<Omit<AuditLogEntry, 'id'>> = [];
private auditLogFlushTimer: ReturnType<typeof setTimeout> | null = null;
private auditLogInsertStmt: Database.Statement | null = null;
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath);
this.initSchema();
this.migrateJsonConfig(dataDir);
this.migrateAdminToUsersTable();
this.migrateEncryptNodeTokens();
this.migrateSSOColumns();
this.migrateRegistries();
this.migrateRoleAssignments();
this.migrateNotificationRoutes();
this.migrateNotificationRoutesNodeId();
this.migrateNotificationRoutesMatchers();
this.migrateNotificationHistoryContext();
this.migrateScanPolicyFleetColumns();
this.migrateSecretMisconfigColumns();
this.migrateAgentsAndNotificationsNodeId();
this.migratePolicyEvaluationColumn();
this.migrateNotificationCategory();
this.migrateNotificationActor();
this.migrateMeshTables();
this.migrateNodeLabels();
this.migrateBlueprints();
this.migrateAddNodeLastContact();
this.migrateAddNodeCordonFields();
this.migrateAddBlueprintPinnedNode();
// Reset the cache once at end of constructor in case any migration
// populated it via getGlobalSettings() and a subsequent migration
// changed the underlying rows.
this.cachedGlobalSettings = null;
}
public static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
public getDb(): Database.Database {
return this.db;
}
private initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS global_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS stack_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL,
metric TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL NOT NULL,
duration_mins INTEGER NOT NULL,
cooldown_mins INTEGER NOT NULL,
last_fired_at INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS notification_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
level TEXT NOT NULL,
message TEXT NOT NULL,
timestamp INTEGER NOT NULL,
is_read INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS container_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
cpu_percent REAL NOT NULL,
memory_mb REAL NOT NULL,
net_rx_mb REAL NOT NULL,
net_tx_mb REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
CREATE TABLE IF NOT EXISTS stack_update_status (
node_id INTEGER NOT NULL DEFAULT 0,
stack_name TEXT NOT NULL,
has_update INTEGER DEFAULT 0,
checked_at INTEGER NOT NULL,
PRIMARY KEY (node_id, stack_name)
);
CREATE TABLE IF NOT EXISTS stack_auto_update_settings (
node_id INTEGER NOT NULL DEFAULT 0,
stack_name TEXT NOT NULL,
auto_update_enabled INTEGER NOT NULL DEFAULT 1,
updated_at INTEGER NOT NULL,
PRIMARY KEY (node_id, stack_name)
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
status TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pilot_enrollments (
node_id INTEGER PRIMARY KEY,
token_hash TEXT NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
stack_name TEXT NOT NULL,
action TEXT NOT NULL DEFAULT 'deploy',
secret TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_executions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
trigger_source TEXT,
duration_ms INTEGER,
error TEXT,
executed_at INTEGER NOT NULL,
FOREIGN KEY(webhook_id) REFERENCES webhooks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_webhook_executions_webhook ON webhook_executions(webhook_id);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'admin',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS fleet_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
description TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
node_count INTEGER NOT NULL,
stack_count INTEGER NOT NULL,
skipped_nodes TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS fleet_snapshot_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
snapshot_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
node_name TEXT NOT NULL,
stack_name TEXT NOT NULL,
filename TEXT NOT NULL,
content TEXT NOT NULL,
FOREIGN KEY(snapshot_id) REFERENCES fleet_snapshots(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_snapshot_files_snapshot ON fleet_snapshot_files(snapshot_id);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
username TEXT NOT NULL,
method TEXT NOT NULL,
path TEXT NOT NULL,
status_code INTEGER NOT NULL DEFAULT 0,
node_id INTEGER,
ip_address TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_log_username ON audit_log(username);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
scope TEXT NOT NULL DEFAULT 'read-only',
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER,
revoked_at INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
node_id INTEGER,
action TEXT NOT NULL,
cron_expression TEXT NOT NULL,
enabled INTEGER DEFAULT 1,
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_run_at INTEGER,
next_run_at INTEGER,
last_status TEXT,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS scheduled_task_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
started_at INTEGER NOT NULL,
completed_at INTEGER,
status TEXT NOT NULL DEFAULT 'running',
output TEXT,
error TEXT,
FOREIGN KEY(task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_task ON scheduled_task_runs(task_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_task_runs_status ON scheduled_task_runs(status);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_next_run ON scheduled_tasks(next_run_at);
CREATE TABLE IF NOT EXISTS vulnerability_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
image_ref TEXT NOT NULL,
image_digest TEXT,
scanned_at INTEGER NOT NULL,
total_vulnerabilities INTEGER NOT NULL DEFAULT 0,
critical_count INTEGER NOT NULL DEFAULT 0,
high_count INTEGER NOT NULL DEFAULT 0,
medium_count INTEGER NOT NULL DEFAULT 0,
low_count INTEGER NOT NULL DEFAULT 0,
unknown_count INTEGER NOT NULL DEFAULT 0,
fixable_count INTEGER NOT NULL DEFAULT 0,
secret_count INTEGER NOT NULL DEFAULT 0,
misconfig_count INTEGER NOT NULL DEFAULT 0,
scanners_used TEXT NOT NULL DEFAULT 'vuln',
highest_severity TEXT,
os_info TEXT,
trivy_version TEXT,
scan_duration_ms INTEGER,
triggered_by TEXT NOT NULL DEFAULT 'manual',
status TEXT NOT NULL DEFAULT 'completed',
error TEXT,
stack_context TEXT
);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_node_image ON vulnerability_scans(node_id, image_ref);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_digest ON vulnerability_scans(image_digest);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_scanned_at ON vulnerability_scans(scanned_at);
CREATE INDEX IF NOT EXISTS idx_vuln_scans_status ON vulnerability_scans(status);
CREATE TABLE IF NOT EXISTS vulnerability_details (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
vulnerability_id TEXT NOT NULL,
pkg_name TEXT NOT NULL,
installed_version TEXT NOT NULL,
fixed_version TEXT,
severity TEXT NOT NULL,
title TEXT,
description TEXT,
primary_url TEXT,
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_vuln_details_scan ON vulnerability_details(scan_id);
CREATE INDEX IF NOT EXISTS idx_vuln_details_severity ON vulnerability_details(severity);
CREATE TABLE IF NOT EXISTS secret_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
rule_id TEXT NOT NULL,
category TEXT,
severity TEXT NOT NULL,
title TEXT,
target TEXT NOT NULL,
start_line INTEGER,
end_line INTEGER,
match_excerpt TEXT,
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_secret_findings_scan ON secret_findings(scan_id);
CREATE TABLE IF NOT EXISTS misconfig_findings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scan_id INTEGER NOT NULL,
rule_id TEXT NOT NULL,
check_id TEXT,
severity TEXT NOT NULL,
title TEXT,
message TEXT,
resolution TEXT,
target TEXT NOT NULL,
primary_url TEXT,
FOREIGN KEY(scan_id) REFERENCES vulnerability_scans(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_misconfig_findings_scan ON misconfig_findings(scan_id);
CREATE TABLE IF NOT EXISTS scan_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
node_id INTEGER,
stack_pattern TEXT,
max_severity TEXT NOT NULL DEFAULT 'CRITICAL',
block_on_deploy INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS fleet_sync_status (
node_id INTEGER NOT NULL,
resource TEXT NOT NULL,
last_success_at INTEGER,
last_failure_at INTEGER,
last_error TEXT,
PRIMARY KEY (node_id, resource)
);
CREATE TABLE IF NOT EXISTS cve_suppressions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cve_id TEXT NOT NULL,
pkg_name TEXT,
image_pattern TEXT,
reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
replicated_from_control INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_cve ON cve_suppressions(cve_id);
CREATE INDEX IF NOT EXISTS idx_cve_suppressions_expires ON cve_suppressions(expires_at);
-- COALESCE makes NULL scope slots collide the way users expect (NULL == NULL here).
CREATE UNIQUE INDEX IF NOT EXISTS idx_cve_suppressions_unique
ON cve_suppressions(cve_id, COALESCE(pkg_name, ''), COALESCE(image_pattern, ''));
CREATE TABLE IF NOT EXISTS misconfig_acknowledgements (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_id TEXT NOT NULL,
stack_pattern TEXT,
reason TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
replicated_from_control INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_misconfig_ack_rule ON misconfig_acknowledgements(rule_id);
CREATE INDEX IF NOT EXISTS idx_misconfig_ack_expires ON misconfig_acknowledgements(expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_misconfig_ack_unique
ON misconfig_acknowledgements(rule_id, COALESCE(stack_pattern, ''));
CREATE TABLE IF NOT EXISTS stack_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL,
color TEXT NOT NULL,
UNIQUE(node_id, name)
);
CREATE TABLE IF NOT EXISTS stack_label_assignments (
label_id INTEGER NOT NULL REFERENCES stack_labels(id) ON DELETE CASCADE,
stack_name TEXT NOT NULL,
node_id INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (label_id, stack_name, node_id)
);
CREATE INDEX IF NOT EXISTS idx_label_assignments_stack
ON stack_label_assignments(stack_name, node_id);
CREATE TABLE IF NOT EXISTS user_mfa (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0,
totp_secret_encrypted TEXT,
backup_codes_json TEXT,
sso_enforce_mfa INTEGER NOT NULL DEFAULT 0,
failed_attempts INTEGER NOT NULL DEFAULT 0,
locked_until INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS mfa_used_tokens (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
code TEXT NOT NULL,
window INTEGER NOT NULL,
used_at INTEGER NOT NULL,
PRIMARY KEY (user_id, code, window)
);
CREATE INDEX IF NOT EXISTS idx_mfa_used_tokens_used_at ON mfa_used_tokens(used_at);
CREATE TABLE IF NOT EXISTS stack_git_sources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL UNIQUE,
repo_url TEXT NOT NULL,
branch TEXT NOT NULL,
compose_path TEXT NOT NULL,
sync_env INTEGER NOT NULL DEFAULT 0,
env_path TEXT,
auth_type TEXT NOT NULL DEFAULT 'none',
encrypted_token TEXT,
auto_apply_on_webhook INTEGER NOT NULL DEFAULT 0,
auto_deploy_on_apply INTEGER NOT NULL DEFAULT 0,
last_applied_commit_sha TEXT,
last_applied_content_hash TEXT,
pending_commit_sha TEXT,
pending_compose_content TEXT,
pending_env_content TEXT,
pending_fetched_at INTEGER,
last_debounce_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auto_heal_policies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL,
service_name TEXT,
unhealthy_duration_mins INTEGER NOT NULL,
cooldown_mins INTEGER NOT NULL DEFAULT 5,
max_restarts_per_hour INTEGER NOT NULL DEFAULT 3,
auto_disable_after_failures INTEGER NOT NULL DEFAULT 5,
enabled INTEGER NOT NULL DEFAULT 1,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
last_fired_at INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS auto_heal_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
policy_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
service_name TEXT,
container_name TEXT NOT NULL,
container_id TEXT NOT NULL,
action TEXT NOT NULL,
reason TEXT NOT NULL,
success INTEGER NOT NULL,
error TEXT,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_auto_heal_history_policy_ts
ON auto_heal_history(policy_id, timestamp DESC);
CREATE TABLE IF NOT EXISTS secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
current_version INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_secrets_name ON secrets(name);
CREATE TABLE IF NOT EXISTS secret_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_id INTEGER NOT NULL,
version INTEGER NOT NULL,
encrypted_payload TEXT NOT NULL,
key_count INTEGER NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT NOT NULL,
note TEXT NOT NULL DEFAULT '',
UNIQUE(secret_id, version),
FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_secret_versions_secret ON secret_versions(secret_id);
CREATE TABLE IF NOT EXISTS secret_pushes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
secret_id INTEGER NOT NULL,
version INTEGER NOT NULL,
push_id TEXT NOT NULL,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
env_file_basename TEXT NOT NULL DEFAULT '.env',
status TEXT NOT NULL,
error TEXT NOT NULL DEFAULT '',
added_count INTEGER NOT NULL DEFAULT 0,
changed_count INTEGER NOT NULL DEFAULT 0,
unchanged_count INTEGER NOT NULL DEFAULT 0,
pushed_by TEXT NOT NULL,
pushed_at INTEGER NOT NULL,
FOREIGN KEY(secret_id) REFERENCES secrets(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_push ON secret_pushes(push_id);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_secret_version ON secret_pushes(secret_id, version);
CREATE INDEX IF NOT EXISTS idx_secret_pushes_node ON secret_pushes(node_id, stack_name);
`);
// Apply migrations safely (ignore if columns already exist)
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Distributed API model columns
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Pilot Agent outbound-mode columns
maybeAddCol('nodes', 'mode', "TEXT NOT NULL DEFAULT 'proxy'");
maybeAddCol('nodes', 'pilot_last_seen', 'INTEGER');
maybeAddCol('nodes', 'pilot_agent_version', 'TEXT');
// Scheduled operations migrations
maybeAddCol('scheduled_task_runs', 'triggered_by', "TEXT NOT NULL DEFAULT 'scheduler'");
maybeAddCol('scheduled_tasks', 'prune_targets', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'target_services', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'prune_label_filter', 'TEXT DEFAULT NULL');
maybeAddCol('scheduled_tasks', 'delete_after_run', 'INTEGER DEFAULT 0');
// Recreate stack_update_status with composite PK (node_id, stack_name).
// Original table had stack_name as sole PK which breaks when multiple nodes share stack names.
const susInfo = this.db.pragma('table_info(stack_update_status)') as Array<{ name: string; pk: number }>;
const needsRecreate = susInfo.some(c => c.name === 'stack_name' && c.pk === 1) && !susInfo.some(c => c.name === 'node_id' && c.pk > 0);
if (needsRecreate) {
this.db.exec(`
CREATE TABLE stack_update_status_new (
node_id INTEGER NOT NULL DEFAULT 0,
stack_name TEXT NOT NULL,
has_update INTEGER DEFAULT 0,
checked_at INTEGER NOT NULL,
PRIMARY KEY (node_id, stack_name)
);
INSERT OR IGNORE INTO stack_update_status_new (node_id, stack_name, has_update, checked_at)
SELECT COALESCE(node_id, 0), stack_name, has_update, checked_at FROM stack_update_status;
DROP TABLE stack_update_status;
ALTER TABLE stack_update_status_new RENAME TO stack_update_status;
`);
}
// Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written)
const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key'];
for (const col of legacyCols) {
try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch (e: unknown) {
// Expected: column already dropped or never existed
if (!String((e as Error)?.message).includes('no such column')) {
console.warn(`[DatabaseService] Unexpected error dropping legacy column "${col}":`, (e as Error).message);
}
}
}
// Initialize default global settings if they don't exist
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run('host_cpu_limit', '90');
stmt.run('host_ram_limit', '90');
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
stmt.run('docker_janitor_gb', '5');
stmt.run('developer_mode', '0');
stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30');
stmt.run('trivy_auto_update', '0');
stmt.run('trivy_last_notified_version', '');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
if (nodeCount === 0) {
this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
private migrateAdminToUsersTable(): void {
const userCount = (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
if (userCount > 0) return;
const settings = this.getGlobalSettings();
const username = settings.auth_username;
const passwordHash = settings.auth_password_hash;
if (!username || !passwordHash) return;
const now = Date.now();
this.db.prepare(
'INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
).run(username, passwordHash, 'admin', now, now);
console.log('Migrated legacy admin user to users table.');
}
private migrateJsonConfig(dataDir: string) {
const configPath = path.join(dataDir, 'sencho.json');
if (fs.existsSync(configPath)) {
try {
const data = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(data);
if (config.username && config.passwordHash && config.jwtSecret) {
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run('auth_username', config.username);
stmt.run('auth_password_hash', config.passwordHash);
stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
fs.unlinkSync(configPath);
}
} catch (err) {
console.error('Failed to migrate sencho.json:', err);
}
}
}
private migrateEncryptNodeTokens(): void {
const crypto = CryptoService.getInstance();
const rows = this.db.prepare("SELECT id, api_token FROM nodes WHERE api_token != '' AND api_token IS NOT NULL").all() as Array<{ id: number; api_token: string }>;
for (const row of rows) {
if (!crypto.isEncrypted(row.api_token)) {
const encrypted = crypto.encrypt(row.api_token);
this.db.prepare('UPDATE nodes SET api_token = ? WHERE id = ?').run(encrypted, row.id);
}
}
}
private migrateSSOColumns(): void {
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e: unknown) {
// Expected: column already exists
if (!String((e as Error)?.message).includes('duplicate column')) {
console.warn(`[DatabaseService] Unexpected error adding column "${col}" to "${table}":`, (e as Error).message);
}
}
};
maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'");
maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL');
maybeAddCol('users', 'email', 'TEXT DEFAULT NULL');
maybeAddCol('users', 'token_version', 'INTEGER NOT NULL DEFAULT 1');
this.db.exec(`
CREATE TABLE IF NOT EXISTS sso_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
provider TEXT NOT NULL UNIQUE,
enabled INTEGER DEFAULT 0,
config_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_provider ON users(auth_provider, provider_id) WHERE provider_id IS NOT NULL;
`);
}
private migrateRegistries(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS registries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'custom',
username TEXT NOT NULL DEFAULT '',
secret TEXT NOT NULL DEFAULT '',
aws_region TEXT DEFAULT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`);
}
private migrateRoleAssignments(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS role_assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_role_assignments_user ON role_assignments(user_id);
CREATE INDEX IF NOT EXISTS idx_role_assignments_resource ON role_assignments(resource_type, resource_id);
`);
try {
this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)');
} catch (e) {
console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message);
}
}
private migrateNotificationRoutes(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS notification_routes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
stack_patterns TEXT NOT NULL,
channel_type TEXT NOT NULL,
channel_url TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 0,
enabled INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notification_routes_priority ON notification_routes(priority);
`);
// Track external dispatch errors on notification records
try { this.db.prepare('ALTER TABLE notification_history ADD COLUMN dispatch_error TEXT').run(); } catch { /* already exists */ }
}
private migrateNotificationRoutesNodeId(): void {
try {
this.db.prepare('ALTER TABLE notification_routes ADD COLUMN node_id INTEGER NULL').run();
} catch {
// column already present
}
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_notification_routes_node_priority ON notification_routes(node_id, enabled, priority)').run();
}
private tryAddColumn(table: string, col: string, def: string): void {
try {
this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run();
} catch {
/* column already present */
}
}
private migrateNotificationRoutesMatchers(): void {
this.tryAddColumn('notification_routes', 'label_ids', 'TEXT NULL');
this.tryAddColumn('notification_routes', 'categories', 'TEXT NULL');
}
private migrateNotificationHistoryContext(): void {
this.tryAddColumn('notification_history', 'stack_name', 'TEXT');
this.tryAddColumn('notification_history', 'container_name', 'TEXT');
}
private migrateScanPolicyFleetColumns(): void {
this.tryAddColumn('scan_policies', 'node_identity', "TEXT NOT NULL DEFAULT ''");
this.tryAddColumn('scan_policies', 'replicated_from_control', 'INTEGER NOT NULL DEFAULT 0');
}
private migrateSecretMisconfigColumns(): void {
this.tryAddColumn('vulnerability_scans', 'secret_count', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('vulnerability_scans', 'misconfig_count', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('vulnerability_scans', 'scanners_used', "TEXT NOT NULL DEFAULT 'vuln'");
}
private migrateAgentsAndNotificationsNodeId(): void {
this.tryAddColumn('agents', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('notification_history', 'node_id', 'INTEGER NOT NULL DEFAULT 0');
const tryIndex = (sql: string, label: string) => {
try {
this.db.prepare(sql).run();
} catch (e) {
console.warn(`[DatabaseService] Could not create ${label}:`, (e as Error).message);
}
};
tryIndex(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_node_type ON agents(node_id, type)',
'agents(node_id, type) unique index',
);
tryIndex(
'CREATE INDEX IF NOT EXISTS idx_notif_history_node_timestamp ON notification_history(node_id, timestamp DESC)',
'notification_history(node_id, timestamp) index',
);
}
private migratePolicyEvaluationColumn(): void {
try {
this.db
.prepare('ALTER TABLE vulnerability_scans ADD COLUMN policy_evaluation TEXT')
.run();
} catch {
/* column already present */
}
}
private migrateNotificationCategory(): void {
try {
this.db.prepare('ALTER TABLE notification_history ADD COLUMN category TEXT').run();
} catch {
// column already present
}
}
private migrateNotificationActor(): void {
this.tryAddColumn('notification_history', 'actor_username', 'TEXT');
try {
this.db.prepare(
'CREATE INDEX IF NOT EXISTS idx_notif_history_node_stack_ts ON notification_history(node_id, stack_name, timestamp DESC) WHERE stack_name IS NOT NULL'
).run();
} catch {
// index already present or partial-index syntax unsupported
}
}
private migrateMeshTables(): void {
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS mesh_stacks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
stack_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
created_by TEXT,
UNIQUE(node_id, stack_name),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_mesh_stacks_node ON mesh_stacks(node_id)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create mesh_stacks:', (e as Error).message);
}
this.tryAddColumn('nodes', 'mesh_enabled', 'INTEGER NOT NULL DEFAULT 0');
}
private migrateNodeLabels(): void {
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS node_labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
node_id INTEGER NOT NULL,
label TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE(node_id, label),
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_node ON node_labels(node_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_node_labels_label ON node_labels(label)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create node_labels:', (e as Error).message);
}
}
private migrateBlueprints(): void {
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS blueprints (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
compose_content TEXT NOT NULL,
selector_json TEXT NOT NULL,
drift_mode TEXT NOT NULL DEFAULT 'suggest',
classification TEXT NOT NULL DEFAULT 'unknown',
classification_reasons TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
revision INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
created_by TEXT
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprints_enabled ON blueprints(enabled)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create blueprints:', (e as Error).message);
}
try {
this.db.prepare(`
CREATE TABLE IF NOT EXISTS blueprint_deployments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
blueprint_id INTEGER NOT NULL,
node_id INTEGER NOT NULL,
status TEXT NOT NULL,
applied_revision INTEGER,
last_deployed_at INTEGER,
last_checked_at INTEGER,
last_drift_at INTEGER,
drift_summary TEXT,
last_error TEXT,
UNIQUE(blueprint_id, node_id),
FOREIGN KEY (blueprint_id) REFERENCES blueprints(id) ON DELETE CASCADE,
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
)
`).run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_blueprint ON blueprint_deployments(blueprint_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_node ON blueprint_deployments(node_id)').run();
this.db.prepare('CREATE INDEX IF NOT EXISTS idx_blueprint_deployments_status ON blueprint_deployments(status)').run();
} catch (e) {
console.warn('[DatabaseService] Could not create blueprint_deployments:', (e as Error).message);
}
}
private migrateAddNodeLastContact(): void {
this.tryAddColumn('nodes', 'last_successful_contact', 'INTEGER');
}
private migrateAddNodeCordonFields(): void {
this.tryAddColumn('nodes', 'cordoned', 'INTEGER NOT NULL DEFAULT 0');
this.tryAddColumn('nodes', 'cordoned_at', 'INTEGER');
this.tryAddColumn('nodes', 'cordoned_reason', 'TEXT');
}
private migrateAddBlueprintPinnedNode(): void {
this.tryAddColumn('blueprints', 'pinned_node_id', 'INTEGER');
}
// --- Sencho Mesh ---
public listMeshStacks(nodeId?: number): Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }> {
const sql = nodeId !== undefined
? 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks WHERE node_id = ?'
: 'SELECT id, node_id, stack_name, created_at, created_by FROM mesh_stacks';
const rows = nodeId !== undefined
? this.db.prepare(sql).all(nodeId)
: this.db.prepare(sql).all();
return rows as Array<{ id: number; node_id: number; stack_name: string; created_at: number; created_by: string | null }>;
}
public isMeshStackEnabled(nodeId: number, stackName: string): boolean {
const row = this.db.prepare('SELECT 1 FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').get(nodeId, stackName);
return !!row;
}
public insertMeshStack(nodeId: number, stackName: string, createdBy: string | null): void {
this.db.prepare(
'INSERT INTO mesh_stacks (node_id, stack_name, created_at, created_by) VALUES (?, ?, ?, ?)'
).run(nodeId, stackName, Date.now(), createdBy);
}
public deleteMeshStack(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM mesh_stacks WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
public setNodeMeshEnabled(nodeId: number, enabled: boolean): void {
this.db.prepare('UPDATE nodes SET mesh_enabled = ? WHERE id = ?').run(enabled ? 1 : 0, nodeId);
}
public getNodeMeshEnabled(nodeId: number): boolean {
const row = this.db.prepare('SELECT mesh_enabled FROM nodes WHERE id = ?').get(nodeId) as { mesh_enabled?: number } | undefined;
return !!row?.mesh_enabled;
}
// --- Agents ---
public getAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ?');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
}
public getEnabledAgents(nodeId: number): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE node_id = ? AND enabled = 1');
return stmt.all(nodeId).map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
}
public upsertAgent(nodeId: number, agent: Agent): void {
const existing = this.db.prepare('SELECT id FROM agents WHERE node_id = ? AND type = ?').get(nodeId, agent.type) as any;
if (existing) {
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE node_id = ? AND type = ?');
stmt.run(agent.url, agent.enabled ? 1 : 0, nodeId, agent.type);
} else {
const stmt = this.db.prepare('INSERT INTO agents (node_id, type, url, enabled) VALUES (?, ?, ?, ?)');
stmt.run(nodeId, agent.type, agent.url, agent.enabled ? 1 : 0);
}
}
// --- Notification Routes ---
private parseNotificationRoute(row: Record<string, unknown>): NotificationRoute {
return {
id: row.id as number,
name: row.name as string,
node_id: row.node_id != null ? (row.node_id as number) : null,
stack_patterns: JSON.parse(row.stack_patterns as string) as string[],
label_ids: row.label_ids ? JSON.parse(row.label_ids as string) as number[] : null,
categories: row.categories ? JSON.parse(row.categories as string) as string[] : null,
channel_type: row.channel_type as 'discord' | 'slack' | 'webhook',
channel_url: row.channel_url as string,
priority: row.priority as number,
enabled: row.enabled === 1,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getStackLabelIds(nodeId: number, stackName: string): number[] {
const rows = this.db.prepare(
'SELECT label_id FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?'
).all(stackName, nodeId) as { label_id: number }[];
return rows.map(r => r.label_id);
}
public getNotificationRoutes(): NotificationRoute[] {
return this.db.prepare('SELECT * FROM notification_routes ORDER BY priority ASC')
.all()
.map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
}
public getEnabledNotificationRoutes(): NotificationRoute[] {
return this.db.prepare('SELECT * FROM notification_routes WHERE enabled = 1 ORDER BY priority ASC')
.all()
.map((row) => this.parseNotificationRoute(row as Record<string, unknown>));
}
public getNotificationRoute(id: number): NotificationRoute | undefined {
const row = this.db.prepare('SELECT * FROM notification_routes WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseNotificationRoute(row) : undefined;
}
public createNotificationRoute(route: Omit<NotificationRoute, 'id'>): NotificationRoute {
const result = this.db.prepare(
'INSERT INTO notification_routes (name, node_id, stack_patterns, label_ids, categories, channel_type, channel_url, priority, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
route.name,
route.node_id ?? null,
JSON.stringify(route.stack_patterns),
route.label_ids ? JSON.stringify(route.label_ids) : null,
route.categories ? JSON.stringify(route.categories) : null,
route.channel_type,
route.channel_url,
route.priority,
route.enabled ? 1 : 0,
route.created_at,
route.updated_at
);
return this.getNotificationRoute(result.lastInsertRowid as number)!;
}
public updateNotificationRoute(id: number, updates: Partial<Omit<NotificationRoute, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if ('node_id' in updates) { fields.push('node_id = ?'); values.push(updates.node_id ?? null); }
if (updates.stack_patterns !== undefined) { fields.push('stack_patterns = ?'); values.push(JSON.stringify(updates.stack_patterns)); }
if ('label_ids' in updates) { fields.push('label_ids = ?'); values.push(updates.label_ids ? JSON.stringify(updates.label_ids) : null); }
if ('categories' in updates) { fields.push('categories = ?'); values.push(updates.categories ? JSON.stringify(updates.categories) : null); }
if (updates.channel_type !== undefined) { fields.push('channel_type = ?'); values.push(updates.channel_type); }
if (updates.channel_url !== undefined) { fields.push('channel_url = ?'); values.push(updates.channel_url); }
if (updates.priority !== undefined) { fields.push('priority = ?'); values.push(updates.priority); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE notification_routes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNotificationRoute(id: number): number {
return this.db.prepare('DELETE FROM notification_routes WHERE id = ?').run(id).changes;
}
// --- Global Settings ---
public getGlobalSettings(): Readonly<Record<string, string>> {
if (this.cachedGlobalSettings) return this.cachedGlobalSettings;
const stmt = this.db.prepare('SELECT * FROM global_settings');
const rows = stmt.all() as Array<{ key: string; value: string }>;
const settings: Record<string, string> = {};
for (const row of rows) settings[row.key] = row.value;
this.cachedGlobalSettings = Object.freeze(settings);
return this.cachedGlobalSettings;
}
public updateGlobalSetting(key: string, value: string): void {
const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run(key, value);
this.cachedGlobalSettings = null;
}
// --- System State (operational/runtime values - not user-defined config) ---
public getSystemState(key: string): string | null {
const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
}
public setSystemState(key: string, value: string): void {
this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
}
/**
* Run `fn` inside a single SQLite transaction. better-sqlite3 promotes a
* nested call to a SAVEPOINT, so callers can compose this with methods
* that already wrap their own writes in `this.db.transaction(...)`.
*
* Used by FleetSync receive to keep the row replacement and the
* received_pushed_at watermark write atomic. If either step fails, both
* roll back.
*/
public transaction<T>(fn: () => T): T {
return this.db.transaction(fn)();
}
// --- Stack Alerts ---
public getStackAlerts(stackName?: string): StackAlert[] {
let stmt;
if (stackName) {
stmt = this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?');
return stmt.all(stackName) as StackAlert[];
} else {
stmt = this.db.prepare('SELECT * FROM stack_alerts');
return stmt.all() as StackAlert[];
}
}
public addStackAlert(alert: StackAlert): StackAlert {
const stmt = this.db.prepare(
'INSERT INTO stack_alerts (stack_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
alert.stack_name,
alert.metric,
alert.operator,
alert.threshold,
alert.duration_mins,
alert.cooldown_mins,
alert.last_fired_at || 0
);
return this.db.prepare('SELECT * FROM stack_alerts WHERE id = ?').get(result.lastInsertRowid) as StackAlert;
}
public deleteStackAlert(id: number): void {
const stmt = this.db.prepare('DELETE FROM stack_alerts WHERE id = ?');
stmt.run(id);
}
public updateStackAlertLastFired(id: number, timestamp: number): void {
const stmt = this.db.prepare('UPDATE stack_alerts SET last_fired_at = ? WHERE id = ?');
stmt.run(timestamp, id);
}
// --- Auto-Heal Policies ---
public getAutoHealPolicies(stackName?: string): AutoHealPolicy[] {
if (stackName) {
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE stack_name = ?').all(stackName) as AutoHealPolicy[];
}
return this.db.prepare('SELECT * FROM auto_heal_policies').all() as AutoHealPolicy[];
}
public getAutoHealPolicy(id: number): AutoHealPolicy | undefined {
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE id = ?').get(id) as AutoHealPolicy | undefined;
}
public addAutoHealPolicy(policy: Omit<AutoHealPolicy, 'id'>): AutoHealPolicy {
const stmt = this.db.prepare(
'INSERT INTO auto_heal_policies (stack_name, service_name, unhealthy_duration_mins, cooldown_mins, max_restarts_per_hour, auto_disable_after_failures, enabled, consecutive_failures, last_fired_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
policy.stack_name,
policy.service_name ?? null,
policy.unhealthy_duration_mins,
policy.cooldown_mins,
policy.max_restarts_per_hour,
policy.auto_disable_after_failures,
policy.enabled,
policy.consecutive_failures,
policy.last_fired_at,
policy.created_at,
policy.updated_at
);
return this.db.prepare('SELECT * FROM auto_heal_policies WHERE id = ?').get(result.lastInsertRowid) as AutoHealPolicy;
}
public updateAutoHealPolicy(id: number, patch: Partial<Omit<AutoHealPolicy, 'id' | 'stack_name' | 'created_at'>>): void {
const ALLOWED_KEYS = new Set([
'service_name', 'unhealthy_duration_mins', 'cooldown_mins',
'max_restarts_per_hour', 'auto_disable_after_failures',
'enabled', 'consecutive_failures', 'last_fired_at',
]);
const entries = Object.entries(patch).filter(([k, v]) => ALLOWED_KEYS.has(k) && v !== undefined);
if (entries.length === 0) return;
const fields = entries.map(([k]) => `${k} = ?`).join(', ');
const values = entries.map(([, v]) => v);
this.db.prepare(`UPDATE auto_heal_policies SET ${fields}, updated_at = ? WHERE id = ?`).run(...values, Date.now(), id);
}
public deleteAutoHealPolicy(id: number): void {
this.db.transaction(() => {
this.db.prepare('DELETE FROM auto_heal_history WHERE policy_id = ?').run(id);
this.db.prepare('DELETE FROM auto_heal_policies WHERE id = ?').run(id);
})();
}
public recordAutoHealHistory(entry: Omit<AutoHealHistoryEntry, 'id'>): void {
this.db.prepare(
'INSERT INTO auto_heal_history (policy_id, stack_name, service_name, container_name, container_id, action, reason, success, error, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
entry.policy_id,
entry.stack_name,
entry.service_name ?? null,
entry.container_name,
entry.container_id,
entry.action,
entry.reason,
entry.success,
entry.error ?? null,
entry.timestamp
);
}
public getAutoHealHistory(policyId: number, limit = 50): AutoHealHistoryEntry[] {
return this.db.prepare(
'SELECT * FROM auto_heal_history WHERE policy_id = ? ORDER BY timestamp DESC LIMIT ?'
).all(policyId, limit) as AutoHealHistoryEntry[];
}
public incrementConsecutiveFailures(policyId: number): void {
this.db.prepare('UPDATE auto_heal_policies SET consecutive_failures = consecutive_failures + 1, updated_at = ? WHERE id = ?').run(Date.now(), policyId);
}
public resetConsecutiveFailures(policyId: number): void {
this.db.prepare('UPDATE auto_heal_policies SET consecutive_failures = 0, updated_at = ? WHERE id = ?').run(Date.now(), policyId);
}
public setPolicyEnabled(policyId: number, enabled: boolean): void {
this.db.prepare('UPDATE auto_heal_policies SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, Date.now(), policyId);
}
// --- Notification History ---
private mapNotificationRow(row: any): NotificationHistory {
return {
...row,
is_read: row.is_read === 1,
stack_name: row.stack_name ?? undefined,
container_name: row.container_name ?? undefined,
category: row.category ?? undefined,
actor_username: row.actor_username ?? null,
};
}
public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] {
const sql = category
? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?'
: 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?';
const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit];
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
}
public addNotificationHistory(nodeId: number, notification: Omit<NotificationHistory, 'id' | 'is_read'>): NotificationHistory {
const stmt = this.db.prepare(
'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)'
);
const result = stmt.run(
nodeId,
notification.level,
notification.message,
notification.timestamp,
notification.stack_name ?? null,
notification.container_name ?? null,
notification.category ?? null,
notification.actor_username ?? null,
);
this.db.prepare(`
DELETE FROM notification_history
WHERE node_id = ? AND id NOT IN (
SELECT id FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT 100
)
`).run(nodeId, nodeId);
return {
id: result.lastInsertRowid as number,
level: notification.level,
category: notification.category,
message: notification.message,
timestamp: notification.timestamp,
is_read: false,
stack_name: notification.stack_name,
container_name: notification.container_name,
actor_username: notification.actor_username,
};
}
public getStackActivity(nodeId: number, stackName: string, opts: { limit: number; before?: number }): NotificationHistory[] {
const sql = opts.before
? 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?'
: 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? ORDER BY timestamp DESC LIMIT ?';
const args: (number | string)[] = opts.before
? [nodeId, stackName, opts.before, opts.limit]
: [nodeId, stackName, opts.limit];
return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any));
}
public markAllNotificationsRead(nodeId: number): void {
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?');
stmt.run(nodeId);
}
public deleteNotification(nodeId: number, id: number): void {
const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ? AND id = ?');
stmt.run(nodeId, id);
}
public deleteAllNotifications(nodeId: number): void {
const stmt = this.db.prepare('DELETE FROM notification_history WHERE node_id = ?');
stmt.run(nodeId);
}
public updateNotificationDispatchError(id: number, error: string): void {
this.db.prepare('UPDATE notification_history SET dispatch_error = ? WHERE id = ?').run(error, id);
}
public getStackRestartSummary(nodeId: number, days: number): StackRestartSummary[] {
const since = Date.now() - days * 86400 * 1000;
return this.db.prepare(`
SELECT
stack_name AS stackName,
SUM(CASE WHEN category = 'deploy_failure' THEN 1 ELSE 0 END) AS crash,
SUM(CASE WHEN category = 'autoheal_triggered' THEN 1 ELSE 0 END) AS autoheal,
SUM(CASE WHEN category = 'stack_restarted' THEN 1 ELSE 0 END) AS manual,
COUNT(*) AS total
FROM notification_history
WHERE node_id = ?
AND timestamp >= ?
AND category IN ('deploy_failure', 'autoheal_triggered', 'stack_restarted')
AND stack_name IS NOT NULL
GROUP BY stack_name
ORDER BY total DESC
`).all(nodeId, since) as StackRestartSummary[];
}
// --- Container Metrics ---
public addContainerMetric(metric: Omit<any, 'id'>): void {
const stmt = this.db.prepare(
'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp);
}
public bulkAddContainerMetrics(metrics: Omit<any, 'id'>[]): void {
const stmt = this.db.prepare(
'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
const insertAll = this.db.transaction((items: Omit<any, 'id'>[]) => {
for (const m of items) {
stmt.run(m.container_id, m.stack_name, m.cpu_percent, m.memory_mb, m.net_rx_mb, m.net_tx_mb, m.timestamp);
}
});
insertAll(metrics);
}
public getContainerMetrics(hoursLookback = 24): any[] {
const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000);
// Aggregate into 5-minute buckets (300000ms) to keep response size bounded.
// With 24h lookback that's ~288 buckets per container instead of ~1440.
const bucketMs = 300000;
const stmt = this.db.prepare(`
SELECT
container_id,
stack_name,
AVG(cpu_percent) as cpu_percent,
AVG(memory_mb) as memory_mb,
MAX(net_rx_mb) as net_rx_mb,
MAX(net_tx_mb) as net_tx_mb,
(timestamp / ${bucketMs}) * ${bucketMs} as timestamp
FROM container_metrics
WHERE timestamp >= ?
GROUP BY container_id, stack_name, (timestamp / ${bucketMs})
ORDER BY timestamp ASC
`);
return stmt.all(cutoff);
}
public cleanupOldMetrics(hoursToKeep = 24): void {
const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
stmt.run(cutoff);
}
public cleanupOldNotifications(daysToKeep = 30): void {
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff);
}
// --- Nodes ---
private decryptNodeRow(row: any): Node {
const crypto = CryptoService.getInstance();
return {
...row,
is_default: row.is_default === 1,
mode: (row.mode === 'pilot_agent' ? 'pilot_agent' : 'proxy') as NodeMode,
api_token: row.api_token ? crypto.decrypt(row.api_token) : '',
pilot_last_seen: row.pilot_last_seen ?? null,
pilot_agent_version: row.pilot_agent_version ?? null,
last_successful_contact: row.last_successful_contact ?? null,
cordoned: row.cordoned === 1,
cordoned_at: row.cordoned_at ?? null,
cordoned_reason: row.cordoned_reason ?? null,
};
}
private static readonly NODE_COLUMNS =
'id, name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode, pilot_last_seen, pilot_agent_version, last_successful_contact, cordoned, cordoned_at, cordoned_reason';
public getNodes(): Node[] {
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes ORDER BY is_default DESC, name ASC`);
return stmt.all().map((row: any) => this.decryptNodeRow(row));
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE id = ?`);
const row = stmt.get(id) as any;
if (!row) return undefined;
return this.decryptNodeRow(row);
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare(`SELECT ${DatabaseService.NODE_COLUMNS} FROM nodes WHERE is_default = 1 LIMIT 1`);
const row = stmt.get() as any;
if (!row) return undefined;
return this.decryptNodeRow(row);
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at' | 'mode' | 'cordoned' | 'cordoned_at' | 'cordoned_reason'> & { mode?: NodeMode }): number {
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const crypto = CryptoService.getInstance();
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token, mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.compose_dir || '/app/compose',
node.is_default ? 1 : 0,
'unknown',
Date.now(),
node.api_url || '',
node.api_token ? crypto.encrypt(node.api_token) : '',
node.mode || 'proxy'
);
return result.lastInsertRowid as number;
}
public updateNode(id: number, updates: Partial<Omit<Node, 'id' | 'created_at'>>): void {
const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`);
if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const fields: string[] = [];
const values: any[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.api_token !== undefined) {
fields.push('api_token = ?');
values.push(updates.api_token ? CryptoService.getInstance().encrypt(updates.api_token) : '');
}
if (updates.mode !== undefined) { fields.push('mode = ?'); values.push(updates.mode); }
if (updates.pilot_last_seen !== undefined) { fields.push('pilot_last_seen = ?'); values.push(updates.pilot_last_seen); }
if (updates.pilot_agent_version !== undefined) { fields.push('pilot_agent_version = ?'); values.push(updates.pilot_agent_version); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE nodes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNode(id: number): void {
const node = this.getNode(id);
if (node?.is_default) {
throw new Error('Cannot delete the default node');
}
this.db.transaction(() => {
this.db.prepare('DELETE FROM scheduled_task_runs WHERE task_id IN (SELECT id FROM scheduled_tasks WHERE node_id = ?)').run(id);
this.db.prepare('DELETE FROM scheduled_tasks WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM stack_labels WHERE node_id = ?').run(id);
this.db.prepare('UPDATE blueprints SET pinned_node_id = NULL WHERE pinned_node_id = ?').run(id);
this.deleteRoleAssignmentsByResource('node', String(id));
this.db.prepare('DELETE FROM fleet_sync_status WHERE node_id = ?').run(id);
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
})();
}
public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
}
public setNodeCordoned(id: number, cordoned: boolean, reason: string | null): Node | undefined {
if (cordoned) {
this.db.prepare(
'UPDATE nodes SET cordoned = 1, cordoned_at = ?, cordoned_reason = ? WHERE id = ?'
).run(Date.now(), reason, id);
} else {
this.db.prepare(
'UPDATE nodes SET cordoned = 0, cordoned_at = NULL, cordoned_reason = NULL WHERE id = ?'
).run(id);
}
return this.getNode(id);
}
public setBlueprintPinnedNode(blueprintId: number, nodeId: number | null): Blueprint | undefined {
this.db.prepare('UPDATE blueprints SET pinned_node_id = ?, updated_at = ? WHERE id = ?')
.run(nodeId, Date.now(), blueprintId);
return this.getBlueprint(blueprintId);
}
public updateNodeLastContact(nodeId: number): void {
this.db.prepare('UPDATE nodes SET last_successful_contact = ? WHERE id = ?')
.run(Math.floor(Date.now() / 1000), nodeId);
}
// --- Pilot enrollments ---
public getPilotEnrollment(nodeId: number): PilotEnrollment | undefined {
const row = this.db.prepare(
'SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments WHERE node_id = ?'
).get(nodeId) as PilotEnrollment | undefined;
return row;
}
public createPilotEnrollment(nodeId: number, tokenHash: string, expiresAt: number): void {
this.db.prepare(
`INSERT INTO pilot_enrollments (node_id, token_hash, expires_at, used_at)
VALUES (?, ?, ?, NULL)
ON CONFLICT(node_id) DO UPDATE SET
token_hash = excluded.token_hash,
expires_at = excluded.expires_at,
used_at = NULL`
).run(nodeId, tokenHash, expiresAt);
}
public consumePilotEnrollment(tokenHash: string): PilotEnrollment | undefined {
const now = Date.now();
return this.db.transaction(() => {
const row = this.db.prepare(
`SELECT node_id, token_hash, expires_at, used_at FROM pilot_enrollments
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`
).get(tokenHash, now) as PilotEnrollment | undefined;
if (!row) return undefined;
this.db.prepare('UPDATE pilot_enrollments SET used_at = ? WHERE node_id = ?').run(now, row.node_id);
return { ...row, used_at: now };
})();
}
public deletePilotEnrollment(nodeId: number): void {
this.db.prepare('DELETE FROM pilot_enrollments WHERE node_id = ?').run(nodeId);
}
// --- Stack Update Status ---
public upsertStackUpdateStatus(nodeId: number, stackName: string, hasUpdate: boolean, checkedAt: number): void {
this.db.prepare(
`INSERT INTO stack_update_status (node_id, stack_name, has_update, checked_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET has_update = excluded.has_update, checked_at = excluded.checked_at`
).run(nodeId, stackName, hasUpdate ? 1 : 0, checkedAt);
}
public getStackUpdateStatus(nodeId?: number): Record<string, boolean> {
const rows = nodeId !== undefined
? this.db.prepare('SELECT stack_name, has_update FROM stack_update_status WHERE node_id = ?').all(nodeId) as Array<{ stack_name: string; has_update: number }>
: this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as Array<{ stack_name: string; has_update: number }>;
const result: Record<string, boolean> = {};
for (const row of rows) {
result[row.stack_name] = row.has_update === 1;
}
return result;
}
public clearStackUpdateStatus(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_update_status WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
// --- Stack Auto-Update Settings ---
public getStackAutoUpdateEnabled(nodeId: number, stackName: string): boolean {
const row = this.db.prepare(
'SELECT auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?'
).get(nodeId, stackName) as { auto_update_enabled: number } | undefined;
return row === undefined ? true : row.auto_update_enabled === 1;
}
// Returns only stacks with an explicit row. Missing keys default to true
// (auto-update enabled); callers must not treat absence as false.
public getStackAutoUpdateSettingsForNode(nodeId: number): Record<string, boolean> {
const rows = this.db.prepare(
'SELECT stack_name, auto_update_enabled FROM stack_auto_update_settings WHERE node_id = ?'
).all(nodeId) as Array<{ stack_name: string; auto_update_enabled: number }>;
const result: Record<string, boolean> = {};
for (const row of rows) {
result[row.stack_name] = row.auto_update_enabled === 1;
}
return result;
}
public upsertStackAutoUpdateEnabled(nodeId: number, stackName: string, enabled: boolean): void {
this.db.prepare(
`INSERT INTO stack_auto_update_settings (node_id, stack_name, auto_update_enabled, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(node_id, stack_name) DO UPDATE SET auto_update_enabled = excluded.auto_update_enabled, updated_at = excluded.updated_at`
).run(nodeId, stackName, enabled ? 1 : 0, Date.now());
}
public clearStackAutoUpdateSetting(nodeId: number, stackName: string): void {
this.db.prepare('DELETE FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName);
}
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
return this.db.prepare(
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
).all() as Array<{ node_id: number; stacks_with_updates: number }>;
}
public getNodeSchedulingSummary(): Array<{
node_id: number;
active_tasks: number;
auto_update_enabled: number;
next_run_at: number | null;
}> {
return this.db.prepare(`
SELECT
node_id,
COUNT(*) as active_tasks,
MAX(CASE WHEN action = 'update' AND enabled = 1 THEN 1 ELSE 0 END) as auto_update_enabled,
MIN(next_run_at) as next_run_at
FROM scheduled_tasks
WHERE enabled = 1 AND node_id IS NOT NULL
GROUP BY node_id
`).all() as Array<{ node_id: number; active_tasks: number; auto_update_enabled: number; next_run_at: number | null }>;
}
// --- Webhooks ---
public getWebhooks(): Webhook[] {
return this.db.prepare('SELECT * FROM webhooks ORDER BY created_at DESC').all().map((row: any) => ({
...row,
enabled: row.enabled === 1,
}));
}
public getWebhook(id: number): Webhook | undefined {
const row = this.db.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as any;
if (!row) return undefined;
return { ...row, enabled: row.enabled === 1 };
}
public addWebhook(webhook: Omit<Webhook, 'id' | 'created_at' | 'updated_at'>): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO webhooks (name, stack_name, action, secret, enabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(webhook.name, webhook.stack_name, webhook.action, webhook.secret, webhook.enabled ? 1 : 0, now, now);
return result.lastInsertRowid as number;
}
public updateWebhook(id: number, updates: Partial<Pick<Webhook, 'name' | 'stack_name' | 'action' | 'enabled'>>): void {
const fields: string[] = [];
const values: (string | number)[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.stack_name !== undefined) { fields.push('stack_name = ?'); values.push(updates.stack_name); }
if (updates.action !== undefined) { fields.push('action = ?'); values.push(updates.action); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (fields.length === 0) return;
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db.prepare(`UPDATE webhooks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteWebhook(id: number): void {
this.db.prepare('DELETE FROM webhooks WHERE id = ?').run(id);
}
// --- Webhook Executions ---
public getWebhookExecutions(webhookId: number, limit = 20): WebhookExecution[] {
return this.db.prepare(
'SELECT * FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT ?'
).all(webhookId, limit) as WebhookExecution[];
}
public addWebhookExecution(execution: Omit<WebhookExecution, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO webhook_executions (webhook_id, action, status, trigger_source, duration_ms, error, executed_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(execution.webhook_id, execution.action, execution.status, execution.trigger_source, execution.duration_ms, execution.error, execution.executed_at);
// Keep only last 100 executions per webhook
this.db.prepare(
'DELETE FROM webhook_executions WHERE webhook_id = ? AND id NOT IN (SELECT id FROM webhook_executions WHERE webhook_id = ? ORDER BY executed_at DESC LIMIT 100)'
).run(execution.webhook_id, execution.webhook_id);
return result.lastInsertRowid as number;
}
// --- Users ---
public getUsers(): Omit<User, 'password_hash'>[] {
return this.db.prepare('SELECT id, username, role, auth_provider, provider_id, email, created_at, updated_at FROM users ORDER BY created_at ASC').all() as Omit<User, 'password_hash'>[];
}
public getUser(id: number): User | undefined {
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
public getUserByUsername(username: string): User | undefined {
return this.db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
}
public getUserById(id: number): User | undefined {
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) as User | undefined;
}
public getUserByProviderIdentity(authProvider: string, providerId: string): User | undefined {
return this.db.prepare('SELECT * FROM users WHERE auth_provider = ? AND provider_id = ?').get(authProvider, providerId) as User | undefined;
}
public addUser(user: { username: string; password_hash: string; role: UserRole; auth_provider?: AuthProvider; provider_id?: string | null; email?: string | null }): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO users (username, password_hash, role, auth_provider, provider_id, email, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(user.username, user.password_hash, user.role, user.auth_provider ?? 'local', user.provider_id ?? null, user.email ?? null, now, now);
return result.lastInsertRowid as number;
}
public updateUser(id: number, updates: Partial<{ username: string; password_hash: string; role: string; email: string }>): void {
const fields: string[] = [];
const values: (string | number)[] = [];
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.password_hash !== undefined) { fields.push('password_hash = ?'); values.push(updates.password_hash); }
if (updates.role !== undefined) { fields.push('role = ?'); values.push(updates.role); }
if (updates.email !== undefined) { fields.push('email = ?'); values.push(updates.email); }
if (fields.length === 0) return;
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteUser(id: number): void {
this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
}
public getUserCount(): number {
return (this.db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number })?.count || 0;
}
public getAdminCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get() as { count: number })?.count || 0;
}
public getViewerCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'viewer'").get() as { count: number })?.count || 0;
}
public getNonAdminCount(): number {
return (this.db.prepare("SELECT COUNT(*) as count FROM users WHERE role != 'admin'").get() as { count: number })?.count || 0;
}
public bumpTokenVersion(userId: number): void {
this.db.prepare('UPDATE users SET token_version = token_version + 1, updated_at = ? WHERE id = ?').run(Date.now(), userId);
}
// --- User MFA ---
public getUserMfa(userId: number): UserMfa | undefined {
return this.db.prepare('SELECT * FROM user_mfa WHERE user_id = ?').get(userId) as UserMfa | undefined;
}
/**
* Create or merge a user_mfa row. Any field left undefined on the update
* object is preserved. Boolean flags are normalized to 0/1.
*/
public upsertUserMfa(userId: number, updates: UserMfaUpdate): void {
const now = Date.now();
const existing = this.getUserMfa(userId);
if (!existing) {
this.db.prepare(
`INSERT INTO user_mfa
(user_id, enabled, totp_secret_encrypted, backup_codes_json, sso_enforce_mfa,
failed_attempts, locked_until, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
userId,
updates.enabled ? 1 : 0,
updates.totp_secret_encrypted ?? null,
updates.backup_codes_json ?? null,
updates.sso_enforce_mfa ? 1 : 0,
updates.failed_attempts ?? 0,
updates.locked_until ?? null,
now,
now,
);
return;
}
const fields: string[] = [];
const values: (string | number | null)[] = [];
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.totp_secret_encrypted !== undefined) { fields.push('totp_secret_encrypted = ?'); values.push(updates.totp_secret_encrypted); }
if (updates.backup_codes_json !== undefined) { fields.push('backup_codes_json = ?'); values.push(updates.backup_codes_json); }
if (updates.sso_enforce_mfa !== undefined) { fields.push('sso_enforce_mfa = ?'); values.push(updates.sso_enforce_mfa ? 1 : 0); }
if (updates.failed_attempts !== undefined) { fields.push('failed_attempts = ?'); values.push(updates.failed_attempts); }
if (updates.locked_until !== undefined) { fields.push('locked_until = ?'); values.push(updates.locked_until); }
if (fields.length === 0) return;
fields.push('updated_at = ?');
values.push(now);
values.push(userId);
this.db.prepare(`UPDATE user_mfa SET ${fields.join(', ')} WHERE user_id = ?`).run(...values);
}
public deleteUserMfa(userId: number): void {
this.db.prepare('DELETE FROM user_mfa WHERE user_id = ?').run(userId);
this.db.prepare('DELETE FROM mfa_used_tokens WHERE user_id = ?').run(userId);
}
/**
* Single-query helper to enrich a user list with MFA status without the
* N+1 cost of calling getUserMfa() per row.
*/
public getUsersWithMfaEnabled(): Set<number> {
const rows = this.db.prepare('SELECT user_id FROM user_mfa WHERE enabled = 1').all() as { user_id: number }[];
return new Set(rows.map((r) => r.user_id));
}
public recordMfaFailure(userId: number): number {
const row = this.db.prepare(
`UPDATE user_mfa
SET failed_attempts = failed_attempts + 1,
updated_at = ?
WHERE user_id = ?
RETURNING failed_attempts`
).get(Date.now(), userId) as { failed_attempts: number } | undefined;
return row?.failed_attempts ?? 0;
}
public clearMfaFailures(userId: number): void {
this.db.prepare(
`UPDATE user_mfa
SET failed_attempts = 0,
locked_until = NULL,
updated_at = ?
WHERE user_id = ?`
).run(Date.now(), userId);
}
public lockMfa(userId: number, untilMs: number): void {
this.db.prepare(
`UPDATE user_mfa SET locked_until = ?, updated_at = ? WHERE user_id = ?`
).run(untilMs, Date.now(), userId);
}
public isMfaCodeUsed(userId: number, code: string, window: number): boolean {
const row = this.db.prepare(
'SELECT 1 FROM mfa_used_tokens WHERE user_id = ? AND code = ? AND window = ?'
).get(userId, code, window);
return !!row;
}
public markMfaCodeUsed(userId: number, code: string, window: number): void {
this.db.prepare(
'INSERT OR IGNORE INTO mfa_used_tokens (user_id, code, window, used_at) VALUES (?, ?, ?, ?)'
).run(userId, code, window, Date.now());
}
public purgeOldMfaCodes(olderThanMs: number): number {
const result = this.db.prepare('DELETE FROM mfa_used_tokens WHERE used_at < ?').run(olderThanMs);
return result.changes;
}
// --- Role Assignments ---
public getRoleAssignments(userId: number, resourceType: ResourceType, resourceId: string): RoleAssignment[] {
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? AND resource_type = ? AND resource_id = ?'
).all(userId, resourceType, resourceId) as RoleAssignment[];
}
public getAllRoleAssignments(userId: number): RoleAssignment[] {
return this.db.prepare(
'SELECT * FROM role_assignments WHERE user_id = ? ORDER BY resource_type, resource_id'
).all(userId) as RoleAssignment[];
}
public addRoleAssignment(assignment: { user_id: number; role: UserRole; resource_type: ResourceType; resource_id: string }): number {
const now = Date.now();
const result = this.db.prepare(
'INSERT INTO role_assignments (user_id, role, resource_type, resource_id, created_at) VALUES (?, ?, ?, ?, ?)'
).run(assignment.user_id, assignment.role, assignment.resource_type, assignment.resource_id, now);
return result.lastInsertRowid as number;
}
public getRoleAssignmentById(id: number): RoleAssignment | undefined {
return this.db.prepare('SELECT * FROM role_assignments WHERE id = ?').get(id) as RoleAssignment | undefined;
}
public deleteRoleAssignment(id: number): void {
this.db.prepare('DELETE FROM role_assignments WHERE id = ?').run(id);
}
public deleteRoleAssignmentsByUser(userId: number): void {
this.db.prepare('DELETE FROM role_assignments WHERE user_id = ?').run(userId);
}
public deleteRoleAssignmentsByResource(resourceType: ResourceType, resourceId: string): void {
this.db.prepare('DELETE FROM role_assignments WHERE resource_type = ? AND resource_id = ?').run(resourceType, resourceId);
}
// --- SSO Config ---
public getSSOConfigs(): SSOConfig[] {
return this.db.prepare('SELECT * FROM sso_config ORDER BY provider ASC').all() as SSOConfig[];
}
public getSSOConfig(provider: string): SSOConfig | undefined {
return this.db.prepare('SELECT * FROM sso_config WHERE provider = ?').get(provider) as SSOConfig | undefined;
}
public getEnabledSSOConfigs(): SSOConfig[] {
return this.db.prepare('SELECT * FROM sso_config WHERE enabled = 1 ORDER BY provider ASC').all() as SSOConfig[];
}
public upsertSSOConfig(provider: string, enabled: boolean, configJson: string): void {
const now = Date.now();
const existing = this.getSSOConfig(provider);
if (existing) {
this.db.prepare('UPDATE sso_config SET enabled = ?, config_json = ?, updated_at = ? WHERE provider = ?')
.run(enabled ? 1 : 0, configJson, now, provider);
} else {
this.db.prepare('INSERT INTO sso_config (provider, enabled, config_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
.run(provider, enabled ? 1 : 0, configJson, now, now);
}
}
public deleteSSOConfig(provider: string): void {
this.db.prepare('DELETE FROM sso_config WHERE provider = ?').run(provider);
}
// --- Fleet Snapshots ---
public createSnapshot(description: string, createdBy: string, nodeCount: number, stackCount: number, skippedNodes: string): number {
const result = this.db.prepare(
'INSERT INTO fleet_snapshots (description, created_by, node_count, stack_count, skipped_nodes, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(description, createdBy, nodeCount, stackCount, skippedNodes, Date.now());
return result.lastInsertRowid as number;
}
public insertSnapshotFiles(snapshotId: number, files: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>): void {
const insert = this.db.prepare(
'INSERT INTO fleet_snapshot_files (snapshot_id, node_id, node_name, stack_name, filename, content) VALUES (?, ?, ?, ?, ?, ?)'
);
const insertMany = this.db.transaction((rows: Array<{ nodeId: number; nodeName: string; stackName: string; filename: string; content: string }>) => {
for (const row of rows) {
insert.run(snapshotId, row.nodeId, row.nodeName, row.stackName, row.filename, row.content);
}
});
insertMany(files);
}
public getSnapshots(limit = 50, offset = 0): FleetSnapshot[] {
return this.db.prepare(
'SELECT * FROM fleet_snapshots ORDER BY created_at DESC LIMIT ? OFFSET ?'
).all(limit, offset) as FleetSnapshot[];
}
public getSnapshot(id: number): FleetSnapshot | undefined {
return this.db.prepare('SELECT * FROM fleet_snapshots WHERE id = ?').get(id) as FleetSnapshot | undefined;
}
public getSnapshotFiles(snapshotId: number): FleetSnapshotFile[] {
return this.db.prepare(
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? ORDER BY node_name, stack_name'
).all(snapshotId) as FleetSnapshotFile[];
}
public getSnapshotStackFiles(snapshotId: number, nodeId: number, stackName: string): FleetSnapshotFile[] {
return this.db.prepare(
'SELECT * FROM fleet_snapshot_files WHERE snapshot_id = ? AND node_id = ? AND stack_name = ?'
).all(snapshotId, nodeId, stackName) as FleetSnapshotFile[];
}
public deleteSnapshot(id: number): void {
this.db.prepare('DELETE FROM fleet_snapshots WHERE id = ?').run(id);
}
public getSnapshotCount(): number {
return (this.db.prepare('SELECT COUNT(*) as count FROM fleet_snapshots').get() as { count: number })?.count || 0;
}
// --- Audit Log ---
public insertAuditLog(entry: Omit<AuditLogEntry, 'id'>): void {
this.auditLogBuffer.push(entry);
if (this.auditLogBuffer.length >= AUDIT_LOG_FLUSH_THRESHOLD) {
this.flushAuditLogBuffer();
return;
}
if (!this.auditLogFlushTimer) {
// unref(): the buffer flush should not by itself keep the process
// alive. The HTTP server keeps the event loop running during
// normal operation; on shutdown, the explicit flush in
// bootstrap/shutdown.ts drains the buffer before db.close().
this.auditLogFlushTimer = setTimeout(
() => this.flushAuditLogBuffer(),
AUDIT_LOG_FLUSH_INTERVAL_MS,
);
this.auditLogFlushTimer.unref();
}
}
/**
* Drain the audit-log buffer to disk in a single transaction. Safe to
* call from any path: read methods flush before querying so callers
* observe buffered writes, and the shutdown handler flushes before the
* DB connection closes.
*/
public flushAuditLogBuffer(): void {
if (this.auditLogFlushTimer) {
clearTimeout(this.auditLogFlushTimer);
this.auditLogFlushTimer = null;
}
if (this.auditLogBuffer.length === 0) return;
// Swap the buffer reference before running the transaction. Any
// re-entrant insertAuditLog call (e.g. from a future hook that audits
// its own writes) lands on the new empty buffer and survives to the
// next flush, instead of being captured into the in-flight batch and
// potentially dropped on transaction failure.
const batch = this.auditLogBuffer;
this.auditLogBuffer = [];
if (!this.auditLogInsertStmt) {
this.auditLogInsertStmt = this.db.prepare(
'INSERT INTO audit_log (timestamp, username, method, path, status_code, node_id, ip_address, summary) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
);
}
const stmt = this.auditLogInsertStmt;
const insertMany = this.db.transaction((entries: typeof batch) => {
for (const entry of entries) {
stmt.run(
entry.timestamp,
entry.username,
entry.method,
entry.path,
entry.status_code,
entry.node_id,
entry.ip_address,
entry.summary,
);
}
});
try {
insertMany(batch);
} catch (err) {
console.error('[Audit] Failed to flush audit log buffer:', err);
}
}
public getAuditLogs(filters: {
page?: number;
limit?: number;
username?: string;
method?: string;
from?: number;
to?: number;
search?: string;
} = {}): { entries: AuditLogEntry[]; total: number } {
this.flushAuditLogBuffer();
const page = filters.page ?? 1;
const limit = filters.limit ?? 50;
const offset = (page - 1) * limit;
const conditions: string[] = [];
const params: (string | number)[] = [];
if (filters.username) {
conditions.push('username = ?');
params.push(filters.username);
}
if (filters.method) {
conditions.push('method = ?');
params.push(filters.method);
}
if (filters.from) {
conditions.push('timestamp >= ?');
params.push(filters.from);
}
if (filters.to) {
conditions.push('timestamp <= ?');
params.push(filters.to);
}
if (filters.search) {
conditions.push('(summary LIKE ? OR path LIKE ? OR username LIKE ?)');
const term = `%${filters.search}%`;
params.push(term, term, term);
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const total = (this.db.prepare(`SELECT COUNT(*) as count FROM audit_log ${where}`).get(...params) as { count: number })?.count || 0;
const entries = this.db.prepare(
`SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ? OFFSET ?`
).all(...params, limit, offset) as AuditLogEntry[];
return { entries, total };
}
public cleanupOldAuditLogs(daysToKeep = 90): void {
this.flushAuditLogBuffer();
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM audit_log WHERE timestamp < ?').run(cutoff);
}
public getAuditLogsInRange(from: number, to: number): AuditLogEntry[] {
this.flushAuditLogBuffer();
return this.db.prepare(
'SELECT * FROM audit_log WHERE timestamp >= ? AND timestamp < ? ORDER BY timestamp ASC'
).all(from, to) as AuditLogEntry[];
}
// --- API Tokens ---
public addApiToken(token: Omit<ApiToken, 'id' | 'last_used_at' | 'revoked_at'>): number {
const result = this.db.prepare(
'INSERT INTO api_tokens (token_hash, name, scope, user_id, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(token.token_hash, token.name, token.scope, token.user_id, token.created_at, token.expires_at);
return result.lastInsertRowid as number;
}
public getApiTokensByUser(userId: number): ApiToken[] {
return this.db.prepare(
'SELECT * FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC'
).all(userId) as ApiToken[];
}
public getApiTokenByHash(tokenHash: string): ApiToken | undefined {
return this.db.prepare(
'SELECT * FROM api_tokens WHERE token_hash = ?'
).get(tokenHash) as ApiToken | undefined;
}
public getApiTokenById(id: number): ApiToken | undefined {
return this.db.prepare(
'SELECT * FROM api_tokens WHERE id = ?'
).get(id) as ApiToken | undefined;
}
public revokeApiToken(id: number): void {
this.db.prepare('UPDATE api_tokens SET revoked_at = ? WHERE id = ?').run(Date.now(), id);
}
public updateApiTokenLastUsed(id: number): void {
this.db.prepare('UPDATE api_tokens SET last_used_at = ? WHERE id = ?').run(Date.now(), id);
}
public getActiveApiTokenCountByUser(userId: number): number {
const row = this.db.prepare(
'SELECT COUNT(*) AS cnt FROM api_tokens WHERE user_id = ? AND revoked_at IS NULL'
).get(userId) as { cnt: number };
return row.cnt;
}
public getActiveApiTokenByNameAndUser(name: string, userId: number): ApiToken | undefined {
return this.db.prepare(
'SELECT * FROM api_tokens WHERE name = ? AND user_id = ? AND revoked_at IS NULL LIMIT 1'
).get(name, userId) as ApiToken | undefined;
}
// --- Registries ---
public getRegistries(): Registry[] {
return this.db.prepare('SELECT * FROM registries ORDER BY name ASC').all() as Registry[];
}
public getRegistry(id: number): Registry | undefined {
return this.db.prepare('SELECT * FROM registries WHERE id = ?').get(id) as Registry | undefined;
}
public addRegistry(reg: Omit<Registry, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO registries (name, url, type, username, secret, aws_region, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(reg.name, reg.url, reg.type, reg.username, reg.secret, reg.aws_region, reg.created_at, reg.updated_at);
return result.lastInsertRowid as number;
}
public updateRegistry(id: number, updates: Partial<Omit<Registry, 'id' | 'created_at'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.url !== undefined) { fields.push('url = ?'); values.push(updates.url); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.username !== undefined) { fields.push('username = ?'); values.push(updates.username); }
if (updates.secret !== undefined) { fields.push('secret = ?'); values.push(updates.secret); }
if (updates.aws_region !== undefined) { fields.push('aws_region = ?'); values.push(updates.aws_region); }
if (updates.updated_at !== undefined) { fields.push('updated_at = ?'); values.push(updates.updated_at); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE registries SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteRegistry(id: number): void {
this.db.prepare('DELETE FROM registries WHERE id = ?').run(id);
}
// --- Stack Git Sources ---
private parseGitSource(row: Record<string, unknown> | undefined): StackGitSource | undefined {
if (!row) return undefined;
return {
id: row.id as number,
stack_name: row.stack_name as string,
repo_url: row.repo_url as string,
branch: row.branch as string,
compose_path: row.compose_path as string,
sync_env: Number(row.sync_env) === 1,
env_path: (row.env_path as string | null) ?? null,
auth_type: row.auth_type as GitSourceAuthType,
encrypted_token: (row.encrypted_token as string | null) ?? null,
auto_apply_on_webhook: Number(row.auto_apply_on_webhook) === 1,
auto_deploy_on_apply: Number(row.auto_deploy_on_apply) === 1,
last_applied_commit_sha: (row.last_applied_commit_sha as string | null) ?? null,
last_applied_content_hash: (row.last_applied_content_hash as string | null) ?? null,
pending_commit_sha: (row.pending_commit_sha as string | null) ?? null,
pending_compose_content: (row.pending_compose_content as string | null) ?? null,
pending_env_content: (row.pending_env_content as string | null) ?? null,
pending_fetched_at: (row.pending_fetched_at as number | null) ?? null,
last_debounce_at: (row.last_debounce_at as number | null) ?? null,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
};
}
public getGitSource(stackName: string): StackGitSource | undefined {
const row = this.db.prepare('SELECT * FROM stack_git_sources WHERE stack_name = ?').get(stackName) as Record<string, unknown> | undefined;
return this.parseGitSource(row);
}
public getGitSources(): StackGitSource[] {
const rows = this.db.prepare('SELECT * FROM stack_git_sources ORDER BY stack_name ASC').all() as Record<string, unknown>[];
return rows.map(r => this.parseGitSource(r)!);
}
public upsertGitSource(source: Omit<StackGitSource, 'id' | 'created_at' | 'updated_at'>): number {
const now = Date.now();
const existing = this.getGitSource(source.stack_name);
if (existing) {
this.db.prepare(
`UPDATE stack_git_sources SET
repo_url = ?, branch = ?, compose_path = ?, sync_env = ?, env_path = ?,
auth_type = ?, encrypted_token = ?,
auto_apply_on_webhook = ?, auto_deploy_on_apply = ?,
updated_at = ?
WHERE stack_name = ?`
).run(
source.repo_url, source.branch, source.compose_path,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
now, source.stack_name
);
return existing.id!;
}
const result = this.db.prepare(
`INSERT INTO stack_git_sources
(stack_name, repo_url, branch, compose_path, sync_env, env_path,
auth_type, encrypted_token, auto_apply_on_webhook, auto_deploy_on_apply,
created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
source.stack_name, source.repo_url, source.branch, source.compose_path,
source.sync_env ? 1 : 0, source.env_path,
source.auth_type, source.encrypted_token,
source.auto_apply_on_webhook ? 1 : 0, source.auto_deploy_on_apply ? 1 : 0,
now, now
);
return result.lastInsertRowid as number;
}
public deleteGitSource(stackName: string): void {
this.db.prepare('DELETE FROM stack_git_sources WHERE stack_name = ?').run(stackName);
}
public setGitSourcePending(stackName: string, commitSha: string, composeContent: string, envContent: string | null): void {
this.db.prepare(
`UPDATE stack_git_sources SET
pending_commit_sha = ?,
pending_compose_content = ?,
pending_env_content = ?,
pending_fetched_at = ?,
updated_at = ?
WHERE stack_name = ?`
).run(commitSha, composeContent, envContent, Date.now(), Date.now(), stackName);
}
public clearGitSourcePending(stackName: string): void {
this.db.prepare(
`UPDATE stack_git_sources SET
pending_commit_sha = NULL,
pending_compose_content = NULL,
pending_env_content = NULL,
pending_fetched_at = NULL,
updated_at = ?
WHERE stack_name = ?`
).run(Date.now(), stackName);
}
public markGitSourceApplied(stackName: string, commitSha: string, contentHash: string): void {
this.db.prepare(
`UPDATE stack_git_sources SET
last_applied_commit_sha = ?,
last_applied_content_hash = ?,
pending_commit_sha = NULL,
pending_compose_content = NULL,
pending_env_content = NULL,
pending_fetched_at = NULL,
updated_at = ?
WHERE stack_name = ?`
).run(commitSha, contentHash, Date.now(), stackName);
}
public touchGitSourceDebounce(stackName: string): void {
this.db.prepare('UPDATE stack_git_sources SET last_debounce_at = ? WHERE stack_name = ?')
.run(Date.now(), stackName);
}
// --- Scheduled Tasks ---
public getScheduledTasks(): ScheduledTask[] {
return this.db.prepare('SELECT * FROM scheduled_tasks ORDER BY created_at DESC').all() as ScheduledTask[];
}
public getScheduledTask(id: number): ScheduledTask | undefined {
return this.db.prepare('SELECT * FROM scheduled_tasks WHERE id = ?').get(id) as ScheduledTask | undefined;
}
public createScheduledTask(task: Omit<ScheduledTask, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_tasks (name, target_type, target_id, node_id, action, cron_expression, enabled, created_by, created_at, updated_at, last_run_at, next_run_at, last_status, last_error, prune_targets, target_services, prune_label_filter, delete_after_run) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
task.name, task.target_type, task.target_id, task.node_id,
task.action, task.cron_expression, task.enabled, task.created_by,
task.created_at, task.updated_at, task.last_run_at, task.next_run_at,
task.last_status, task.last_error, task.prune_targets, task.target_services,
task.prune_label_filter, task.delete_after_run ?? 0
);
return result.lastInsertRowid as number;
}
public updateScheduledTask(id: number, updates: Partial<Omit<ScheduledTask, 'id'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
const map: Record<string, unknown> = {
name: updates.name, target_type: updates.target_type, target_id: updates.target_id,
node_id: updates.node_id, action: updates.action, cron_expression: updates.cron_expression,
enabled: updates.enabled, created_by: updates.created_by, updated_at: updates.updated_at,
last_run_at: updates.last_run_at, next_run_at: updates.next_run_at,
last_status: updates.last_status, last_error: updates.last_error,
prune_targets: updates.prune_targets, target_services: updates.target_services,
prune_label_filter: updates.prune_label_filter,
delete_after_run: updates.delete_after_run,
};
for (const [col, val] of Object.entries(map)) {
if (val !== undefined) {
fields.push(`${col} = ?`);
values.push(val);
}
}
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE scheduled_tasks SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteScheduledTask(id: number): void {
this.db.transaction(() => {
this.db.prepare('DELETE FROM scheduled_task_runs WHERE task_id = ?').run(id);
this.db.prepare('DELETE FROM scheduled_tasks WHERE id = ?').run(id);
})();
}
public getDueScheduledTasks(now: number): ScheduledTask[] {
return this.db.prepare(
'SELECT * FROM scheduled_tasks WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?'
).all(now) as ScheduledTask[];
}
public getScheduledTaskRuns(taskId: number, limit = 20, offset = 0): { runs: ScheduledTaskRun[]; total: number } {
const runs = this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC LIMIT ? OFFSET ?'
).all(taskId, limit, offset) as ScheduledTaskRun[];
const { total } = this.db.prepare(
'SELECT COUNT(*) as total FROM scheduled_task_runs WHERE task_id = ?'
).get(taskId) as { total: number };
return { runs, total };
}
public createScheduledTaskRun(run: Omit<ScheduledTaskRun, 'id'>): number {
const result = this.db.prepare(
'INSERT INTO scheduled_task_runs (task_id, started_at, completed_at, status, output, error, triggered_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(run.task_id, run.started_at, run.completed_at, run.status, run.output, run.error, run.triggered_by);
return result.lastInsertRowid as number;
}
public updateScheduledTaskRun(id: number, updates: Partial<Omit<ScheduledTaskRun, 'id' | 'task_id'>>): void {
const fields: string[] = [];
const values: unknown[] = [];
if (updates.completed_at !== undefined) { fields.push('completed_at = ?'); values.push(updates.completed_at); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.output !== undefined) { fields.push('output = ?'); values.push(updates.output); }
if (updates.error !== undefined) { fields.push('error = ?'); values.push(updates.error); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE scheduled_task_runs SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public getAllScheduledTaskRuns(taskId: number): ScheduledTaskRun[] {
return this.db.prepare(
'SELECT * FROM scheduled_task_runs WHERE task_id = ? ORDER BY started_at DESC'
).all(taskId) as ScheduledTaskRun[];
}
public markStaleRunsAsFailed(): number {
const result = this.db.prepare(
'UPDATE scheduled_task_runs SET status = ?, completed_at = ?, error = ? WHERE status = ?'
).run('failure', Date.now(), 'Server restarted during execution', 'running');
return result.changes;
}
public cleanupOldTaskRuns(retentionDays = 30): void {
const cutoff = Date.now() - (retentionDays * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM scheduled_task_runs WHERE started_at < ?').run(cutoff);
}
// --- Vulnerability Scans ---
public createVulnerabilityScan(
scan: Omit<VulnerabilityScan, 'id' | 'policy_evaluation'> & {
policy_evaluation?: string | null;
},
): number {
const stmt = this.db.prepare(
`INSERT INTO vulnerability_scans (
node_id, image_ref, image_digest, scanned_at,
total_vulnerabilities, critical_count, high_count, medium_count,
low_count, unknown_count, fixable_count,
secret_count, misconfig_count, scanners_used,
highest_severity, os_info, trivy_version, scan_duration_ms,
triggered_by, status, error, stack_context, policy_evaluation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const result = stmt.run(
scan.node_id,
scan.image_ref,
scan.image_digest,
scan.scanned_at,
scan.total_vulnerabilities,
scan.critical_count,
scan.high_count,
scan.medium_count,
scan.low_count,
scan.unknown_count,
scan.fixable_count,
scan.secret_count,
scan.misconfig_count,
scan.scanners_used,
scan.highest_severity,
scan.os_info,
scan.trivy_version,
scan.scan_duration_ms,
scan.triggered_by,
scan.status,
scan.error,
scan.stack_context,
scan.policy_evaluation ?? null,
);
return result.lastInsertRowid as number;
}
public updateVulnerabilityScan(
id: number,
updates: Partial<Omit<VulnerabilityScan, 'id'>>,
): void {
const ALLOWED_COLUMNS = new Set([
'node_id', 'image_ref', 'image_digest', 'scanned_at',
'total_vulnerabilities', 'critical_count', 'high_count',
'medium_count', 'low_count', 'unknown_count', 'fixable_count',
'secret_count', 'misconfig_count', 'scanners_used',
'highest_severity', 'os_info', 'trivy_version', 'scan_duration_ms',
'triggered_by', 'status', 'error', 'stack_context', 'policy_evaluation',
]);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED_COLUMNS.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return;
values.push(id);
this.db
.prepare(`UPDATE vulnerability_scans SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
}
public getVulnerabilityScan(id: number): VulnerabilityScan | null {
return (
(this.db
.prepare('SELECT * FROM vulnerability_scans WHERE id = ?')
.get(id) as VulnerabilityScan | undefined) ?? null
);
}
public getVulnerabilityScans(
nodeId: number,
opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {},
): { items: VulnerabilityScan[]; total: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
const offset = Math.max(0, opts.offset ?? 0);
const where = ['node_id = ?'];
const params: unknown[] = [nodeId];
if (opts.imageRef) {
where.push('image_ref = ?');
params.push(opts.imageRef);
}
if (opts.imageRefLike) {
where.push('image_ref LIKE ?');
params.push(`%${opts.imageRefLike}%`);
}
if (opts.status) {
where.push('status = ?');
params.push(opts.status);
}
const whereSql = where.join(' AND ');
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_scans WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
).cnt;
const items = this.db
.prepare(
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
return { items, total };
}
public getLatestScanForImage(
nodeId: number,
imageRef: string,
): VulnerabilityScan | null {
return (
(this.db
.prepare(
'SELECT * FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? ORDER BY scanned_at DESC LIMIT 1',
)
.get(nodeId, imageRef) as VulnerabilityScan | undefined) ?? null
);
}
public getLatestScanSummaryByImageRefs(
nodeId: number,
imageRefs: string[],
): Map<string, { total: number; critical: number; high: number; scannedAt: number }> {
const summary = new Map<string, { total: number; critical: number; high: number; scannedAt: number }>();
if (imageRefs.length === 0) return summary;
const placeholders = imageRefs.map(() => '?').join(',');
const rows = this.db
.prepare(
`SELECT image_ref, total_vulnerabilities, critical_count, high_count, scanned_at
FROM vulnerability_scans v1
WHERE node_id = ?
AND image_ref IN (${placeholders})
AND scanned_at = (
SELECT MAX(scanned_at) FROM vulnerability_scans v2
WHERE v2.node_id = v1.node_id AND v2.image_ref = v1.image_ref
)`,
)
.all(nodeId, ...imageRefs) as Array<{
image_ref: string;
total_vulnerabilities: number;
critical_count: number;
high_count: number;
scanned_at: number;
}>;
for (const row of rows) {
summary.set(row.image_ref, {
total: row.total_vulnerabilities,
critical: row.critical_count,
high: row.high_count,
scannedAt: row.scanned_at,
});
}
return summary;
}
public getLatestScanByDigest(digest: string, scannersUsed?: string): VulnerabilityScan | null {
if (!digest) return null;
if (scannersUsed) {
return (
(this.db
.prepare(
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND scanners_used = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
)
.get(digest, scannersUsed) as VulnerabilityScan | undefined) ?? null
);
}
return (
(this.db
.prepare(
"SELECT * FROM vulnerability_scans WHERE image_digest = ? AND status = 'completed' ORDER BY scanned_at DESC LIMIT 1",
)
.get(digest) as VulnerabilityScan | undefined) ?? null
);
}
public deleteOldScans(olderThanMs: number): number {
const cutoff = Date.now() - olderThanMs;
const result = this.db
.prepare('DELETE FROM vulnerability_scans WHERE scanned_at < ?')
.run(cutoff);
return result.changes;
}
public markStaleScansAsFailed(olderThanMs: number): number {
const cutoff = Date.now() - olderThanMs;
const result = this.db
.prepare(
`UPDATE vulnerability_scans
SET status = 'failed',
error = 'Scan did not complete within expected time',
scan_duration_ms = ? - scanned_at
WHERE status = 'in_progress' AND scanned_at < ?`,
)
.run(Date.now(), cutoff);
return result.changes;
}
public isImageBeingScanned(nodeId: number, imageRef: string): boolean {
const row = this.db
.prepare(
"SELECT id FROM vulnerability_scans WHERE node_id = ? AND image_ref = ? AND status = 'in_progress' LIMIT 1",
)
.get(nodeId, imageRef);
return !!row;
}
public insertVulnerabilityDetails(
scanId: number,
details: Array<Omit<VulnerabilityDetail, 'id' | 'scan_id'>>,
): void {
if (details.length === 0) return;
const stmt = this.db.prepare(
`INSERT INTO vulnerability_details (
scan_id, vulnerability_id, pkg_name, installed_version,
fixed_version, severity, title, description, primary_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const txn = this.db.transaction((rows: typeof details) => {
for (const d of rows) {
stmt.run(
scanId,
d.vulnerability_id,
d.pkg_name,
d.installed_version,
d.fixed_version,
d.severity,
d.title,
d.description,
d.primary_url,
);
}
});
txn(details);
}
public getVulnerabilityDetails(
scanId: number,
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
): { items: VulnerabilityDetail[]; total: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
const offset = Math.max(0, opts.offset ?? 0);
const where = ['scan_id = ?'];
const params: unknown[] = [scanId];
if (opts.severity) {
where.push('severity = ?');
params.push(opts.severity);
}
const whereSql = where.join(' AND ');
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM vulnerability_details WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
).cnt;
const severityOrder = `CASE severity
WHEN 'CRITICAL' THEN 0
WHEN 'HIGH' THEN 1
WHEN 'MEDIUM' THEN 2
WHEN 'LOW' THEN 3
ELSE 4 END`;
const items = this.db
.prepare(
`SELECT * FROM vulnerability_details WHERE ${whereSql} ORDER BY ${severityOrder}, pkg_name LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as VulnerabilityDetail[];
return { items, total };
}
public insertSecretFindings(
scanId: number,
findings: Array<Omit<SecretFinding, 'id' | 'scan_id'>>,
): void {
if (findings.length === 0) return;
const stmt = this.db.prepare(
`INSERT INTO secret_findings (
scan_id, rule_id, category, severity, title, target, start_line, end_line, match_excerpt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const txn = this.db.transaction((rows: typeof findings) => {
for (const f of rows) {
stmt.run(
scanId,
f.rule_id,
f.category,
f.severity,
f.title,
f.target,
f.start_line,
f.end_line,
f.match_excerpt,
);
}
});
txn(findings);
}
public getSecretFindings(
scanId: number,
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
): { items: SecretFinding[]; total: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
const offset = Math.max(0, opts.offset ?? 0);
const where = ['scan_id = ?'];
const params: unknown[] = [scanId];
if (opts.severity) {
where.push('severity = ?');
params.push(opts.severity);
}
const whereSql = where.join(' AND ');
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM secret_findings WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
).cnt;
const severityOrder = `CASE severity
WHEN 'CRITICAL' THEN 0
WHEN 'HIGH' THEN 1
WHEN 'MEDIUM' THEN 2
WHEN 'LOW' THEN 3
ELSE 4 END`;
const items = this.db
.prepare(
`SELECT * FROM secret_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as SecretFinding[];
return { items, total };
}
public insertMisconfigFindings(
scanId: number,
findings: Array<Omit<MisconfigFinding, 'id' | 'scan_id'>>,
): void {
if (findings.length === 0) return;
const stmt = this.db.prepare(
`INSERT INTO misconfig_findings (
scan_id, rule_id, check_id, severity, title, message, resolution, target, primary_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
);
const txn = this.db.transaction((rows: typeof findings) => {
for (const f of rows) {
stmt.run(
scanId,
f.rule_id,
f.check_id,
f.severity,
f.title,
f.message,
f.resolution,
f.target,
f.primary_url,
);
}
});
txn(findings);
}
public getMisconfigFindings(
scanId: number,
opts: { severity?: VulnSeverity; limit?: number; offset?: number } = {},
): { items: MisconfigFinding[]; total: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 100, 1000));
const offset = Math.max(0, opts.offset ?? 0);
const where = ['scan_id = ?'];
const params: unknown[] = [scanId];
if (opts.severity) {
where.push('severity = ?');
params.push(opts.severity);
}
const whereSql = where.join(' AND ');
const total = (
this.db
.prepare(`SELECT COUNT(*) as cnt FROM misconfig_findings WHERE ${whereSql}`)
.get(...(params as never[])) as { cnt: number }
).cnt;
const severityOrder = `CASE severity
WHEN 'CRITICAL' THEN 0
WHEN 'HIGH' THEN 1
WHEN 'MEDIUM' THEN 2
WHEN 'LOW' THEN 3
ELSE 4 END`;
const items = this.db
.prepare(
`SELECT * FROM misconfig_findings WHERE ${whereSql} ORDER BY ${severityOrder}, target LIMIT ? OFFSET ?`,
)
.all(...(params as never[]), limit, offset) as MisconfigFinding[];
return { items, total };
}
public getImageScanSummaries(nodeId: number): Record<string, ScanSummary> {
const rows = this.db
.prepare(
`SELECT vs.image_ref, vs.id as scan_id, vs.highest_severity, vs.total_vulnerabilities,
vs.critical_count, vs.high_count, vs.medium_count, vs.low_count,
vs.unknown_count, vs.fixable_count, vs.scanned_at
FROM vulnerability_scans vs
INNER JOIN (
SELECT image_ref, MAX(scanned_at) AS max_scanned
FROM vulnerability_scans
WHERE node_id = ? AND status = 'completed'
GROUP BY image_ref
) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at
WHERE vs.node_id = ? AND vs.status = 'completed'`,
)
.all(nodeId, nodeId) as Array<{
image_ref: string;
scan_id: number;
highest_severity: VulnSeverity | null;
total_vulnerabilities: number;
critical_count: number;
high_count: number;
medium_count: number;
low_count: number;
unknown_count: number;
fixable_count: number;
scanned_at: number;
}>;
const out: Record<string, ScanSummary> = {};
for (const r of rows) {
out[r.image_ref] = {
image_ref: r.image_ref,
highest_severity: r.highest_severity,
total: r.total_vulnerabilities,
critical: r.critical_count,
high: r.high_count,
medium: r.medium_count,
low: r.low_count,
unknown: r.unknown_count,
fixable: r.fixable_count,
scanned_at: r.scanned_at,
scan_id: r.scan_id,
};
}
return out;
}
// --- Scan Policies ---
public getScanPolicies(): ScanPolicy[] {
return this.db
.prepare('SELECT * FROM scan_policies ORDER BY created_at DESC')
.all() as ScanPolicy[];
}
/**
* Local-only scan policies (created on this instance, not replicated from a
* control). Used by the fleet sync sender so it never re-replicates rows
* that came from a control in the first place.
*/
public getLocalScanPolicies(): ScanPolicy[] {
return this.db
.prepare('SELECT * FROM scan_policies WHERE replicated_from_control = 0 ORDER BY created_at DESC')
.all() as ScanPolicy[];
}
/**
* Variant of `getScanPolicies` for the security-settings UI.
*
* On a control instance: returns the full set, identical to
* `getScanPolicies`.
*
* On a replica: returns only the policies that apply to THIS replica.
* Replicated rows scoped to a different replica's identity (the
* `node_identity` of a sibling node in the fleet) are filtered out so
* an operator on Replica A cannot enumerate the names of identity-scoped
* policies meant for Replica B. Internal evaluators
* (`getMatchingPolicy`, `evaluateScanAgainstPolicies`) keep using the
* unfiltered list because they already enforce identity matching at
* evaluation time.
*/
public getScanPoliciesForUi(role: 'control' | 'replica', selfIdentity: string): ScanPolicy[] {
const all = this.getScanPolicies();
if (role === 'control') return all;
return all.filter((p) => {
if (p.replicated_from_control === 0) return true;
// Fleet-wide replicated rows have an empty node_identity and
// apply on every replica.
if (!p.node_identity) return true;
return p.node_identity === selfIdentity;
});
}
public getScanPolicy(id: number): ScanPolicy | null {
return (
(this.db
.prepare('SELECT * FROM scan_policies WHERE id = ?')
.get(id) as ScanPolicy | undefined) ?? null
);
}
public createScanPolicy(
policy: Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>,
): ScanPolicy {
const now = Date.now();
const result = this.db
.prepare(
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
policy.name,
policy.node_id,
policy.node_identity ?? '',
policy.stack_pattern,
policy.max_severity,
policy.block_on_deploy,
policy.enabled,
policy.replicated_from_control ?? 0,
now,
now,
);
return {
...policy,
node_identity: policy.node_identity ?? '',
replicated_from_control: policy.replicated_from_control ?? 0,
id: result.lastInsertRowid as number,
created_at: now,
updated_at: now,
};
}
public updateScanPolicy(
id: number,
updates: Partial<Omit<ScanPolicy, 'id' | 'created_at' | 'updated_at'>>,
): ScanPolicy | null {
const existing = this.getScanPolicy(id);
if (!existing) return null;
const ALLOWED_COLUMNS = new Set([
'name', 'node_id', 'node_identity', 'stack_pattern', 'max_severity',
'block_on_deploy', 'enabled', 'replicated_from_control',
]);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED_COLUMNS.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return existing;
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db
.prepare(`UPDATE scan_policies SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
return this.getScanPolicy(id);
}
public deleteScanPolicy(id: number): void {
// policy_evaluation is a JSON blob containing the policyId of the policy
// that produced it. Clear it on every scan referencing the deleted policy
// so cached scans no longer report stale violations after the policy is gone.
const clearEval = this.db.prepare(
`UPDATE vulnerability_scans
SET policy_evaluation = NULL
WHERE json_extract(policy_evaluation, '$.policyId') = ?`,
);
const deletePolicy = this.db.prepare('DELETE FROM scan_policies WHERE id = ?');
const txn = this.db.transaction((policyId: number) => {
clearEval.run(policyId);
deletePolicy.run(policyId);
});
txn(id);
}
/**
* Replace all policies that were replicated from a control node with the
* provided rows in a single transaction. Local-only policies (created on
* this instance directly) are left untouched.
*
* Replicated policies always insert with fresh ids on the replica, so any
* `vulnerability_scans.policy_evaluation` row pointing at a replicated
* policy from the previous push refers to a now-deleted id. Clear those
* orphaned cache entries inside the same transaction so a replica's UI
* stops showing violations from a policy that no longer exists.
*/
public replaceReplicatedScanPolicies(rows: ScanPolicy[]): void {
const now = Date.now();
const deleteStmt = this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO scan_policies (name, node_id, node_identity, stack_pattern, max_severity, block_on_deploy, enabled, replicated_from_control, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`,
);
const txn = this.db.transaction((policies: ScanPolicy[]) => {
deleteStmt.run();
for (const p of policies) {
insertStmt.run(
p.name,
null,
p.node_identity ?? '',
p.stack_pattern,
p.max_severity,
p.block_on_deploy,
p.enabled,
p.created_at ?? now,
p.updated_at ?? now,
);
}
this.clearOrphanPolicyEvaluations();
});
txn(rows);
}
public getMatchingPolicy(
nodeId: number,
stackName: string | null,
selfIdentity: string,
): ScanPolicy | null {
// Filter on node_id at SQL: rows are eligible when fleet-wide
// (node_id IS NULL) or when locally scoped to this node (node_id = ?).
// Replicated rows always insert node_id = NULL (see
// replaceReplicatedScanPolicies), so identity scoping for replicated
// rows is enforced in `matchesIdentity` below, not in SQL.
const policies = this.db
.prepare(
'SELECT * FROM scan_policies WHERE enabled = 1 AND (node_id IS NULL OR node_id = ?)',
)
.all(nodeId) as ScanPolicy[];
const matchesStack = (pattern: string | null): boolean => {
if (!pattern) return true;
if (!stackName) return false;
const regex = new RegExp(
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
);
return regex.test(stackName);
};
const matchesIdentity = (p: ScanPolicy): boolean => {
// Locally created policies (never replicated) apply based on node_id logic already filtered.
if (p.replicated_from_control === 0) return true;
// Replicated policies without a specific identity are fleet-wide.
if (!p.node_identity) return true;
// Identity-scoped replicated policies only apply to their target instance.
return p.node_identity === selfIdentity;
};
const scoped = policies.filter((p) => matchesStack(p.stack_pattern) && matchesIdentity(p));
if (scoped.length === 0) return null;
const isNodeScoped = (p: ScanPolicy): boolean => Boolean(p.node_id) || Boolean(p.node_identity);
scoped.sort((a, b) => {
const aNode = isNodeScoped(a);
const bNode = isNodeScoped(b);
if (aNode && !bNode) return -1;
if (!aNode && bNode) return 1;
if (a.stack_pattern && !b.stack_pattern) return -1;
if (!a.stack_pattern && b.stack_pattern) return 1;
// Deterministic tiebreaker: lowest id wins. Two rows in the same
// scope class (e.g. both fleet-wide stack-wildcard) must resolve
// to the same policy on every replica, regardless of SQLite row
// iteration order.
return a.id - b.id;
});
return scoped[0];
}
/**
* Evaluate a completed scan against the matching policy for its node and
* stack context. Returns the evaluation that should be persisted to the
* scan row, or null when no policy matches.
*
* The result is informational. `violated=false` means a policy matched
* but the scan was within limits; the UI surfaces a banner only when
* `violated=true`. Blocking enforcement lives in the pre-deploy gate.
*/
public evaluateScanAgainstPolicies(
nodeId: number,
scan: VulnerabilityScan,
selfIdentity: string,
): PolicyEvaluation | null {
const policy = this.getMatchingPolicy(nodeId, scan.stack_context, selfIdentity);
if (!policy) return null;
return {
policyId: policy.id,
policyName: policy.name,
maxSeverity: policy.max_severity,
violated: isSeverityAtLeast(scan.highest_severity, policy.max_severity),
evaluatedAt: Date.now(),
};
}
/**
* Persist a PolicyEvaluation onto a scan row. Pass null to clear.
* Encoded as JSON so consumers round-trip through parsePolicyEvaluation().
*/
public setScanPolicyEvaluation(
scanId: number,
evaluation: PolicyEvaluation | null,
): void {
const json = evaluation ? JSON.stringify(evaluation) : null;
this.db
.prepare('UPDATE vulnerability_scans SET policy_evaluation = ? WHERE id = ?')
.run(json, scanId);
}
// --- Fleet Sync Status ---
public getFleetSyncStatuses(): FleetSyncStatus[] {
return this.db
.prepare('SELECT * FROM fleet_sync_status ORDER BY node_id, resource')
.all() as FleetSyncStatus[];
}
public recordFleetSyncSuccess(nodeId: number, resource: string): void {
const now = Date.now();
this.db
.prepare(
`INSERT INTO fleet_sync_status (node_id, resource, last_success_at, last_failure_at, last_error)
VALUES (?, ?, ?, NULL, NULL)
ON CONFLICT(node_id, resource) DO UPDATE SET
last_success_at = excluded.last_success_at,
last_error = NULL`,
)
.run(nodeId, resource, now);
}
public recordFleetSyncFailure(nodeId: number, resource: string, error: string): void {
const now = Date.now();
this.db
.prepare(
`INSERT INTO fleet_sync_status (node_id, resource, last_failure_at, last_error)
VALUES (?, ?, ?, ?)
ON CONFLICT(node_id, resource) DO UPDATE SET
last_failure_at = excluded.last_failure_at,
last_error = excluded.last_error`,
)
.run(nodeId, resource, now, error);
}
public getFailedSyncTargets(resource: string, maxAgeMs: number): FleetSyncStatus[] {
const cutoff = Date.now() - maxAgeMs;
return this.db
.prepare(
`SELECT * FROM fleet_sync_status
WHERE resource = ?
AND (last_failure_at IS NOT NULL AND last_failure_at > ?)
AND (last_success_at IS NULL OR last_success_at < last_failure_at)`,
)
.all(resource, cutoff) as FleetSyncStatus[];
}
// --- CVE Suppressions ---
public getCveSuppressions(): CveSuppression[] {
return this.db
.prepare('SELECT * FROM cve_suppressions ORDER BY cve_id, pkg_name')
.all() as CveSuppression[];
}
/** Local-only CVE suppressions; mirrors `getLocalScanPolicies`. */
public getLocalCveSuppressions(): CveSuppression[] {
return this.db
.prepare('SELECT * FROM cve_suppressions WHERE replicated_from_control = 0 ORDER BY cve_id, pkg_name')
.all() as CveSuppression[];
}
public getCveSuppression(id: number): CveSuppression | null {
return (
(this.db.prepare('SELECT * FROM cve_suppressions WHERE id = ?')
.get(id) as CveSuppression | undefined) ?? null
);
}
public createCveSuppression(
suppression: Omit<CveSuppression, 'id'>,
): CveSuppression {
const result = this.db
.prepare(
`INSERT INTO cve_suppressions
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
)
.run(
suppression.cve_id,
suppression.pkg_name,
suppression.image_pattern,
suppression.reason,
suppression.created_by,
suppression.created_at,
suppression.expires_at,
suppression.replicated_from_control ?? 0,
);
return { ...suppression, id: result.lastInsertRowid as number };
}
public updateCveSuppression(
id: number,
updates: Partial<Pick<CveSuppression, 'reason' | 'image_pattern' | 'expires_at'>>,
): CveSuppression | null {
const existing = this.getCveSuppression(id);
if (!existing) return null;
const ALLOWED = new Set(['reason', 'image_pattern', 'expires_at']);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return existing;
values.push(id);
this.db
.prepare(`UPDATE cve_suppressions SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
return this.getCveSuppression(id);
}
public deleteCveSuppression(id: number): void {
this.db.prepare('DELETE FROM cve_suppressions WHERE id = ?').run(id);
}
/**
* Replace all replicated CVE suppressions in a single transaction.
* Preserves rows flagged as locally created on this instance.
*/
public replaceReplicatedCveSuppressions(rows: Array<Omit<CveSuppression, 'id'>>): void {
const deleteStmt = this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO cve_suppressions
(cve_id, pkg_name, image_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
);
const txn = this.db.transaction((items: Array<Omit<CveSuppression, 'id'>>) => {
deleteStmt.run();
for (const s of items) {
insertStmt.run(
s.cve_id,
s.pkg_name,
s.image_pattern,
s.reason,
s.created_by,
s.created_at,
s.expires_at,
);
}
});
txn(rows);
}
// --- Misconfig Acknowledgements ---
public getMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
return this.db
.prepare('SELECT * FROM misconfig_acknowledgements ORDER BY rule_id, stack_pattern')
.all() as MisconfigAcknowledgement[];
}
/** Local-only acknowledgements; mirrors `getLocalCveSuppressions`. */
public getLocalMisconfigAcknowledgements(): MisconfigAcknowledgement[] {
return this.db
.prepare('SELECT * FROM misconfig_acknowledgements WHERE replicated_from_control = 0 ORDER BY rule_id, stack_pattern')
.all() as MisconfigAcknowledgement[];
}
public getMisconfigAcknowledgement(id: number): MisconfigAcknowledgement | null {
return (
(this.db.prepare('SELECT * FROM misconfig_acknowledgements WHERE id = ?')
.get(id) as MisconfigAcknowledgement | undefined) ?? null
);
}
public createMisconfigAcknowledgement(
ack: Omit<MisconfigAcknowledgement, 'id'>,
): MisconfigAcknowledgement {
const result = this.db
.prepare(
`INSERT INTO misconfig_acknowledgements
(rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(
ack.rule_id,
ack.stack_pattern,
ack.reason,
ack.created_by,
ack.created_at,
ack.expires_at,
ack.replicated_from_control ?? 0,
);
return { ...ack, id: result.lastInsertRowid as number };
}
public updateMisconfigAcknowledgement(
id: number,
updates: Partial<Pick<MisconfigAcknowledgement, 'reason' | 'stack_pattern' | 'expires_at'>>,
): MisconfigAcknowledgement | null {
const existing = this.getMisconfigAcknowledgement(id);
if (!existing) return null;
const ALLOWED = new Set(['reason', 'stack_pattern', 'expires_at']);
const fields: string[] = [];
const values: unknown[] = [];
for (const [key, value] of Object.entries(updates)) {
if (!ALLOWED.has(key)) continue;
fields.push(`${key} = ?`);
values.push(value);
}
if (fields.length === 0) return existing;
values.push(id);
this.db
.prepare(`UPDATE misconfig_acknowledgements SET ${fields.join(', ')} WHERE id = ?`)
.run(...(values as never[]));
return this.getMisconfigAcknowledgement(id);
}
public deleteMisconfigAcknowledgement(id: number): void {
this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE id = ?').run(id);
}
/**
* Replace all replicated misconfig acknowledgements in a single transaction.
* Preserves rows flagged as locally created on this instance.
*/
public replaceReplicatedMisconfigAcknowledgements(
rows: Array<Omit<MisconfigAcknowledgement, 'id'>>,
): void {
const deleteStmt = this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1');
const insertStmt = this.db.prepare(
`INSERT INTO misconfig_acknowledgements
(rule_id, stack_pattern, reason, created_by, created_at, expires_at, replicated_from_control)
VALUES (?, ?, ?, ?, ?, ?, 1)`,
);
const txn = this.db.transaction((items: Array<Omit<MisconfigAcknowledgement, 'id'>>) => {
deleteStmt.run();
for (const a of items) {
insertStmt.run(
a.rule_id,
a.stack_pattern,
a.reason,
a.created_by,
a.created_at,
a.expires_at,
);
}
});
txn(rows);
}
/**
* Null out `vulnerability_scans.policy_evaluation` rows whose `$.policyId`
* no longer exists in `scan_policies`. Used after replicated rows are
* wiped (sync replace, demote) so cached scan banners do not reference
* deleted policies.
*/
public clearOrphanPolicyEvaluations(): void {
this.db
.prepare(
`UPDATE vulnerability_scans
SET policy_evaluation = NULL
WHERE policy_evaluation IS NOT NULL
AND CAST(json_extract(policy_evaluation, '$.policyId') AS INTEGER)
NOT IN (SELECT id FROM scan_policies)`,
)
.run();
}
/**
* Atomically delete every replicated_from_control row from scan_policies,
* cve_suppressions, and misconfig_acknowledgements, then null out any
* orphaned policy_evaluation cache. Used by the demote endpoint and any
* future "drop replicated state" operation.
*/
public clearReplicatedRows(): void {
this.transaction(() => {
this.db.prepare('DELETE FROM scan_policies WHERE replicated_from_control = 1').run();
this.db.prepare('DELETE FROM cve_suppressions WHERE replicated_from_control = 1').run();
this.db.prepare('DELETE FROM misconfig_acknowledgements WHERE replicated_from_control = 1').run();
this.clearOrphanPolicyEvaluations();
});
}
// --- Stack Labels ---
public getLabels(nodeId: number): Label[] {
return this.db.prepare('SELECT * FROM stack_labels WHERE node_id = ? ORDER BY name').all(nodeId) as Label[];
}
public getLabel(id: number, nodeId: number): Label | null {
return (this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?')
.get(id, nodeId) as Label) ?? null;
}
public getLabelCount(nodeId: number): number {
const row = this.db.prepare('SELECT COUNT(*) as cnt FROM stack_labels WHERE node_id = ?')
.get(nodeId) as { cnt: number };
return row.cnt;
}
public createLabel(nodeId: number, name: string, color: string): Label {
const result = this.db.prepare(
'INSERT INTO stack_labels (node_id, name, color) VALUES (?, ?, ?)'
).run(nodeId, name, color);
return { id: result.lastInsertRowid as number, node_id: nodeId, name, color };
}
public updateLabel(id: number, nodeId: number, updates: { name?: string; color?: string }): Label | null {
const label = this.db.prepare('SELECT * FROM stack_labels WHERE id = ? AND node_id = ?').get(id, nodeId) as Label | undefined;
if (!label) return null;
const name = updates.name ?? label.name;
const color = updates.color ?? label.color;
this.db.prepare('UPDATE stack_labels SET name = ?, color = ? WHERE id = ? AND node_id = ?').run(name, color, id, nodeId);
return { ...label, name, color };
}
public deleteLabel(id: number, nodeId: number): void {
this.db.prepare('DELETE FROM stack_labels WHERE id = ? AND node_id = ?').run(id, nodeId);
}
public setStackLabels(stackName: string, nodeId: number, labelIds: number[]): void {
const txn = this.db.transaction(() => {
if (labelIds.length > 0) {
const placeholders = labelIds.map(() => '?').join(',');
const validCount = this.db.prepare(
`SELECT COUNT(*) as cnt FROM stack_labels WHERE id IN (${placeholders}) AND node_id = ?`
).get(...labelIds, nodeId) as { cnt: number };
if (validCount.cnt !== labelIds.length) {
throw new Error('One or more label IDs are invalid for this node');
}
}
this.db.prepare('DELETE FROM stack_label_assignments WHERE stack_name = ? AND node_id = ?').run(stackName, nodeId);
const insert = this.db.prepare('INSERT INTO stack_label_assignments (label_id, stack_name, node_id) VALUES (?, ?, ?)');
for (const labelId of labelIds) {
insert.run(labelId, stackName, nodeId);
}
});
txn();
}
public getLabelsForStacks(nodeId: number): Record<string, Label[]> {
const rows = this.db.prepare(`
SELECT a.stack_name, l.id, l.node_id, l.name, l.color
FROM stack_label_assignments a
JOIN stack_labels l ON a.label_id = l.id
WHERE a.node_id = ?
ORDER BY l.name
`).all(nodeId) as (Label & { stack_name: string })[];
const result: Record<string, Label[]> = {};
for (const row of rows) {
if (!result[row.stack_name]) result[row.stack_name] = [];
result[row.stack_name].push({ id: row.id, node_id: row.node_id, name: row.name, color: row.color });
}
return result;
}
public getStacksForLabel(labelId: number, nodeId: number): string[] {
const rows = this.db.prepare('SELECT stack_name FROM stack_label_assignments WHERE label_id = ? AND node_id = ?')
.all(labelId, nodeId) as { stack_name: string }[];
return rows.map(r => r.stack_name);
}
public cleanupStaleAssignments(nodeId: number, validStackNames: string[]): number {
if (validStackNames.length === 0) {
const result = this.db.prepare('DELETE FROM stack_label_assignments WHERE node_id = ?').run(nodeId);
return result.changes;
}
const placeholders = validStackNames.map(() => '?').join(',');
const result = this.db.prepare(
`DELETE FROM stack_label_assignments WHERE node_id = ? AND stack_name NOT IN (${placeholders})`
).run(nodeId, ...validStackNames);
return result.changes;
}
// --- Node Labels (fleet-level orchestration) ---
public listNodeLabels(nodeId?: number): NodeLabelRow[] {
const sql = nodeId !== undefined
? 'SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? ORDER BY label'
: 'SELECT id, node_id, label, created_at FROM node_labels ORDER BY node_id, label';
const rows = nodeId !== undefined
? this.db.prepare(sql).all(nodeId)
: this.db.prepare(sql).all();
return rows as NodeLabelRow[];
}
public listDistinctNodeLabels(): string[] {
const rows = this.db.prepare('SELECT DISTINCT label FROM node_labels ORDER BY label').all() as { label: string }[];
return rows.map(r => r.label);
}
public addNodeLabel(nodeId: number, label: string): NodeLabelRow {
const trimmed = label.trim();
if (!trimmed) throw new Error('Label must not be empty');
const now = Date.now();
const result = this.db.prepare(
'INSERT OR IGNORE INTO node_labels (node_id, label, created_at) VALUES (?, ?, ?)'
).run(nodeId, trimmed, now);
const row = result.changes > 0
? this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE id = ?').get(result.lastInsertRowid)
: this.db.prepare('SELECT id, node_id, label, created_at FROM node_labels WHERE node_id = ? AND label = ?').get(nodeId, trimmed);
return row as NodeLabelRow;
}
public removeNodeLabel(nodeId: number, label: string): boolean {
const result = this.db.prepare('DELETE FROM node_labels WHERE node_id = ? AND label = ?').run(nodeId, label);
return result.changes > 0;
}
public getNodeLabelsMap(): Record<number, string[]> {
const rows = this.db.prepare('SELECT node_id, label FROM node_labels ORDER BY node_id, label').all() as { node_id: number; label: string }[];
const map: Record<number, string[]> = {};
for (const row of rows) {
if (!map[row.node_id]) map[row.node_id] = [];
map[row.node_id].push(row.label);
}
return map;
}
// --- Blueprints ---
private parseBlueprint(row: Record<string, unknown>): Blueprint {
return {
id: row.id as number,
name: row.name as string,
description: (row.description as string | null) ?? null,
compose_content: row.compose_content as string,
selector: JSON.parse(row.selector_json as string) as BlueprintSelector,
drift_mode: row.drift_mode as DriftMode,
classification: row.classification as BlueprintClassification,
classification_reasons: row.classification_reasons
? (JSON.parse(row.classification_reasons as string) as string[])
: [],
enabled: row.enabled === 1,
revision: row.revision as number,
created_at: row.created_at as number,
updated_at: row.updated_at as number,
created_by: (row.created_by as string | null) ?? null,
pinned_node_id: (row.pinned_node_id as number | null) ?? null,
};
}
public listBlueprints(): Blueprint[] {
return this.db.prepare('SELECT * FROM blueprints ORDER BY name')
.all()
.map(row => this.parseBlueprint(row as Record<string, unknown>));
}
public listEnabledBlueprints(): Blueprint[] {
return this.db.prepare('SELECT * FROM blueprints WHERE enabled = 1 ORDER BY name')
.all()
.map(row => this.parseBlueprint(row as Record<string, unknown>));
}
public getBlueprint(id: number): Blueprint | undefined {
const row = this.db.prepare('SELECT * FROM blueprints WHERE id = ?').get(id) as Record<string, unknown> | undefined;
return row ? this.parseBlueprint(row) : undefined;
}
public getBlueprintByName(name: string): Blueprint | undefined {
const row = this.db.prepare('SELECT * FROM blueprints WHERE name = ?').get(name) as Record<string, unknown> | undefined;
return row ? this.parseBlueprint(row) : undefined;
}
public createBlueprint(input: {
name: string;
description: string | null;
compose_content: string;
selector: BlueprintSelector;
drift_mode: DriftMode;
classification: BlueprintClassification;
classification_reasons: string[];
enabled: boolean;
created_by: string | null;
}): Blueprint {
const now = Date.now();
const result = this.db.prepare(
`INSERT INTO blueprints (name, description, compose_content, selector_json, drift_mode, classification, classification_reasons, enabled, revision, created_at, updated_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`
).run(
input.name,
input.description,
input.compose_content,
JSON.stringify(input.selector),
input.drift_mode,
input.classification,
JSON.stringify(input.classification_reasons),
input.enabled ? 1 : 0,
now,
now,
input.created_by,
);
const created = this.getBlueprint(result.lastInsertRowid as number);
if (!created) throw new Error('Failed to fetch created blueprint');
return created;
}
public updateBlueprint(id: number, updates: {
name?: string;
description?: string | null;
compose_content?: string;
selector?: BlueprintSelector;
drift_mode?: DriftMode;
classification?: BlueprintClassification;
classification_reasons?: string[];
enabled?: boolean;
bumpRevision?: boolean;
}): Blueprint | undefined {
const existing = this.getBlueprint(id);
if (!existing) return undefined;
const fields: string[] = [];
const values: unknown[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.description !== undefined) { fields.push('description = ?'); values.push(updates.description); }
if (updates.compose_content !== undefined) { fields.push('compose_content = ?'); values.push(updates.compose_content); }
if (updates.selector !== undefined) { fields.push('selector_json = ?'); values.push(JSON.stringify(updates.selector)); }
if (updates.drift_mode !== undefined) { fields.push('drift_mode = ?'); values.push(updates.drift_mode); }
if (updates.classification !== undefined) { fields.push('classification = ?'); values.push(updates.classification); }
if (updates.classification_reasons !== undefined) { fields.push('classification_reasons = ?'); values.push(JSON.stringify(updates.classification_reasons)); }
if (updates.enabled !== undefined) { fields.push('enabled = ?'); values.push(updates.enabled ? 1 : 0); }
if (updates.bumpRevision) { fields.push('revision = revision + 1'); }
fields.push('updated_at = ?');
values.push(Date.now());
values.push(id);
this.db.prepare(`UPDATE blueprints SET ${fields.join(', ')} WHERE id = ?`).run(...values);
return this.getBlueprint(id);
}
public deleteBlueprint(id: number): boolean {
const result = this.db.prepare('DELETE FROM blueprints WHERE id = ?').run(id);
return result.changes > 0;
}
// --- Blueprint Deployments ---
private parseBlueprintDeployment(row: Record<string, unknown>): BlueprintDeployment {
return {
id: row.id as number,
blueprint_id: row.blueprint_id as number,
node_id: row.node_id as number,
status: row.status as BlueprintDeploymentStatus,
applied_revision: (row.applied_revision as number | null) ?? null,
last_deployed_at: (row.last_deployed_at as number | null) ?? null,
last_checked_at: (row.last_checked_at as number | null) ?? null,
last_drift_at: (row.last_drift_at as number | null) ?? null,
drift_summary: (row.drift_summary as string | null) ?? null,
last_error: (row.last_error as string | null) ?? null,
};
}
public listDeployments(blueprintId: number): BlueprintDeployment[] {
return this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? ORDER BY node_id')
.all(blueprintId)
.map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
}
public listAllDeployments(): BlueprintDeployment[] {
return this.db.prepare('SELECT * FROM blueprint_deployments')
.all()
.map(row => this.parseBlueprintDeployment(row as Record<string, unknown>));
}
public getDeployment(blueprintId: number, nodeId: number): BlueprintDeployment | undefined {
const row = this.db.prepare('SELECT * FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').get(blueprintId, nodeId) as Record<string, unknown> | undefined;
return row ? this.parseBlueprintDeployment(row) : undefined;
}
public upsertDeployment(input: {
blueprint_id: number;
node_id: number;
status: BlueprintDeploymentStatus;
applied_revision?: number | null;
last_deployed_at?: number | null;
last_checked_at?: number | null;
last_drift_at?: number | null;
drift_summary?: string | null;
last_error?: string | null;
}): BlueprintDeployment {
const existing = this.getDeployment(input.blueprint_id, input.node_id);
if (existing) {
const fields: string[] = ['status = ?'];
const values: unknown[] = [input.status];
if (input.applied_revision !== undefined) { fields.push('applied_revision = ?'); values.push(input.applied_revision); }
if (input.last_deployed_at !== undefined) { fields.push('last_deployed_at = ?'); values.push(input.last_deployed_at); }
if (input.last_checked_at !== undefined) { fields.push('last_checked_at = ?'); values.push(input.last_checked_at); }
if (input.last_drift_at !== undefined) { fields.push('last_drift_at = ?'); values.push(input.last_drift_at); }
if (input.drift_summary !== undefined) { fields.push('drift_summary = ?'); values.push(input.drift_summary); }
if (input.last_error !== undefined) { fields.push('last_error = ?'); values.push(input.last_error); }
values.push(input.blueprint_id, input.node_id);
this.db.prepare(`UPDATE blueprint_deployments SET ${fields.join(', ')} WHERE blueprint_id = ? AND node_id = ?`).run(...values);
} else {
this.db.prepare(
`INSERT INTO blueprint_deployments (blueprint_id, node_id, status, applied_revision, last_deployed_at, last_checked_at, last_drift_at, drift_summary, last_error)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
input.blueprint_id,
input.node_id,
input.status,
input.applied_revision ?? null,
input.last_deployed_at ?? null,
input.last_checked_at ?? null,
input.last_drift_at ?? null,
input.drift_summary ?? null,
input.last_error ?? null,
);
}
const updated = this.getDeployment(input.blueprint_id, input.node_id);
if (!updated) throw new Error('Failed to upsert deployment');
return updated;
}
public deleteDeployment(blueprintId: number, nodeId: number): void {
this.db.prepare('DELETE FROM blueprint_deployments WHERE blueprint_id = ? AND node_id = ?').run(blueprintId, nodeId);
}
// --- Secrets ---
public listSecrets(): SecretRow[] {
return this.db.prepare(
'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets ORDER BY name ASC'
).all() as SecretRow[];
}
public getSecret(id: number): SecretRow | undefined {
return this.db.prepare(
'SELECT id, name, description, current_version, created_at, created_by, updated_at FROM secrets WHERE id = ?'
).get(id) as SecretRow | undefined;
}
public listSecretVersions(secretId: number): SecretVersionRow[] {
return this.db.prepare(
'SELECT id, secret_id, version, encrypted_payload, key_count, created_at, created_by, note FROM secret_versions WHERE secret_id = ? ORDER BY version DESC'
).all(secretId) as SecretVersionRow[];
}
public getCurrentSecretVersion(secretId: number): SecretVersionRow | undefined {
return this.db.prepare(
`SELECT v.id, v.secret_id, v.version, v.encrypted_payload, v.key_count, v.created_at, v.created_by, v.note
FROM secret_versions v
INNER JOIN secrets s ON s.id = v.secret_id AND s.current_version = v.version
WHERE v.secret_id = ?`
).get(secretId) as SecretVersionRow | undefined;
}
public createSecretWithVersion(input: {
name: string;
description: string;
encryptedPayload: string;
keyCount: number;
createdBy: string;
note: string;
}): { id: number; version: number } {
const now = Date.now();
const txn = this.db.transaction(() => {
const insertSecret = this.db.prepare(
'INSERT INTO secrets (name, description, current_version, created_at, created_by, updated_at) VALUES (?, ?, 1, ?, ?, ?)'
);
const result = insertSecret.run(input.name, input.description, now, input.createdBy, now);
const secretId = Number(result.lastInsertRowid);
this.db.prepare(
'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, 1, ?, ?, ?, ?, ?)'
).run(secretId, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
return { id: secretId, version: 1 };
});
return txn();
}
public updateSecretWithVersion(input: {
secretId: number;
description: string | null;
encryptedPayload: string;
keyCount: number;
createdBy: string;
note: string;
}): { version: number } {
const now = Date.now();
const txn = this.db.transaction(() => {
const current = this.db.prepare('SELECT current_version FROM secrets WHERE id = ?').get(input.secretId) as { current_version: number } | undefined;
if (!current) throw new Error('Secret not found');
const nextVersion = current.current_version + 1;
this.db.prepare(
'INSERT INTO secret_versions (secret_id, version, encrypted_payload, key_count, created_at, created_by, note) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(input.secretId, nextVersion, input.encryptedPayload, input.keyCount, now, input.createdBy, input.note);
if (input.description === null) {
this.db.prepare('UPDATE secrets SET current_version = ?, updated_at = ? WHERE id = ?').run(nextVersion, now, input.secretId);
} else {
this.db.prepare('UPDATE secrets SET current_version = ?, description = ?, updated_at = ? WHERE id = ?').run(nextVersion, input.description, now, input.secretId);
}
return { version: nextVersion };
});
return txn();
}
public deleteSecret(id: number): boolean {
const result = this.db.prepare('DELETE FROM secrets WHERE id = ?').run(id);
return result.changes > 0;
}
public insertSecretPushes(rows: Array<Omit<SecretPushRow, 'id'>>): void {
if (rows.length === 0) return;
const stmt = this.db.prepare(
`INSERT INTO secret_pushes (secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error, added_count, changed_count, unchanged_count, pushed_by, pushed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
const txn = this.db.transaction((entries: Array<Omit<SecretPushRow, 'id'>>) => {
for (const r of entries) {
stmt.run(
r.secret_id, r.version, r.push_id, r.node_id, r.stack_name, r.env_file_basename,
r.status, r.error, r.added_count, r.changed_count, r.unchanged_count, r.pushed_by, r.pushed_at,
);
}
});
txn(rows);
}
public listSecretPushes(secretId: number, limit = 50): SecretPushRow[] {
return this.db.prepare(
`SELECT id, secret_id, version, push_id, node_id, stack_name, env_file_basename, status, error,
added_count, changed_count, unchanged_count, pushed_by, pushed_at
FROM secret_pushes
WHERE secret_id = ?
ORDER BY pushed_at DESC
LIMIT ?`
).all(secretId, limit) as SecretPushRow[];
}
}