@hasna/uptime 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +72 -1
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2427 -123
- package/dist/checks.d.ts +23 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +131 -2
- package/dist/cli/index.js +2772 -115
- package/dist/dashboard.js +1 -1
- package/dist/imports.d.ts +90 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +556 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2437 -123
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +2307 -49
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- package/dist/probes.d.ts +13 -0
- package/dist/probes.d.ts.map +1 -0
- package/dist/probes.js +62 -0
- package/dist/report.d.ts +2 -7
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +1 -1
- package/dist/service.d.ts +152 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +2154 -60
- package/dist/store.d.ts +130 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1086 -18
- package/dist/target-policy.d.ts +7 -0
- package/dist/target-policy.d.ts.map +1 -0
- package/dist/types.d.ts +189 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/store.js
CHANGED
|
@@ -9,6 +9,9 @@ function uptimeHome() {
|
|
|
9
9
|
function uptimeDbPath() {
|
|
10
10
|
return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
|
|
11
11
|
}
|
|
12
|
+
function uptimeHostedFallbackDbPath() {
|
|
13
|
+
return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
|
|
14
|
+
}
|
|
12
15
|
function ensureUptimeHome() {
|
|
13
16
|
const home = uptimeHome();
|
|
14
17
|
mkdirSync(home, { recursive: true });
|
|
@@ -16,11 +19,84 @@ function ensureUptimeHome() {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
// src/store.ts
|
|
19
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
20
|
-
import { dirname } from "path";
|
|
22
|
+
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statSync } from "fs";
|
|
23
|
+
import { dirname, join as join2 } from "path";
|
|
21
24
|
import { randomUUID } from "crypto";
|
|
22
25
|
import { Database } from "bun:sqlite";
|
|
23
26
|
|
|
27
|
+
// src/target-policy.ts
|
|
28
|
+
import net from "net";
|
|
29
|
+
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
30
|
+
function assertHostedTargetAllowed(target) {
|
|
31
|
+
if (target.kind === "http" || target.kind === "browser_page") {
|
|
32
|
+
if (!target.url)
|
|
33
|
+
throw new Error("HTTP monitors require url");
|
|
34
|
+
assertHostedHttpUrlAllowed(target.url);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (target.kind === "tcp") {
|
|
38
|
+
if (!target.host)
|
|
39
|
+
throw new Error("TCP monitors require host");
|
|
40
|
+
assertHostedHostAllowed(target.host, "TCP host");
|
|
41
|
+
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
42
|
+
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
47
|
+
}
|
|
48
|
+
function assertHostedHttpUrlAllowed(value) {
|
|
49
|
+
const parsed = new URL(value);
|
|
50
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
51
|
+
throw new Error("HTTP monitor url must use http or https");
|
|
52
|
+
}
|
|
53
|
+
if (parsed.username || parsed.password) {
|
|
54
|
+
throw new Error("hosted target URLs must not contain userinfo");
|
|
55
|
+
}
|
|
56
|
+
for (const key of parsed.searchParams.keys()) {
|
|
57
|
+
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
58
|
+
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
62
|
+
throw new Error("hosted target URL fragment contains secret-like data");
|
|
63
|
+
}
|
|
64
|
+
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
65
|
+
}
|
|
66
|
+
function assertHostedHostAllowed(hostname, label = "host") {
|
|
67
|
+
const host = normalizeHost(hostname);
|
|
68
|
+
if (!host)
|
|
69
|
+
throw new Error(`${label} is required`);
|
|
70
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
71
|
+
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
72
|
+
}
|
|
73
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
74
|
+
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
75
|
+
}
|
|
76
|
+
const ipVersion = net.isIP(host);
|
|
77
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
78
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
79
|
+
}
|
|
80
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
81
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function normalizeHost(hostname) {
|
|
85
|
+
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
86
|
+
}
|
|
87
|
+
function isDeniedIpv4(ip) {
|
|
88
|
+
const parts = ip.split(".").map((part) => Number(part));
|
|
89
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const [a, b] = parts;
|
|
93
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
|
|
94
|
+
}
|
|
95
|
+
function isDeniedIpv6(ip) {
|
|
96
|
+
const normalized = ip.toLowerCase();
|
|
97
|
+
return normalized === "::" || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:169.254.") || /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) || normalized.startsWith("::ffff:192.168.");
|
|
98
|
+
}
|
|
99
|
+
|
|
24
100
|
// src/limits.ts
|
|
25
101
|
var MIN_INTERVAL_SECONDS = 1;
|
|
26
102
|
var MAX_INTERVAL_SECONDS = 86400;
|
|
@@ -31,6 +107,26 @@ var MAX_RETRY_COUNT = 10;
|
|
|
31
107
|
var MAX_RESULT_LIMIT = 1000;
|
|
32
108
|
|
|
33
109
|
// src/store.ts
|
|
110
|
+
var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
111
|
+
var REQUIRED_TABLES = [
|
|
112
|
+
"schema_migrations",
|
|
113
|
+
"monitors",
|
|
114
|
+
"check_results",
|
|
115
|
+
"incidents",
|
|
116
|
+
"check_leases",
|
|
117
|
+
"monitor_provenance",
|
|
118
|
+
"import_batches",
|
|
119
|
+
"probe_identities",
|
|
120
|
+
"probe_check_jobs",
|
|
121
|
+
"probe_submissions",
|
|
122
|
+
"report_schedules",
|
|
123
|
+
"report_runs",
|
|
124
|
+
"audit_events"
|
|
125
|
+
];
|
|
126
|
+
var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
|
|
127
|
+
var REPORT_AUDIT_TABLES = new Set(["report_schedules", "report_runs", "audit_events"]);
|
|
128
|
+
var CURRENT_SCHEMA_VERSION = "3";
|
|
129
|
+
|
|
34
130
|
class StaleCheckResultError extends Error {
|
|
35
131
|
constructor(message) {
|
|
36
132
|
super(message);
|
|
@@ -40,9 +136,20 @@ class StaleCheckResultError extends Error {
|
|
|
40
136
|
|
|
41
137
|
class UptimeStore {
|
|
42
138
|
dbPath;
|
|
139
|
+
mode;
|
|
140
|
+
dataMode;
|
|
43
141
|
db;
|
|
44
142
|
constructor(options = {}) {
|
|
45
|
-
this.
|
|
143
|
+
this.mode = resolveRuntimeMode(options.mode ?? "local");
|
|
144
|
+
const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
|
|
145
|
+
if (this.mode === "hosted" && cloudDatabaseUrl) {
|
|
146
|
+
throw new Error("hosted cloud database adapter is not implemented yet");
|
|
147
|
+
}
|
|
148
|
+
if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
149
|
+
throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
|
|
150
|
+
}
|
|
151
|
+
this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
|
|
152
|
+
this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
|
|
46
153
|
if (this.dbPath !== ":memory:") {
|
|
47
154
|
mkdirSync2(dirname(this.dbPath), { recursive: true });
|
|
48
155
|
}
|
|
@@ -59,7 +166,7 @@ class UptimeStore {
|
|
|
59
166
|
CREATE TABLE IF NOT EXISTS monitors (
|
|
60
167
|
id TEXT PRIMARY KEY,
|
|
61
168
|
name TEXT NOT NULL UNIQUE,
|
|
62
|
-
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
|
|
169
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
63
170
|
url TEXT,
|
|
64
171
|
host TEXT,
|
|
65
172
|
port INTEGER,
|
|
@@ -77,6 +184,7 @@ class UptimeStore {
|
|
|
77
184
|
)
|
|
78
185
|
`);
|
|
79
186
|
this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
|
|
187
|
+
this.ensureMonitorKindAllowsBrowserPage();
|
|
80
188
|
this.db.run(`
|
|
81
189
|
CREATE TABLE IF NOT EXISTS check_results (
|
|
82
190
|
id TEXT PRIMARY KEY,
|
|
@@ -86,9 +194,11 @@ class UptimeStore {
|
|
|
86
194
|
latency_ms REAL,
|
|
87
195
|
status_code INTEGER,
|
|
88
196
|
error TEXT,
|
|
89
|
-
attempt_count INTEGER NOT NULL DEFAULT 1
|
|
197
|
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
198
|
+
evidence_json TEXT
|
|
90
199
|
)
|
|
91
200
|
`);
|
|
201
|
+
this.ensureColumn("check_results", "evidence_json", "TEXT");
|
|
92
202
|
this.db.run(`
|
|
93
203
|
CREATE TABLE IF NOT EXISTS incidents (
|
|
94
204
|
id TEXT PRIMARY KEY,
|
|
@@ -102,6 +212,71 @@ class UptimeStore {
|
|
|
102
212
|
reason TEXT
|
|
103
213
|
)
|
|
104
214
|
`);
|
|
215
|
+
this.db.run(`
|
|
216
|
+
CREATE TABLE IF NOT EXISTS monitor_provenance (
|
|
217
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
218
|
+
source TEXT NOT NULL,
|
|
219
|
+
source_id TEXT NOT NULL,
|
|
220
|
+
source_label TEXT,
|
|
221
|
+
imported_at TEXT NOT NULL,
|
|
222
|
+
snapshot_json TEXT NOT NULL,
|
|
223
|
+
PRIMARY KEY (source, source_id)
|
|
224
|
+
)
|
|
225
|
+
`);
|
|
226
|
+
this.db.run(`
|
|
227
|
+
CREATE TABLE IF NOT EXISTS import_batches (
|
|
228
|
+
id TEXT PRIMARY KEY,
|
|
229
|
+
source TEXT NOT NULL,
|
|
230
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
|
|
231
|
+
created_at TEXT NOT NULL,
|
|
232
|
+
rolled_back_at TEXT,
|
|
233
|
+
records_json TEXT NOT NULL
|
|
234
|
+
)
|
|
235
|
+
`);
|
|
236
|
+
this.db.run(`
|
|
237
|
+
CREATE TABLE IF NOT EXISTS probe_identities (
|
|
238
|
+
id TEXT PRIMARY KEY,
|
|
239
|
+
name TEXT NOT NULL UNIQUE,
|
|
240
|
+
public_key_pem TEXT NOT NULL,
|
|
241
|
+
public_key_fingerprint TEXT NOT NULL UNIQUE,
|
|
242
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
243
|
+
created_at TEXT NOT NULL,
|
|
244
|
+
last_seen_at TEXT
|
|
245
|
+
)
|
|
246
|
+
`);
|
|
247
|
+
this.db.run(`
|
|
248
|
+
CREATE TABLE IF NOT EXISTS probe_submissions (
|
|
249
|
+
id TEXT PRIMARY KEY,
|
|
250
|
+
probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
|
|
251
|
+
job_id TEXT NOT NULL,
|
|
252
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
253
|
+
check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
|
|
254
|
+
nonce TEXT NOT NULL,
|
|
255
|
+
checked_at TEXT NOT NULL,
|
|
256
|
+
submitted_at TEXT NOT NULL,
|
|
257
|
+
UNIQUE (probe_id, nonce)
|
|
258
|
+
)
|
|
259
|
+
`);
|
|
260
|
+
this.ensureColumn("probe_submissions", "job_id", "TEXT");
|
|
261
|
+
this.db.run(`
|
|
262
|
+
CREATE TABLE IF NOT EXISTS probe_check_jobs (
|
|
263
|
+
id TEXT PRIMARY KEY,
|
|
264
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
265
|
+
monitor_revision INTEGER NOT NULL DEFAULT 1,
|
|
266
|
+
schedule_slot TEXT NOT NULL,
|
|
267
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
|
|
268
|
+
claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
|
|
269
|
+
fencing_token TEXT,
|
|
270
|
+
due_at TEXT NOT NULL,
|
|
271
|
+
claimed_at TEXT,
|
|
272
|
+
lease_expires_at TEXT,
|
|
273
|
+
submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
|
|
274
|
+
created_at TEXT NOT NULL,
|
|
275
|
+
updated_at TEXT NOT NULL,
|
|
276
|
+
UNIQUE (monitor_id, schedule_slot)
|
|
277
|
+
)
|
|
278
|
+
`);
|
|
279
|
+
this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
|
|
105
280
|
this.db.run(`
|
|
106
281
|
CREATE TABLE IF NOT EXISTS check_leases (
|
|
107
282
|
monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
|
|
@@ -110,12 +285,113 @@ class UptimeStore {
|
|
|
110
285
|
acquired_at TEXT NOT NULL
|
|
111
286
|
)
|
|
112
287
|
`);
|
|
288
|
+
this.db.run(`
|
|
289
|
+
CREATE TABLE IF NOT EXISTS report_schedules (
|
|
290
|
+
id TEXT PRIMARY KEY,
|
|
291
|
+
name TEXT NOT NULL UNIQUE,
|
|
292
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
293
|
+
interval_seconds INTEGER NOT NULL,
|
|
294
|
+
next_run_at TEXT NOT NULL,
|
|
295
|
+
last_run_at TEXT,
|
|
296
|
+
subject TEXT,
|
|
297
|
+
channels_json TEXT NOT NULL,
|
|
298
|
+
created_at TEXT NOT NULL,
|
|
299
|
+
updated_at TEXT NOT NULL
|
|
300
|
+
)
|
|
301
|
+
`);
|
|
302
|
+
this.db.run(`
|
|
303
|
+
CREATE TABLE IF NOT EXISTS report_runs (
|
|
304
|
+
id TEXT PRIMARY KEY,
|
|
305
|
+
schedule_id TEXT REFERENCES report_schedules(id) ON DELETE SET NULL,
|
|
306
|
+
status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
|
|
307
|
+
started_at TEXT NOT NULL,
|
|
308
|
+
finished_at TEXT NOT NULL,
|
|
309
|
+
deliveries_json TEXT NOT NULL,
|
|
310
|
+
error TEXT,
|
|
311
|
+
report_json TEXT
|
|
312
|
+
)
|
|
313
|
+
`);
|
|
314
|
+
this.db.run(`
|
|
315
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
316
|
+
id TEXT PRIMARY KEY,
|
|
317
|
+
action TEXT NOT NULL,
|
|
318
|
+
resource_type TEXT,
|
|
319
|
+
resource_id TEXT,
|
|
320
|
+
message TEXT,
|
|
321
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
322
|
+
actor TEXT,
|
|
323
|
+
created_at TEXT NOT NULL
|
|
324
|
+
)
|
|
325
|
+
`);
|
|
326
|
+
this.db.run(`
|
|
327
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
328
|
+
key TEXT PRIMARY KEY,
|
|
329
|
+
value TEXT NOT NULL,
|
|
330
|
+
updated_at TEXT NOT NULL
|
|
331
|
+
)
|
|
332
|
+
`);
|
|
333
|
+
this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
|
|
113
334
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
|
|
114
335
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
|
|
115
336
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
|
|
337
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
|
|
338
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
|
|
339
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
|
|
340
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
|
|
341
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
|
|
342
|
+
this.db.run("CREATE UNIQUE INDEX IF NOT EXISTS idx_probe_submissions_job ON probe_submissions(job_id) WHERE job_id IS NOT NULL AND job_id != ''");
|
|
343
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_schedules_due ON report_schedules(enabled, next_run_at)");
|
|
344
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_runs_schedule_time ON report_runs(schedule_id, started_at DESC)");
|
|
345
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_resource_time ON audit_events(resource_type, resource_id, created_at DESC)");
|
|
346
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_time ON audit_events(created_at DESC)");
|
|
347
|
+
}
|
|
348
|
+
backup(destinationPath) {
|
|
349
|
+
if (this.dbPath === ":memory:" && !destinationPath) {
|
|
350
|
+
throw new Error("backup path is required for in-memory stores");
|
|
351
|
+
}
|
|
352
|
+
const createdAt = new Date().toISOString();
|
|
353
|
+
const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
|
|
354
|
+
mkdirSync2(dirname(backupPath), { recursive: true });
|
|
355
|
+
if (this.dbPath === ":memory:") {
|
|
356
|
+
this.vacuumInto(backupPath);
|
|
357
|
+
} else {
|
|
358
|
+
this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
359
|
+
copyFileSync(this.dbPath, backupPath);
|
|
360
|
+
}
|
|
361
|
+
const bytes = statSync(backupPath).size;
|
|
362
|
+
return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
|
|
116
363
|
}
|
|
117
|
-
|
|
118
|
-
|
|
364
|
+
verifyBackup(backupPath) {
|
|
365
|
+
return verifyBackupFile(backupPath);
|
|
366
|
+
}
|
|
367
|
+
static verifyBackup(backupPath) {
|
|
368
|
+
return verifyBackupFile(backupPath);
|
|
369
|
+
}
|
|
370
|
+
static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
|
|
371
|
+
const check = verifyBackupFile(backupPath);
|
|
372
|
+
if (!check.ok)
|
|
373
|
+
throw new Error(`backup integrity check failed: ${check.integrity}`);
|
|
374
|
+
if (destinationPath === ":memory:")
|
|
375
|
+
throw new Error("cannot restore a backup to an in-memory store");
|
|
376
|
+
if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
|
|
377
|
+
throw new Error("restore destination already exists or has SQLite sidecar files");
|
|
378
|
+
}
|
|
379
|
+
mkdirSync2(dirname(destinationPath), { recursive: true });
|
|
380
|
+
copyFileSync(backupPath, destinationPath);
|
|
381
|
+
const bytes = statSync(destinationPath).size;
|
|
382
|
+
return {
|
|
383
|
+
sourcePath: backupPath,
|
|
384
|
+
backupPath: destinationPath,
|
|
385
|
+
bytes,
|
|
386
|
+
createdAt: new Date().toISOString()
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
createMonitor(input, options = {}) {
|
|
390
|
+
if (this.mode === "hosted")
|
|
391
|
+
assertHostedTargetAllowed(input);
|
|
392
|
+
const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
|
|
393
|
+
if (this.mode === "hosted")
|
|
394
|
+
assertHostedTargetAllowed(normalized);
|
|
119
395
|
const now = new Date().toISOString();
|
|
120
396
|
const monitor = {
|
|
121
397
|
id: newId("mon"),
|
|
@@ -151,12 +427,22 @@ class UptimeStore {
|
|
|
151
427
|
const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
152
428
|
return row ? monitorFromRow(row) : null;
|
|
153
429
|
}
|
|
154
|
-
updateMonitor(idOrName, input) {
|
|
430
|
+
updateMonitor(idOrName, input, options = {}) {
|
|
155
431
|
const current = this.getMonitor(idOrName);
|
|
156
432
|
if (!current)
|
|
157
433
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
434
|
+
if (this.mode === "hosted") {
|
|
435
|
+
assertHostedTargetAllowed({
|
|
436
|
+
kind: input.kind ?? current.kind,
|
|
437
|
+
url: input.url ?? current.url ?? undefined,
|
|
438
|
+
host: input.host ?? current.host ?? undefined,
|
|
439
|
+
port: input.port ?? current.port ?? undefined
|
|
440
|
+
});
|
|
441
|
+
}
|
|
158
442
|
const updatedAt = new Date().toISOString();
|
|
159
|
-
const next = normalizeUpdateMonitor(current, input, updatedAt);
|
|
443
|
+
const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
|
|
444
|
+
if (this.mode === "hosted")
|
|
445
|
+
assertHostedTargetAllowed(next);
|
|
160
446
|
this.db.query(`UPDATE monitors SET
|
|
161
447
|
name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
|
|
162
448
|
expected_status = ?, interval_seconds = ?, timeout_ms = ?,
|
|
@@ -175,6 +461,315 @@ class UptimeStore {
|
|
|
175
461
|
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
176
462
|
return true;
|
|
177
463
|
}
|
|
464
|
+
createProbeIdentity(input) {
|
|
465
|
+
const name = input.name.trim();
|
|
466
|
+
if (!name)
|
|
467
|
+
throw new Error("Probe name is required");
|
|
468
|
+
rejectControlCharacters(name, "Probe name");
|
|
469
|
+
const now = new Date().toISOString();
|
|
470
|
+
const probe = {
|
|
471
|
+
id: newId("prb"),
|
|
472
|
+
name,
|
|
473
|
+
publicKeyPem: input.publicKeyPem.trim(),
|
|
474
|
+
publicKeyFingerprint: input.publicKeyFingerprint,
|
|
475
|
+
enabled: input.enabled ?? true,
|
|
476
|
+
createdAt: now,
|
|
477
|
+
lastSeenAt: null
|
|
478
|
+
};
|
|
479
|
+
if (!probe.publicKeyPem)
|
|
480
|
+
throw new Error("Probe public key is required");
|
|
481
|
+
this.db.query(`INSERT INTO probe_identities (
|
|
482
|
+
id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
|
|
483
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
|
|
484
|
+
return probe;
|
|
485
|
+
}
|
|
486
|
+
listProbeIdentities(options = {}) {
|
|
487
|
+
const rows = options.includeDisabled ? this.db.query("SELECT * FROM probe_identities ORDER BY name ASC").all() : this.db.query("SELECT * FROM probe_identities WHERE enabled = 1 ORDER BY name ASC").all();
|
|
488
|
+
return rows.map(probeIdentityFromRow);
|
|
489
|
+
}
|
|
490
|
+
getProbeIdentity(idOrName) {
|
|
491
|
+
const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
492
|
+
return row ? probeIdentityFromRow(row) : null;
|
|
493
|
+
}
|
|
494
|
+
updateProbeIdentity(idOrName, input) {
|
|
495
|
+
const current = this.getProbeIdentity(idOrName);
|
|
496
|
+
if (!current)
|
|
497
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
498
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
499
|
+
if (!name)
|
|
500
|
+
throw new Error("Probe name is required");
|
|
501
|
+
rejectControlCharacters(name, "Probe name");
|
|
502
|
+
const enabled = input.enabled ?? current.enabled;
|
|
503
|
+
this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
|
|
504
|
+
return this.getProbeIdentity(current.id);
|
|
505
|
+
}
|
|
506
|
+
touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
|
|
507
|
+
const probe = this.getProbeIdentity(idOrName);
|
|
508
|
+
if (!probe)
|
|
509
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
510
|
+
this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
|
|
511
|
+
}
|
|
512
|
+
createProbeCheckJob(input) {
|
|
513
|
+
const monitor = this.getMonitor(input.monitorId);
|
|
514
|
+
if (!monitor)
|
|
515
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
516
|
+
if (!monitor.enabled)
|
|
517
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
518
|
+
const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
|
|
519
|
+
const dueAt = input.dueAt ?? new Date().toISOString();
|
|
520
|
+
assertIsoTimestamp(dueAt, "Probe job dueAt");
|
|
521
|
+
const now = new Date().toISOString();
|
|
522
|
+
const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
|
|
523
|
+
if (existing)
|
|
524
|
+
return probeCheckJobFromRow(existing);
|
|
525
|
+
const job = {
|
|
526
|
+
id: newId("job"),
|
|
527
|
+
monitorId: monitor.id,
|
|
528
|
+
monitorRevision: monitor.revision,
|
|
529
|
+
scheduleSlot,
|
|
530
|
+
status: "pending",
|
|
531
|
+
claimedByProbeId: null,
|
|
532
|
+
fencingToken: null,
|
|
533
|
+
dueAt,
|
|
534
|
+
claimedAt: null,
|
|
535
|
+
leaseExpiresAt: null,
|
|
536
|
+
submittedResultId: null,
|
|
537
|
+
createdAt: now,
|
|
538
|
+
updatedAt: now
|
|
539
|
+
};
|
|
540
|
+
this.db.query(`INSERT INTO probe_check_jobs (
|
|
541
|
+
id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
|
|
542
|
+
due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
|
|
543
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(job.id, job.monitorId, job.monitorRevision, job.scheduleSlot, job.status, job.claimedByProbeId, job.fencingToken, job.dueAt, job.claimedAt, job.leaseExpiresAt, job.submittedResultId, job.createdAt, job.updatedAt);
|
|
544
|
+
return job;
|
|
545
|
+
}
|
|
546
|
+
getProbeCheckJob(id) {
|
|
547
|
+
const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
|
|
548
|
+
return row ? probeCheckJobFromRow(row) : null;
|
|
549
|
+
}
|
|
550
|
+
claimProbeCheckJob(input) {
|
|
551
|
+
const tx = this.db.transaction(() => {
|
|
552
|
+
const probe = this.getProbeIdentity(input.probeId);
|
|
553
|
+
if (!probe)
|
|
554
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
555
|
+
if (!probe.enabled)
|
|
556
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
557
|
+
const current = this.getProbeCheckJob(input.jobId);
|
|
558
|
+
if (!current)
|
|
559
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
560
|
+
const now = new Date;
|
|
561
|
+
const nowIso = now.toISOString();
|
|
562
|
+
if (current.status === "submitted")
|
|
563
|
+
throw new Error("Probe job already submitted");
|
|
564
|
+
if (current.status === "cancelled")
|
|
565
|
+
throw new Error("Probe job is cancelled");
|
|
566
|
+
if (current.dueAt > nowIso)
|
|
567
|
+
throw new Error("Probe job is not due yet");
|
|
568
|
+
const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
|
|
569
|
+
if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
|
|
570
|
+
throw new Error("Probe job already claimed by another probe");
|
|
571
|
+
}
|
|
572
|
+
if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
|
|
573
|
+
throw new Error(`Probe job is not claimable: ${current.status}`);
|
|
574
|
+
}
|
|
575
|
+
const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
|
|
576
|
+
const fencingToken = newId("fence");
|
|
577
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
578
|
+
SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
|
|
579
|
+
WHERE id = ?
|
|
580
|
+
AND submitted_result_id IS NULL
|
|
581
|
+
AND (
|
|
582
|
+
status IN ('pending', 'expired')
|
|
583
|
+
OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
|
|
584
|
+
)`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
|
|
585
|
+
if (statementChanges(update) !== 1)
|
|
586
|
+
throw new Error("Probe job claim raced; retry");
|
|
587
|
+
this.touchProbeIdentity(probe.id, nowIso);
|
|
588
|
+
return this.getProbeCheckJob(current.id);
|
|
589
|
+
});
|
|
590
|
+
return tx();
|
|
591
|
+
}
|
|
592
|
+
completeProbeCheckJob(input) {
|
|
593
|
+
const job = this.getProbeCheckJob(input.jobId);
|
|
594
|
+
if (!job)
|
|
595
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
596
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
597
|
+
if (job.status !== "claimed")
|
|
598
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
599
|
+
if (job.claimedByProbeId !== input.probeId)
|
|
600
|
+
throw new Error("Probe job was claimed by another probe");
|
|
601
|
+
if (job.fencingToken !== input.fencingToken)
|
|
602
|
+
throw new Error("Probe job fencing token is invalid");
|
|
603
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
|
|
604
|
+
this.expireProbeCheckJob(job.id, submittedAt);
|
|
605
|
+
throw new Error("Probe job lease expired");
|
|
606
|
+
}
|
|
607
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
608
|
+
SET status = 'submitted', submitted_result_id = ?, updated_at = ?
|
|
609
|
+
WHERE id = ?
|
|
610
|
+
AND status = 'claimed'
|
|
611
|
+
AND claimed_by_probe_id = ?
|
|
612
|
+
AND fencing_token = ?
|
|
613
|
+
AND lease_expires_at > ?
|
|
614
|
+
AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
|
|
615
|
+
if (statementChanges(update) !== 1)
|
|
616
|
+
throw new Error("Probe job submission raced; retry");
|
|
617
|
+
return this.getProbeCheckJob(job.id);
|
|
618
|
+
}
|
|
619
|
+
expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
|
|
620
|
+
this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
|
|
621
|
+
}
|
|
622
|
+
getProbeSubmission(probeId, nonce) {
|
|
623
|
+
const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
|
|
624
|
+
return row ? probeSubmissionFromRow(row) : null;
|
|
625
|
+
}
|
|
626
|
+
recordProbeSubmission(input) {
|
|
627
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
628
|
+
const receipt = {
|
|
629
|
+
id: newId("psb"),
|
|
630
|
+
probeId: input.probeId,
|
|
631
|
+
jobId: input.jobId,
|
|
632
|
+
monitorId: input.monitorId,
|
|
633
|
+
checkResultId: input.checkResultId,
|
|
634
|
+
nonce: input.nonce,
|
|
635
|
+
checkedAt: input.checkedAt,
|
|
636
|
+
submittedAt
|
|
637
|
+
};
|
|
638
|
+
this.db.query(`INSERT INTO probe_submissions (
|
|
639
|
+
id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
|
|
640
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
|
|
641
|
+
return receipt;
|
|
642
|
+
}
|
|
643
|
+
createReportSchedule(input) {
|
|
644
|
+
const normalized = normalizeReportScheduleInput(input);
|
|
645
|
+
const now = new Date().toISOString();
|
|
646
|
+
const schedule = {
|
|
647
|
+
id: newId("rps"),
|
|
648
|
+
name: normalized.name,
|
|
649
|
+
enabled: normalized.enabled,
|
|
650
|
+
intervalSeconds: normalized.intervalSeconds,
|
|
651
|
+
nextRunAt: normalized.nextRunAt,
|
|
652
|
+
lastRunAt: null,
|
|
653
|
+
subject: normalized.subject,
|
|
654
|
+
channels: normalized.channels,
|
|
655
|
+
createdAt: now,
|
|
656
|
+
updatedAt: now
|
|
657
|
+
};
|
|
658
|
+
this.db.query(`INSERT INTO report_schedules (
|
|
659
|
+
id, name, enabled, interval_seconds, next_run_at, last_run_at,
|
|
660
|
+
subject, channels_json, created_at, updated_at
|
|
661
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(schedule.id, schedule.name, schedule.enabled ? 1 : 0, schedule.intervalSeconds, schedule.nextRunAt, schedule.lastRunAt, schedule.subject, JSON.stringify(schedule.channels), schedule.createdAt, schedule.updatedAt);
|
|
662
|
+
return schedule;
|
|
663
|
+
}
|
|
664
|
+
listReportSchedules(options = {}) {
|
|
665
|
+
const rows = options.includeDisabled ? this.db.query("SELECT * FROM report_schedules ORDER BY name ASC").all() : this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 ORDER BY name ASC").all();
|
|
666
|
+
return rows.map(reportScheduleFromRow);
|
|
667
|
+
}
|
|
668
|
+
listDueReportSchedules(nowIso = new Date().toISOString()) {
|
|
669
|
+
assertIsoTimestamp(nowIso, "Report schedule due timestamp");
|
|
670
|
+
const rows = this.db.query("SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC, name ASC").all(nowIso);
|
|
671
|
+
return rows.map(reportScheduleFromRow);
|
|
672
|
+
}
|
|
673
|
+
getReportSchedule(idOrName) {
|
|
674
|
+
const row = this.db.query("SELECT * FROM report_schedules WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
675
|
+
return row ? reportScheduleFromRow(row) : null;
|
|
676
|
+
}
|
|
677
|
+
updateReportSchedule(idOrName, input) {
|
|
678
|
+
const current = this.getReportSchedule(idOrName);
|
|
679
|
+
if (!current)
|
|
680
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
681
|
+
const normalized = normalizeReportScheduleInput({
|
|
682
|
+
name: input.name ?? current.name,
|
|
683
|
+
intervalSeconds: input.intervalSeconds ?? current.intervalSeconds,
|
|
684
|
+
nextRunAt: input.nextRunAt ?? current.nextRunAt,
|
|
685
|
+
enabled: input.enabled ?? current.enabled,
|
|
686
|
+
subject: input.subject === undefined ? current.subject : input.subject,
|
|
687
|
+
channels: input.channels ?? current.channels
|
|
688
|
+
});
|
|
689
|
+
const updatedAt = new Date().toISOString();
|
|
690
|
+
this.db.query(`UPDATE report_schedules SET
|
|
691
|
+
name = ?, enabled = ?, interval_seconds = ?, next_run_at = ?,
|
|
692
|
+
subject = ?, channels_json = ?, updated_at = ?
|
|
693
|
+
WHERE id = ?`).run(normalized.name, normalized.enabled ? 1 : 0, normalized.intervalSeconds, normalized.nextRunAt, normalized.subject, JSON.stringify(normalized.channels), updatedAt, current.id);
|
|
694
|
+
return this.getReportSchedule(current.id);
|
|
695
|
+
}
|
|
696
|
+
deleteReportSchedule(idOrName) {
|
|
697
|
+
const current = this.getReportSchedule(idOrName);
|
|
698
|
+
if (!current)
|
|
699
|
+
return false;
|
|
700
|
+
this.db.query("DELETE FROM report_schedules WHERE id = ?").run(current.id);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
recordReportRun(input) {
|
|
704
|
+
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
705
|
+
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
|
706
|
+
assertIsoTimestamp(startedAt, "Report run startedAt");
|
|
707
|
+
assertIsoTimestamp(finishedAt, "Report run finishedAt");
|
|
708
|
+
if (input.status !== "success" && input.status !== "failed") {
|
|
709
|
+
throw new Error("Report run status must be success or failed");
|
|
710
|
+
}
|
|
711
|
+
if (input.scheduleId && !this.getReportSchedule(input.scheduleId)) {
|
|
712
|
+
throw new Error(`Report schedule not found: ${input.scheduleId}`);
|
|
713
|
+
}
|
|
714
|
+
const run = {
|
|
715
|
+
id: newId("rpr"),
|
|
716
|
+
scheduleId: input.scheduleId ?? null,
|
|
717
|
+
status: input.status,
|
|
718
|
+
startedAt,
|
|
719
|
+
finishedAt,
|
|
720
|
+
deliveries: normalizeReportDeliveries(input.deliveries ?? []),
|
|
721
|
+
error: normalizeNullableRedactedText(input.error, "Report run error", 1000),
|
|
722
|
+
reportJson: input.reportJson ?? null
|
|
723
|
+
};
|
|
724
|
+
this.db.query(`INSERT INTO report_runs (
|
|
725
|
+
id, schedule_id, status, started_at, finished_at, deliveries_json,
|
|
726
|
+
error, report_json
|
|
727
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(run.id, run.scheduleId, run.status, run.startedAt, run.finishedAt, JSON.stringify(run.deliveries), run.error, run.reportJson ? JSON.stringify(run.reportJson) : null);
|
|
728
|
+
if (run.scheduleId) {
|
|
729
|
+
this.advanceReportSchedule(run.scheduleId, run.finishedAt);
|
|
730
|
+
}
|
|
731
|
+
return run;
|
|
732
|
+
}
|
|
733
|
+
listReportRuns(options = {}) {
|
|
734
|
+
const limit = clampLimit(options.limit ?? 50);
|
|
735
|
+
const rows = options.scheduleId ? this.db.query("SELECT * FROM report_runs WHERE schedule_id = ? ORDER BY started_at DESC, id DESC LIMIT ?").all(options.scheduleId, limit) : this.db.query("SELECT * FROM report_runs ORDER BY started_at DESC, id DESC LIMIT ?").all(limit);
|
|
736
|
+
return rows.map(reportRunFromRow);
|
|
737
|
+
}
|
|
738
|
+
recordAuditEvent(input) {
|
|
739
|
+
const action = normalizeAuditText(input.action, "Audit action", 160);
|
|
740
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
741
|
+
assertIsoTimestamp(createdAt, "Audit event createdAt");
|
|
742
|
+
const event = {
|
|
743
|
+
id: newId("aud"),
|
|
744
|
+
action,
|
|
745
|
+
resourceType: normalizeNullableAuditText(input.resourceType, "Audit resourceType", 80),
|
|
746
|
+
resourceId: normalizeNullableAuditText(input.resourceId, "Audit resourceId", 160),
|
|
747
|
+
message: normalizeNullableAuditText(input.message, "Audit message", 500),
|
|
748
|
+
metadata: normalizeAuditMetadata(input.metadata ?? {}),
|
|
749
|
+
actor: normalizeNullableAuditText(input.actor, "Audit actor", 160),
|
|
750
|
+
createdAt
|
|
751
|
+
};
|
|
752
|
+
this.db.query(`INSERT INTO audit_events (
|
|
753
|
+
id, action, resource_type, resource_id, message, metadata_json, actor, created_at
|
|
754
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.action, event.resourceType, event.resourceId, event.message, JSON.stringify(event.metadata), event.actor, event.createdAt);
|
|
755
|
+
return event;
|
|
756
|
+
}
|
|
757
|
+
listAuditEvents(options = {}) {
|
|
758
|
+
const clauses = [];
|
|
759
|
+
const args = [];
|
|
760
|
+
if (options.resourceType) {
|
|
761
|
+
clauses.push("resource_type = ?");
|
|
762
|
+
args.push(options.resourceType);
|
|
763
|
+
}
|
|
764
|
+
if (options.resourceId) {
|
|
765
|
+
clauses.push("resource_id = ?");
|
|
766
|
+
args.push(options.resourceId);
|
|
767
|
+
}
|
|
768
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
769
|
+
args.push(clampLimit(options.limit ?? 50));
|
|
770
|
+
const rows = this.db.query(`SELECT * FROM audit_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args);
|
|
771
|
+
return rows.map(auditEventFromRow);
|
|
772
|
+
}
|
|
178
773
|
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
179
774
|
const now = new Date;
|
|
180
775
|
const nowIso = now.toISOString();
|
|
@@ -209,7 +804,8 @@ class UptimeStore {
|
|
|
209
804
|
latencyMs: input.latencyMs,
|
|
210
805
|
statusCode: input.statusCode,
|
|
211
806
|
error: input.error,
|
|
212
|
-
attemptCount: Math.max(1, input.attemptCount)
|
|
807
|
+
attemptCount: Math.max(1, input.attemptCount),
|
|
808
|
+
evidence: input.evidence ?? null
|
|
213
809
|
};
|
|
214
810
|
const tx = this.db.transaction(() => {
|
|
215
811
|
const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
|
|
@@ -222,19 +818,59 @@ class UptimeStore {
|
|
|
222
818
|
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
|
|
223
819
|
}
|
|
224
820
|
this.db.query(`INSERT INTO check_results (
|
|
225
|
-
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
|
|
226
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
|
|
821
|
+
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
|
|
822
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount, result.evidence ? JSON.stringify(result.evidence) : null);
|
|
227
823
|
this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
|
|
228
824
|
this.reconcileIncidentInTransaction(result);
|
|
229
825
|
});
|
|
230
826
|
tx();
|
|
231
827
|
return result;
|
|
232
828
|
}
|
|
829
|
+
getCheckResult(id) {
|
|
830
|
+
const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
|
|
831
|
+
return row ? checkResultFromRow(row) : null;
|
|
832
|
+
}
|
|
233
833
|
listResults(options = {}) {
|
|
234
834
|
const limit = clampLimit(options.limit ?? 50);
|
|
235
835
|
const rows = options.monitorId ? this.db.query("SELECT * FROM check_results WHERE monitor_id = ? ORDER BY checked_at DESC LIMIT ?").all(options.monitorId, limit) : this.db.query("SELECT * FROM check_results ORDER BY checked_at DESC LIMIT ?").all(limit);
|
|
236
836
|
return rows.map(checkResultFromRow);
|
|
237
837
|
}
|
|
838
|
+
getProvenance(source, sourceId) {
|
|
839
|
+
const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
|
|
840
|
+
return row ? provenanceFromRow(row) : null;
|
|
841
|
+
}
|
|
842
|
+
upsertMonitorProvenance(input) {
|
|
843
|
+
const importedAt = new Date().toISOString();
|
|
844
|
+
this.db.query(`INSERT INTO monitor_provenance (
|
|
845
|
+
monitor_id, source, source_id, source_label, imported_at, snapshot_json
|
|
846
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
847
|
+
ON CONFLICT(source, source_id) DO UPDATE SET
|
|
848
|
+
monitor_id = excluded.monitor_id,
|
|
849
|
+
source_label = excluded.source_label,
|
|
850
|
+
imported_at = excluded.imported_at,
|
|
851
|
+
snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
|
|
852
|
+
return this.getProvenance(input.source, input.sourceId);
|
|
853
|
+
}
|
|
854
|
+
saveImportBatch(input) {
|
|
855
|
+
const createdAt = new Date().toISOString();
|
|
856
|
+
this.db.query("INSERT INTO import_batches (id, source, status, created_at, rolled_back_at, records_json) VALUES (?, ?, 'applied', ?, NULL, ?)").run(input.id, input.source, createdAt, JSON.stringify(input.records));
|
|
857
|
+
return this.getImportBatch(input.id);
|
|
858
|
+
}
|
|
859
|
+
getImportBatch(batchId) {
|
|
860
|
+
const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
|
|
861
|
+
return row ? importBatchFromRow(row) : null;
|
|
862
|
+
}
|
|
863
|
+
markImportBatchRolledBack(batchId) {
|
|
864
|
+
const rolledBackAt = new Date().toISOString();
|
|
865
|
+
this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
|
|
866
|
+
const batch = this.getImportBatch(batchId);
|
|
867
|
+
if (!batch)
|
|
868
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
869
|
+
return batch;
|
|
870
|
+
}
|
|
871
|
+
runInTransaction(fn) {
|
|
872
|
+
return this.db.transaction(fn)();
|
|
873
|
+
}
|
|
238
874
|
listIncidents(options = {}) {
|
|
239
875
|
const clauses = [];
|
|
240
876
|
const args = [];
|
|
@@ -316,14 +952,123 @@ class UptimeStore {
|
|
|
316
952
|
closeOpenIncident(monitorId, closedAt) {
|
|
317
953
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
318
954
|
}
|
|
955
|
+
advanceReportSchedule(scheduleId, finishedAt) {
|
|
956
|
+
const schedule = this.getReportSchedule(scheduleId);
|
|
957
|
+
if (!schedule)
|
|
958
|
+
throw new Error(`Report schedule not found: ${scheduleId}`);
|
|
959
|
+
const finishedMs = Date.parse(finishedAt);
|
|
960
|
+
let nextMs = Math.max(Date.parse(schedule.nextRunAt), finishedMs);
|
|
961
|
+
do {
|
|
962
|
+
nextMs += schedule.intervalSeconds * 1000;
|
|
963
|
+
} while (nextMs <= finishedMs);
|
|
964
|
+
const nextRunAt = new Date(nextMs).toISOString();
|
|
965
|
+
this.db.query("UPDATE report_schedules SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?").run(finishedAt, nextRunAt, finishedAt, schedule.id);
|
|
966
|
+
}
|
|
319
967
|
ensureColumn(table, name, definition) {
|
|
320
968
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
321
969
|
if (!columns.some((column) => column.name === name)) {
|
|
322
970
|
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
323
971
|
}
|
|
324
972
|
}
|
|
973
|
+
ensureMonitorKindAllowsBrowserPage() {
|
|
974
|
+
const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
|
|
975
|
+
if (!row?.sql || row.sql.includes("browser_page"))
|
|
976
|
+
return;
|
|
977
|
+
this.db.run("PRAGMA foreign_keys = OFF");
|
|
978
|
+
this.db.run("PRAGMA legacy_alter_table = ON");
|
|
979
|
+
try {
|
|
980
|
+
const migrate = this.db.transaction(() => {
|
|
981
|
+
this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
|
|
982
|
+
this.db.run(`
|
|
983
|
+
CREATE TABLE monitors (
|
|
984
|
+
id TEXT PRIMARY KEY,
|
|
985
|
+
name TEXT NOT NULL UNIQUE,
|
|
986
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
987
|
+
url TEXT,
|
|
988
|
+
host TEXT,
|
|
989
|
+
port INTEGER,
|
|
990
|
+
method TEXT NOT NULL DEFAULT 'GET',
|
|
991
|
+
expected_status INTEGER,
|
|
992
|
+
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
|
993
|
+
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
|
994
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
995
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
996
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
997
|
+
last_checked_at TEXT,
|
|
998
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
999
|
+
created_at TEXT NOT NULL,
|
|
1000
|
+
updated_at TEXT NOT NULL
|
|
1001
|
+
)
|
|
1002
|
+
`);
|
|
1003
|
+
this.db.run(`
|
|
1004
|
+
INSERT INTO monitors (
|
|
1005
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
1006
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
1007
|
+
last_checked_at, revision, created_at, updated_at
|
|
1008
|
+
)
|
|
1009
|
+
SELECT
|
|
1010
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
1011
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
1012
|
+
last_checked_at, revision, created_at, updated_at
|
|
1013
|
+
FROM monitors_old_kind
|
|
1014
|
+
`);
|
|
1015
|
+
this.db.run("DROP TABLE monitors_old_kind");
|
|
1016
|
+
});
|
|
1017
|
+
migrate();
|
|
1018
|
+
} finally {
|
|
1019
|
+
this.db.run("PRAGMA legacy_alter_table = OFF");
|
|
1020
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
vacuumInto(backupPath) {
|
|
1024
|
+
const quoted = backupPath.replace(/'/g, "''");
|
|
1025
|
+
this.db.run(`VACUUM INTO '${quoted}'`);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
function resolveRuntimeMode(mode) {
|
|
1029
|
+
const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
|
|
1030
|
+
if (value === "local" || value === "hosted")
|
|
1031
|
+
return value;
|
|
1032
|
+
throw new Error("HASNA_UPTIME_MODE must be local or hosted");
|
|
1033
|
+
}
|
|
1034
|
+
function allowHostedLocalStore(value) {
|
|
1035
|
+
return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
|
|
1036
|
+
}
|
|
1037
|
+
function verifyBackupFile(backupPath) {
|
|
1038
|
+
const db = new Database(backupPath, { readonly: true });
|
|
1039
|
+
try {
|
|
1040
|
+
const integrityRow = db.query("PRAGMA integrity_check").get();
|
|
1041
|
+
const integrity = String(integrityRow?.integrity_check ?? "unknown");
|
|
1042
|
+
const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
|
|
1043
|
+
const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
|
|
1044
|
+
const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
|
|
1045
|
+
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table) || REPORT_AUDIT_TABLES.has(table));
|
|
1046
|
+
const restorableV2 = schemaVersion === "2" && missingTables.every((table) => REPORT_AUDIT_TABLES.has(table));
|
|
1047
|
+
return {
|
|
1048
|
+
ok: integrity === "ok" && (currentOk || restorableV1 || restorableV2),
|
|
1049
|
+
backupPath,
|
|
1050
|
+
integrity,
|
|
1051
|
+
schemaVersion,
|
|
1052
|
+
missingTables,
|
|
1053
|
+
monitors: tableCount(db, "monitors"),
|
|
1054
|
+
results: tableCount(db, "check_results"),
|
|
1055
|
+
incidents: tableCount(db, "incidents")
|
|
1056
|
+
};
|
|
1057
|
+
} finally {
|
|
1058
|
+
db.close();
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function tableCount(db, table) {
|
|
1062
|
+
if (!tableExists(db, table))
|
|
1063
|
+
return 0;
|
|
1064
|
+
const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
1065
|
+
return Number(row?.count ?? 0);
|
|
1066
|
+
}
|
|
1067
|
+
function tableExists(db, table) {
|
|
1068
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
1069
|
+
return Number(row?.count ?? 0) > 0;
|
|
325
1070
|
}
|
|
326
|
-
function normalizeCreateMonitor(input) {
|
|
1071
|
+
function normalizeCreateMonitor(input, allowBrowserPage = false) {
|
|
327
1072
|
const name = input.name?.trim();
|
|
328
1073
|
if (!name)
|
|
329
1074
|
throw new Error("Monitor name is required");
|
|
@@ -331,7 +1076,10 @@ function normalizeCreateMonitor(input) {
|
|
|
331
1076
|
const method = normalizeMethod(input.method ?? "GET");
|
|
332
1077
|
const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
|
|
333
1078
|
const enabled = normalizeEnabled(input.enabled);
|
|
334
|
-
if (input.kind === "http") {
|
|
1079
|
+
if (input.kind === "http" || input.kind === "browser_page") {
|
|
1080
|
+
if (input.kind === "browser_page" && !allowBrowserPage) {
|
|
1081
|
+
throw new Error("browser_page monitors must be imported with explicit browser evidence support");
|
|
1082
|
+
}
|
|
335
1083
|
const url = normalizeHttpUrl(input.url);
|
|
336
1084
|
return {
|
|
337
1085
|
name,
|
|
@@ -365,13 +1113,13 @@ function normalizeCreateMonitor(input) {
|
|
|
365
1113
|
enabled
|
|
366
1114
|
};
|
|
367
1115
|
} else {
|
|
368
|
-
throw new Error("Monitor kind must be http or
|
|
1116
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
369
1117
|
}
|
|
370
1118
|
}
|
|
371
1119
|
function definitionChanged(current, next) {
|
|
372
1120
|
return next.kind !== current.kind || next.url !== current.url || next.host !== current.host || next.port !== current.port || next.method !== current.method || next.expectedStatus !== current.expectedStatus;
|
|
373
1121
|
}
|
|
374
|
-
function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
1122
|
+
function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
|
|
375
1123
|
const merged = {
|
|
376
1124
|
...current,
|
|
377
1125
|
...input,
|
|
@@ -390,7 +1138,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
|
390
1138
|
timeoutMs: merged.timeoutMs,
|
|
391
1139
|
retryCount: merged.retryCount,
|
|
392
1140
|
enabled: merged.enabled
|
|
393
|
-
});
|
|
1141
|
+
}, allowBrowserPage || current.kind === "browser_page");
|
|
394
1142
|
const checkDefinitionChanged = normalized.kind !== current.kind || (normalized.url ?? null) !== current.url || (normalized.host ?? null) !== current.host || (normalized.port ?? null) !== current.port || normalized.method !== current.method || normalized.expectedStatus !== current.expectedStatus;
|
|
395
1143
|
const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
|
|
396
1144
|
return {
|
|
@@ -419,6 +1167,11 @@ function normalizeHttpUrl(value) {
|
|
|
419
1167
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
420
1168
|
throw new Error("HTTP monitor url must use http or https");
|
|
421
1169
|
}
|
|
1170
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
1171
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
1172
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
1173
|
+
}
|
|
1174
|
+
parsed.hash = "";
|
|
422
1175
|
return parsed.toString();
|
|
423
1176
|
}
|
|
424
1177
|
function normalizeMethod(value) {
|
|
@@ -447,6 +1200,189 @@ function rejectControlCharacters(value, label) {
|
|
|
447
1200
|
throw new Error(`${label} must not contain control characters`);
|
|
448
1201
|
}
|
|
449
1202
|
}
|
|
1203
|
+
function normalizeScheduleSlot(value) {
|
|
1204
|
+
const slot = value.trim();
|
|
1205
|
+
if (!slot)
|
|
1206
|
+
throw new Error("Probe job scheduleSlot is required");
|
|
1207
|
+
if (slot.length > 128)
|
|
1208
|
+
throw new Error("Probe job scheduleSlot is too long");
|
|
1209
|
+
rejectControlCharacters(slot, "Probe job scheduleSlot");
|
|
1210
|
+
return slot;
|
|
1211
|
+
}
|
|
1212
|
+
function normalizeReportScheduleInput(input) {
|
|
1213
|
+
const name = input.name?.trim();
|
|
1214
|
+
if (!name)
|
|
1215
|
+
throw new Error("Report schedule name is required");
|
|
1216
|
+
rejectControlCharacters(name, "Report schedule name");
|
|
1217
|
+
const intervalSeconds = boundedInteger(input.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
1218
|
+
const nextRunAt = input.nextRunAt ?? new Date().toISOString();
|
|
1219
|
+
assertIsoTimestamp(nextRunAt, "Report schedule nextRunAt");
|
|
1220
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
1221
|
+
const subject = normalizeNullableBoundedText(input.subject, "Report schedule subject", 200);
|
|
1222
|
+
const channels = normalizeReportChannels(input.channels);
|
|
1223
|
+
return { name, intervalSeconds, nextRunAt, enabled, subject, channels };
|
|
1224
|
+
}
|
|
1225
|
+
function normalizeReportChannels(channels) {
|
|
1226
|
+
if (!channels || typeof channels !== "object")
|
|
1227
|
+
throw new Error("Report schedule channels are required");
|
|
1228
|
+
const normalized = {};
|
|
1229
|
+
if (channels.email !== undefined)
|
|
1230
|
+
normalized.email = normalizeChannelTarget(channels.email, "email", ["apiUrl", "from", "to", "subject", "providerId"]);
|
|
1231
|
+
if (channels.sms !== undefined)
|
|
1232
|
+
normalized.sms = normalizeChannelTarget(channels.sms, "sms", ["apiUrl", "from", "to"]);
|
|
1233
|
+
if (channels.logs !== undefined)
|
|
1234
|
+
normalized.logs = normalizeChannelTarget(channels.logs, "logs", ["apiUrl", "projectId", "environment", "service"]);
|
|
1235
|
+
if (!normalized.email && !normalized.sms && !normalized.logs) {
|
|
1236
|
+
throw new Error("Report schedule requires at least one channel");
|
|
1237
|
+
}
|
|
1238
|
+
return normalized;
|
|
1239
|
+
}
|
|
1240
|
+
function normalizeChannelTarget(value, channel, allowedKeys) {
|
|
1241
|
+
if (value === false || value == null)
|
|
1242
|
+
return false;
|
|
1243
|
+
if (value === true)
|
|
1244
|
+
return true;
|
|
1245
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1246
|
+
throw new Error(`Report schedule ${channel} channel must be true or an object`);
|
|
1247
|
+
}
|
|
1248
|
+
const record = value;
|
|
1249
|
+
const normalized = {};
|
|
1250
|
+
for (const [key, rawValue] of Object.entries(record)) {
|
|
1251
|
+
if (!allowedKeys.includes(key)) {
|
|
1252
|
+
if (/key|token|secret|password|credential|auth/i.test(key)) {
|
|
1253
|
+
throw new Error("Report schedules must not persist API keys or tokens; use environment variables or cloud channel refs");
|
|
1254
|
+
}
|
|
1255
|
+
throw new Error(`Unsupported report schedule ${channel} channel field: ${key}`);
|
|
1256
|
+
}
|
|
1257
|
+
if (rawValue === undefined || rawValue === null || rawValue === "")
|
|
1258
|
+
continue;
|
|
1259
|
+
if (key === "apiUrl" && Array.isArray(rawValue)) {
|
|
1260
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string`);
|
|
1261
|
+
}
|
|
1262
|
+
if (Array.isArray(rawValue)) {
|
|
1263
|
+
const items = rawValue.map((item) => normalizeBoundedText(String(item), `Report schedule ${channel}.${key}`, 300));
|
|
1264
|
+
if (items.length > 0)
|
|
1265
|
+
normalized[key] = items;
|
|
1266
|
+
} else if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
1267
|
+
normalized[key] = key === "apiUrl" ? normalizeHttpIntegrationUrl(String(rawValue)) : normalizeBoundedText(String(rawValue), `Report schedule ${channel}.${key}`, 500);
|
|
1268
|
+
} else {
|
|
1269
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string or string array`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return Object.keys(normalized).length > 0 ? normalized : true;
|
|
1273
|
+
}
|
|
1274
|
+
function normalizeHttpIntegrationUrl(value) {
|
|
1275
|
+
const parsed = new URL(value.trim());
|
|
1276
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
1277
|
+
throw new Error("Report schedule integration API URL must use http or https");
|
|
1278
|
+
}
|
|
1279
|
+
if (parsed.username || parsed.password) {
|
|
1280
|
+
throw new Error("Report schedule integration API URL must not include credentials");
|
|
1281
|
+
}
|
|
1282
|
+
for (const key of parsed.searchParams.keys()) {
|
|
1283
|
+
if (SECRET_URL_PARAM_PATTERN.test(key)) {
|
|
1284
|
+
throw new Error("Report schedule integration API URL must not include secret query parameters");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
parsed.hash = "";
|
|
1288
|
+
return parsed.toString();
|
|
1289
|
+
}
|
|
1290
|
+
function normalizeReportDeliveries(deliveries) {
|
|
1291
|
+
return deliveries.map((delivery) => {
|
|
1292
|
+
if (delivery.channel !== "email" && delivery.channel !== "sms" && delivery.channel !== "logs") {
|
|
1293
|
+
throw new Error("Report delivery channel must be email, sms, or logs");
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
channel: delivery.channel,
|
|
1297
|
+
ok: Boolean(delivery.ok),
|
|
1298
|
+
status: delivery.status,
|
|
1299
|
+
id: delivery.id === undefined ? undefined : normalizeRedactedText(String(delivery.id), "Report delivery id", 300),
|
|
1300
|
+
error: delivery.error === undefined ? undefined : normalizeRedactedText(String(delivery.error), "Report delivery error", 1000)
|
|
1301
|
+
};
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
function normalizeAuditText(value, label, maxLength) {
|
|
1305
|
+
return normalizeBoundedText(value ?? "", label, maxLength);
|
|
1306
|
+
}
|
|
1307
|
+
function normalizeNullableAuditText(value, label, maxLength) {
|
|
1308
|
+
return normalizeNullableBoundedText(value, label, maxLength);
|
|
1309
|
+
}
|
|
1310
|
+
function normalizeNullableBoundedText(value, label, maxLength) {
|
|
1311
|
+
if (value == null)
|
|
1312
|
+
return null;
|
|
1313
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
1314
|
+
return normalized || null;
|
|
1315
|
+
}
|
|
1316
|
+
function normalizeBoundedText(value, label, maxLength) {
|
|
1317
|
+
const normalized = value.trim();
|
|
1318
|
+
rejectControlCharacters(normalized, label);
|
|
1319
|
+
if (normalized.length > maxLength)
|
|
1320
|
+
throw new Error(`${label} is too long`);
|
|
1321
|
+
return normalized;
|
|
1322
|
+
}
|
|
1323
|
+
function normalizeNullableRedactedText(value, label, maxLength) {
|
|
1324
|
+
if (value == null)
|
|
1325
|
+
return null;
|
|
1326
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
1327
|
+
return normalized || null;
|
|
1328
|
+
}
|
|
1329
|
+
function normalizeRedactedText(value, label, maxLength) {
|
|
1330
|
+
return normalizeBoundedText(redactSecretString(value), label, maxLength);
|
|
1331
|
+
}
|
|
1332
|
+
function normalizeAuditMetadata(value) {
|
|
1333
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1334
|
+
throw new Error("Audit metadata must be an object");
|
|
1335
|
+
}
|
|
1336
|
+
return redactAuditSecrets(JSON.parse(JSON.stringify(value)));
|
|
1337
|
+
}
|
|
1338
|
+
function redactAuditSecrets(value) {
|
|
1339
|
+
if (Array.isArray(value))
|
|
1340
|
+
return value.map(redactAuditSecrets);
|
|
1341
|
+
if (typeof value === "string")
|
|
1342
|
+
return redactSecretString(value);
|
|
1343
|
+
if (!value || typeof value !== "object")
|
|
1344
|
+
return value;
|
|
1345
|
+
const output = {};
|
|
1346
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
1347
|
+
output[key] = /key|token|secret|password|credential|auth/i.test(key) ? "[REDACTED]" : redactAuditSecrets(nested);
|
|
1348
|
+
}
|
|
1349
|
+
return output;
|
|
1350
|
+
}
|
|
1351
|
+
function redactSecretString(value) {
|
|
1352
|
+
let output = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
1353
|
+
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrlString(match));
|
|
1354
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(output))
|
|
1355
|
+
return output;
|
|
1356
|
+
return redactUrlString(output);
|
|
1357
|
+
}
|
|
1358
|
+
function redactUrlString(value) {
|
|
1359
|
+
let trailing = "";
|
|
1360
|
+
let candidate = value;
|
|
1361
|
+
while (/[),.;\]]$/.test(candidate)) {
|
|
1362
|
+
trailing = `${candidate.slice(-1)}${trailing}`;
|
|
1363
|
+
candidate = candidate.slice(0, -1);
|
|
1364
|
+
}
|
|
1365
|
+
try {
|
|
1366
|
+
const parsed = new URL(candidate);
|
|
1367
|
+
if (parsed.username)
|
|
1368
|
+
parsed.username = "[REDACTED]";
|
|
1369
|
+
if (parsed.password)
|
|
1370
|
+
parsed.password = "[REDACTED]";
|
|
1371
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
1372
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
1373
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
1374
|
+
}
|
|
1375
|
+
parsed.hash = "";
|
|
1376
|
+
return `${parsed.toString()}${trailing}`;
|
|
1377
|
+
} catch {
|
|
1378
|
+
return value;
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
function assertIsoTimestamp(value, label) {
|
|
1382
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
1383
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
450
1386
|
function monitorFromRow(row) {
|
|
451
1387
|
return {
|
|
452
1388
|
id: row.id,
|
|
@@ -477,9 +1413,137 @@ function checkResultFromRow(row) {
|
|
|
477
1413
|
latencyMs: row.latency_ms,
|
|
478
1414
|
statusCode: row.status_code,
|
|
479
1415
|
error: row.error,
|
|
480
|
-
attemptCount: row.attempt_count
|
|
1416
|
+
attemptCount: row.attempt_count,
|
|
1417
|
+
evidence: parseEvidence(row.evidence_json)
|
|
1418
|
+
};
|
|
1419
|
+
}
|
|
1420
|
+
function provenanceFromRow(row) {
|
|
1421
|
+
return {
|
|
1422
|
+
monitorId: row.monitor_id,
|
|
1423
|
+
source: row.source,
|
|
1424
|
+
sourceId: row.source_id,
|
|
1425
|
+
sourceLabel: row.source_label,
|
|
1426
|
+
importedAt: row.imported_at,
|
|
1427
|
+
snapshot: parseJson(row.snapshot_json)
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
function importBatchFromRow(row) {
|
|
1431
|
+
return {
|
|
1432
|
+
id: row.id,
|
|
1433
|
+
source: row.source,
|
|
1434
|
+
status: row.status,
|
|
1435
|
+
createdAt: row.created_at,
|
|
1436
|
+
rolledBackAt: row.rolled_back_at,
|
|
1437
|
+
records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
function probeIdentityFromRow(row) {
|
|
1441
|
+
return {
|
|
1442
|
+
id: row.id,
|
|
1443
|
+
name: row.name,
|
|
1444
|
+
publicKeyPem: row.public_key_pem,
|
|
1445
|
+
publicKeyFingerprint: row.public_key_fingerprint,
|
|
1446
|
+
enabled: Boolean(row.enabled),
|
|
1447
|
+
createdAt: row.created_at,
|
|
1448
|
+
lastSeenAt: row.last_seen_at
|
|
481
1449
|
};
|
|
482
1450
|
}
|
|
1451
|
+
function probeSubmissionFromRow(row) {
|
|
1452
|
+
return {
|
|
1453
|
+
id: row.id,
|
|
1454
|
+
probeId: row.probe_id,
|
|
1455
|
+
jobId: row.job_id ?? "",
|
|
1456
|
+
monitorId: row.monitor_id,
|
|
1457
|
+
checkResultId: row.check_result_id,
|
|
1458
|
+
nonce: row.nonce,
|
|
1459
|
+
checkedAt: row.checked_at,
|
|
1460
|
+
submittedAt: row.submitted_at
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function probeCheckJobFromRow(row) {
|
|
1464
|
+
return {
|
|
1465
|
+
id: row.id,
|
|
1466
|
+
monitorId: row.monitor_id,
|
|
1467
|
+
monitorRevision: row.monitor_revision ?? 1,
|
|
1468
|
+
scheduleSlot: row.schedule_slot,
|
|
1469
|
+
status: row.status,
|
|
1470
|
+
claimedByProbeId: row.claimed_by_probe_id,
|
|
1471
|
+
fencingToken: row.fencing_token,
|
|
1472
|
+
dueAt: row.due_at,
|
|
1473
|
+
claimedAt: row.claimed_at,
|
|
1474
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
1475
|
+
submittedResultId: row.submitted_result_id,
|
|
1476
|
+
createdAt: row.created_at,
|
|
1477
|
+
updatedAt: row.updated_at
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
function reportScheduleFromRow(row) {
|
|
1481
|
+
return {
|
|
1482
|
+
id: row.id,
|
|
1483
|
+
name: row.name,
|
|
1484
|
+
enabled: Boolean(row.enabled),
|
|
1485
|
+
intervalSeconds: row.interval_seconds,
|
|
1486
|
+
nextRunAt: row.next_run_at,
|
|
1487
|
+
lastRunAt: row.last_run_at,
|
|
1488
|
+
subject: row.subject,
|
|
1489
|
+
channels: parseReportChannels(row.channels_json),
|
|
1490
|
+
createdAt: row.created_at,
|
|
1491
|
+
updatedAt: row.updated_at
|
|
1492
|
+
};
|
|
1493
|
+
}
|
|
1494
|
+
function reportRunFromRow(row) {
|
|
1495
|
+
return {
|
|
1496
|
+
id: row.id,
|
|
1497
|
+
scheduleId: row.schedule_id,
|
|
1498
|
+
status: row.status,
|
|
1499
|
+
startedAt: row.started_at,
|
|
1500
|
+
finishedAt: row.finished_at,
|
|
1501
|
+
deliveries: parseReportDeliveries(row.deliveries_json),
|
|
1502
|
+
error: row.error,
|
|
1503
|
+
reportJson: parseRecord(row.report_json)
|
|
1504
|
+
};
|
|
1505
|
+
}
|
|
1506
|
+
function auditEventFromRow(row) {
|
|
1507
|
+
return {
|
|
1508
|
+
id: row.id,
|
|
1509
|
+
action: row.action,
|
|
1510
|
+
resourceType: row.resource_type,
|
|
1511
|
+
resourceId: row.resource_id,
|
|
1512
|
+
message: row.message,
|
|
1513
|
+
metadata: parseRecord(row.metadata_json) ?? {},
|
|
1514
|
+
actor: row.actor,
|
|
1515
|
+
createdAt: row.created_at
|
|
1516
|
+
};
|
|
1517
|
+
}
|
|
1518
|
+
function parseEvidence(value) {
|
|
1519
|
+
if (!value)
|
|
1520
|
+
return null;
|
|
1521
|
+
const parsed = parseJson(value);
|
|
1522
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
1523
|
+
}
|
|
1524
|
+
function parseReportChannels(value) {
|
|
1525
|
+
const parsed = parseJson(value);
|
|
1526
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1527
|
+
return {};
|
|
1528
|
+
return parsed;
|
|
1529
|
+
}
|
|
1530
|
+
function parseReportDeliveries(value) {
|
|
1531
|
+
const parsed = parseJson(value);
|
|
1532
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
1533
|
+
}
|
|
1534
|
+
function parseRecord(value) {
|
|
1535
|
+
if (!value)
|
|
1536
|
+
return null;
|
|
1537
|
+
const parsed = parseJson(value);
|
|
1538
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1539
|
+
}
|
|
1540
|
+
function parseJson(value) {
|
|
1541
|
+
try {
|
|
1542
|
+
return JSON.parse(value);
|
|
1543
|
+
} catch {
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
483
1547
|
function incidentFromRow(row) {
|
|
484
1548
|
return {
|
|
485
1549
|
id: row.id,
|
|
@@ -507,11 +1571,15 @@ function clampLimit(value) {
|
|
|
507
1571
|
return 50;
|
|
508
1572
|
return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
|
|
509
1573
|
}
|
|
1574
|
+
function statementChanges(result) {
|
|
1575
|
+
return Number(result?.changes ?? 0);
|
|
1576
|
+
}
|
|
510
1577
|
function round(value, places) {
|
|
511
1578
|
const factor = 10 ** places;
|
|
512
1579
|
return Math.round(value * factor) / factor;
|
|
513
1580
|
}
|
|
514
1581
|
export {
|
|
1582
|
+
resolveRuntimeMode,
|
|
515
1583
|
UptimeStore,
|
|
516
1584
|
StaleCheckResultError
|
|
517
1585
|
};
|