@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/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.dbPath = options.dbPath ?? uptimeDbPath();
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
- createMonitor(input) {
118
- const normalized = normalizeCreateMonitor(input);
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 tcp");
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
  };