@hasna/uptime 0.1.1 → 0.1.3

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,11 @@ 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 = ["schema_migrations", "monitors", "check_results", "incidents", "check_leases", "monitor_provenance", "import_batches", "probe_identities", "probe_check_jobs", "probe_submissions"];
112
+ var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
113
+ var CURRENT_SCHEMA_VERSION = "2";
114
+
34
115
  class StaleCheckResultError extends Error {
35
116
  constructor(message) {
36
117
  super(message);
@@ -40,9 +121,20 @@ class StaleCheckResultError extends Error {
40
121
 
41
122
  class UptimeStore {
42
123
  dbPath;
124
+ mode;
125
+ dataMode;
43
126
  db;
44
127
  constructor(options = {}) {
45
- this.dbPath = options.dbPath ?? uptimeDbPath();
128
+ this.mode = resolveRuntimeMode(options.mode ?? "local");
129
+ const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
130
+ if (this.mode === "hosted" && cloudDatabaseUrl) {
131
+ throw new Error("hosted cloud database adapter is not implemented yet");
132
+ }
133
+ if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
134
+ throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
135
+ }
136
+ this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
137
+ this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
46
138
  if (this.dbPath !== ":memory:") {
47
139
  mkdirSync2(dirname(this.dbPath), { recursive: true });
48
140
  }
@@ -59,7 +151,7 @@ class UptimeStore {
59
151
  CREATE TABLE IF NOT EXISTS monitors (
60
152
  id TEXT PRIMARY KEY,
61
153
  name TEXT NOT NULL UNIQUE,
62
- kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
154
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
63
155
  url TEXT,
64
156
  host TEXT,
65
157
  port INTEGER,
@@ -77,6 +169,7 @@ class UptimeStore {
77
169
  )
78
170
  `);
79
171
  this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
172
+ this.ensureMonitorKindAllowsBrowserPage();
80
173
  this.db.run(`
81
174
  CREATE TABLE IF NOT EXISTS check_results (
82
175
  id TEXT PRIMARY KEY,
@@ -86,9 +179,11 @@ class UptimeStore {
86
179
  latency_ms REAL,
87
180
  status_code INTEGER,
88
181
  error TEXT,
89
- attempt_count INTEGER NOT NULL DEFAULT 1
182
+ attempt_count INTEGER NOT NULL DEFAULT 1,
183
+ evidence_json TEXT
90
184
  )
91
185
  `);
186
+ this.ensureColumn("check_results", "evidence_json", "TEXT");
92
187
  this.db.run(`
93
188
  CREATE TABLE IF NOT EXISTS incidents (
94
189
  id TEXT PRIMARY KEY,
@@ -102,6 +197,71 @@ class UptimeStore {
102
197
  reason TEXT
103
198
  )
104
199
  `);
200
+ this.db.run(`
201
+ CREATE TABLE IF NOT EXISTS monitor_provenance (
202
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
203
+ source TEXT NOT NULL,
204
+ source_id TEXT NOT NULL,
205
+ source_label TEXT,
206
+ imported_at TEXT NOT NULL,
207
+ snapshot_json TEXT NOT NULL,
208
+ PRIMARY KEY (source, source_id)
209
+ )
210
+ `);
211
+ this.db.run(`
212
+ CREATE TABLE IF NOT EXISTS import_batches (
213
+ id TEXT PRIMARY KEY,
214
+ source TEXT NOT NULL,
215
+ status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
216
+ created_at TEXT NOT NULL,
217
+ rolled_back_at TEXT,
218
+ records_json TEXT NOT NULL
219
+ )
220
+ `);
221
+ this.db.run(`
222
+ CREATE TABLE IF NOT EXISTS probe_identities (
223
+ id TEXT PRIMARY KEY,
224
+ name TEXT NOT NULL UNIQUE,
225
+ public_key_pem TEXT NOT NULL,
226
+ public_key_fingerprint TEXT NOT NULL UNIQUE,
227
+ enabled INTEGER NOT NULL DEFAULT 1,
228
+ created_at TEXT NOT NULL,
229
+ last_seen_at TEXT
230
+ )
231
+ `);
232
+ this.db.run(`
233
+ CREATE TABLE IF NOT EXISTS probe_submissions (
234
+ id TEXT PRIMARY KEY,
235
+ probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
236
+ job_id TEXT NOT NULL,
237
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
238
+ check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
239
+ nonce TEXT NOT NULL,
240
+ checked_at TEXT NOT NULL,
241
+ submitted_at TEXT NOT NULL,
242
+ UNIQUE (probe_id, nonce)
243
+ )
244
+ `);
245
+ this.ensureColumn("probe_submissions", "job_id", "TEXT");
246
+ this.db.run(`
247
+ CREATE TABLE IF NOT EXISTS probe_check_jobs (
248
+ id TEXT PRIMARY KEY,
249
+ monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
250
+ monitor_revision INTEGER NOT NULL DEFAULT 1,
251
+ schedule_slot TEXT NOT NULL,
252
+ status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
253
+ claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
254
+ fencing_token TEXT,
255
+ due_at TEXT NOT NULL,
256
+ claimed_at TEXT,
257
+ lease_expires_at TEXT,
258
+ submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
259
+ created_at TEXT NOT NULL,
260
+ updated_at TEXT NOT NULL,
261
+ UNIQUE (monitor_id, schedule_slot)
262
+ )
263
+ `);
264
+ this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
105
265
  this.db.run(`
106
266
  CREATE TABLE IF NOT EXISTS check_leases (
107
267
  monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
@@ -110,12 +270,71 @@ class UptimeStore {
110
270
  acquired_at TEXT NOT NULL
111
271
  )
112
272
  `);
273
+ this.db.run(`
274
+ CREATE TABLE IF NOT EXISTS schema_migrations (
275
+ key TEXT PRIMARY KEY,
276
+ value TEXT NOT NULL,
277
+ updated_at TEXT NOT NULL
278
+ )
279
+ `);
280
+ this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
113
281
  this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
114
282
  this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
115
283
  this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
284
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
285
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
286
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
287
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
288
+ this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
289
+ 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 != ''");
116
290
  }
117
- createMonitor(input) {
118
- const normalized = normalizeCreateMonitor(input);
291
+ backup(destinationPath) {
292
+ if (this.dbPath === ":memory:" && !destinationPath) {
293
+ throw new Error("backup path is required for in-memory stores");
294
+ }
295
+ const createdAt = new Date().toISOString();
296
+ const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
297
+ mkdirSync2(dirname(backupPath), { recursive: true });
298
+ if (this.dbPath === ":memory:") {
299
+ this.vacuumInto(backupPath);
300
+ } else {
301
+ this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
302
+ copyFileSync(this.dbPath, backupPath);
303
+ }
304
+ const bytes = statSync(backupPath).size;
305
+ return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
306
+ }
307
+ verifyBackup(backupPath) {
308
+ return verifyBackupFile(backupPath);
309
+ }
310
+ static verifyBackup(backupPath) {
311
+ return verifyBackupFile(backupPath);
312
+ }
313
+ static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
314
+ const check = verifyBackupFile(backupPath);
315
+ if (!check.ok)
316
+ throw new Error(`backup integrity check failed: ${check.integrity}`);
317
+ if (destinationPath === ":memory:")
318
+ throw new Error("cannot restore a backup to an in-memory store");
319
+ if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
320
+ throw new Error("restore destination already exists or has SQLite sidecar files");
321
+ }
322
+ mkdirSync2(dirname(destinationPath), { recursive: true });
323
+ copyFileSync(backupPath, destinationPath);
324
+ const bytes = statSync(destinationPath).size;
325
+ return {
326
+ sourcePath: backupPath,
327
+ backupPath: destinationPath,
328
+ bytes,
329
+ createdAt: new Date().toISOString()
330
+ };
331
+ }
332
+ createMonitor(input, options = {}) {
333
+ if (this.mode === "hosted")
334
+ assertHostedTargetAllowed(input);
335
+ const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
336
+ if (this.mode === "hosted")
337
+ assertHostedTargetAllowed(normalized);
119
338
  const now = new Date().toISOString();
120
339
  const monitor = {
121
340
  id: newId("mon"),
@@ -151,12 +370,22 @@ class UptimeStore {
151
370
  const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
152
371
  return row ? monitorFromRow(row) : null;
153
372
  }
154
- updateMonitor(idOrName, input) {
373
+ updateMonitor(idOrName, input, options = {}) {
155
374
  const current = this.getMonitor(idOrName);
156
375
  if (!current)
157
376
  throw new Error(`Monitor not found: ${idOrName}`);
377
+ if (this.mode === "hosted") {
378
+ assertHostedTargetAllowed({
379
+ kind: input.kind ?? current.kind,
380
+ url: input.url ?? current.url ?? undefined,
381
+ host: input.host ?? current.host ?? undefined,
382
+ port: input.port ?? current.port ?? undefined
383
+ });
384
+ }
158
385
  const updatedAt = new Date().toISOString();
159
- const next = normalizeUpdateMonitor(current, input, updatedAt);
386
+ const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
387
+ if (this.mode === "hosted")
388
+ assertHostedTargetAllowed(next);
160
389
  this.db.query(`UPDATE monitors SET
161
390
  name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
162
391
  expected_status = ?, interval_seconds = ?, timeout_ms = ?,
@@ -175,6 +404,185 @@ class UptimeStore {
175
404
  this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
176
405
  return true;
177
406
  }
407
+ createProbeIdentity(input) {
408
+ const name = input.name.trim();
409
+ if (!name)
410
+ throw new Error("Probe name is required");
411
+ rejectControlCharacters(name, "Probe name");
412
+ const now = new Date().toISOString();
413
+ const probe = {
414
+ id: newId("prb"),
415
+ name,
416
+ publicKeyPem: input.publicKeyPem.trim(),
417
+ publicKeyFingerprint: input.publicKeyFingerprint,
418
+ enabled: input.enabled ?? true,
419
+ createdAt: now,
420
+ lastSeenAt: null
421
+ };
422
+ if (!probe.publicKeyPem)
423
+ throw new Error("Probe public key is required");
424
+ this.db.query(`INSERT INTO probe_identities (
425
+ id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
426
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
427
+ return probe;
428
+ }
429
+ listProbeIdentities(options = {}) {
430
+ 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();
431
+ return rows.map(probeIdentityFromRow);
432
+ }
433
+ getProbeIdentity(idOrName) {
434
+ const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
435
+ return row ? probeIdentityFromRow(row) : null;
436
+ }
437
+ updateProbeIdentity(idOrName, input) {
438
+ const current = this.getProbeIdentity(idOrName);
439
+ if (!current)
440
+ throw new Error(`Probe not found: ${idOrName}`);
441
+ const name = input.name === undefined ? current.name : input.name.trim();
442
+ if (!name)
443
+ throw new Error("Probe name is required");
444
+ rejectControlCharacters(name, "Probe name");
445
+ const enabled = input.enabled ?? current.enabled;
446
+ this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
447
+ return this.getProbeIdentity(current.id);
448
+ }
449
+ touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
450
+ const probe = this.getProbeIdentity(idOrName);
451
+ if (!probe)
452
+ throw new Error(`Probe not found: ${idOrName}`);
453
+ this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
454
+ }
455
+ createProbeCheckJob(input) {
456
+ const monitor = this.getMonitor(input.monitorId);
457
+ if (!monitor)
458
+ throw new Error(`Monitor not found: ${input.monitorId}`);
459
+ if (!monitor.enabled)
460
+ throw new Error(`Monitor is disabled: ${monitor.name}`);
461
+ const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
462
+ const dueAt = input.dueAt ?? new Date().toISOString();
463
+ assertIsoTimestamp(dueAt, "Probe job dueAt");
464
+ const now = new Date().toISOString();
465
+ const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
466
+ if (existing)
467
+ return probeCheckJobFromRow(existing);
468
+ const job = {
469
+ id: newId("job"),
470
+ monitorId: monitor.id,
471
+ monitorRevision: monitor.revision,
472
+ scheduleSlot,
473
+ status: "pending",
474
+ claimedByProbeId: null,
475
+ fencingToken: null,
476
+ dueAt,
477
+ claimedAt: null,
478
+ leaseExpiresAt: null,
479
+ submittedResultId: null,
480
+ createdAt: now,
481
+ updatedAt: now
482
+ };
483
+ this.db.query(`INSERT INTO probe_check_jobs (
484
+ id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
485
+ due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
486
+ ) 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);
487
+ return job;
488
+ }
489
+ getProbeCheckJob(id) {
490
+ const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
491
+ return row ? probeCheckJobFromRow(row) : null;
492
+ }
493
+ claimProbeCheckJob(input) {
494
+ const tx = this.db.transaction(() => {
495
+ const probe = this.getProbeIdentity(input.probeId);
496
+ if (!probe)
497
+ throw new Error(`Probe not found: ${input.probeId}`);
498
+ if (!probe.enabled)
499
+ throw new Error(`Probe is disabled: ${probe.name}`);
500
+ const current = this.getProbeCheckJob(input.jobId);
501
+ if (!current)
502
+ throw new Error(`Probe job not found: ${input.jobId}`);
503
+ const now = new Date;
504
+ const nowIso = now.toISOString();
505
+ if (current.status === "submitted")
506
+ throw new Error("Probe job already submitted");
507
+ if (current.status === "cancelled")
508
+ throw new Error("Probe job is cancelled");
509
+ if (current.dueAt > nowIso)
510
+ throw new Error("Probe job is not due yet");
511
+ const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
512
+ if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
513
+ throw new Error("Probe job already claimed by another probe");
514
+ }
515
+ if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
516
+ throw new Error(`Probe job is not claimable: ${current.status}`);
517
+ }
518
+ const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
519
+ const fencingToken = newId("fence");
520
+ const update = this.db.query(`UPDATE probe_check_jobs
521
+ SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
522
+ WHERE id = ?
523
+ AND submitted_result_id IS NULL
524
+ AND (
525
+ status IN ('pending', 'expired')
526
+ OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
527
+ )`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
528
+ if (statementChanges(update) !== 1)
529
+ throw new Error("Probe job claim raced; retry");
530
+ this.touchProbeIdentity(probe.id, nowIso);
531
+ return this.getProbeCheckJob(current.id);
532
+ });
533
+ return tx();
534
+ }
535
+ completeProbeCheckJob(input) {
536
+ const job = this.getProbeCheckJob(input.jobId);
537
+ if (!job)
538
+ throw new Error(`Probe job not found: ${input.jobId}`);
539
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
540
+ if (job.status !== "claimed")
541
+ throw new Error(`Probe job is not claimable for submission: ${job.status}`);
542
+ if (job.claimedByProbeId !== input.probeId)
543
+ throw new Error("Probe job was claimed by another probe");
544
+ if (job.fencingToken !== input.fencingToken)
545
+ throw new Error("Probe job fencing token is invalid");
546
+ if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
547
+ this.expireProbeCheckJob(job.id, submittedAt);
548
+ throw new Error("Probe job lease expired");
549
+ }
550
+ const update = this.db.query(`UPDATE probe_check_jobs
551
+ SET status = 'submitted', submitted_result_id = ?, updated_at = ?
552
+ WHERE id = ?
553
+ AND status = 'claimed'
554
+ AND claimed_by_probe_id = ?
555
+ AND fencing_token = ?
556
+ AND lease_expires_at > ?
557
+ AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
558
+ if (statementChanges(update) !== 1)
559
+ throw new Error("Probe job submission raced; retry");
560
+ return this.getProbeCheckJob(job.id);
561
+ }
562
+ expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
563
+ this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
564
+ }
565
+ getProbeSubmission(probeId, nonce) {
566
+ const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
567
+ return row ? probeSubmissionFromRow(row) : null;
568
+ }
569
+ recordProbeSubmission(input) {
570
+ const submittedAt = input.submittedAt ?? new Date().toISOString();
571
+ const receipt = {
572
+ id: newId("psb"),
573
+ probeId: input.probeId,
574
+ jobId: input.jobId,
575
+ monitorId: input.monitorId,
576
+ checkResultId: input.checkResultId,
577
+ nonce: input.nonce,
578
+ checkedAt: input.checkedAt,
579
+ submittedAt
580
+ };
581
+ this.db.query(`INSERT INTO probe_submissions (
582
+ id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
583
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
584
+ return receipt;
585
+ }
178
586
  acquireCheckLease(monitorId, owner, ttlMs) {
179
587
  const now = new Date;
180
588
  const nowIso = now.toISOString();
@@ -209,7 +617,8 @@ class UptimeStore {
209
617
  latencyMs: input.latencyMs,
210
618
  statusCode: input.statusCode,
211
619
  error: input.error,
212
- attemptCount: Math.max(1, input.attemptCount)
620
+ attemptCount: Math.max(1, input.attemptCount),
621
+ evidence: input.evidence ?? null
213
622
  };
214
623
  const tx = this.db.transaction(() => {
215
624
  const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
@@ -222,19 +631,59 @@ class UptimeStore {
222
631
  throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
223
632
  }
224
633
  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);
634
+ id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
635
+ ) 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
636
  this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
228
637
  this.reconcileIncidentInTransaction(result);
229
638
  });
230
639
  tx();
231
640
  return result;
232
641
  }
642
+ getCheckResult(id) {
643
+ const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
644
+ return row ? checkResultFromRow(row) : null;
645
+ }
233
646
  listResults(options = {}) {
234
647
  const limit = clampLimit(options.limit ?? 50);
235
648
  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
649
  return rows.map(checkResultFromRow);
237
650
  }
651
+ getProvenance(source, sourceId) {
652
+ const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
653
+ return row ? provenanceFromRow(row) : null;
654
+ }
655
+ upsertMonitorProvenance(input) {
656
+ const importedAt = new Date().toISOString();
657
+ this.db.query(`INSERT INTO monitor_provenance (
658
+ monitor_id, source, source_id, source_label, imported_at, snapshot_json
659
+ ) VALUES (?, ?, ?, ?, ?, ?)
660
+ ON CONFLICT(source, source_id) DO UPDATE SET
661
+ monitor_id = excluded.monitor_id,
662
+ source_label = excluded.source_label,
663
+ imported_at = excluded.imported_at,
664
+ snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
665
+ return this.getProvenance(input.source, input.sourceId);
666
+ }
667
+ saveImportBatch(input) {
668
+ const createdAt = new Date().toISOString();
669
+ 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));
670
+ return this.getImportBatch(input.id);
671
+ }
672
+ getImportBatch(batchId) {
673
+ const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
674
+ return row ? importBatchFromRow(row) : null;
675
+ }
676
+ markImportBatchRolledBack(batchId) {
677
+ const rolledBackAt = new Date().toISOString();
678
+ this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
679
+ const batch = this.getImportBatch(batchId);
680
+ if (!batch)
681
+ throw new Error(`Import batch not found: ${batchId}`);
682
+ return batch;
683
+ }
684
+ runInTransaction(fn) {
685
+ return this.db.transaction(fn)();
686
+ }
238
687
  listIncidents(options = {}) {
239
688
  const clauses = [];
240
689
  const args = [];
@@ -322,8 +771,104 @@ class UptimeStore {
322
771
  this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
323
772
  }
324
773
  }
774
+ ensureMonitorKindAllowsBrowserPage() {
775
+ const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
776
+ if (!row?.sql || row.sql.includes("browser_page"))
777
+ return;
778
+ this.db.run("PRAGMA foreign_keys = OFF");
779
+ this.db.run("PRAGMA legacy_alter_table = ON");
780
+ try {
781
+ const migrate = this.db.transaction(() => {
782
+ this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
783
+ this.db.run(`
784
+ CREATE TABLE monitors (
785
+ id TEXT PRIMARY KEY,
786
+ name TEXT NOT NULL UNIQUE,
787
+ kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
788
+ url TEXT,
789
+ host TEXT,
790
+ port INTEGER,
791
+ method TEXT NOT NULL DEFAULT 'GET',
792
+ expected_status INTEGER,
793
+ interval_seconds INTEGER NOT NULL DEFAULT 60,
794
+ timeout_ms INTEGER NOT NULL DEFAULT 5000,
795
+ retry_count INTEGER NOT NULL DEFAULT 0,
796
+ enabled INTEGER NOT NULL DEFAULT 1,
797
+ status TEXT NOT NULL DEFAULT 'unknown',
798
+ last_checked_at TEXT,
799
+ revision INTEGER NOT NULL DEFAULT 1,
800
+ created_at TEXT NOT NULL,
801
+ updated_at TEXT NOT NULL
802
+ )
803
+ `);
804
+ this.db.run(`
805
+ INSERT INTO monitors (
806
+ id, name, kind, url, host, port, method, expected_status,
807
+ interval_seconds, timeout_ms, retry_count, enabled, status,
808
+ last_checked_at, revision, created_at, updated_at
809
+ )
810
+ SELECT
811
+ id, name, kind, url, host, port, method, expected_status,
812
+ interval_seconds, timeout_ms, retry_count, enabled, status,
813
+ last_checked_at, revision, created_at, updated_at
814
+ FROM monitors_old_kind
815
+ `);
816
+ this.db.run("DROP TABLE monitors_old_kind");
817
+ });
818
+ migrate();
819
+ } finally {
820
+ this.db.run("PRAGMA legacy_alter_table = OFF");
821
+ this.db.run("PRAGMA foreign_keys = ON");
822
+ }
823
+ }
824
+ vacuumInto(backupPath) {
825
+ const quoted = backupPath.replace(/'/g, "''");
826
+ this.db.run(`VACUUM INTO '${quoted}'`);
827
+ }
828
+ }
829
+ function resolveRuntimeMode(mode) {
830
+ const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
831
+ if (value === "local" || value === "hosted")
832
+ return value;
833
+ throw new Error("HASNA_UPTIME_MODE must be local or hosted");
834
+ }
835
+ function allowHostedLocalStore(value) {
836
+ return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
837
+ }
838
+ function verifyBackupFile(backupPath) {
839
+ const db = new Database(backupPath, { readonly: true });
840
+ try {
841
+ const integrityRow = db.query("PRAGMA integrity_check").get();
842
+ const integrity = String(integrityRow?.integrity_check ?? "unknown");
843
+ const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
844
+ const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
845
+ const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
846
+ const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table));
847
+ return {
848
+ ok: integrity === "ok" && (currentOk || restorableV1),
849
+ backupPath,
850
+ integrity,
851
+ schemaVersion,
852
+ missingTables,
853
+ monitors: tableCount(db, "monitors"),
854
+ results: tableCount(db, "check_results"),
855
+ incidents: tableCount(db, "incidents")
856
+ };
857
+ } finally {
858
+ db.close();
859
+ }
860
+ }
861
+ function tableCount(db, table) {
862
+ if (!tableExists(db, table))
863
+ return 0;
864
+ const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
865
+ return Number(row?.count ?? 0);
325
866
  }
326
- function normalizeCreateMonitor(input) {
867
+ function tableExists(db, table) {
868
+ const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
869
+ return Number(row?.count ?? 0) > 0;
870
+ }
871
+ function normalizeCreateMonitor(input, allowBrowserPage = false) {
327
872
  const name = input.name?.trim();
328
873
  if (!name)
329
874
  throw new Error("Monitor name is required");
@@ -331,7 +876,10 @@ function normalizeCreateMonitor(input) {
331
876
  const method = normalizeMethod(input.method ?? "GET");
332
877
  const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
333
878
  const enabled = normalizeEnabled(input.enabled);
334
- if (input.kind === "http") {
879
+ if (input.kind === "http" || input.kind === "browser_page") {
880
+ if (input.kind === "browser_page" && !allowBrowserPage) {
881
+ throw new Error("browser_page monitors must be imported with explicit browser evidence support");
882
+ }
335
883
  const url = normalizeHttpUrl(input.url);
336
884
  return {
337
885
  name,
@@ -365,13 +913,13 @@ function normalizeCreateMonitor(input) {
365
913
  enabled
366
914
  };
367
915
  } else {
368
- throw new Error("Monitor kind must be http or tcp");
916
+ throw new Error("Monitor kind must be http, tcp, or browser_page");
369
917
  }
370
918
  }
371
919
  function definitionChanged(current, next) {
372
920
  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
921
  }
374
- function normalizeUpdateMonitor(current, input, updatedAt) {
922
+ function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
375
923
  const merged = {
376
924
  ...current,
377
925
  ...input,
@@ -390,7 +938,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
390
938
  timeoutMs: merged.timeoutMs,
391
939
  retryCount: merged.retryCount,
392
940
  enabled: merged.enabled
393
- });
941
+ }, allowBrowserPage || current.kind === "browser_page");
394
942
  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
943
  const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
396
944
  return {
@@ -419,6 +967,11 @@ function normalizeHttpUrl(value) {
419
967
  if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
420
968
  throw new Error("HTTP monitor url must use http or https");
421
969
  }
970
+ for (const key of [...parsed.searchParams.keys()]) {
971
+ if (SECRET_URL_PARAM_PATTERN.test(key))
972
+ parsed.searchParams.set(key, "[redacted]");
973
+ }
974
+ parsed.hash = "";
422
975
  return parsed.toString();
423
976
  }
424
977
  function normalizeMethod(value) {
@@ -447,6 +1000,20 @@ function rejectControlCharacters(value, label) {
447
1000
  throw new Error(`${label} must not contain control characters`);
448
1001
  }
449
1002
  }
1003
+ function normalizeScheduleSlot(value) {
1004
+ const slot = value.trim();
1005
+ if (!slot)
1006
+ throw new Error("Probe job scheduleSlot is required");
1007
+ if (slot.length > 128)
1008
+ throw new Error("Probe job scheduleSlot is too long");
1009
+ rejectControlCharacters(slot, "Probe job scheduleSlot");
1010
+ return slot;
1011
+ }
1012
+ function assertIsoTimestamp(value, label) {
1013
+ if (!Number.isFinite(Date.parse(value))) {
1014
+ throw new Error(`${label} must be an ISO timestamp`);
1015
+ }
1016
+ }
450
1017
  function monitorFromRow(row) {
451
1018
  return {
452
1019
  id: row.id,
@@ -477,9 +1044,83 @@ function checkResultFromRow(row) {
477
1044
  latencyMs: row.latency_ms,
478
1045
  statusCode: row.status_code,
479
1046
  error: row.error,
480
- attemptCount: row.attempt_count
1047
+ attemptCount: row.attempt_count,
1048
+ evidence: parseEvidence(row.evidence_json)
1049
+ };
1050
+ }
1051
+ function provenanceFromRow(row) {
1052
+ return {
1053
+ monitorId: row.monitor_id,
1054
+ source: row.source,
1055
+ sourceId: row.source_id,
1056
+ sourceLabel: row.source_label,
1057
+ importedAt: row.imported_at,
1058
+ snapshot: parseJson(row.snapshot_json)
1059
+ };
1060
+ }
1061
+ function importBatchFromRow(row) {
1062
+ return {
1063
+ id: row.id,
1064
+ source: row.source,
1065
+ status: row.status,
1066
+ createdAt: row.created_at,
1067
+ rolledBackAt: row.rolled_back_at,
1068
+ records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
1069
+ };
1070
+ }
1071
+ function probeIdentityFromRow(row) {
1072
+ return {
1073
+ id: row.id,
1074
+ name: row.name,
1075
+ publicKeyPem: row.public_key_pem,
1076
+ publicKeyFingerprint: row.public_key_fingerprint,
1077
+ enabled: Boolean(row.enabled),
1078
+ createdAt: row.created_at,
1079
+ lastSeenAt: row.last_seen_at
481
1080
  };
482
1081
  }
1082
+ function probeSubmissionFromRow(row) {
1083
+ return {
1084
+ id: row.id,
1085
+ probeId: row.probe_id,
1086
+ jobId: row.job_id ?? "",
1087
+ monitorId: row.monitor_id,
1088
+ checkResultId: row.check_result_id,
1089
+ nonce: row.nonce,
1090
+ checkedAt: row.checked_at,
1091
+ submittedAt: row.submitted_at
1092
+ };
1093
+ }
1094
+ function probeCheckJobFromRow(row) {
1095
+ return {
1096
+ id: row.id,
1097
+ monitorId: row.monitor_id,
1098
+ monitorRevision: row.monitor_revision ?? 1,
1099
+ scheduleSlot: row.schedule_slot,
1100
+ status: row.status,
1101
+ claimedByProbeId: row.claimed_by_probe_id,
1102
+ fencingToken: row.fencing_token,
1103
+ dueAt: row.due_at,
1104
+ claimedAt: row.claimed_at,
1105
+ leaseExpiresAt: row.lease_expires_at,
1106
+ submittedResultId: row.submitted_result_id,
1107
+ createdAt: row.created_at,
1108
+ updatedAt: row.updated_at
1109
+ };
1110
+ }
1111
+ function parseEvidence(value) {
1112
+ if (!value)
1113
+ return null;
1114
+ const parsed = parseJson(value);
1115
+ return parsed && typeof parsed === "object" ? parsed : null;
1116
+ }
1117
+ function parseJson(value) {
1118
+ try {
1119
+ return JSON.parse(value);
1120
+ } catch {
1121
+ return null;
1122
+ }
1123
+ }
483
1124
  function incidentFromRow(row) {
484
1125
  return {
485
1126
  id: row.id,
@@ -507,11 +1148,15 @@ function clampLimit(value) {
507
1148
  return 50;
508
1149
  return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
509
1150
  }
1151
+ function statementChanges(result) {
1152
+ return Number(result?.changes ?? 0);
1153
+ }
510
1154
  function round(value, places) {
511
1155
  const factor = 10 ** places;
512
1156
  return Math.round(value * factor) / factor;
513
1157
  }
514
1158
  export {
1159
+ resolveRuntimeMode,
515
1160
  UptimeStore,
516
1161
  StaleCheckResultError
517
1162
  };