@hasna/uptime 0.1.2 → 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/CHANGELOG.md +24 -0
- package/README.md +40 -1
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +1788 -106
- package/dist/checks.d.ts +23 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +131 -2
- package/dist/cli/index.js +2018 -114
- package/dist/dashboard.js +1 -1
- package/dist/imports.d.ts +90 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +556 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1798 -106
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +1646 -48
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- package/dist/probes.d.ts +13 -0
- package/dist/probes.d.ts.map +1 -0
- package/dist/probes.js +62 -0
- package/dist/report.js +1 -1
- package/dist/service.d.ts +112 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1567 -46
- package/dist/store.d.ts +109 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +663 -18
- package/dist/target-policy.d.ts +7 -0
- package/dist/target-policy.d.ts.map +1 -0
- package/dist/types.d.ts +97 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/store.js
CHANGED
|
@@ -9,6 +9,9 @@ function uptimeHome() {
|
|
|
9
9
|
function uptimeDbPath() {
|
|
10
10
|
return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
|
|
11
11
|
}
|
|
12
|
+
function uptimeHostedFallbackDbPath() {
|
|
13
|
+
return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
|
|
14
|
+
}
|
|
12
15
|
function ensureUptimeHome() {
|
|
13
16
|
const home = uptimeHome();
|
|
14
17
|
mkdirSync(home, { recursive: true });
|
|
@@ -16,11 +19,84 @@ function ensureUptimeHome() {
|
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
// src/store.ts
|
|
19
|
-
import { mkdirSync as mkdirSync2 } from "fs";
|
|
20
|
-
import { dirname } from "path";
|
|
22
|
+
import { copyFileSync, existsSync, mkdirSync as mkdirSync2, statSync } from "fs";
|
|
23
|
+
import { dirname, join as join2 } from "path";
|
|
21
24
|
import { randomUUID } from "crypto";
|
|
22
25
|
import { Database } from "bun:sqlite";
|
|
23
26
|
|
|
27
|
+
// src/target-policy.ts
|
|
28
|
+
import net from "net";
|
|
29
|
+
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
30
|
+
function assertHostedTargetAllowed(target) {
|
|
31
|
+
if (target.kind === "http" || target.kind === "browser_page") {
|
|
32
|
+
if (!target.url)
|
|
33
|
+
throw new Error("HTTP monitors require url");
|
|
34
|
+
assertHostedHttpUrlAllowed(target.url);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (target.kind === "tcp") {
|
|
38
|
+
if (!target.host)
|
|
39
|
+
throw new Error("TCP monitors require host");
|
|
40
|
+
assertHostedHostAllowed(target.host, "TCP host");
|
|
41
|
+
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
42
|
+
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
47
|
+
}
|
|
48
|
+
function assertHostedHttpUrlAllowed(value) {
|
|
49
|
+
const parsed = new URL(value);
|
|
50
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
51
|
+
throw new Error("HTTP monitor url must use http or https");
|
|
52
|
+
}
|
|
53
|
+
if (parsed.username || parsed.password) {
|
|
54
|
+
throw new Error("hosted target URLs must not contain userinfo");
|
|
55
|
+
}
|
|
56
|
+
for (const key of parsed.searchParams.keys()) {
|
|
57
|
+
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
58
|
+
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
62
|
+
throw new Error("hosted target URL fragment contains secret-like data");
|
|
63
|
+
}
|
|
64
|
+
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
65
|
+
}
|
|
66
|
+
function assertHostedHostAllowed(hostname, label = "host") {
|
|
67
|
+
const host = normalizeHost(hostname);
|
|
68
|
+
if (!host)
|
|
69
|
+
throw new Error(`${label} is required`);
|
|
70
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
71
|
+
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
72
|
+
}
|
|
73
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
74
|
+
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
75
|
+
}
|
|
76
|
+
const ipVersion = net.isIP(host);
|
|
77
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
78
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
79
|
+
}
|
|
80
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
81
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function normalizeHost(hostname) {
|
|
85
|
+
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
86
|
+
}
|
|
87
|
+
function isDeniedIpv4(ip) {
|
|
88
|
+
const parts = ip.split(".").map((part) => Number(part));
|
|
89
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
const [a, b] = parts;
|
|
93
|
+
return a === 0 || a === 10 || a === 127 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168 || a >= 224;
|
|
94
|
+
}
|
|
95
|
+
function isDeniedIpv6(ip) {
|
|
96
|
+
const normalized = ip.toLowerCase();
|
|
97
|
+
return normalized === "::" || normalized === "::1" || normalized.startsWith("fe80:") || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("ff") || normalized.startsWith("::ffff:127.") || normalized.startsWith("::ffff:10.") || normalized.startsWith("::ffff:169.254.") || /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized) || normalized.startsWith("::ffff:192.168.");
|
|
98
|
+
}
|
|
99
|
+
|
|
24
100
|
// src/limits.ts
|
|
25
101
|
var MIN_INTERVAL_SECONDS = 1;
|
|
26
102
|
var MAX_INTERVAL_SECONDS = 86400;
|
|
@@ -31,6 +107,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.
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|
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
|
};
|