@hasna/uptime 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +72 -1
- package/dist/api.d.ts +12 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +2427 -123
- package/dist/checks.d.ts +23 -1
- package/dist/checks.d.ts.map +1 -1
- package/dist/checks.js +131 -2
- package/dist/cli/index.js +2772 -115
- package/dist/dashboard.js +1 -1
- package/dist/imports.d.ts +90 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +556 -0
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2437 -123
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +2307 -49
- package/dist/paths.d.ts +1 -0
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +4 -0
- package/dist/probes.d.ts +13 -0
- package/dist/probes.d.ts.map +1 -0
- package/dist/probes.js +62 -0
- package/dist/report.d.ts +2 -7
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +1 -1
- package/dist/service.d.ts +152 -4
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +2154 -60
- package/dist/store.d.ts +130 -3
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1086 -18
- package/dist/target-policy.d.ts +7 -0
- package/dist/target-policy.d.ts.map +1 -0
- package/dist/types.d.ts +189 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +6 -2
package/dist/mcp/index.js
CHANGED
|
@@ -14303,7 +14303,11 @@ async function runMonitorCheck(monitor, options = {}) {
|
|
|
14303
14303
|
}
|
|
14304
14304
|
if (monitor.kind === "http")
|
|
14305
14305
|
return runHttpCheck(monitor, options.fetch ?? fetch);
|
|
14306
|
-
|
|
14306
|
+
if (monitor.kind === "browser_page")
|
|
14307
|
+
return runBrowserPageCheck(monitor, { fetch: options.fetch, runner: options.browserPage });
|
|
14308
|
+
if (monitor.kind === "tcp")
|
|
14309
|
+
return runTcpCheck(monitor);
|
|
14310
|
+
return { status: "down", latencyMs: null, error: `unsupported monitor kind: ${monitor.kind ?? "unknown"}` };
|
|
14307
14311
|
}
|
|
14308
14312
|
async function runHttpCheck(monitor, fetchImpl = fetch) {
|
|
14309
14313
|
if (!monitor.url)
|
|
@@ -14361,14 +14365,741 @@ async function runTcpCheck(monitor) {
|
|
|
14361
14365
|
});
|
|
14362
14366
|
});
|
|
14363
14367
|
}
|
|
14368
|
+
async function runBrowserPageCheck(monitor, options = {}) {
|
|
14369
|
+
if (!monitor.url)
|
|
14370
|
+
return { status: "down", latencyMs: null, error: "missing url" };
|
|
14371
|
+
validateBrowserPageUrl(monitor.url);
|
|
14372
|
+
if (!options.runner) {
|
|
14373
|
+
const evidence = normalizeBrowserEvidence(monitor.url, {
|
|
14374
|
+
finalUrl: monitor.url,
|
|
14375
|
+
navigationStatus: null,
|
|
14376
|
+
pageErrors: ["browser_page checks require a configured browser runner"]
|
|
14377
|
+
});
|
|
14378
|
+
return {
|
|
14379
|
+
status: "down",
|
|
14380
|
+
latencyMs: null,
|
|
14381
|
+
statusCode: null,
|
|
14382
|
+
error: "browser_page checks require a configured browser runner",
|
|
14383
|
+
evidence
|
|
14384
|
+
};
|
|
14385
|
+
}
|
|
14386
|
+
const started = performance.now();
|
|
14387
|
+
try {
|
|
14388
|
+
const raw = await options.runner(monitor);
|
|
14389
|
+
const latencyMs = raw.latencyMs ?? Math.round((performance.now() - started) * 100) / 100;
|
|
14390
|
+
const evidence = normalizeBrowserEvidence(monitor.url, raw);
|
|
14391
|
+
const statusCode = raw.navigationStatus ?? evidence.navigationStatus;
|
|
14392
|
+
const statusOk = statusCode == null ? false : monitor.expectedStatus == null ? statusCode >= 200 && statusCode < 400 : statusCode === monitor.expectedStatus;
|
|
14393
|
+
const browserFailures = evidence.consoleErrors.length + evidence.pageErrors.length + evidence.failedRequests.length;
|
|
14394
|
+
return {
|
|
14395
|
+
status: statusOk && browserFailures === 0 ? "up" : "down",
|
|
14396
|
+
latencyMs,
|
|
14397
|
+
statusCode,
|
|
14398
|
+
error: statusOk ? browserFailures === 0 ? null : `browser page captured ${browserFailures} error signal${browserFailures === 1 ? "" : "s"}` : `unexpected navigation status ${statusCode ?? "unknown"}`,
|
|
14399
|
+
evidence
|
|
14400
|
+
};
|
|
14401
|
+
} catch (error51) {
|
|
14402
|
+
const safeError = redactText(error51 instanceof Error ? error51.message : String(error51));
|
|
14403
|
+
const evidence = normalizeBrowserEvidence(monitor.url, {
|
|
14404
|
+
finalUrl: monitor.url,
|
|
14405
|
+
navigationStatus: null,
|
|
14406
|
+
pageErrors: [safeError]
|
|
14407
|
+
});
|
|
14408
|
+
return {
|
|
14409
|
+
status: "down",
|
|
14410
|
+
latencyMs: Math.round((performance.now() - started) * 100) / 100,
|
|
14411
|
+
statusCode: null,
|
|
14412
|
+
error: safeError,
|
|
14413
|
+
evidence
|
|
14414
|
+
};
|
|
14415
|
+
}
|
|
14416
|
+
}
|
|
14417
|
+
function normalizeBrowserEvidence(sourceUrl, raw) {
|
|
14418
|
+
return {
|
|
14419
|
+
kind: "browser_page",
|
|
14420
|
+
finalUrl: raw.finalUrl ? redactUrl(raw.finalUrl) : redactUrl(sourceUrl),
|
|
14421
|
+
navigationStatus: raw.navigationStatus ?? null,
|
|
14422
|
+
consoleErrors: sanitizeStrings(raw.consoleErrors ?? []),
|
|
14423
|
+
pageErrors: sanitizeStrings(raw.pageErrors ?? []),
|
|
14424
|
+
failedRequests: (raw.failedRequests ?? []).slice(0, 50).map((request) => ({
|
|
14425
|
+
url: redactUrl(request.url),
|
|
14426
|
+
statusCode: request.statusCode ?? null,
|
|
14427
|
+
error: request.error ? redactText(request.error) : null
|
|
14428
|
+
})),
|
|
14429
|
+
screenshot: raw.screenshot ? sanitizeArtifact(raw.screenshot) : null,
|
|
14430
|
+
artifacts: (raw.artifacts ?? []).slice(0, 20).map(sanitizeArtifact),
|
|
14431
|
+
redacted: true,
|
|
14432
|
+
redactionStatus: "redacted",
|
|
14433
|
+
retentionClass: "short"
|
|
14434
|
+
};
|
|
14435
|
+
}
|
|
14436
|
+
function validateBrowserPageUrl(value) {
|
|
14437
|
+
const parsed = new URL(value);
|
|
14438
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14439
|
+
throw new Error("browser_page monitors require an http or https URL");
|
|
14440
|
+
}
|
|
14441
|
+
if (parsed.username || parsed.password) {
|
|
14442
|
+
throw new Error("browser_page URLs must not contain userinfo");
|
|
14443
|
+
}
|
|
14444
|
+
}
|
|
14445
|
+
function sanitizeStrings(values) {
|
|
14446
|
+
return values.slice(0, 50).map(redactText).filter(Boolean);
|
|
14447
|
+
}
|
|
14448
|
+
function sanitizeArtifact(artifact) {
|
|
14449
|
+
const ref = artifact.ref.trim();
|
|
14450
|
+
if (artifact.path || ref.startsWith("/") || ref.toLowerCase().startsWith("file:")) {
|
|
14451
|
+
throw new Error("browser evidence artifacts must use redacted artifact refs, not local paths");
|
|
14452
|
+
}
|
|
14453
|
+
if (!artifact.sha256 || !/^[a-f0-9]{64}$/i.test(artifact.sha256)) {
|
|
14454
|
+
throw new Error("browser evidence artifacts require a sha256 checksum");
|
|
14455
|
+
}
|
|
14456
|
+
const bytes = artifact.bytes;
|
|
14457
|
+
if (!Number.isInteger(bytes) || bytes == null || bytes < 0) {
|
|
14458
|
+
throw new Error("browser evidence artifacts require a byte size");
|
|
14459
|
+
}
|
|
14460
|
+
return {
|
|
14461
|
+
ref: redactText(ref),
|
|
14462
|
+
sha256: artifact.sha256,
|
|
14463
|
+
bytes,
|
|
14464
|
+
contentType: redactText(artifact.contentType ?? "application/octet-stream") || "application/octet-stream",
|
|
14465
|
+
retentionClass: "short"
|
|
14466
|
+
};
|
|
14467
|
+
}
|
|
14468
|
+
function redactUrl(value) {
|
|
14469
|
+
try {
|
|
14470
|
+
const parsed = new URL(value);
|
|
14471
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14472
|
+
return "[blocked-url]";
|
|
14473
|
+
}
|
|
14474
|
+
parsed.username = "";
|
|
14475
|
+
parsed.password = "";
|
|
14476
|
+
parsed.hash = "";
|
|
14477
|
+
for (const key of parsed.searchParams.keys()) {
|
|
14478
|
+
if (isSecretKey(key))
|
|
14479
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
14480
|
+
}
|
|
14481
|
+
return parsed.toString();
|
|
14482
|
+
} catch {
|
|
14483
|
+
return redactText(value);
|
|
14484
|
+
}
|
|
14485
|
+
}
|
|
14486
|
+
function redactText(value) {
|
|
14487
|
+
return value.replace(/\/(?:home|Users)\/[^\s"'<>]+/g, "[local-path]").replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/((?:token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)[=:]\s*)[^\s&]+/gi, "$1[redacted]");
|
|
14488
|
+
}
|
|
14489
|
+
function isSecretKey(value) {
|
|
14490
|
+
return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
|
|
14491
|
+
}
|
|
14364
14492
|
|
|
14365
14493
|
// src/service.ts
|
|
14366
|
-
import { randomUUID as
|
|
14494
|
+
import { createPublicKey, randomUUID as randomUUID3 } from "crypto";
|
|
14367
14495
|
|
|
14368
|
-
// src/
|
|
14369
|
-
import { mkdirSync } from "fs";
|
|
14370
|
-
import { dirname } from "path";
|
|
14496
|
+
// src/imports.ts
|
|
14371
14497
|
import { randomUUID } from "crypto";
|
|
14498
|
+
|
|
14499
|
+
// src/limits.ts
|
|
14500
|
+
var MIN_INTERVAL_SECONDS = 1;
|
|
14501
|
+
var MAX_INTERVAL_SECONDS = 86400;
|
|
14502
|
+
var MIN_TIMEOUT_MS = 1;
|
|
14503
|
+
var MAX_TIMEOUT_MS = 60000;
|
|
14504
|
+
var MIN_RETRY_COUNT = 0;
|
|
14505
|
+
var MAX_RETRY_COUNT = 10;
|
|
14506
|
+
var MAX_RESULT_LIMIT = 1000;
|
|
14507
|
+
|
|
14508
|
+
// src/target-policy.ts
|
|
14509
|
+
import net2 from "net";
|
|
14510
|
+
var SECRET_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
14511
|
+
function assertHostedTargetAllowed(target) {
|
|
14512
|
+
if (target.kind === "http" || target.kind === "browser_page") {
|
|
14513
|
+
if (!target.url)
|
|
14514
|
+
throw new Error("HTTP monitors require url");
|
|
14515
|
+
assertHostedHttpUrlAllowed(target.url);
|
|
14516
|
+
return;
|
|
14517
|
+
}
|
|
14518
|
+
if (target.kind === "tcp") {
|
|
14519
|
+
if (!target.host)
|
|
14520
|
+
throw new Error("TCP monitors require host");
|
|
14521
|
+
assertHostedHostAllowed(target.host, "TCP host");
|
|
14522
|
+
if (!Number.isInteger(target.port) || target.port <= 0 || target.port > 65535) {
|
|
14523
|
+
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
14524
|
+
}
|
|
14525
|
+
return;
|
|
14526
|
+
}
|
|
14527
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
14528
|
+
}
|
|
14529
|
+
function assertHostedHttpUrlAllowed(value) {
|
|
14530
|
+
const parsed = new URL(value);
|
|
14531
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14532
|
+
throw new Error("HTTP monitor url must use http or https");
|
|
14533
|
+
}
|
|
14534
|
+
if (parsed.username || parsed.password) {
|
|
14535
|
+
throw new Error("hosted target URLs must not contain userinfo");
|
|
14536
|
+
}
|
|
14537
|
+
for (const key of parsed.searchParams.keys()) {
|
|
14538
|
+
if (SECRET_PARAM_PATTERN.test(key)) {
|
|
14539
|
+
throw new Error(`hosted target URL query parameter is not allowed: ${key}`);
|
|
14540
|
+
}
|
|
14541
|
+
}
|
|
14542
|
+
if (parsed.hash && SECRET_PARAM_PATTERN.test(parsed.hash)) {
|
|
14543
|
+
throw new Error("hosted target URL fragment contains secret-like data");
|
|
14544
|
+
}
|
|
14545
|
+
assertHostedHostAllowed(parsed.hostname, "HTTP host");
|
|
14546
|
+
}
|
|
14547
|
+
function assertHostedHostAllowed(hostname3, label = "host") {
|
|
14548
|
+
const host = normalizeHost(hostname3);
|
|
14549
|
+
if (!host)
|
|
14550
|
+
throw new Error(`${label} is required`);
|
|
14551
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
14552
|
+
throw new Error(`${label} is not allowed in hosted mode: localhost`);
|
|
14553
|
+
}
|
|
14554
|
+
if (host.endsWith(".local") || host.endsWith(".internal")) {
|
|
14555
|
+
throw new Error(`${label} is not allowed in hosted mode: private DNS name`);
|
|
14556
|
+
}
|
|
14557
|
+
const ipVersion = net2.isIP(host);
|
|
14558
|
+
if (ipVersion === 4 && isDeniedIpv4(host)) {
|
|
14559
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv4`);
|
|
14560
|
+
}
|
|
14561
|
+
if (ipVersion === 6 && isDeniedIpv6(host)) {
|
|
14562
|
+
throw new Error(`${label} is not allowed in hosted mode: private or reserved IPv6`);
|
|
14563
|
+
}
|
|
14564
|
+
}
|
|
14565
|
+
function normalizeHost(hostname3) {
|
|
14566
|
+
return hostname3.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
14567
|
+
}
|
|
14568
|
+
function isDeniedIpv4(ip) {
|
|
14569
|
+
const parts = ip.split(".").map((part) => Number(part));
|
|
14570
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
14571
|
+
return true;
|
|
14572
|
+
}
|
|
14573
|
+
const [a, b] = parts;
|
|
14574
|
+
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;
|
|
14575
|
+
}
|
|
14576
|
+
function isDeniedIpv6(ip) {
|
|
14577
|
+
const normalized = ip.toLowerCase();
|
|
14578
|
+
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.");
|
|
14579
|
+
}
|
|
14580
|
+
|
|
14581
|
+
// src/imports.ts
|
|
14582
|
+
function previewImport(store, request) {
|
|
14583
|
+
const source = normalizeSource(request.source);
|
|
14584
|
+
const items = dedupePreviewItems(request.records.map((record2) => previewRecord(store, source, record2, request.defaults ?? {})));
|
|
14585
|
+
return {
|
|
14586
|
+
source,
|
|
14587
|
+
generatedAt: new Date().toISOString(),
|
|
14588
|
+
dryRun: true,
|
|
14589
|
+
items,
|
|
14590
|
+
totals: countActions(items)
|
|
14591
|
+
};
|
|
14592
|
+
}
|
|
14593
|
+
function dedupePreviewItems(items) {
|
|
14594
|
+
const seenSources = new Set;
|
|
14595
|
+
const seenNames = new Set;
|
|
14596
|
+
return items.map((item) => {
|
|
14597
|
+
if (item.action === "blocked")
|
|
14598
|
+
return item;
|
|
14599
|
+
const sourceKey = `${item.candidate.source}:${item.candidate.sourceId}`;
|
|
14600
|
+
const nameKey = item.candidate.name.toLowerCase();
|
|
14601
|
+
if (seenSources.has(sourceKey) || seenNames.has(nameKey)) {
|
|
14602
|
+
return {
|
|
14603
|
+
...item,
|
|
14604
|
+
action: "conflict",
|
|
14605
|
+
monitor: item.monitor,
|
|
14606
|
+
warnings: [...item.warnings, "duplicate import candidate in request"],
|
|
14607
|
+
reason: "duplicate import candidate in request"
|
|
14608
|
+
};
|
|
14609
|
+
}
|
|
14610
|
+
seenSources.add(sourceKey);
|
|
14611
|
+
seenNames.add(nameKey);
|
|
14612
|
+
return item;
|
|
14613
|
+
});
|
|
14614
|
+
}
|
|
14615
|
+
function applyImport(store, request) {
|
|
14616
|
+
if (store.mode === "hosted") {
|
|
14617
|
+
throw new Error("hosted import apply requires cloud import_batches and audit");
|
|
14618
|
+
}
|
|
14619
|
+
const execute = () => {
|
|
14620
|
+
const preview = previewImport(store, request);
|
|
14621
|
+
const appliedAt = new Date().toISOString();
|
|
14622
|
+
const items = preview.items.map((item) => applyPreviewItem(store, item));
|
|
14623
|
+
const batchId = `imp_${randomUUID().replace(/-/g, "").slice(0, 18)}`;
|
|
14624
|
+
store.saveImportBatch({
|
|
14625
|
+
id: batchId,
|
|
14626
|
+
source: preview.source,
|
|
14627
|
+
records: items.map((item) => ({
|
|
14628
|
+
action: item.action,
|
|
14629
|
+
sourceId: item.candidate.sourceId,
|
|
14630
|
+
monitorId: item.after?.id ?? item.monitor?.id ?? item.before?.id ?? null,
|
|
14631
|
+
before: item.before,
|
|
14632
|
+
after: item.after,
|
|
14633
|
+
candidate: item.candidate
|
|
14634
|
+
}))
|
|
14635
|
+
});
|
|
14636
|
+
return { batchId, source: preview.source, appliedAt, items, totals: countActions(items) };
|
|
14637
|
+
};
|
|
14638
|
+
return store.runInTransaction ? store.runInTransaction(execute) : execute();
|
|
14639
|
+
}
|
|
14640
|
+
function rollbackImport(store, batchId) {
|
|
14641
|
+
if (store.mode === "hosted") {
|
|
14642
|
+
throw new Error("hosted import rollback requires cloud import_batches and audit");
|
|
14643
|
+
}
|
|
14644
|
+
const batch = store.getImportBatch(batchId);
|
|
14645
|
+
if (!batch)
|
|
14646
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
14647
|
+
if (batch.status === "rolled_back")
|
|
14648
|
+
throw new Error(`Import batch already rolled back: ${batchId}`);
|
|
14649
|
+
const items = [...batch.records].reverse().map((record2) => rollbackRecord(store, record2));
|
|
14650
|
+
const rolledBack = store.markImportBatchRolledBack(batchId);
|
|
14651
|
+
return {
|
|
14652
|
+
batchId,
|
|
14653
|
+
source: rolledBack.source,
|
|
14654
|
+
rolledBackAt: rolledBack.rolledBackAt ?? new Date().toISOString(),
|
|
14655
|
+
items
|
|
14656
|
+
};
|
|
14657
|
+
}
|
|
14658
|
+
function previewRecord(store, source, record2, defaults) {
|
|
14659
|
+
const warnings = [];
|
|
14660
|
+
let candidate;
|
|
14661
|
+
try {
|
|
14662
|
+
if (store.mode === "hosted")
|
|
14663
|
+
assertHostedTargetAllowed(rawTargetForHostedPolicy(source, record2, defaults));
|
|
14664
|
+
candidate = normalizeCandidate(source, record2, defaults);
|
|
14665
|
+
validateCandidate(candidate);
|
|
14666
|
+
if (store.mode === "hosted")
|
|
14667
|
+
assertHostedTargetAllowed(candidate);
|
|
14668
|
+
} catch (error51) {
|
|
14669
|
+
return {
|
|
14670
|
+
candidate: fallbackCandidate(source, record2),
|
|
14671
|
+
action: "blocked",
|
|
14672
|
+
monitor: null,
|
|
14673
|
+
provenance: null,
|
|
14674
|
+
warnings,
|
|
14675
|
+
reason: error51 instanceof Error ? error51.message : String(error51)
|
|
14676
|
+
};
|
|
14677
|
+
}
|
|
14678
|
+
const provenance = store.getProvenance(candidate.source, candidate.sourceId);
|
|
14679
|
+
const monitor = provenance ? store.getMonitor(provenance.monitorId) : store.getMonitor(candidate.name);
|
|
14680
|
+
if (provenance && !monitor) {
|
|
14681
|
+
return { candidate, action: "create", monitor: null, provenance, warnings: ["source provenance points to a missing monitor"], reason: null };
|
|
14682
|
+
}
|
|
14683
|
+
if (provenance && monitor) {
|
|
14684
|
+
const nameOwner = store.getMonitor(candidate.name);
|
|
14685
|
+
if (nameOwner && nameOwner.id !== monitor.id) {
|
|
14686
|
+
return {
|
|
14687
|
+
candidate,
|
|
14688
|
+
action: "conflict",
|
|
14689
|
+
monitor,
|
|
14690
|
+
provenance,
|
|
14691
|
+
warnings,
|
|
14692
|
+
reason: "monitor name already exists on another monitor"
|
|
14693
|
+
};
|
|
14694
|
+
}
|
|
14695
|
+
return {
|
|
14696
|
+
candidate,
|
|
14697
|
+
action: sameTarget(monitor, candidate) ? "unchanged" : "update",
|
|
14698
|
+
monitor,
|
|
14699
|
+
provenance,
|
|
14700
|
+
warnings,
|
|
14701
|
+
reason: null
|
|
14702
|
+
};
|
|
14703
|
+
}
|
|
14704
|
+
if (monitor) {
|
|
14705
|
+
return {
|
|
14706
|
+
candidate,
|
|
14707
|
+
action: "conflict",
|
|
14708
|
+
monitor,
|
|
14709
|
+
provenance: null,
|
|
14710
|
+
warnings,
|
|
14711
|
+
reason: "monitor name already exists without matching source provenance"
|
|
14712
|
+
};
|
|
14713
|
+
}
|
|
14714
|
+
return { candidate, action: "create", monitor: null, provenance: null, warnings, reason: null };
|
|
14715
|
+
}
|
|
14716
|
+
function applyPreviewItem(store, item) {
|
|
14717
|
+
if (item.action === "blocked" || item.action === "conflict") {
|
|
14718
|
+
return { ...item, before: item.monitor, after: item.monitor };
|
|
14719
|
+
}
|
|
14720
|
+
const input = candidateToMonitorInput(item.candidate);
|
|
14721
|
+
const before = item.monitor;
|
|
14722
|
+
const after = item.action === "create" ? store.createMonitor(input, { allowBrowserPage: true }) : item.action === "update" ? store.updateMonitor(item.monitor.id, input, { allowBrowserPage: true }) : item.monitor;
|
|
14723
|
+
if (after) {
|
|
14724
|
+
store.upsertMonitorProvenance({
|
|
14725
|
+
monitorId: after.id,
|
|
14726
|
+
source: item.candidate.source,
|
|
14727
|
+
sourceId: item.candidate.sourceId,
|
|
14728
|
+
sourceLabel: item.candidate.sourceLabel,
|
|
14729
|
+
snapshot: item.candidate.snapshot
|
|
14730
|
+
});
|
|
14731
|
+
}
|
|
14732
|
+
return { ...item, before, after };
|
|
14733
|
+
}
|
|
14734
|
+
function rollbackRecord(store, record2) {
|
|
14735
|
+
const value = asRecord(record2);
|
|
14736
|
+
const action = stringValue(value.action);
|
|
14737
|
+
const monitorId = stringValue(value.monitorId);
|
|
14738
|
+
const before = isMonitor(value.before) ? value.before : null;
|
|
14739
|
+
const after = isMonitor(value.after) ? value.after : null;
|
|
14740
|
+
const targetId = after?.id ?? before?.id ?? monitorId;
|
|
14741
|
+
if (!targetId)
|
|
14742
|
+
return { monitorId: null, action: "skipped", reason: "batch record has no monitor id" };
|
|
14743
|
+
if (action === "create") {
|
|
14744
|
+
const hasHistory = store.listResults({ monitorId: targetId, limit: 1 }).length > 0;
|
|
14745
|
+
if (hasHistory) {
|
|
14746
|
+
store.updateMonitor(targetId, { enabled: false }, { allowBrowserPage: true });
|
|
14747
|
+
return { monitorId: targetId, action: "disabled", reason: "created monitor has check history, so rollback preserved history and disabled it" };
|
|
14748
|
+
}
|
|
14749
|
+
return { monitorId: targetId, action: store.deleteMonitor(targetId) ? "deleted" : "skipped", reason: null };
|
|
14750
|
+
}
|
|
14751
|
+
if (action === "update" && before) {
|
|
14752
|
+
store.updateMonitor(targetId, monitorToUpdateInput(before), { allowBrowserPage: true });
|
|
14753
|
+
return { monitorId: targetId, action: "restored", reason: null };
|
|
14754
|
+
}
|
|
14755
|
+
return { monitorId: targetId, action: "skipped", reason: `no rollback needed for ${action || "unknown"} action` };
|
|
14756
|
+
}
|
|
14757
|
+
function normalizeCandidate(source, record2, defaults) {
|
|
14758
|
+
const value = asRecord(record2);
|
|
14759
|
+
const monitor = asRecord(value.monitor);
|
|
14760
|
+
const sourceId = sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id) ?? stringValue(value.slug) ?? stringValue(value.name));
|
|
14761
|
+
let url2 = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
|
|
14762
|
+
if (source === "domains" && !url2 && stringValue(value.domain)) {
|
|
14763
|
+
url2 = `https://${stringValue(value.domain)}`;
|
|
14764
|
+
}
|
|
14765
|
+
const rawHost = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname);
|
|
14766
|
+
const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url2 ? "http" : "tcp");
|
|
14767
|
+
const kind = normalizeKind(rawKind);
|
|
14768
|
+
const normalizedUrl = normalizeCandidateUrl(url2 ?? defaults.url);
|
|
14769
|
+
const normalizedHost = kind === "tcp" ? rawHost ?? defaults.host : undefined;
|
|
14770
|
+
const port = numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port;
|
|
14771
|
+
const normalizedTargetKey = sanitizeGeneratedTargetKey(kind, normalizedUrl, normalizedHost, port);
|
|
14772
|
+
const normalizedSourceId = sourceId ?? `${source}:${normalizedTargetKey}`;
|
|
14773
|
+
const name = stringValue(monitor.name) ?? stringValue(value.monitorName) ?? stringValue(value.name) ?? stringValue(value.slug) ?? (source === "domains" ? stringValue(value.domain) : undefined) ?? (kind === "tcp" ? stringValue(value.hostname) : undefined) ?? `${source}-${normalizedTargetKey}`;
|
|
14774
|
+
const expectedStatus = firstDefined(nullableNumberValue(monitor.expectedStatus), nullableNumberValue(value.expectedStatus), defaults.expectedStatus);
|
|
14775
|
+
const candidate = {
|
|
14776
|
+
source,
|
|
14777
|
+
sourceId: normalizedSourceId,
|
|
14778
|
+
sourceLabel: sanitizeIdentity(stringValue(value.label) ?? stringValue(value.name) ?? stringValue(value.slug)) ?? null,
|
|
14779
|
+
name: sanitizeIdentity(name) ?? name,
|
|
14780
|
+
kind,
|
|
14781
|
+
url: normalizedUrl,
|
|
14782
|
+
host: normalizedHost,
|
|
14783
|
+
port,
|
|
14784
|
+
method: normalizeCandidateMethod(stringValue(monitor.method) ?? stringValue(value.method) ?? defaults.method),
|
|
14785
|
+
expectedStatus,
|
|
14786
|
+
intervalSeconds: numberValue(monitor.intervalSeconds) ?? numberValue(value.intervalSeconds) ?? defaults.intervalSeconds,
|
|
14787
|
+
timeoutMs: numberValue(monitor.timeoutMs) ?? numberValue(value.timeoutMs) ?? defaults.timeoutMs,
|
|
14788
|
+
retryCount: numberValue(monitor.retryCount) ?? numberValue(value.retryCount) ?? defaults.retryCount,
|
|
14789
|
+
enabled: booleanValue(monitor.enabled) ?? booleanValue(value.enabled) ?? defaults.enabled,
|
|
14790
|
+
snapshot: sanitizeSnapshot(record2)
|
|
14791
|
+
};
|
|
14792
|
+
return candidate;
|
|
14793
|
+
}
|
|
14794
|
+
function rawTargetForHostedPolicy(source, record2, defaults) {
|
|
14795
|
+
const value = asRecord(record2);
|
|
14796
|
+
const monitor = asRecord(value.monitor);
|
|
14797
|
+
let url2 = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.healthUrl) ?? stringValue(value.homepageUrl) ?? stringValue(value.environmentUrl);
|
|
14798
|
+
if (source === "domains" && !url2 && stringValue(value.domain)) {
|
|
14799
|
+
url2 = `https://${stringValue(value.domain)}`;
|
|
14800
|
+
}
|
|
14801
|
+
const host = stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname) ?? defaults.host;
|
|
14802
|
+
const rawKind = stringValue(monitor.kind) ?? stringValue(value.kind) ?? (url2 ? "http" : "tcp");
|
|
14803
|
+
const kind = normalizeKind(rawKind);
|
|
14804
|
+
return {
|
|
14805
|
+
kind,
|
|
14806
|
+
url: url2 ?? defaults.url,
|
|
14807
|
+
host: kind === "tcp" ? host : undefined,
|
|
14808
|
+
port: numberValue(monitor.port) ?? numberValue(value.port) ?? defaults.port
|
|
14809
|
+
};
|
|
14810
|
+
}
|
|
14811
|
+
function validateCandidate(candidate) {
|
|
14812
|
+
if (!candidate.name.trim())
|
|
14813
|
+
throw new Error("import candidate requires name");
|
|
14814
|
+
rejectControlCharacters(candidate.name.trim(), "Monitor name");
|
|
14815
|
+
if (candidate.method !== undefined && !/^[A-Z]+$/.test(candidate.method)) {
|
|
14816
|
+
throw new Error("HTTP method must contain only letters");
|
|
14817
|
+
}
|
|
14818
|
+
if (candidate.expectedStatus !== undefined && candidate.expectedStatus !== null) {
|
|
14819
|
+
if (!Number.isInteger(candidate.expectedStatus) || candidate.expectedStatus < 100 || candidate.expectedStatus > 599) {
|
|
14820
|
+
throw new Error("expectedStatus must be an HTTP status from 100 to 599");
|
|
14821
|
+
}
|
|
14822
|
+
}
|
|
14823
|
+
if (candidate.intervalSeconds !== undefined) {
|
|
14824
|
+
boundedInteger(candidate.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
14825
|
+
}
|
|
14826
|
+
if (candidate.timeoutMs !== undefined) {
|
|
14827
|
+
boundedInteger(candidate.timeoutMs, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS);
|
|
14828
|
+
}
|
|
14829
|
+
if (candidate.retryCount !== undefined) {
|
|
14830
|
+
boundedInteger(candidate.retryCount, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT);
|
|
14831
|
+
}
|
|
14832
|
+
if (candidate.kind === "http" || candidate.kind === "browser_page") {
|
|
14833
|
+
if (!candidate.url)
|
|
14834
|
+
throw new Error(`${candidate.kind} import candidate requires url`);
|
|
14835
|
+
const parsed = new URL(candidate.url);
|
|
14836
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14837
|
+
throw new Error(`${candidate.kind} import candidate URL must use http or https`);
|
|
14838
|
+
}
|
|
14839
|
+
if (parsed.username || parsed.password)
|
|
14840
|
+
throw new Error(`${candidate.kind} import candidate URL must not contain userinfo`);
|
|
14841
|
+
return;
|
|
14842
|
+
}
|
|
14843
|
+
if (candidate.kind === "tcp") {
|
|
14844
|
+
if (!candidate.host)
|
|
14845
|
+
throw new Error("tcp import candidate requires host");
|
|
14846
|
+
rejectControlCharacters(candidate.host, "TCP host");
|
|
14847
|
+
if (!Number.isInteger(candidate.port) || candidate.port <= 0 || candidate.port > 65535) {
|
|
14848
|
+
throw new Error("tcp import candidate requires a port from 1 to 65535");
|
|
14849
|
+
}
|
|
14850
|
+
return;
|
|
14851
|
+
}
|
|
14852
|
+
throw new Error(`unsupported import candidate kind: ${candidate.kind}`);
|
|
14853
|
+
}
|
|
14854
|
+
function candidateToMonitorInput(candidate) {
|
|
14855
|
+
return {
|
|
14856
|
+
name: candidate.name,
|
|
14857
|
+
kind: candidate.kind,
|
|
14858
|
+
url: candidate.url,
|
|
14859
|
+
host: candidate.host,
|
|
14860
|
+
port: candidate.port,
|
|
14861
|
+
method: candidate.method,
|
|
14862
|
+
expectedStatus: candidate.expectedStatus,
|
|
14863
|
+
intervalSeconds: candidate.intervalSeconds,
|
|
14864
|
+
timeoutMs: candidate.timeoutMs,
|
|
14865
|
+
retryCount: candidate.retryCount,
|
|
14866
|
+
enabled: candidate.enabled
|
|
14867
|
+
};
|
|
14868
|
+
}
|
|
14869
|
+
function monitorToUpdateInput(monitor) {
|
|
14870
|
+
return {
|
|
14871
|
+
name: monitor.name,
|
|
14872
|
+
kind: monitor.kind,
|
|
14873
|
+
url: monitor.url ?? undefined,
|
|
14874
|
+
host: monitor.host ?? undefined,
|
|
14875
|
+
port: monitor.port ?? undefined,
|
|
14876
|
+
method: monitor.method,
|
|
14877
|
+
expectedStatus: monitor.expectedStatus,
|
|
14878
|
+
intervalSeconds: monitor.intervalSeconds,
|
|
14879
|
+
timeoutMs: monitor.timeoutMs,
|
|
14880
|
+
retryCount: monitor.retryCount,
|
|
14881
|
+
enabled: monitor.enabled
|
|
14882
|
+
};
|
|
14883
|
+
}
|
|
14884
|
+
function sameTarget(monitor, candidate) {
|
|
14885
|
+
return monitor.kind === candidate.kind && monitor.name === candidate.name && monitor.url === (candidate.url ?? null) && monitor.host === (candidate.host ?? null) && monitor.port === (candidate.port ?? null) && monitor.method === (candidate.method ?? monitor.method) && (candidate.expectedStatus === undefined || monitor.expectedStatus === candidate.expectedStatus) && monitor.intervalSeconds === (candidate.intervalSeconds ?? monitor.intervalSeconds) && monitor.timeoutMs === (candidate.timeoutMs ?? monitor.timeoutMs) && monitor.retryCount === (candidate.retryCount ?? monitor.retryCount) && monitor.enabled === (candidate.enabled ?? monitor.enabled);
|
|
14886
|
+
}
|
|
14887
|
+
function countActions(items) {
|
|
14888
|
+
return {
|
|
14889
|
+
create: items.filter((item) => item.action === "create").length,
|
|
14890
|
+
update: items.filter((item) => item.action === "update").length,
|
|
14891
|
+
unchanged: items.filter((item) => item.action === "unchanged").length,
|
|
14892
|
+
blocked: items.filter((item) => item.action === "blocked").length,
|
|
14893
|
+
conflict: items.filter((item) => item.action === "conflict").length
|
|
14894
|
+
};
|
|
14895
|
+
}
|
|
14896
|
+
function normalizeSource(source) {
|
|
14897
|
+
if (["manual", "projects", "servers", "domains", "deployment"].includes(source))
|
|
14898
|
+
return source;
|
|
14899
|
+
throw new Error(`unsupported import source: ${source}`);
|
|
14900
|
+
}
|
|
14901
|
+
function normalizeKind(value) {
|
|
14902
|
+
if (value === "http" || value === "tcp" || value === "browser_page")
|
|
14903
|
+
return value;
|
|
14904
|
+
return value === "browser" || value === "page" ? "browser_page" : "http";
|
|
14905
|
+
}
|
|
14906
|
+
function targetKey(kind, url2, host, port) {
|
|
14907
|
+
return kind === "tcp" ? `${host ?? "host"}:${port ?? "port"}` : url2 ?? "url";
|
|
14908
|
+
}
|
|
14909
|
+
function sanitizeGeneratedTargetKey(kind, url2, host, port) {
|
|
14910
|
+
const key = targetKey(kind, url2, host, port);
|
|
14911
|
+
return kind === "tcp" ? key : sanitizeIdentity(key) ?? key;
|
|
14912
|
+
}
|
|
14913
|
+
function normalizeCandidateUrl(value) {
|
|
14914
|
+
if (!value)
|
|
14915
|
+
return;
|
|
14916
|
+
try {
|
|
14917
|
+
const parsed = new URL(value);
|
|
14918
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
14919
|
+
if (isSecretKey2(key))
|
|
14920
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
14921
|
+
}
|
|
14922
|
+
parsed.hash = "";
|
|
14923
|
+
return parsed.toString();
|
|
14924
|
+
} catch {
|
|
14925
|
+
return value;
|
|
14926
|
+
}
|
|
14927
|
+
}
|
|
14928
|
+
function normalizeCandidateMethod(value) {
|
|
14929
|
+
return value?.trim().toUpperCase();
|
|
14930
|
+
}
|
|
14931
|
+
function fallbackCandidate(source, record2) {
|
|
14932
|
+
const value = asRecord(record2);
|
|
14933
|
+
const monitor = asRecord(value.monitor);
|
|
14934
|
+
const name = stringValue(monitor.name) ?? stringValue(value.name) ?? stringValue(value.domain) ?? "invalid import candidate";
|
|
14935
|
+
const rawUrl = stringValue(monitor.url) ?? stringValue(value.url) ?? stringValue(value.domain);
|
|
14936
|
+
const kind = normalizeKind(stringValue(monitor.kind) ?? stringValue(value.kind) ?? "http");
|
|
14937
|
+
return {
|
|
14938
|
+
source,
|
|
14939
|
+
sourceId: sanitizeIdentity(stringValue(value.sourceId) ?? stringValue(value.id)) ?? `${source}:invalid`,
|
|
14940
|
+
sourceLabel: sanitizeIdentity(stringValue(value.label)) ?? null,
|
|
14941
|
+
name: sanitizeIdentity(name) ?? name,
|
|
14942
|
+
kind,
|
|
14943
|
+
url: redactUrlForDisplay(rawUrl),
|
|
14944
|
+
host: kind === "tcp" ? sanitizeHost(stringValue(monitor.host) ?? stringValue(value.host) ?? stringValue(value.hostname)) : undefined,
|
|
14945
|
+
port: numberValue(monitor.port) ?? numberValue(value.port),
|
|
14946
|
+
snapshot: sanitizeSnapshot(record2)
|
|
14947
|
+
};
|
|
14948
|
+
}
|
|
14949
|
+
function asRecord(value) {
|
|
14950
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
14951
|
+
}
|
|
14952
|
+
function stringValue(value) {
|
|
14953
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
14954
|
+
}
|
|
14955
|
+
function numberValue(value) {
|
|
14956
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
14957
|
+
}
|
|
14958
|
+
function nullableNumberValue(value) {
|
|
14959
|
+
if (value === null)
|
|
14960
|
+
return null;
|
|
14961
|
+
return numberValue(value);
|
|
14962
|
+
}
|
|
14963
|
+
function booleanValue(value) {
|
|
14964
|
+
return typeof value === "boolean" ? value : undefined;
|
|
14965
|
+
}
|
|
14966
|
+
function firstDefined(...values) {
|
|
14967
|
+
return values.find((value) => value !== undefined);
|
|
14968
|
+
}
|
|
14969
|
+
function isMonitor(value) {
|
|
14970
|
+
const row = asRecord(value);
|
|
14971
|
+
return Boolean(stringValue(row.id) && stringValue(row.name) && stringValue(row.kind));
|
|
14972
|
+
}
|
|
14973
|
+
function sanitizeSnapshot(value) {
|
|
14974
|
+
if (Array.isArray(value))
|
|
14975
|
+
return value.map(sanitizeSnapshot);
|
|
14976
|
+
if (!value || typeof value !== "object")
|
|
14977
|
+
return sanitizeScalar(value);
|
|
14978
|
+
const output = {};
|
|
14979
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
14980
|
+
if (isSecretKey2(key))
|
|
14981
|
+
output[key] = "[redacted]";
|
|
14982
|
+
else
|
|
14983
|
+
output[key] = sanitizeSnapshot(entry);
|
|
14984
|
+
}
|
|
14985
|
+
return output;
|
|
14986
|
+
}
|
|
14987
|
+
function sanitizeScalar(value) {
|
|
14988
|
+
if (typeof value !== "string")
|
|
14989
|
+
return value;
|
|
14990
|
+
return value.replace(/\/(?:home|Users)\/[^\s"'<>]+/g, "[local-path]").replace(/\b(?:localhost|(?:[a-z0-9-]+\.)+(?:local|internal))\b/gi, "[private-host]").replace(/(https?:\/\/)[^/?#\s"'<>]+:[^@/?#\s"'<>]+@/gi, "$1[redacted]@").replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").replace(/((?:token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)[=:]\s*)[^\s&]+/gi, "$1[redacted]");
|
|
14991
|
+
}
|
|
14992
|
+
function isSecretKey2(value) {
|
|
14993
|
+
return /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i.test(value);
|
|
14994
|
+
}
|
|
14995
|
+
function rejectControlCharacters(value, label) {
|
|
14996
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
14997
|
+
throw new Error(`${label} must not contain control characters`);
|
|
14998
|
+
}
|
|
14999
|
+
}
|
|
15000
|
+
function boundedInteger(value, label, min, max) {
|
|
15001
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
15002
|
+
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
15003
|
+
}
|
|
15004
|
+
return value;
|
|
15005
|
+
}
|
|
15006
|
+
function redactUrlForDisplay(value) {
|
|
15007
|
+
if (!value)
|
|
15008
|
+
return;
|
|
15009
|
+
try {
|
|
15010
|
+
const parsed = new URL(value);
|
|
15011
|
+
parsed.username = parsed.username ? "[redacted]" : "";
|
|
15012
|
+
parsed.password = "";
|
|
15013
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
15014
|
+
if (isSecretKey2(key))
|
|
15015
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
15016
|
+
}
|
|
15017
|
+
if (parsed.hash && isSecretKey2(parsed.hash))
|
|
15018
|
+
parsed.hash = "#[redacted]";
|
|
15019
|
+
return parsed.toString();
|
|
15020
|
+
} catch {
|
|
15021
|
+
return sanitizeScalar(value);
|
|
15022
|
+
}
|
|
15023
|
+
}
|
|
15024
|
+
function sanitizeIdentity(value) {
|
|
15025
|
+
if (!value)
|
|
15026
|
+
return;
|
|
15027
|
+
try {
|
|
15028
|
+
const parsed = new URL(value);
|
|
15029
|
+
parsed.username = parsed.username ? "[redacted]" : "";
|
|
15030
|
+
parsed.password = "";
|
|
15031
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
15032
|
+
if (isSecretKey2(key))
|
|
15033
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
15034
|
+
}
|
|
15035
|
+
parsed.hash = "";
|
|
15036
|
+
return parsed.toString();
|
|
15037
|
+
} catch {
|
|
15038
|
+
return sanitizeScalar(value);
|
|
15039
|
+
}
|
|
15040
|
+
}
|
|
15041
|
+
function sanitizeHost(value) {
|
|
15042
|
+
if (!value)
|
|
15043
|
+
return;
|
|
15044
|
+
return sanitizeScalar(value);
|
|
15045
|
+
}
|
|
15046
|
+
|
|
15047
|
+
// src/probes.ts
|
|
15048
|
+
import { createHash, generateKeyPairSync, sign, verify } from "crypto";
|
|
15049
|
+
function generateProbeKeyPair() {
|
|
15050
|
+
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
|
15051
|
+
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
15052
|
+
const privateKeyPem = privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
15053
|
+
return {
|
|
15054
|
+
publicKeyPem,
|
|
15055
|
+
privateKeyPem,
|
|
15056
|
+
publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
|
|
15057
|
+
};
|
|
15058
|
+
}
|
|
15059
|
+
function probePublicKeyFingerprint(publicKeyPem) {
|
|
15060
|
+
return createHash("sha256").update(publicKeyPem.trim()).digest("hex");
|
|
15061
|
+
}
|
|
15062
|
+
function verifyProbeResultSignature(input, publicKeyPem) {
|
|
15063
|
+
try {
|
|
15064
|
+
return verify(null, Buffer.from(probeResultSigningPayload(input)), publicKeyPem, Buffer.from(input.signature, "base64url"));
|
|
15065
|
+
} catch {
|
|
15066
|
+
return false;
|
|
15067
|
+
}
|
|
15068
|
+
}
|
|
15069
|
+
function probeResultSigningPayload(input) {
|
|
15070
|
+
return stableJson({
|
|
15071
|
+
version: "open-uptime.probe-result.v1",
|
|
15072
|
+
probeId: input.probeId,
|
|
15073
|
+
jobId: input.jobId,
|
|
15074
|
+
scheduleSlot: input.scheduleSlot,
|
|
15075
|
+
fencingToken: input.fencingToken,
|
|
15076
|
+
monitorId: input.monitorId,
|
|
15077
|
+
nonce: input.nonce,
|
|
15078
|
+
checkedAt: input.checkedAt,
|
|
15079
|
+
status: input.status,
|
|
15080
|
+
latencyMs: input.latencyMs,
|
|
15081
|
+
statusCode: input.statusCode ?? null,
|
|
15082
|
+
error: input.error ?? null,
|
|
15083
|
+
attemptCount: input.attemptCount ?? 1,
|
|
15084
|
+
monitorRevision: input.monitorRevision,
|
|
15085
|
+
evidenceSha256: createHash("sha256").update(stableJson(input.evidence ?? null)).digest("hex")
|
|
15086
|
+
});
|
|
15087
|
+
}
|
|
15088
|
+
function stableJson(value) {
|
|
15089
|
+
if (value === undefined)
|
|
15090
|
+
return "null";
|
|
15091
|
+
if (value === null || typeof value !== "object")
|
|
15092
|
+
return JSON.stringify(value);
|
|
15093
|
+
if (Array.isArray(value))
|
|
15094
|
+
return `[${value.map(stableJson).join(",")}]`;
|
|
15095
|
+
const entries = Object.entries(value).filter(([, entryValue]) => entryValue !== undefined).sort(([left], [right]) => left.localeCompare(right));
|
|
15096
|
+
return `{${entries.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`).join(",")}}`;
|
|
15097
|
+
}
|
|
15098
|
+
|
|
15099
|
+
// src/store.ts
|
|
15100
|
+
import { copyFileSync, existsSync, mkdirSync, statSync } from "fs";
|
|
15101
|
+
import { dirname, join as join2 } from "path";
|
|
15102
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
14372
15103
|
import { Database } from "bun:sqlite";
|
|
14373
15104
|
|
|
14374
15105
|
// src/paths.ts
|
|
@@ -14380,17 +15111,31 @@ function uptimeHome() {
|
|
|
14380
15111
|
function uptimeDbPath() {
|
|
14381
15112
|
return process.env.HASNA_UPTIME_DB || join(uptimeHome(), "uptime.db");
|
|
14382
15113
|
}
|
|
14383
|
-
|
|
14384
|
-
|
|
14385
|
-
|
|
14386
|
-
var MAX_INTERVAL_SECONDS = 86400;
|
|
14387
|
-
var MIN_TIMEOUT_MS = 1;
|
|
14388
|
-
var MAX_TIMEOUT_MS = 60000;
|
|
14389
|
-
var MIN_RETRY_COUNT = 0;
|
|
14390
|
-
var MAX_RETRY_COUNT = 10;
|
|
14391
|
-
var MAX_RESULT_LIMIT = 1000;
|
|
15114
|
+
function uptimeHostedFallbackDbPath() {
|
|
15115
|
+
return process.env.HASNA_UPTIME_HOSTED_FALLBACK_DB || join(uptimeHome(), "hosted-fallback", "uptime.db");
|
|
15116
|
+
}
|
|
14392
15117
|
|
|
14393
15118
|
// src/store.ts
|
|
15119
|
+
var SECRET_URL_PARAM_PATTERN = /(token|secret|password|passwd|api[_-]?key|access[_-]?token|auth|credential|session)/i;
|
|
15120
|
+
var REQUIRED_TABLES = [
|
|
15121
|
+
"schema_migrations",
|
|
15122
|
+
"monitors",
|
|
15123
|
+
"check_results",
|
|
15124
|
+
"incidents",
|
|
15125
|
+
"check_leases",
|
|
15126
|
+
"monitor_provenance",
|
|
15127
|
+
"import_batches",
|
|
15128
|
+
"probe_identities",
|
|
15129
|
+
"probe_check_jobs",
|
|
15130
|
+
"probe_submissions",
|
|
15131
|
+
"report_schedules",
|
|
15132
|
+
"report_runs",
|
|
15133
|
+
"audit_events"
|
|
15134
|
+
];
|
|
15135
|
+
var PROBE_TABLES = new Set(["probe_identities", "probe_check_jobs", "probe_submissions"]);
|
|
15136
|
+
var REPORT_AUDIT_TABLES = new Set(["report_schedules", "report_runs", "audit_events"]);
|
|
15137
|
+
var CURRENT_SCHEMA_VERSION = "3";
|
|
15138
|
+
|
|
14394
15139
|
class StaleCheckResultError extends Error {
|
|
14395
15140
|
constructor(message) {
|
|
14396
15141
|
super(message);
|
|
@@ -14400,9 +15145,20 @@ class StaleCheckResultError extends Error {
|
|
|
14400
15145
|
|
|
14401
15146
|
class UptimeStore {
|
|
14402
15147
|
dbPath;
|
|
15148
|
+
mode;
|
|
15149
|
+
dataMode;
|
|
14403
15150
|
db;
|
|
14404
15151
|
constructor(options = {}) {
|
|
14405
|
-
this.
|
|
15152
|
+
this.mode = resolveRuntimeMode(options.mode ?? "local");
|
|
15153
|
+
const cloudDatabaseUrl = options.cloudDatabaseUrl ?? process.env.HASNA_UPTIME_DATABASE_URL;
|
|
15154
|
+
if (this.mode === "hosted" && cloudDatabaseUrl) {
|
|
15155
|
+
throw new Error("hosted cloud database adapter is not implemented yet");
|
|
15156
|
+
}
|
|
15157
|
+
if (this.mode === "hosted" && !allowHostedLocalStore(options.allowHostedLocalStore)) {
|
|
15158
|
+
throw new Error("hosted mode requires a cloud data layer; set HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE=1 only for explicit local fallback testing");
|
|
15159
|
+
}
|
|
15160
|
+
this.dataMode = this.mode === "hosted" ? "hosted-local-sqlite" : "local-sqlite";
|
|
15161
|
+
this.dbPath = options.dbPath ?? (this.mode === "hosted" ? uptimeHostedFallbackDbPath() : uptimeDbPath());
|
|
14406
15162
|
if (this.dbPath !== ":memory:") {
|
|
14407
15163
|
mkdirSync(dirname(this.dbPath), { recursive: true });
|
|
14408
15164
|
}
|
|
@@ -14419,7 +15175,7 @@ class UptimeStore {
|
|
|
14419
15175
|
CREATE TABLE IF NOT EXISTS monitors (
|
|
14420
15176
|
id TEXT PRIMARY KEY,
|
|
14421
15177
|
name TEXT NOT NULL UNIQUE,
|
|
14422
|
-
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp')),
|
|
15178
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
14423
15179
|
url TEXT,
|
|
14424
15180
|
host TEXT,
|
|
14425
15181
|
port INTEGER,
|
|
@@ -14437,6 +15193,7 @@ class UptimeStore {
|
|
|
14437
15193
|
)
|
|
14438
15194
|
`);
|
|
14439
15195
|
this.ensureColumn("monitors", "revision", "INTEGER NOT NULL DEFAULT 1");
|
|
15196
|
+
this.ensureMonitorKindAllowsBrowserPage();
|
|
14440
15197
|
this.db.run(`
|
|
14441
15198
|
CREATE TABLE IF NOT EXISTS check_results (
|
|
14442
15199
|
id TEXT PRIMARY KEY,
|
|
@@ -14446,9 +15203,11 @@ class UptimeStore {
|
|
|
14446
15203
|
latency_ms REAL,
|
|
14447
15204
|
status_code INTEGER,
|
|
14448
15205
|
error TEXT,
|
|
14449
|
-
attempt_count INTEGER NOT NULL DEFAULT 1
|
|
15206
|
+
attempt_count INTEGER NOT NULL DEFAULT 1,
|
|
15207
|
+
evidence_json TEXT
|
|
14450
15208
|
)
|
|
14451
15209
|
`);
|
|
15210
|
+
this.ensureColumn("check_results", "evidence_json", "TEXT");
|
|
14452
15211
|
this.db.run(`
|
|
14453
15212
|
CREATE TABLE IF NOT EXISTS incidents (
|
|
14454
15213
|
id TEXT PRIMARY KEY,
|
|
@@ -14462,6 +15221,71 @@ class UptimeStore {
|
|
|
14462
15221
|
reason TEXT
|
|
14463
15222
|
)
|
|
14464
15223
|
`);
|
|
15224
|
+
this.db.run(`
|
|
15225
|
+
CREATE TABLE IF NOT EXISTS monitor_provenance (
|
|
15226
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
15227
|
+
source TEXT NOT NULL,
|
|
15228
|
+
source_id TEXT NOT NULL,
|
|
15229
|
+
source_label TEXT,
|
|
15230
|
+
imported_at TEXT NOT NULL,
|
|
15231
|
+
snapshot_json TEXT NOT NULL,
|
|
15232
|
+
PRIMARY KEY (source, source_id)
|
|
15233
|
+
)
|
|
15234
|
+
`);
|
|
15235
|
+
this.db.run(`
|
|
15236
|
+
CREATE TABLE IF NOT EXISTS import_batches (
|
|
15237
|
+
id TEXT PRIMARY KEY,
|
|
15238
|
+
source TEXT NOT NULL,
|
|
15239
|
+
status TEXT NOT NULL CHECK (status IN ('applied', 'rolled_back')),
|
|
15240
|
+
created_at TEXT NOT NULL,
|
|
15241
|
+
rolled_back_at TEXT,
|
|
15242
|
+
records_json TEXT NOT NULL
|
|
15243
|
+
)
|
|
15244
|
+
`);
|
|
15245
|
+
this.db.run(`
|
|
15246
|
+
CREATE TABLE IF NOT EXISTS probe_identities (
|
|
15247
|
+
id TEXT PRIMARY KEY,
|
|
15248
|
+
name TEXT NOT NULL UNIQUE,
|
|
15249
|
+
public_key_pem TEXT NOT NULL,
|
|
15250
|
+
public_key_fingerprint TEXT NOT NULL UNIQUE,
|
|
15251
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
15252
|
+
created_at TEXT NOT NULL,
|
|
15253
|
+
last_seen_at TEXT
|
|
15254
|
+
)
|
|
15255
|
+
`);
|
|
15256
|
+
this.db.run(`
|
|
15257
|
+
CREATE TABLE IF NOT EXISTS probe_submissions (
|
|
15258
|
+
id TEXT PRIMARY KEY,
|
|
15259
|
+
probe_id TEXT NOT NULL REFERENCES probe_identities(id) ON DELETE CASCADE,
|
|
15260
|
+
job_id TEXT NOT NULL,
|
|
15261
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
15262
|
+
check_result_id TEXT NOT NULL REFERENCES check_results(id) ON DELETE CASCADE,
|
|
15263
|
+
nonce TEXT NOT NULL,
|
|
15264
|
+
checked_at TEXT NOT NULL,
|
|
15265
|
+
submitted_at TEXT NOT NULL,
|
|
15266
|
+
UNIQUE (probe_id, nonce)
|
|
15267
|
+
)
|
|
15268
|
+
`);
|
|
15269
|
+
this.ensureColumn("probe_submissions", "job_id", "TEXT");
|
|
15270
|
+
this.db.run(`
|
|
15271
|
+
CREATE TABLE IF NOT EXISTS probe_check_jobs (
|
|
15272
|
+
id TEXT PRIMARY KEY,
|
|
15273
|
+
monitor_id TEXT NOT NULL REFERENCES monitors(id) ON DELETE CASCADE,
|
|
15274
|
+
monitor_revision INTEGER NOT NULL DEFAULT 1,
|
|
15275
|
+
schedule_slot TEXT NOT NULL,
|
|
15276
|
+
status TEXT NOT NULL CHECK (status IN ('pending', 'claimed', 'submitted', 'expired', 'cancelled')),
|
|
15277
|
+
claimed_by_probe_id TEXT REFERENCES probe_identities(id) ON DELETE SET NULL,
|
|
15278
|
+
fencing_token TEXT,
|
|
15279
|
+
due_at TEXT NOT NULL,
|
|
15280
|
+
claimed_at TEXT,
|
|
15281
|
+
lease_expires_at TEXT,
|
|
15282
|
+
submitted_result_id TEXT REFERENCES check_results(id) ON DELETE SET NULL,
|
|
15283
|
+
created_at TEXT NOT NULL,
|
|
15284
|
+
updated_at TEXT NOT NULL,
|
|
15285
|
+
UNIQUE (monitor_id, schedule_slot)
|
|
15286
|
+
)
|
|
15287
|
+
`);
|
|
15288
|
+
this.ensureColumn("probe_check_jobs", "monitor_revision", "INTEGER NOT NULL DEFAULT 1");
|
|
14465
15289
|
this.db.run(`
|
|
14466
15290
|
CREATE TABLE IF NOT EXISTS check_leases (
|
|
14467
15291
|
monitor_id TEXT PRIMARY KEY REFERENCES monitors(id) ON DELETE CASCADE,
|
|
@@ -14470,12 +15294,113 @@ class UptimeStore {
|
|
|
14470
15294
|
acquired_at TEXT NOT NULL
|
|
14471
15295
|
)
|
|
14472
15296
|
`);
|
|
15297
|
+
this.db.run(`
|
|
15298
|
+
CREATE TABLE IF NOT EXISTS report_schedules (
|
|
15299
|
+
id TEXT PRIMARY KEY,
|
|
15300
|
+
name TEXT NOT NULL UNIQUE,
|
|
15301
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
15302
|
+
interval_seconds INTEGER NOT NULL,
|
|
15303
|
+
next_run_at TEXT NOT NULL,
|
|
15304
|
+
last_run_at TEXT,
|
|
15305
|
+
subject TEXT,
|
|
15306
|
+
channels_json TEXT NOT NULL,
|
|
15307
|
+
created_at TEXT NOT NULL,
|
|
15308
|
+
updated_at TEXT NOT NULL
|
|
15309
|
+
)
|
|
15310
|
+
`);
|
|
15311
|
+
this.db.run(`
|
|
15312
|
+
CREATE TABLE IF NOT EXISTS report_runs (
|
|
15313
|
+
id TEXT PRIMARY KEY,
|
|
15314
|
+
schedule_id TEXT REFERENCES report_schedules(id) ON DELETE SET NULL,
|
|
15315
|
+
status TEXT NOT NULL CHECK (status IN ('success', 'failed')),
|
|
15316
|
+
started_at TEXT NOT NULL,
|
|
15317
|
+
finished_at TEXT NOT NULL,
|
|
15318
|
+
deliveries_json TEXT NOT NULL,
|
|
15319
|
+
error TEXT,
|
|
15320
|
+
report_json TEXT
|
|
15321
|
+
)
|
|
15322
|
+
`);
|
|
15323
|
+
this.db.run(`
|
|
15324
|
+
CREATE TABLE IF NOT EXISTS audit_events (
|
|
15325
|
+
id TEXT PRIMARY KEY,
|
|
15326
|
+
action TEXT NOT NULL,
|
|
15327
|
+
resource_type TEXT,
|
|
15328
|
+
resource_id TEXT,
|
|
15329
|
+
message TEXT,
|
|
15330
|
+
metadata_json TEXT NOT NULL DEFAULT '{}',
|
|
15331
|
+
actor TEXT,
|
|
15332
|
+
created_at TEXT NOT NULL
|
|
15333
|
+
)
|
|
15334
|
+
`);
|
|
15335
|
+
this.db.run(`
|
|
15336
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
15337
|
+
key TEXT PRIMARY KEY,
|
|
15338
|
+
value TEXT NOT NULL,
|
|
15339
|
+
updated_at TEXT NOT NULL
|
|
15340
|
+
)
|
|
15341
|
+
`);
|
|
15342
|
+
this.db.query("INSERT OR REPLACE INTO schema_migrations (key, value, updated_at) VALUES ('schema_version', ?, ?)").run(CURRENT_SCHEMA_VERSION, new Date().toISOString());
|
|
14473
15343
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_results_monitor_time ON check_results(monitor_id, checked_at DESC)");
|
|
14474
15344
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_incidents_monitor_status ON incidents(monitor_id, status)");
|
|
14475
15345
|
this.db.run("CREATE INDEX IF NOT EXISTS idx_check_leases_until ON check_leases(leased_until)");
|
|
15346
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_monitor_provenance_monitor ON monitor_provenance(monitor_id)");
|
|
15347
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_status_due ON probe_check_jobs(status, due_at)");
|
|
15348
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_jobs_probe_status ON probe_check_jobs(claimed_by_probe_id, status)");
|
|
15349
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_probe_time ON probe_submissions(probe_id, submitted_at DESC)");
|
|
15350
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_probe_submissions_monitor_time ON probe_submissions(monitor_id, checked_at DESC)");
|
|
15351
|
+
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 != ''");
|
|
15352
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_schedules_due ON report_schedules(enabled, next_run_at)");
|
|
15353
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_report_runs_schedule_time ON report_runs(schedule_id, started_at DESC)");
|
|
15354
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_resource_time ON audit_events(resource_type, resource_id, created_at DESC)");
|
|
15355
|
+
this.db.run("CREATE INDEX IF NOT EXISTS idx_audit_events_time ON audit_events(created_at DESC)");
|
|
15356
|
+
}
|
|
15357
|
+
backup(destinationPath) {
|
|
15358
|
+
if (this.dbPath === ":memory:" && !destinationPath) {
|
|
15359
|
+
throw new Error("backup path is required for in-memory stores");
|
|
15360
|
+
}
|
|
15361
|
+
const createdAt = new Date().toISOString();
|
|
15362
|
+
const backupPath = destinationPath ?? join2(dirname(this.dbPath), "backups", `uptime-${createdAt.replace(/[:.]/g, "-")}.db`);
|
|
15363
|
+
mkdirSync(dirname(backupPath), { recursive: true });
|
|
15364
|
+
if (this.dbPath === ":memory:") {
|
|
15365
|
+
this.vacuumInto(backupPath);
|
|
15366
|
+
} else {
|
|
15367
|
+
this.db.run("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
15368
|
+
copyFileSync(this.dbPath, backupPath);
|
|
15369
|
+
}
|
|
15370
|
+
const bytes = statSync(backupPath).size;
|
|
15371
|
+
return { sourcePath: this.dbPath, backupPath, bytes, createdAt };
|
|
15372
|
+
}
|
|
15373
|
+
verifyBackup(backupPath) {
|
|
15374
|
+
return verifyBackupFile(backupPath);
|
|
15375
|
+
}
|
|
15376
|
+
static verifyBackup(backupPath) {
|
|
15377
|
+
return verifyBackupFile(backupPath);
|
|
15378
|
+
}
|
|
15379
|
+
static restoreBackup(backupPath, destinationPath = uptimeDbPath()) {
|
|
15380
|
+
const check2 = verifyBackupFile(backupPath);
|
|
15381
|
+
if (!check2.ok)
|
|
15382
|
+
throw new Error(`backup integrity check failed: ${check2.integrity}`);
|
|
15383
|
+
if (destinationPath === ":memory:")
|
|
15384
|
+
throw new Error("cannot restore a backup to an in-memory store");
|
|
15385
|
+
if (existsSync(destinationPath) || existsSync(`${destinationPath}-wal`) || existsSync(`${destinationPath}-shm`)) {
|
|
15386
|
+
throw new Error("restore destination already exists or has SQLite sidecar files");
|
|
15387
|
+
}
|
|
15388
|
+
mkdirSync(dirname(destinationPath), { recursive: true });
|
|
15389
|
+
copyFileSync(backupPath, destinationPath);
|
|
15390
|
+
const bytes = statSync(destinationPath).size;
|
|
15391
|
+
return {
|
|
15392
|
+
sourcePath: backupPath,
|
|
15393
|
+
backupPath: destinationPath,
|
|
15394
|
+
bytes,
|
|
15395
|
+
createdAt: new Date().toISOString()
|
|
15396
|
+
};
|
|
14476
15397
|
}
|
|
14477
|
-
createMonitor(input) {
|
|
14478
|
-
|
|
15398
|
+
createMonitor(input, options = {}) {
|
|
15399
|
+
if (this.mode === "hosted")
|
|
15400
|
+
assertHostedTargetAllowed(input);
|
|
15401
|
+
const normalized = normalizeCreateMonitor(input, options.allowBrowserPage === true);
|
|
15402
|
+
if (this.mode === "hosted")
|
|
15403
|
+
assertHostedTargetAllowed(normalized);
|
|
14479
15404
|
const now = new Date().toISOString();
|
|
14480
15405
|
const monitor = {
|
|
14481
15406
|
id: newId("mon"),
|
|
@@ -14511,12 +15436,22 @@ class UptimeStore {
|
|
|
14511
15436
|
const row = this.db.query("SELECT * FROM monitors WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
14512
15437
|
return row ? monitorFromRow(row) : null;
|
|
14513
15438
|
}
|
|
14514
|
-
updateMonitor(idOrName, input) {
|
|
15439
|
+
updateMonitor(idOrName, input, options = {}) {
|
|
14515
15440
|
const current = this.getMonitor(idOrName);
|
|
14516
15441
|
if (!current)
|
|
14517
15442
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
15443
|
+
if (this.mode === "hosted") {
|
|
15444
|
+
assertHostedTargetAllowed({
|
|
15445
|
+
kind: input.kind ?? current.kind,
|
|
15446
|
+
url: input.url ?? current.url ?? undefined,
|
|
15447
|
+
host: input.host ?? current.host ?? undefined,
|
|
15448
|
+
port: input.port ?? current.port ?? undefined
|
|
15449
|
+
});
|
|
15450
|
+
}
|
|
14518
15451
|
const updatedAt = new Date().toISOString();
|
|
14519
|
-
const next = normalizeUpdateMonitor(current, input, updatedAt);
|
|
15452
|
+
const next = normalizeUpdateMonitor(current, input, updatedAt, options.allowBrowserPage === true);
|
|
15453
|
+
if (this.mode === "hosted")
|
|
15454
|
+
assertHostedTargetAllowed(next);
|
|
14520
15455
|
this.db.query(`UPDATE monitors SET
|
|
14521
15456
|
name = ?, kind = ?, url = ?, host = ?, port = ?, method = ?,
|
|
14522
15457
|
expected_status = ?, interval_seconds = ?, timeout_ms = ?,
|
|
@@ -14532,9 +15467,318 @@ class UptimeStore {
|
|
|
14532
15467
|
const current = this.getMonitor(idOrName);
|
|
14533
15468
|
if (!current)
|
|
14534
15469
|
return false;
|
|
14535
|
-
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
15470
|
+
this.db.query("DELETE FROM monitors WHERE id = ?").run(current.id);
|
|
15471
|
+
return true;
|
|
15472
|
+
}
|
|
15473
|
+
createProbeIdentity(input) {
|
|
15474
|
+
const name = input.name.trim();
|
|
15475
|
+
if (!name)
|
|
15476
|
+
throw new Error("Probe name is required");
|
|
15477
|
+
rejectControlCharacters2(name, "Probe name");
|
|
15478
|
+
const now = new Date().toISOString();
|
|
15479
|
+
const probe = {
|
|
15480
|
+
id: newId("prb"),
|
|
15481
|
+
name,
|
|
15482
|
+
publicKeyPem: input.publicKeyPem.trim(),
|
|
15483
|
+
publicKeyFingerprint: input.publicKeyFingerprint,
|
|
15484
|
+
enabled: input.enabled ?? true,
|
|
15485
|
+
createdAt: now,
|
|
15486
|
+
lastSeenAt: null
|
|
15487
|
+
};
|
|
15488
|
+
if (!probe.publicKeyPem)
|
|
15489
|
+
throw new Error("Probe public key is required");
|
|
15490
|
+
this.db.query(`INSERT INTO probe_identities (
|
|
15491
|
+
id, name, public_key_pem, public_key_fingerprint, enabled, created_at, last_seen_at
|
|
15492
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(probe.id, probe.name, probe.publicKeyPem, probe.publicKeyFingerprint, probe.enabled ? 1 : 0, probe.createdAt, probe.lastSeenAt);
|
|
15493
|
+
return probe;
|
|
15494
|
+
}
|
|
15495
|
+
listProbeIdentities(options = {}) {
|
|
15496
|
+
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();
|
|
15497
|
+
return rows.map(probeIdentityFromRow);
|
|
15498
|
+
}
|
|
15499
|
+
getProbeIdentity(idOrName) {
|
|
15500
|
+
const row = this.db.query("SELECT * FROM probe_identities WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
15501
|
+
return row ? probeIdentityFromRow(row) : null;
|
|
15502
|
+
}
|
|
15503
|
+
updateProbeIdentity(idOrName, input) {
|
|
15504
|
+
const current = this.getProbeIdentity(idOrName);
|
|
15505
|
+
if (!current)
|
|
15506
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
15507
|
+
const name = input.name === undefined ? current.name : input.name.trim();
|
|
15508
|
+
if (!name)
|
|
15509
|
+
throw new Error("Probe name is required");
|
|
15510
|
+
rejectControlCharacters2(name, "Probe name");
|
|
15511
|
+
const enabled = input.enabled ?? current.enabled;
|
|
15512
|
+
this.db.query("UPDATE probe_identities SET name = ?, enabled = ? WHERE id = ?").run(name, enabled ? 1 : 0, current.id);
|
|
15513
|
+
return this.getProbeIdentity(current.id);
|
|
15514
|
+
}
|
|
15515
|
+
touchProbeIdentity(idOrName, seenAt = new Date().toISOString()) {
|
|
15516
|
+
const probe = this.getProbeIdentity(idOrName);
|
|
15517
|
+
if (!probe)
|
|
15518
|
+
throw new Error(`Probe not found: ${idOrName}`);
|
|
15519
|
+
this.db.query("UPDATE probe_identities SET last_seen_at = ? WHERE id = ?").run(seenAt, probe.id);
|
|
15520
|
+
}
|
|
15521
|
+
createProbeCheckJob(input) {
|
|
15522
|
+
const monitor = this.getMonitor(input.monitorId);
|
|
15523
|
+
if (!monitor)
|
|
15524
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
15525
|
+
if (!monitor.enabled)
|
|
15526
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
15527
|
+
const scheduleSlot = normalizeScheduleSlot(input.scheduleSlot);
|
|
15528
|
+
const dueAt = input.dueAt ?? new Date().toISOString();
|
|
15529
|
+
assertIsoTimestamp(dueAt, "Probe job dueAt");
|
|
15530
|
+
const now = new Date().toISOString();
|
|
15531
|
+
const existing = this.db.query("SELECT * FROM probe_check_jobs WHERE monitor_id = ? AND schedule_slot = ?").get(monitor.id, scheduleSlot);
|
|
15532
|
+
if (existing)
|
|
15533
|
+
return probeCheckJobFromRow(existing);
|
|
15534
|
+
const job = {
|
|
15535
|
+
id: newId("job"),
|
|
15536
|
+
monitorId: monitor.id,
|
|
15537
|
+
monitorRevision: monitor.revision,
|
|
15538
|
+
scheduleSlot,
|
|
15539
|
+
status: "pending",
|
|
15540
|
+
claimedByProbeId: null,
|
|
15541
|
+
fencingToken: null,
|
|
15542
|
+
dueAt,
|
|
15543
|
+
claimedAt: null,
|
|
15544
|
+
leaseExpiresAt: null,
|
|
15545
|
+
submittedResultId: null,
|
|
15546
|
+
createdAt: now,
|
|
15547
|
+
updatedAt: now
|
|
15548
|
+
};
|
|
15549
|
+
this.db.query(`INSERT INTO probe_check_jobs (
|
|
15550
|
+
id, monitor_id, monitor_revision, schedule_slot, status, claimed_by_probe_id, fencing_token,
|
|
15551
|
+
due_at, claimed_at, lease_expires_at, submitted_result_id, created_at, updated_at
|
|
15552
|
+
) 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);
|
|
15553
|
+
return job;
|
|
15554
|
+
}
|
|
15555
|
+
getProbeCheckJob(id) {
|
|
15556
|
+
const row = this.db.query("SELECT * FROM probe_check_jobs WHERE id = ?").get(id);
|
|
15557
|
+
return row ? probeCheckJobFromRow(row) : null;
|
|
15558
|
+
}
|
|
15559
|
+
claimProbeCheckJob(input) {
|
|
15560
|
+
const tx = this.db.transaction(() => {
|
|
15561
|
+
const probe = this.getProbeIdentity(input.probeId);
|
|
15562
|
+
if (!probe)
|
|
15563
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
15564
|
+
if (!probe.enabled)
|
|
15565
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
15566
|
+
const current = this.getProbeCheckJob(input.jobId);
|
|
15567
|
+
if (!current)
|
|
15568
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
15569
|
+
const now = new Date;
|
|
15570
|
+
const nowIso = now.toISOString();
|
|
15571
|
+
if (current.status === "submitted")
|
|
15572
|
+
throw new Error("Probe job already submitted");
|
|
15573
|
+
if (current.status === "cancelled")
|
|
15574
|
+
throw new Error("Probe job is cancelled");
|
|
15575
|
+
if (current.dueAt > nowIso)
|
|
15576
|
+
throw new Error("Probe job is not due yet");
|
|
15577
|
+
const leaseExpired = Boolean(current.leaseExpiresAt && current.leaseExpiresAt <= nowIso);
|
|
15578
|
+
if (current.status === "claimed" && !leaseExpired && current.claimedByProbeId !== probe.id) {
|
|
15579
|
+
throw new Error("Probe job already claimed by another probe");
|
|
15580
|
+
}
|
|
15581
|
+
if (current.status !== "pending" && current.status !== "claimed" && current.status !== "expired") {
|
|
15582
|
+
throw new Error(`Probe job is not claimable: ${current.status}`);
|
|
15583
|
+
}
|
|
15584
|
+
const leaseExpiresAt = new Date(now.getTime() + Math.max(1000, input.leaseTtlMs ?? 120000)).toISOString();
|
|
15585
|
+
const fencingToken = newId("fence");
|
|
15586
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
15587
|
+
SET status = 'claimed', claimed_by_probe_id = ?, fencing_token = ?, claimed_at = ?, lease_expires_at = ?, updated_at = ?
|
|
15588
|
+
WHERE id = ?
|
|
15589
|
+
AND submitted_result_id IS NULL
|
|
15590
|
+
AND (
|
|
15591
|
+
status IN ('pending', 'expired')
|
|
15592
|
+
OR (status = 'claimed' AND (claimed_by_probe_id = ? OR lease_expires_at <= ?))
|
|
15593
|
+
)`).run(probe.id, fencingToken, nowIso, leaseExpiresAt, nowIso, current.id, probe.id, nowIso);
|
|
15594
|
+
if (statementChanges(update) !== 1)
|
|
15595
|
+
throw new Error("Probe job claim raced; retry");
|
|
15596
|
+
this.touchProbeIdentity(probe.id, nowIso);
|
|
15597
|
+
return this.getProbeCheckJob(current.id);
|
|
15598
|
+
});
|
|
15599
|
+
return tx();
|
|
15600
|
+
}
|
|
15601
|
+
completeProbeCheckJob(input) {
|
|
15602
|
+
const job = this.getProbeCheckJob(input.jobId);
|
|
15603
|
+
if (!job)
|
|
15604
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
15605
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
15606
|
+
if (job.status !== "claimed")
|
|
15607
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
15608
|
+
if (job.claimedByProbeId !== input.probeId)
|
|
15609
|
+
throw new Error("Probe job was claimed by another probe");
|
|
15610
|
+
if (job.fencingToken !== input.fencingToken)
|
|
15611
|
+
throw new Error("Probe job fencing token is invalid");
|
|
15612
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= submittedAt) {
|
|
15613
|
+
this.expireProbeCheckJob(job.id, submittedAt);
|
|
15614
|
+
throw new Error("Probe job lease expired");
|
|
15615
|
+
}
|
|
15616
|
+
const update = this.db.query(`UPDATE probe_check_jobs
|
|
15617
|
+
SET status = 'submitted', submitted_result_id = ?, updated_at = ?
|
|
15618
|
+
WHERE id = ?
|
|
15619
|
+
AND status = 'claimed'
|
|
15620
|
+
AND claimed_by_probe_id = ?
|
|
15621
|
+
AND fencing_token = ?
|
|
15622
|
+
AND lease_expires_at > ?
|
|
15623
|
+
AND submitted_result_id IS NULL`).run(input.checkResultId, submittedAt, job.id, input.probeId, input.fencingToken, submittedAt);
|
|
15624
|
+
if (statementChanges(update) !== 1)
|
|
15625
|
+
throw new Error("Probe job submission raced; retry");
|
|
15626
|
+
return this.getProbeCheckJob(job.id);
|
|
15627
|
+
}
|
|
15628
|
+
expireProbeCheckJob(jobId, updatedAt = new Date().toISOString()) {
|
|
15629
|
+
this.db.query("UPDATE probe_check_jobs SET status = 'expired', updated_at = ? WHERE id = ? AND status != 'submitted'").run(updatedAt, jobId);
|
|
15630
|
+
}
|
|
15631
|
+
getProbeSubmission(probeId, nonce) {
|
|
15632
|
+
const row = this.db.query("SELECT * FROM probe_submissions WHERE probe_id = ? AND nonce = ?").get(probeId, nonce);
|
|
15633
|
+
return row ? probeSubmissionFromRow(row) : null;
|
|
15634
|
+
}
|
|
15635
|
+
recordProbeSubmission(input) {
|
|
15636
|
+
const submittedAt = input.submittedAt ?? new Date().toISOString();
|
|
15637
|
+
const receipt = {
|
|
15638
|
+
id: newId("psb"),
|
|
15639
|
+
probeId: input.probeId,
|
|
15640
|
+
jobId: input.jobId,
|
|
15641
|
+
monitorId: input.monitorId,
|
|
15642
|
+
checkResultId: input.checkResultId,
|
|
15643
|
+
nonce: input.nonce,
|
|
15644
|
+
checkedAt: input.checkedAt,
|
|
15645
|
+
submittedAt
|
|
15646
|
+
};
|
|
15647
|
+
this.db.query(`INSERT INTO probe_submissions (
|
|
15648
|
+
id, probe_id, job_id, monitor_id, check_result_id, nonce, checked_at, submitted_at
|
|
15649
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(receipt.id, receipt.probeId, receipt.jobId, receipt.monitorId, receipt.checkResultId, receipt.nonce, receipt.checkedAt, receipt.submittedAt);
|
|
15650
|
+
return receipt;
|
|
15651
|
+
}
|
|
15652
|
+
createReportSchedule(input) {
|
|
15653
|
+
const normalized = normalizeReportScheduleInput(input);
|
|
15654
|
+
const now = new Date().toISOString();
|
|
15655
|
+
const schedule = {
|
|
15656
|
+
id: newId("rps"),
|
|
15657
|
+
name: normalized.name,
|
|
15658
|
+
enabled: normalized.enabled,
|
|
15659
|
+
intervalSeconds: normalized.intervalSeconds,
|
|
15660
|
+
nextRunAt: normalized.nextRunAt,
|
|
15661
|
+
lastRunAt: null,
|
|
15662
|
+
subject: normalized.subject,
|
|
15663
|
+
channels: normalized.channels,
|
|
15664
|
+
createdAt: now,
|
|
15665
|
+
updatedAt: now
|
|
15666
|
+
};
|
|
15667
|
+
this.db.query(`INSERT INTO report_schedules (
|
|
15668
|
+
id, name, enabled, interval_seconds, next_run_at, last_run_at,
|
|
15669
|
+
subject, channels_json, created_at, updated_at
|
|
15670
|
+
) 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);
|
|
15671
|
+
return schedule;
|
|
15672
|
+
}
|
|
15673
|
+
listReportSchedules(options = {}) {
|
|
15674
|
+
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();
|
|
15675
|
+
return rows.map(reportScheduleFromRow);
|
|
15676
|
+
}
|
|
15677
|
+
listDueReportSchedules(nowIso = new Date().toISOString()) {
|
|
15678
|
+
assertIsoTimestamp(nowIso, "Report schedule due timestamp");
|
|
15679
|
+
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);
|
|
15680
|
+
return rows.map(reportScheduleFromRow);
|
|
15681
|
+
}
|
|
15682
|
+
getReportSchedule(idOrName) {
|
|
15683
|
+
const row = this.db.query("SELECT * FROM report_schedules WHERE id = ? OR name = ?").get(idOrName, idOrName);
|
|
15684
|
+
return row ? reportScheduleFromRow(row) : null;
|
|
15685
|
+
}
|
|
15686
|
+
updateReportSchedule(idOrName, input) {
|
|
15687
|
+
const current = this.getReportSchedule(idOrName);
|
|
15688
|
+
if (!current)
|
|
15689
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
15690
|
+
const normalized = normalizeReportScheduleInput({
|
|
15691
|
+
name: input.name ?? current.name,
|
|
15692
|
+
intervalSeconds: input.intervalSeconds ?? current.intervalSeconds,
|
|
15693
|
+
nextRunAt: input.nextRunAt ?? current.nextRunAt,
|
|
15694
|
+
enabled: input.enabled ?? current.enabled,
|
|
15695
|
+
subject: input.subject === undefined ? current.subject : input.subject,
|
|
15696
|
+
channels: input.channels ?? current.channels
|
|
15697
|
+
});
|
|
15698
|
+
const updatedAt = new Date().toISOString();
|
|
15699
|
+
this.db.query(`UPDATE report_schedules SET
|
|
15700
|
+
name = ?, enabled = ?, interval_seconds = ?, next_run_at = ?,
|
|
15701
|
+
subject = ?, channels_json = ?, updated_at = ?
|
|
15702
|
+
WHERE id = ?`).run(normalized.name, normalized.enabled ? 1 : 0, normalized.intervalSeconds, normalized.nextRunAt, normalized.subject, JSON.stringify(normalized.channels), updatedAt, current.id);
|
|
15703
|
+
return this.getReportSchedule(current.id);
|
|
15704
|
+
}
|
|
15705
|
+
deleteReportSchedule(idOrName) {
|
|
15706
|
+
const current = this.getReportSchedule(idOrName);
|
|
15707
|
+
if (!current)
|
|
15708
|
+
return false;
|
|
15709
|
+
this.db.query("DELETE FROM report_schedules WHERE id = ?").run(current.id);
|
|
14536
15710
|
return true;
|
|
14537
15711
|
}
|
|
15712
|
+
recordReportRun(input) {
|
|
15713
|
+
const startedAt = input.startedAt ?? new Date().toISOString();
|
|
15714
|
+
const finishedAt = input.finishedAt ?? new Date().toISOString();
|
|
15715
|
+
assertIsoTimestamp(startedAt, "Report run startedAt");
|
|
15716
|
+
assertIsoTimestamp(finishedAt, "Report run finishedAt");
|
|
15717
|
+
if (input.status !== "success" && input.status !== "failed") {
|
|
15718
|
+
throw new Error("Report run status must be success or failed");
|
|
15719
|
+
}
|
|
15720
|
+
if (input.scheduleId && !this.getReportSchedule(input.scheduleId)) {
|
|
15721
|
+
throw new Error(`Report schedule not found: ${input.scheduleId}`);
|
|
15722
|
+
}
|
|
15723
|
+
const run = {
|
|
15724
|
+
id: newId("rpr"),
|
|
15725
|
+
scheduleId: input.scheduleId ?? null,
|
|
15726
|
+
status: input.status,
|
|
15727
|
+
startedAt,
|
|
15728
|
+
finishedAt,
|
|
15729
|
+
deliveries: normalizeReportDeliveries(input.deliveries ?? []),
|
|
15730
|
+
error: normalizeNullableRedactedText(input.error, "Report run error", 1000),
|
|
15731
|
+
reportJson: input.reportJson ?? null
|
|
15732
|
+
};
|
|
15733
|
+
this.db.query(`INSERT INTO report_runs (
|
|
15734
|
+
id, schedule_id, status, started_at, finished_at, deliveries_json,
|
|
15735
|
+
error, report_json
|
|
15736
|
+
) 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);
|
|
15737
|
+
if (run.scheduleId) {
|
|
15738
|
+
this.advanceReportSchedule(run.scheduleId, run.finishedAt);
|
|
15739
|
+
}
|
|
15740
|
+
return run;
|
|
15741
|
+
}
|
|
15742
|
+
listReportRuns(options = {}) {
|
|
15743
|
+
const limit = clampLimit(options.limit ?? 50);
|
|
15744
|
+
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);
|
|
15745
|
+
return rows.map(reportRunFromRow);
|
|
15746
|
+
}
|
|
15747
|
+
recordAuditEvent(input) {
|
|
15748
|
+
const action = normalizeAuditText(input.action, "Audit action", 160);
|
|
15749
|
+
const createdAt = input.createdAt ?? new Date().toISOString();
|
|
15750
|
+
assertIsoTimestamp(createdAt, "Audit event createdAt");
|
|
15751
|
+
const event = {
|
|
15752
|
+
id: newId("aud"),
|
|
15753
|
+
action,
|
|
15754
|
+
resourceType: normalizeNullableAuditText(input.resourceType, "Audit resourceType", 80),
|
|
15755
|
+
resourceId: normalizeNullableAuditText(input.resourceId, "Audit resourceId", 160),
|
|
15756
|
+
message: normalizeNullableAuditText(input.message, "Audit message", 500),
|
|
15757
|
+
metadata: normalizeAuditMetadata(input.metadata ?? {}),
|
|
15758
|
+
actor: normalizeNullableAuditText(input.actor, "Audit actor", 160),
|
|
15759
|
+
createdAt
|
|
15760
|
+
};
|
|
15761
|
+
this.db.query(`INSERT INTO audit_events (
|
|
15762
|
+
id, action, resource_type, resource_id, message, metadata_json, actor, created_at
|
|
15763
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(event.id, event.action, event.resourceType, event.resourceId, event.message, JSON.stringify(event.metadata), event.actor, event.createdAt);
|
|
15764
|
+
return event;
|
|
15765
|
+
}
|
|
15766
|
+
listAuditEvents(options = {}) {
|
|
15767
|
+
const clauses = [];
|
|
15768
|
+
const args = [];
|
|
15769
|
+
if (options.resourceType) {
|
|
15770
|
+
clauses.push("resource_type = ?");
|
|
15771
|
+
args.push(options.resourceType);
|
|
15772
|
+
}
|
|
15773
|
+
if (options.resourceId) {
|
|
15774
|
+
clauses.push("resource_id = ?");
|
|
15775
|
+
args.push(options.resourceId);
|
|
15776
|
+
}
|
|
15777
|
+
const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
15778
|
+
args.push(clampLimit(options.limit ?? 50));
|
|
15779
|
+
const rows = this.db.query(`SELECT * FROM audit_events ${where} ORDER BY created_at DESC, id DESC LIMIT ?`).all(...args);
|
|
15780
|
+
return rows.map(auditEventFromRow);
|
|
15781
|
+
}
|
|
14538
15782
|
acquireCheckLease(monitorId, owner, ttlMs) {
|
|
14539
15783
|
const now = new Date;
|
|
14540
15784
|
const nowIso = now.toISOString();
|
|
@@ -14569,7 +15813,8 @@ class UptimeStore {
|
|
|
14569
15813
|
latencyMs: input.latencyMs,
|
|
14570
15814
|
statusCode: input.statusCode,
|
|
14571
15815
|
error: input.error,
|
|
14572
|
-
attemptCount: Math.max(1, input.attemptCount)
|
|
15816
|
+
attemptCount: Math.max(1, input.attemptCount),
|
|
15817
|
+
evidence: input.evidence ?? null
|
|
14573
15818
|
};
|
|
14574
15819
|
const tx = this.db.transaction(() => {
|
|
14575
15820
|
const current = this.db.query("SELECT * FROM monitors WHERE id = ?").get(result.monitorId);
|
|
@@ -14582,19 +15827,59 @@ class UptimeStore {
|
|
|
14582
15827
|
throw new StaleCheckResultError(`Monitor was disabled while check was in progress: ${current.name}`);
|
|
14583
15828
|
}
|
|
14584
15829
|
this.db.query(`INSERT INTO check_results (
|
|
14585
|
-
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count
|
|
14586
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(result.id, result.monitorId, result.checkedAt, result.status, result.latencyMs, result.statusCode, result.error, result.attemptCount);
|
|
15830
|
+
id, monitor_id, checked_at, status, latency_ms, status_code, error, attempt_count, evidence_json
|
|
15831
|
+
) 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);
|
|
14587
15832
|
this.db.query("UPDATE monitors SET status = ?, last_checked_at = ?, updated_at = ? WHERE id = ?").run(result.status, result.checkedAt, result.checkedAt, result.monitorId);
|
|
14588
15833
|
this.reconcileIncidentInTransaction(result);
|
|
14589
15834
|
});
|
|
14590
15835
|
tx();
|
|
14591
15836
|
return result;
|
|
14592
15837
|
}
|
|
15838
|
+
getCheckResult(id) {
|
|
15839
|
+
const row = this.db.query("SELECT * FROM check_results WHERE id = ?").get(id);
|
|
15840
|
+
return row ? checkResultFromRow(row) : null;
|
|
15841
|
+
}
|
|
14593
15842
|
listResults(options = {}) {
|
|
14594
15843
|
const limit = clampLimit(options.limit ?? 50);
|
|
14595
15844
|
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);
|
|
14596
15845
|
return rows.map(checkResultFromRow);
|
|
14597
15846
|
}
|
|
15847
|
+
getProvenance(source, sourceId) {
|
|
15848
|
+
const row = this.db.query("SELECT * FROM monitor_provenance WHERE source = ? AND source_id = ?").get(source, sourceId);
|
|
15849
|
+
return row ? provenanceFromRow(row) : null;
|
|
15850
|
+
}
|
|
15851
|
+
upsertMonitorProvenance(input) {
|
|
15852
|
+
const importedAt = new Date().toISOString();
|
|
15853
|
+
this.db.query(`INSERT INTO monitor_provenance (
|
|
15854
|
+
monitor_id, source, source_id, source_label, imported_at, snapshot_json
|
|
15855
|
+
) VALUES (?, ?, ?, ?, ?, ?)
|
|
15856
|
+
ON CONFLICT(source, source_id) DO UPDATE SET
|
|
15857
|
+
monitor_id = excluded.monitor_id,
|
|
15858
|
+
source_label = excluded.source_label,
|
|
15859
|
+
imported_at = excluded.imported_at,
|
|
15860
|
+
snapshot_json = excluded.snapshot_json`).run(input.monitorId, input.source, input.sourceId, input.sourceLabel ?? null, importedAt, JSON.stringify(input.snapshot));
|
|
15861
|
+
return this.getProvenance(input.source, input.sourceId);
|
|
15862
|
+
}
|
|
15863
|
+
saveImportBatch(input) {
|
|
15864
|
+
const createdAt = new Date().toISOString();
|
|
15865
|
+
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));
|
|
15866
|
+
return this.getImportBatch(input.id);
|
|
15867
|
+
}
|
|
15868
|
+
getImportBatch(batchId) {
|
|
15869
|
+
const row = this.db.query("SELECT * FROM import_batches WHERE id = ?").get(batchId);
|
|
15870
|
+
return row ? importBatchFromRow(row) : null;
|
|
15871
|
+
}
|
|
15872
|
+
markImportBatchRolledBack(batchId) {
|
|
15873
|
+
const rolledBackAt = new Date().toISOString();
|
|
15874
|
+
this.db.query("UPDATE import_batches SET status = 'rolled_back', rolled_back_at = ? WHERE id = ?").run(rolledBackAt, batchId);
|
|
15875
|
+
const batch = this.getImportBatch(batchId);
|
|
15876
|
+
if (!batch)
|
|
15877
|
+
throw new Error(`Import batch not found: ${batchId}`);
|
|
15878
|
+
return batch;
|
|
15879
|
+
}
|
|
15880
|
+
runInTransaction(fn) {
|
|
15881
|
+
return this.db.transaction(fn)();
|
|
15882
|
+
}
|
|
14598
15883
|
listIncidents(options = {}) {
|
|
14599
15884
|
const clauses = [];
|
|
14600
15885
|
const args = [];
|
|
@@ -14676,22 +15961,134 @@ class UptimeStore {
|
|
|
14676
15961
|
closeOpenIncident(monitorId, closedAt) {
|
|
14677
15962
|
this.db.query("UPDATE incidents SET status = 'closed', closed_at = ? WHERE monitor_id = ? AND status = 'open'").run(closedAt, monitorId);
|
|
14678
15963
|
}
|
|
15964
|
+
advanceReportSchedule(scheduleId, finishedAt) {
|
|
15965
|
+
const schedule = this.getReportSchedule(scheduleId);
|
|
15966
|
+
if (!schedule)
|
|
15967
|
+
throw new Error(`Report schedule not found: ${scheduleId}`);
|
|
15968
|
+
const finishedMs = Date.parse(finishedAt);
|
|
15969
|
+
let nextMs = Math.max(Date.parse(schedule.nextRunAt), finishedMs);
|
|
15970
|
+
do {
|
|
15971
|
+
nextMs += schedule.intervalSeconds * 1000;
|
|
15972
|
+
} while (nextMs <= finishedMs);
|
|
15973
|
+
const nextRunAt = new Date(nextMs).toISOString();
|
|
15974
|
+
this.db.query("UPDATE report_schedules SET last_run_at = ?, next_run_at = ?, updated_at = ? WHERE id = ?").run(finishedAt, nextRunAt, finishedAt, schedule.id);
|
|
15975
|
+
}
|
|
14679
15976
|
ensureColumn(table, name, definition) {
|
|
14680
15977
|
const columns = this.db.query(`PRAGMA table_info(${table})`).all();
|
|
14681
15978
|
if (!columns.some((column) => column.name === name)) {
|
|
14682
15979
|
this.db.run(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
|
14683
15980
|
}
|
|
14684
15981
|
}
|
|
15982
|
+
ensureMonitorKindAllowsBrowserPage() {
|
|
15983
|
+
const row = this.db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'monitors'").get();
|
|
15984
|
+
if (!row?.sql || row.sql.includes("browser_page"))
|
|
15985
|
+
return;
|
|
15986
|
+
this.db.run("PRAGMA foreign_keys = OFF");
|
|
15987
|
+
this.db.run("PRAGMA legacy_alter_table = ON");
|
|
15988
|
+
try {
|
|
15989
|
+
const migrate = this.db.transaction(() => {
|
|
15990
|
+
this.db.run("ALTER TABLE monitors RENAME TO monitors_old_kind");
|
|
15991
|
+
this.db.run(`
|
|
15992
|
+
CREATE TABLE monitors (
|
|
15993
|
+
id TEXT PRIMARY KEY,
|
|
15994
|
+
name TEXT NOT NULL UNIQUE,
|
|
15995
|
+
kind TEXT NOT NULL CHECK (kind IN ('http', 'tcp', 'browser_page')),
|
|
15996
|
+
url TEXT,
|
|
15997
|
+
host TEXT,
|
|
15998
|
+
port INTEGER,
|
|
15999
|
+
method TEXT NOT NULL DEFAULT 'GET',
|
|
16000
|
+
expected_status INTEGER,
|
|
16001
|
+
interval_seconds INTEGER NOT NULL DEFAULT 60,
|
|
16002
|
+
timeout_ms INTEGER NOT NULL DEFAULT 5000,
|
|
16003
|
+
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
16004
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
16005
|
+
status TEXT NOT NULL DEFAULT 'unknown',
|
|
16006
|
+
last_checked_at TEXT,
|
|
16007
|
+
revision INTEGER NOT NULL DEFAULT 1,
|
|
16008
|
+
created_at TEXT NOT NULL,
|
|
16009
|
+
updated_at TEXT NOT NULL
|
|
16010
|
+
)
|
|
16011
|
+
`);
|
|
16012
|
+
this.db.run(`
|
|
16013
|
+
INSERT INTO monitors (
|
|
16014
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
16015
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
16016
|
+
last_checked_at, revision, created_at, updated_at
|
|
16017
|
+
)
|
|
16018
|
+
SELECT
|
|
16019
|
+
id, name, kind, url, host, port, method, expected_status,
|
|
16020
|
+
interval_seconds, timeout_ms, retry_count, enabled, status,
|
|
16021
|
+
last_checked_at, revision, created_at, updated_at
|
|
16022
|
+
FROM monitors_old_kind
|
|
16023
|
+
`);
|
|
16024
|
+
this.db.run("DROP TABLE monitors_old_kind");
|
|
16025
|
+
});
|
|
16026
|
+
migrate();
|
|
16027
|
+
} finally {
|
|
16028
|
+
this.db.run("PRAGMA legacy_alter_table = OFF");
|
|
16029
|
+
this.db.run("PRAGMA foreign_keys = ON");
|
|
16030
|
+
}
|
|
16031
|
+
}
|
|
16032
|
+
vacuumInto(backupPath) {
|
|
16033
|
+
const quoted = backupPath.replace(/'/g, "''");
|
|
16034
|
+
this.db.run(`VACUUM INTO '${quoted}'`);
|
|
16035
|
+
}
|
|
16036
|
+
}
|
|
16037
|
+
function resolveRuntimeMode(mode) {
|
|
16038
|
+
const value = mode ?? process.env.HASNA_UPTIME_MODE ?? "local";
|
|
16039
|
+
if (value === "local" || value === "hosted")
|
|
16040
|
+
return value;
|
|
16041
|
+
throw new Error("HASNA_UPTIME_MODE must be local or hosted");
|
|
16042
|
+
}
|
|
16043
|
+
function allowHostedLocalStore(value) {
|
|
16044
|
+
return value === true || process.env.HASNA_UPTIME_ALLOW_HOSTED_LOCAL_STORE === "1";
|
|
16045
|
+
}
|
|
16046
|
+
function verifyBackupFile(backupPath) {
|
|
16047
|
+
const db = new Database(backupPath, { readonly: true });
|
|
16048
|
+
try {
|
|
16049
|
+
const integrityRow = db.query("PRAGMA integrity_check").get();
|
|
16050
|
+
const integrity = String(integrityRow?.integrity_check ?? "unknown");
|
|
16051
|
+
const missingTables = REQUIRED_TABLES.filter((table) => !tableExists(db, table));
|
|
16052
|
+
const schemaVersion = missingTables.includes("schema_migrations") ? null : db.query("SELECT value FROM schema_migrations WHERE key = 'schema_version'").get()?.value ?? null;
|
|
16053
|
+
const currentOk = missingTables.length === 0 && schemaVersion === CURRENT_SCHEMA_VERSION;
|
|
16054
|
+
const restorableV1 = schemaVersion === "1" && missingTables.every((table) => PROBE_TABLES.has(table) || REPORT_AUDIT_TABLES.has(table));
|
|
16055
|
+
const restorableV2 = schemaVersion === "2" && missingTables.every((table) => REPORT_AUDIT_TABLES.has(table));
|
|
16056
|
+
return {
|
|
16057
|
+
ok: integrity === "ok" && (currentOk || restorableV1 || restorableV2),
|
|
16058
|
+
backupPath,
|
|
16059
|
+
integrity,
|
|
16060
|
+
schemaVersion,
|
|
16061
|
+
missingTables,
|
|
16062
|
+
monitors: tableCount(db, "monitors"),
|
|
16063
|
+
results: tableCount(db, "check_results"),
|
|
16064
|
+
incidents: tableCount(db, "incidents")
|
|
16065
|
+
};
|
|
16066
|
+
} finally {
|
|
16067
|
+
db.close();
|
|
16068
|
+
}
|
|
16069
|
+
}
|
|
16070
|
+
function tableCount(db, table) {
|
|
16071
|
+
if (!tableExists(db, table))
|
|
16072
|
+
return 0;
|
|
16073
|
+
const row = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get();
|
|
16074
|
+
return Number(row?.count ?? 0);
|
|
16075
|
+
}
|
|
16076
|
+
function tableExists(db, table) {
|
|
16077
|
+
const row = db.query("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
16078
|
+
return Number(row?.count ?? 0) > 0;
|
|
14685
16079
|
}
|
|
14686
|
-
function normalizeCreateMonitor(input) {
|
|
16080
|
+
function normalizeCreateMonitor(input, allowBrowserPage = false) {
|
|
14687
16081
|
const name = input.name?.trim();
|
|
14688
16082
|
if (!name)
|
|
14689
16083
|
throw new Error("Monitor name is required");
|
|
14690
|
-
|
|
16084
|
+
rejectControlCharacters2(name, "Monitor name");
|
|
14691
16085
|
const method = normalizeMethod(input.method ?? "GET");
|
|
14692
16086
|
const expectedStatus = normalizeExpectedStatus(input.expectedStatus);
|
|
14693
16087
|
const enabled = normalizeEnabled(input.enabled);
|
|
14694
|
-
if (input.kind === "http") {
|
|
16088
|
+
if (input.kind === "http" || input.kind === "browser_page") {
|
|
16089
|
+
if (input.kind === "browser_page" && !allowBrowserPage) {
|
|
16090
|
+
throw new Error("browser_page monitors must be imported with explicit browser evidence support");
|
|
16091
|
+
}
|
|
14695
16092
|
const url2 = normalizeHttpUrl(input.url);
|
|
14696
16093
|
return {
|
|
14697
16094
|
name,
|
|
@@ -14699,16 +16096,16 @@ function normalizeCreateMonitor(input) {
|
|
|
14699
16096
|
url: url2,
|
|
14700
16097
|
method,
|
|
14701
16098
|
expectedStatus,
|
|
14702
|
-
intervalSeconds:
|
|
14703
|
-
timeoutMs:
|
|
14704
|
-
retryCount:
|
|
16099
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
16100
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
16101
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
14705
16102
|
enabled
|
|
14706
16103
|
};
|
|
14707
16104
|
} else if (input.kind === "tcp") {
|
|
14708
16105
|
const host = input.host?.trim();
|
|
14709
16106
|
if (!host)
|
|
14710
16107
|
throw new Error("TCP monitors require host");
|
|
14711
|
-
|
|
16108
|
+
rejectControlCharacters2(host, "TCP host");
|
|
14712
16109
|
if (!Number.isInteger(input.port) || input.port <= 0 || input.port > 65535) {
|
|
14713
16110
|
throw new Error("TCP monitors require a port from 1 to 65535");
|
|
14714
16111
|
}
|
|
@@ -14719,19 +16116,19 @@ function normalizeCreateMonitor(input) {
|
|
|
14719
16116
|
port: input.port,
|
|
14720
16117
|
method,
|
|
14721
16118
|
expectedStatus: null,
|
|
14722
|
-
intervalSeconds:
|
|
14723
|
-
timeoutMs:
|
|
14724
|
-
retryCount:
|
|
16119
|
+
intervalSeconds: boundedInteger2(input.intervalSeconds ?? 60, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS),
|
|
16120
|
+
timeoutMs: boundedInteger2(input.timeoutMs ?? 5000, "timeoutMs", MIN_TIMEOUT_MS, MAX_TIMEOUT_MS),
|
|
16121
|
+
retryCount: boundedInteger2(input.retryCount ?? 0, "retryCount", MIN_RETRY_COUNT, MAX_RETRY_COUNT),
|
|
14725
16122
|
enabled
|
|
14726
16123
|
};
|
|
14727
16124
|
} else {
|
|
14728
|
-
throw new Error("Monitor kind must be http or
|
|
16125
|
+
throw new Error("Monitor kind must be http, tcp, or browser_page");
|
|
14729
16126
|
}
|
|
14730
16127
|
}
|
|
14731
16128
|
function definitionChanged(current, next) {
|
|
14732
16129
|
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;
|
|
14733
16130
|
}
|
|
14734
|
-
function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
16131
|
+
function normalizeUpdateMonitor(current, input, updatedAt, allowBrowserPage = false) {
|
|
14735
16132
|
const merged = {
|
|
14736
16133
|
...current,
|
|
14737
16134
|
...input,
|
|
@@ -14750,7 +16147,7 @@ function normalizeUpdateMonitor(current, input, updatedAt) {
|
|
|
14750
16147
|
timeoutMs: merged.timeoutMs,
|
|
14751
16148
|
retryCount: merged.retryCount,
|
|
14752
16149
|
enabled: merged.enabled
|
|
14753
|
-
});
|
|
16150
|
+
}, allowBrowserPage || current.kind === "browser_page");
|
|
14754
16151
|
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;
|
|
14755
16152
|
const status = normalized.enabled ? checkDefinitionChanged || !current.enabled ? "unknown" : current.status : "paused";
|
|
14756
16153
|
return {
|
|
@@ -14779,6 +16176,11 @@ function normalizeHttpUrl(value) {
|
|
|
14779
16176
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
14780
16177
|
throw new Error("HTTP monitor url must use http or https");
|
|
14781
16178
|
}
|
|
16179
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
16180
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
16181
|
+
parsed.searchParams.set(key, "[redacted]");
|
|
16182
|
+
}
|
|
16183
|
+
parsed.hash = "";
|
|
14782
16184
|
return parsed.toString();
|
|
14783
16185
|
}
|
|
14784
16186
|
function normalizeMethod(value) {
|
|
@@ -14802,11 +16204,194 @@ function normalizeEnabled(value) {
|
|
|
14802
16204
|
throw new Error("enabled must be a boolean");
|
|
14803
16205
|
return value;
|
|
14804
16206
|
}
|
|
14805
|
-
function
|
|
16207
|
+
function rejectControlCharacters2(value, label) {
|
|
14806
16208
|
if (/[\x00-\x1f\x7f-\x9f]/.test(value)) {
|
|
14807
16209
|
throw new Error(`${label} must not contain control characters`);
|
|
14808
16210
|
}
|
|
14809
16211
|
}
|
|
16212
|
+
function normalizeScheduleSlot(value) {
|
|
16213
|
+
const slot = value.trim();
|
|
16214
|
+
if (!slot)
|
|
16215
|
+
throw new Error("Probe job scheduleSlot is required");
|
|
16216
|
+
if (slot.length > 128)
|
|
16217
|
+
throw new Error("Probe job scheduleSlot is too long");
|
|
16218
|
+
rejectControlCharacters2(slot, "Probe job scheduleSlot");
|
|
16219
|
+
return slot;
|
|
16220
|
+
}
|
|
16221
|
+
function normalizeReportScheduleInput(input) {
|
|
16222
|
+
const name = input.name?.trim();
|
|
16223
|
+
if (!name)
|
|
16224
|
+
throw new Error("Report schedule name is required");
|
|
16225
|
+
rejectControlCharacters2(name, "Report schedule name");
|
|
16226
|
+
const intervalSeconds = boundedInteger2(input.intervalSeconds, "intervalSeconds", MIN_INTERVAL_SECONDS, MAX_INTERVAL_SECONDS);
|
|
16227
|
+
const nextRunAt = input.nextRunAt ?? new Date().toISOString();
|
|
16228
|
+
assertIsoTimestamp(nextRunAt, "Report schedule nextRunAt");
|
|
16229
|
+
const enabled = normalizeEnabled(input.enabled);
|
|
16230
|
+
const subject = normalizeNullableBoundedText(input.subject, "Report schedule subject", 200);
|
|
16231
|
+
const channels = normalizeReportChannels(input.channels);
|
|
16232
|
+
return { name, intervalSeconds, nextRunAt, enabled, subject, channels };
|
|
16233
|
+
}
|
|
16234
|
+
function normalizeReportChannels(channels) {
|
|
16235
|
+
if (!channels || typeof channels !== "object")
|
|
16236
|
+
throw new Error("Report schedule channels are required");
|
|
16237
|
+
const normalized = {};
|
|
16238
|
+
if (channels.email !== undefined)
|
|
16239
|
+
normalized.email = normalizeChannelTarget(channels.email, "email", ["apiUrl", "from", "to", "subject", "providerId"]);
|
|
16240
|
+
if (channels.sms !== undefined)
|
|
16241
|
+
normalized.sms = normalizeChannelTarget(channels.sms, "sms", ["apiUrl", "from", "to"]);
|
|
16242
|
+
if (channels.logs !== undefined)
|
|
16243
|
+
normalized.logs = normalizeChannelTarget(channels.logs, "logs", ["apiUrl", "projectId", "environment", "service"]);
|
|
16244
|
+
if (!normalized.email && !normalized.sms && !normalized.logs) {
|
|
16245
|
+
throw new Error("Report schedule requires at least one channel");
|
|
16246
|
+
}
|
|
16247
|
+
return normalized;
|
|
16248
|
+
}
|
|
16249
|
+
function normalizeChannelTarget(value, channel, allowedKeys) {
|
|
16250
|
+
if (value === false || value == null)
|
|
16251
|
+
return false;
|
|
16252
|
+
if (value === true)
|
|
16253
|
+
return true;
|
|
16254
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
16255
|
+
throw new Error(`Report schedule ${channel} channel must be true or an object`);
|
|
16256
|
+
}
|
|
16257
|
+
const record2 = value;
|
|
16258
|
+
const normalized = {};
|
|
16259
|
+
for (const [key, rawValue] of Object.entries(record2)) {
|
|
16260
|
+
if (!allowedKeys.includes(key)) {
|
|
16261
|
+
if (/key|token|secret|password|credential|auth/i.test(key)) {
|
|
16262
|
+
throw new Error("Report schedules must not persist API keys or tokens; use environment variables or cloud channel refs");
|
|
16263
|
+
}
|
|
16264
|
+
throw new Error(`Unsupported report schedule ${channel} channel field: ${key}`);
|
|
16265
|
+
}
|
|
16266
|
+
if (rawValue === undefined || rawValue === null || rawValue === "")
|
|
16267
|
+
continue;
|
|
16268
|
+
if (key === "apiUrl" && Array.isArray(rawValue)) {
|
|
16269
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string`);
|
|
16270
|
+
}
|
|
16271
|
+
if (Array.isArray(rawValue)) {
|
|
16272
|
+
const items = rawValue.map((item) => normalizeBoundedText(String(item), `Report schedule ${channel}.${key}`, 300));
|
|
16273
|
+
if (items.length > 0)
|
|
16274
|
+
normalized[key] = items;
|
|
16275
|
+
} else if (typeof rawValue === "string" || typeof rawValue === "number") {
|
|
16276
|
+
normalized[key] = key === "apiUrl" ? normalizeHttpIntegrationUrl(String(rawValue)) : normalizeBoundedText(String(rawValue), `Report schedule ${channel}.${key}`, 500);
|
|
16277
|
+
} else {
|
|
16278
|
+
throw new Error(`Report schedule ${channel}.${key} must be a string or string array`);
|
|
16279
|
+
}
|
|
16280
|
+
}
|
|
16281
|
+
return Object.keys(normalized).length > 0 ? normalized : true;
|
|
16282
|
+
}
|
|
16283
|
+
function normalizeHttpIntegrationUrl(value) {
|
|
16284
|
+
const parsed = new URL(value.trim());
|
|
16285
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
16286
|
+
throw new Error("Report schedule integration API URL must use http or https");
|
|
16287
|
+
}
|
|
16288
|
+
if (parsed.username || parsed.password) {
|
|
16289
|
+
throw new Error("Report schedule integration API URL must not include credentials");
|
|
16290
|
+
}
|
|
16291
|
+
for (const key of parsed.searchParams.keys()) {
|
|
16292
|
+
if (SECRET_URL_PARAM_PATTERN.test(key)) {
|
|
16293
|
+
throw new Error("Report schedule integration API URL must not include secret query parameters");
|
|
16294
|
+
}
|
|
16295
|
+
}
|
|
16296
|
+
parsed.hash = "";
|
|
16297
|
+
return parsed.toString();
|
|
16298
|
+
}
|
|
16299
|
+
function normalizeReportDeliveries(deliveries) {
|
|
16300
|
+
return deliveries.map((delivery) => {
|
|
16301
|
+
if (delivery.channel !== "email" && delivery.channel !== "sms" && delivery.channel !== "logs") {
|
|
16302
|
+
throw new Error("Report delivery channel must be email, sms, or logs");
|
|
16303
|
+
}
|
|
16304
|
+
return {
|
|
16305
|
+
channel: delivery.channel,
|
|
16306
|
+
ok: Boolean(delivery.ok),
|
|
16307
|
+
status: delivery.status,
|
|
16308
|
+
id: delivery.id === undefined ? undefined : normalizeRedactedText(String(delivery.id), "Report delivery id", 300),
|
|
16309
|
+
error: delivery.error === undefined ? undefined : normalizeRedactedText(String(delivery.error), "Report delivery error", 1000)
|
|
16310
|
+
};
|
|
16311
|
+
});
|
|
16312
|
+
}
|
|
16313
|
+
function normalizeAuditText(value, label, maxLength) {
|
|
16314
|
+
return normalizeBoundedText(value ?? "", label, maxLength);
|
|
16315
|
+
}
|
|
16316
|
+
function normalizeNullableAuditText(value, label, maxLength) {
|
|
16317
|
+
return normalizeNullableBoundedText(value, label, maxLength);
|
|
16318
|
+
}
|
|
16319
|
+
function normalizeNullableBoundedText(value, label, maxLength) {
|
|
16320
|
+
if (value == null)
|
|
16321
|
+
return null;
|
|
16322
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
16323
|
+
return normalized || null;
|
|
16324
|
+
}
|
|
16325
|
+
function normalizeBoundedText(value, label, maxLength) {
|
|
16326
|
+
const normalized = value.trim();
|
|
16327
|
+
rejectControlCharacters2(normalized, label);
|
|
16328
|
+
if (normalized.length > maxLength)
|
|
16329
|
+
throw new Error(`${label} is too long`);
|
|
16330
|
+
return normalized;
|
|
16331
|
+
}
|
|
16332
|
+
function normalizeNullableRedactedText(value, label, maxLength) {
|
|
16333
|
+
if (value == null)
|
|
16334
|
+
return null;
|
|
16335
|
+
const normalized = normalizeRedactedText(value, label, maxLength);
|
|
16336
|
+
return normalized || null;
|
|
16337
|
+
}
|
|
16338
|
+
function normalizeRedactedText(value, label, maxLength) {
|
|
16339
|
+
return normalizeBoundedText(redactSecretString(value), label, maxLength);
|
|
16340
|
+
}
|
|
16341
|
+
function normalizeAuditMetadata(value) {
|
|
16342
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
16343
|
+
throw new Error("Audit metadata must be an object");
|
|
16344
|
+
}
|
|
16345
|
+
return redactAuditSecrets(JSON.parse(JSON.stringify(value)));
|
|
16346
|
+
}
|
|
16347
|
+
function redactAuditSecrets(value) {
|
|
16348
|
+
if (Array.isArray(value))
|
|
16349
|
+
return value.map(redactAuditSecrets);
|
|
16350
|
+
if (typeof value === "string")
|
|
16351
|
+
return redactSecretString(value);
|
|
16352
|
+
if (!value || typeof value !== "object")
|
|
16353
|
+
return value;
|
|
16354
|
+
const output = {};
|
|
16355
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
16356
|
+
output[key] = /key|token|secret|password|credential|auth/i.test(key) ? "[REDACTED]" : redactAuditSecrets(nested);
|
|
16357
|
+
}
|
|
16358
|
+
return output;
|
|
16359
|
+
}
|
|
16360
|
+
function redactSecretString(value) {
|
|
16361
|
+
let output = value.replace(/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [REDACTED]");
|
|
16362
|
+
output = output.replace(/https?:\/\/[^\s"'<>]+/gi, (match) => redactUrlString(match));
|
|
16363
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(output))
|
|
16364
|
+
return output;
|
|
16365
|
+
return redactUrlString(output);
|
|
16366
|
+
}
|
|
16367
|
+
function redactUrlString(value) {
|
|
16368
|
+
let trailing = "";
|
|
16369
|
+
let candidate = value;
|
|
16370
|
+
while (/[),.;\]]$/.test(candidate)) {
|
|
16371
|
+
trailing = `${candidate.slice(-1)}${trailing}`;
|
|
16372
|
+
candidate = candidate.slice(0, -1);
|
|
16373
|
+
}
|
|
16374
|
+
try {
|
|
16375
|
+
const parsed = new URL(candidate);
|
|
16376
|
+
if (parsed.username)
|
|
16377
|
+
parsed.username = "[REDACTED]";
|
|
16378
|
+
if (parsed.password)
|
|
16379
|
+
parsed.password = "[REDACTED]";
|
|
16380
|
+
for (const key of [...parsed.searchParams.keys()]) {
|
|
16381
|
+
if (SECRET_URL_PARAM_PATTERN.test(key))
|
|
16382
|
+
parsed.searchParams.set(key, "[REDACTED]");
|
|
16383
|
+
}
|
|
16384
|
+
parsed.hash = "";
|
|
16385
|
+
return `${parsed.toString()}${trailing}`;
|
|
16386
|
+
} catch {
|
|
16387
|
+
return value;
|
|
16388
|
+
}
|
|
16389
|
+
}
|
|
16390
|
+
function assertIsoTimestamp(value, label) {
|
|
16391
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
16392
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
16393
|
+
}
|
|
16394
|
+
}
|
|
14810
16395
|
function monitorFromRow(row) {
|
|
14811
16396
|
return {
|
|
14812
16397
|
id: row.id,
|
|
@@ -14837,9 +16422,137 @@ function checkResultFromRow(row) {
|
|
|
14837
16422
|
latencyMs: row.latency_ms,
|
|
14838
16423
|
statusCode: row.status_code,
|
|
14839
16424
|
error: row.error,
|
|
14840
|
-
attemptCount: row.attempt_count
|
|
16425
|
+
attemptCount: row.attempt_count,
|
|
16426
|
+
evidence: parseEvidence(row.evidence_json)
|
|
16427
|
+
};
|
|
16428
|
+
}
|
|
16429
|
+
function provenanceFromRow(row) {
|
|
16430
|
+
return {
|
|
16431
|
+
monitorId: row.monitor_id,
|
|
16432
|
+
source: row.source,
|
|
16433
|
+
sourceId: row.source_id,
|
|
16434
|
+
sourceLabel: row.source_label,
|
|
16435
|
+
importedAt: row.imported_at,
|
|
16436
|
+
snapshot: parseJson(row.snapshot_json)
|
|
16437
|
+
};
|
|
16438
|
+
}
|
|
16439
|
+
function importBatchFromRow(row) {
|
|
16440
|
+
return {
|
|
16441
|
+
id: row.id,
|
|
16442
|
+
source: row.source,
|
|
16443
|
+
status: row.status,
|
|
16444
|
+
createdAt: row.created_at,
|
|
16445
|
+
rolledBackAt: row.rolled_back_at,
|
|
16446
|
+
records: Array.isArray(parseJson(row.records_json)) ? parseJson(row.records_json) : []
|
|
16447
|
+
};
|
|
16448
|
+
}
|
|
16449
|
+
function probeIdentityFromRow(row) {
|
|
16450
|
+
return {
|
|
16451
|
+
id: row.id,
|
|
16452
|
+
name: row.name,
|
|
16453
|
+
publicKeyPem: row.public_key_pem,
|
|
16454
|
+
publicKeyFingerprint: row.public_key_fingerprint,
|
|
16455
|
+
enabled: Boolean(row.enabled),
|
|
16456
|
+
createdAt: row.created_at,
|
|
16457
|
+
lastSeenAt: row.last_seen_at
|
|
16458
|
+
};
|
|
16459
|
+
}
|
|
16460
|
+
function probeSubmissionFromRow(row) {
|
|
16461
|
+
return {
|
|
16462
|
+
id: row.id,
|
|
16463
|
+
probeId: row.probe_id,
|
|
16464
|
+
jobId: row.job_id ?? "",
|
|
16465
|
+
monitorId: row.monitor_id,
|
|
16466
|
+
checkResultId: row.check_result_id,
|
|
16467
|
+
nonce: row.nonce,
|
|
16468
|
+
checkedAt: row.checked_at,
|
|
16469
|
+
submittedAt: row.submitted_at
|
|
16470
|
+
};
|
|
16471
|
+
}
|
|
16472
|
+
function probeCheckJobFromRow(row) {
|
|
16473
|
+
return {
|
|
16474
|
+
id: row.id,
|
|
16475
|
+
monitorId: row.monitor_id,
|
|
16476
|
+
monitorRevision: row.monitor_revision ?? 1,
|
|
16477
|
+
scheduleSlot: row.schedule_slot,
|
|
16478
|
+
status: row.status,
|
|
16479
|
+
claimedByProbeId: row.claimed_by_probe_id,
|
|
16480
|
+
fencingToken: row.fencing_token,
|
|
16481
|
+
dueAt: row.due_at,
|
|
16482
|
+
claimedAt: row.claimed_at,
|
|
16483
|
+
leaseExpiresAt: row.lease_expires_at,
|
|
16484
|
+
submittedResultId: row.submitted_result_id,
|
|
16485
|
+
createdAt: row.created_at,
|
|
16486
|
+
updatedAt: row.updated_at
|
|
16487
|
+
};
|
|
16488
|
+
}
|
|
16489
|
+
function reportScheduleFromRow(row) {
|
|
16490
|
+
return {
|
|
16491
|
+
id: row.id,
|
|
16492
|
+
name: row.name,
|
|
16493
|
+
enabled: Boolean(row.enabled),
|
|
16494
|
+
intervalSeconds: row.interval_seconds,
|
|
16495
|
+
nextRunAt: row.next_run_at,
|
|
16496
|
+
lastRunAt: row.last_run_at,
|
|
16497
|
+
subject: row.subject,
|
|
16498
|
+
channels: parseReportChannels(row.channels_json),
|
|
16499
|
+
createdAt: row.created_at,
|
|
16500
|
+
updatedAt: row.updated_at
|
|
16501
|
+
};
|
|
16502
|
+
}
|
|
16503
|
+
function reportRunFromRow(row) {
|
|
16504
|
+
return {
|
|
16505
|
+
id: row.id,
|
|
16506
|
+
scheduleId: row.schedule_id,
|
|
16507
|
+
status: row.status,
|
|
16508
|
+
startedAt: row.started_at,
|
|
16509
|
+
finishedAt: row.finished_at,
|
|
16510
|
+
deliveries: parseReportDeliveries(row.deliveries_json),
|
|
16511
|
+
error: row.error,
|
|
16512
|
+
reportJson: parseRecord(row.report_json)
|
|
16513
|
+
};
|
|
16514
|
+
}
|
|
16515
|
+
function auditEventFromRow(row) {
|
|
16516
|
+
return {
|
|
16517
|
+
id: row.id,
|
|
16518
|
+
action: row.action,
|
|
16519
|
+
resourceType: row.resource_type,
|
|
16520
|
+
resourceId: row.resource_id,
|
|
16521
|
+
message: row.message,
|
|
16522
|
+
metadata: parseRecord(row.metadata_json) ?? {},
|
|
16523
|
+
actor: row.actor,
|
|
16524
|
+
createdAt: row.created_at
|
|
14841
16525
|
};
|
|
14842
16526
|
}
|
|
16527
|
+
function parseEvidence(value) {
|
|
16528
|
+
if (!value)
|
|
16529
|
+
return null;
|
|
16530
|
+
const parsed = parseJson(value);
|
|
16531
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
16532
|
+
}
|
|
16533
|
+
function parseReportChannels(value) {
|
|
16534
|
+
const parsed = parseJson(value);
|
|
16535
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
16536
|
+
return {};
|
|
16537
|
+
return parsed;
|
|
16538
|
+
}
|
|
16539
|
+
function parseReportDeliveries(value) {
|
|
16540
|
+
const parsed = parseJson(value);
|
|
16541
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
16542
|
+
}
|
|
16543
|
+
function parseRecord(value) {
|
|
16544
|
+
if (!value)
|
|
16545
|
+
return null;
|
|
16546
|
+
const parsed = parseJson(value);
|
|
16547
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
16548
|
+
}
|
|
16549
|
+
function parseJson(value) {
|
|
16550
|
+
try {
|
|
16551
|
+
return JSON.parse(value);
|
|
16552
|
+
} catch {
|
|
16553
|
+
return null;
|
|
16554
|
+
}
|
|
16555
|
+
}
|
|
14843
16556
|
function incidentFromRow(row) {
|
|
14844
16557
|
return {
|
|
14845
16558
|
id: row.id,
|
|
@@ -14854,9 +16567,9 @@ function incidentFromRow(row) {
|
|
|
14854
16567
|
};
|
|
14855
16568
|
}
|
|
14856
16569
|
function newId(prefix) {
|
|
14857
|
-
return `${prefix}_${
|
|
16570
|
+
return `${prefix}_${randomUUID2().replace(/-/g, "").slice(0, 18)}`;
|
|
14858
16571
|
}
|
|
14859
|
-
function
|
|
16572
|
+
function boundedInteger2(value, label, min, max) {
|
|
14860
16573
|
if (!Number.isInteger(value) || value < min || value > max) {
|
|
14861
16574
|
throw new Error(`${label} must be an integer from ${min} to ${max}`);
|
|
14862
16575
|
}
|
|
@@ -14867,6 +16580,9 @@ function clampLimit(value) {
|
|
|
14867
16580
|
return 50;
|
|
14868
16581
|
return Math.max(1, Math.min(Math.floor(value), MAX_RESULT_LIMIT));
|
|
14869
16582
|
}
|
|
16583
|
+
function statementChanges(result) {
|
|
16584
|
+
return Number(result?.changes ?? 0);
|
|
16585
|
+
}
|
|
14870
16586
|
function round(value, places) {
|
|
14871
16587
|
const factor = 10 ** places;
|
|
14872
16588
|
return Math.round(value * factor) / factor;
|
|
@@ -14941,7 +16657,7 @@ function renderMonitorLine(item) {
|
|
|
14941
16657
|
return `- ${item.monitor.status.toUpperCase()} ${item.monitor.name} (${targetLabel(item)}): uptime ${uptime}, latency ${latency}${incident}`;
|
|
14942
16658
|
}
|
|
14943
16659
|
function targetLabel(item) {
|
|
14944
|
-
return item.monitor.kind === "
|
|
16660
|
+
return item.monitor.kind === "tcp" ? `${item.monitor.host}:${item.monitor.port}` : item.monitor.url ?? "";
|
|
14945
16661
|
}
|
|
14946
16662
|
function resolveEmailTarget(value) {
|
|
14947
16663
|
const target = typeof value === "boolean" ? {} : value;
|
|
@@ -15143,13 +16859,17 @@ function redactOptional(value, secrets) {
|
|
|
15143
16859
|
}
|
|
15144
16860
|
|
|
15145
16861
|
// src/service.ts
|
|
16862
|
+
var MAX_PROBE_RESULT_AGE_MS = 15 * 60000;
|
|
16863
|
+
var MAX_PROBE_RESULT_FUTURE_MS = 5 * 60000;
|
|
16864
|
+
|
|
15146
16865
|
class UptimeService {
|
|
15147
16866
|
store;
|
|
15148
16867
|
checkRunner;
|
|
15149
|
-
leaseOwner = `svc_${
|
|
16868
|
+
leaseOwner = `svc_${randomUUID3().replace(/-/g, "").slice(0, 18)}`;
|
|
15150
16869
|
inFlightChecks = new Set;
|
|
16870
|
+
inFlightReportSchedules = new Set;
|
|
15151
16871
|
constructor(options = {}) {
|
|
15152
|
-
this.store = options.store ?? new UptimeStore(options);
|
|
16872
|
+
this.store = options.store ?? new UptimeStore({ mode: "local", ...options });
|
|
15153
16873
|
this.checkRunner = options.checkRunner ?? runMonitorCheck;
|
|
15154
16874
|
}
|
|
15155
16875
|
close() {
|
|
@@ -15179,13 +16899,180 @@ class UptimeService {
|
|
|
15179
16899
|
summary() {
|
|
15180
16900
|
return this.store.summary();
|
|
15181
16901
|
}
|
|
16902
|
+
createProbe(input) {
|
|
16903
|
+
const store = this.probeStore();
|
|
16904
|
+
const publicKeyPem = input.publicKeyPem ? normalizeProbePublicKeyPem(input.publicKeyPem) : undefined;
|
|
16905
|
+
const keyPair = publicKeyPem ? {
|
|
16906
|
+
publicKeyPem,
|
|
16907
|
+
privateKeyPem: undefined,
|
|
16908
|
+
publicKeyFingerprint: probePublicKeyFingerprint(publicKeyPem)
|
|
16909
|
+
} : generateProbeKeyPair();
|
|
16910
|
+
const probe = store.createProbeIdentity({
|
|
16911
|
+
name: input.name,
|
|
16912
|
+
publicKeyPem: keyPair.publicKeyPem,
|
|
16913
|
+
publicKeyFingerprint: keyPair.publicKeyFingerprint,
|
|
16914
|
+
enabled: input.enabled
|
|
16915
|
+
});
|
|
16916
|
+
return { ...probe, privateKeyPem: keyPair.privateKeyPem };
|
|
16917
|
+
}
|
|
16918
|
+
listProbes(options = {}) {
|
|
16919
|
+
return this.probeStore().listProbeIdentities(options);
|
|
16920
|
+
}
|
|
16921
|
+
getProbe(idOrName) {
|
|
16922
|
+
return this.probeStore().getProbeIdentity(idOrName);
|
|
16923
|
+
}
|
|
16924
|
+
updateProbe(idOrName, input) {
|
|
16925
|
+
return this.probeStore().updateProbeIdentity(idOrName, input);
|
|
16926
|
+
}
|
|
16927
|
+
createProbeCheckJob(input) {
|
|
16928
|
+
return this.probeStore().createProbeCheckJob(input);
|
|
16929
|
+
}
|
|
16930
|
+
getProbeCheckJob(id) {
|
|
16931
|
+
return this.probeStore().getProbeCheckJob(id);
|
|
16932
|
+
}
|
|
16933
|
+
claimProbeCheckJob(input) {
|
|
16934
|
+
return this.probeStore().claimProbeCheckJob(input);
|
|
16935
|
+
}
|
|
16936
|
+
submitProbeResult(input) {
|
|
16937
|
+
const execute = () => this.submitProbeResultInTransaction(input);
|
|
16938
|
+
return this.store.runInTransaction ? this.store.runInTransaction(execute) : execute();
|
|
16939
|
+
}
|
|
16940
|
+
previewImport(request) {
|
|
16941
|
+
return previewImport(this.store, request);
|
|
16942
|
+
}
|
|
16943
|
+
applyImport(request) {
|
|
16944
|
+
return applyImport(this.store, request);
|
|
16945
|
+
}
|
|
16946
|
+
rollbackImport(batchId) {
|
|
16947
|
+
return rollbackImport(this.store, batchId);
|
|
16948
|
+
}
|
|
16949
|
+
backup(destinationPath) {
|
|
16950
|
+
return this.store.backup(destinationPath);
|
|
16951
|
+
}
|
|
16952
|
+
verifyBackup(backupPath) {
|
|
16953
|
+
return this.store.verifyBackup(backupPath);
|
|
16954
|
+
}
|
|
15182
16955
|
buildReport(options = {}) {
|
|
15183
16956
|
return buildUptimeReport(this.summary(), options);
|
|
15184
16957
|
}
|
|
15185
16958
|
async sendReport(options = {}) {
|
|
16959
|
+
if (this.store.mode === "hosted" && (options.email || options.sms || options.logs)) {
|
|
16960
|
+
throw new Error("hosted report delivery requires configured channel refs");
|
|
16961
|
+
}
|
|
15186
16962
|
return sendUptimeReport(this.summary(), options);
|
|
15187
16963
|
}
|
|
16964
|
+
createReportSchedule(input) {
|
|
16965
|
+
const store = this.reportStore();
|
|
16966
|
+
const schedule = store.createReportSchedule(input);
|
|
16967
|
+
this.audit("report_schedule.create", "report_schedule", schedule.id, `Created report schedule ${schedule.name}`, {
|
|
16968
|
+
name: schedule.name,
|
|
16969
|
+
enabled: schedule.enabled,
|
|
16970
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
16971
|
+
channels: enabledReportChannels(schedule)
|
|
16972
|
+
});
|
|
16973
|
+
return schedule;
|
|
16974
|
+
}
|
|
16975
|
+
listReportSchedules(options = {}) {
|
|
16976
|
+
return this.reportStore().listReportSchedules(options);
|
|
16977
|
+
}
|
|
16978
|
+
getReportSchedule(idOrName) {
|
|
16979
|
+
return this.reportStore().getReportSchedule(idOrName);
|
|
16980
|
+
}
|
|
16981
|
+
updateReportSchedule(idOrName, input) {
|
|
16982
|
+
const store = this.reportStore();
|
|
16983
|
+
const schedule = store.updateReportSchedule(idOrName, input);
|
|
16984
|
+
this.audit("report_schedule.update", "report_schedule", schedule.id, `Updated report schedule ${schedule.name}`, {
|
|
16985
|
+
name: schedule.name,
|
|
16986
|
+
enabled: schedule.enabled,
|
|
16987
|
+
intervalSeconds: schedule.intervalSeconds,
|
|
16988
|
+
channels: enabledReportChannels(schedule)
|
|
16989
|
+
});
|
|
16990
|
+
return schedule;
|
|
16991
|
+
}
|
|
16992
|
+
deleteReportSchedule(idOrName) {
|
|
16993
|
+
const store = this.reportStore();
|
|
16994
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
16995
|
+
const deleted = store.deleteReportSchedule(idOrName);
|
|
16996
|
+
if (deleted && schedule) {
|
|
16997
|
+
this.audit("report_schedule.delete", "report_schedule", schedule.id, `Deleted report schedule ${schedule.name}`, {
|
|
16998
|
+
name: schedule.name
|
|
16999
|
+
});
|
|
17000
|
+
}
|
|
17001
|
+
return deleted;
|
|
17002
|
+
}
|
|
17003
|
+
listReportRuns(options = {}) {
|
|
17004
|
+
return this.reportStore().listReportRuns(options);
|
|
17005
|
+
}
|
|
17006
|
+
listAuditEvents(options = {}) {
|
|
17007
|
+
return this.reportStore().listAuditEvents(options);
|
|
17008
|
+
}
|
|
17009
|
+
recordAuditEvent(input) {
|
|
17010
|
+
return this.reportStore().recordAuditEvent(input);
|
|
17011
|
+
}
|
|
17012
|
+
async runReportSchedule(idOrName, options = {}) {
|
|
17013
|
+
const store = this.reportStore();
|
|
17014
|
+
const schedule = store.getReportSchedule(idOrName);
|
|
17015
|
+
if (!schedule)
|
|
17016
|
+
throw new Error(`Report schedule not found: ${idOrName}`);
|
|
17017
|
+
if (!schedule.enabled)
|
|
17018
|
+
throw new Error(`Report schedule is disabled: ${schedule.name}`);
|
|
17019
|
+
if (this.inFlightReportSchedules.has(schedule.id))
|
|
17020
|
+
throw new Error(`Report schedule already running: ${schedule.name}`);
|
|
17021
|
+
this.inFlightReportSchedules.add(schedule.id);
|
|
17022
|
+
try {
|
|
17023
|
+
const startedAt = new Date().toISOString();
|
|
17024
|
+
let deliveries = [];
|
|
17025
|
+
let error51 = null;
|
|
17026
|
+
let reportJson = null;
|
|
17027
|
+
try {
|
|
17028
|
+
const report = this.buildReport({ subject: schedule.subject ?? undefined });
|
|
17029
|
+
reportJson = report.json;
|
|
17030
|
+
deliveries = await this.sendReport({
|
|
17031
|
+
subject: schedule.subject ?? undefined,
|
|
17032
|
+
email: schedule.channels.email,
|
|
17033
|
+
sms: schedule.channels.sms,
|
|
17034
|
+
logs: schedule.channels.logs,
|
|
17035
|
+
fetchImpl: options.fetchImpl
|
|
17036
|
+
});
|
|
17037
|
+
const failed = deliveries.filter((delivery) => !delivery.ok);
|
|
17038
|
+
if (failed.length > 0) {
|
|
17039
|
+
error51 = failed.map((delivery) => `${delivery.channel}: ${delivery.error ?? delivery.status ?? "failed"}`).join("; ");
|
|
17040
|
+
}
|
|
17041
|
+
} catch (caught) {
|
|
17042
|
+
error51 = caught instanceof Error ? caught.message : String(caught);
|
|
17043
|
+
}
|
|
17044
|
+
const finishedAt = new Date().toISOString();
|
|
17045
|
+
const run = store.recordReportRun({
|
|
17046
|
+
scheduleId: schedule.id,
|
|
17047
|
+
status: error51 ? "failed" : "success",
|
|
17048
|
+
startedAt,
|
|
17049
|
+
finishedAt,
|
|
17050
|
+
deliveries,
|
|
17051
|
+
error: error51,
|
|
17052
|
+
reportJson
|
|
17053
|
+
});
|
|
17054
|
+
this.audit("report_schedule.run", "report_schedule", schedule.id, `Ran report schedule ${schedule.name}`, {
|
|
17055
|
+
runId: run.id,
|
|
17056
|
+
status: run.status,
|
|
17057
|
+
deliveryChannels: run.deliveries.map((delivery) => ({ channel: delivery.channel, ok: delivery.ok }))
|
|
17058
|
+
});
|
|
17059
|
+
return run;
|
|
17060
|
+
} finally {
|
|
17061
|
+
this.inFlightReportSchedules.delete(schedule.id);
|
|
17062
|
+
}
|
|
17063
|
+
}
|
|
17064
|
+
async runDueReportSchedules(now = new Date, options = {}) {
|
|
17065
|
+
const store = this.reportStore();
|
|
17066
|
+
const schedules = store.listDueReportSchedules(now.toISOString());
|
|
17067
|
+
const runs = [];
|
|
17068
|
+
for (const schedule of schedules) {
|
|
17069
|
+
runs.push(await this.runReportSchedule(schedule.id, options));
|
|
17070
|
+
}
|
|
17071
|
+
return runs;
|
|
17072
|
+
}
|
|
15188
17073
|
async checkMonitor(idOrName) {
|
|
17074
|
+
if (this.store.mode === "hosted")
|
|
17075
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
15189
17076
|
const monitor = this.store.getMonitor(idOrName);
|
|
15190
17077
|
if (!monitor)
|
|
15191
17078
|
throw new Error(`Monitor not found: ${idOrName}`);
|
|
@@ -15214,6 +17101,7 @@ class UptimeService {
|
|
|
15214
17101
|
latencyMs: last.latencyMs,
|
|
15215
17102
|
statusCode: last.statusCode ?? null,
|
|
15216
17103
|
error: last.error ?? null,
|
|
17104
|
+
evidence: last.evidence ?? null,
|
|
15217
17105
|
attemptCount,
|
|
15218
17106
|
expectedMonitorRevision: monitor.revision
|
|
15219
17107
|
});
|
|
@@ -15223,6 +17111,8 @@ class UptimeService {
|
|
|
15223
17111
|
}
|
|
15224
17112
|
}
|
|
15225
17113
|
async checkAll() {
|
|
17114
|
+
if (this.store.mode === "hosted")
|
|
17115
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
15226
17116
|
const monitors = this.store.listMonitors();
|
|
15227
17117
|
const results = [];
|
|
15228
17118
|
for (const monitor of monitors) {
|
|
@@ -15231,17 +17121,24 @@ class UptimeService {
|
|
|
15231
17121
|
return results;
|
|
15232
17122
|
}
|
|
15233
17123
|
startScheduler(options = {}) {
|
|
17124
|
+
if (this.store.mode === "hosted")
|
|
17125
|
+
throw new Error("hosted scheduler requires check_jobs and probes");
|
|
15234
17126
|
const tickMs = options.tickMs ?? 1000;
|
|
15235
17127
|
const timer = setInterval(() => {
|
|
15236
17128
|
this.runDueChecks().catch((error51) => {
|
|
15237
17129
|
console.error(error51 instanceof Error ? error51.message : String(error51));
|
|
15238
17130
|
});
|
|
17131
|
+
this.runDueReportSchedules(new Date, { fetchImpl: options.reportFetchImpl }).catch((error51) => {
|
|
17132
|
+
console.error(error51 instanceof Error ? error51.message : String(error51));
|
|
17133
|
+
});
|
|
15239
17134
|
}, tickMs);
|
|
15240
17135
|
return {
|
|
15241
17136
|
stop: () => clearInterval(timer)
|
|
15242
17137
|
};
|
|
15243
17138
|
}
|
|
15244
17139
|
async runDueChecks(now = new Date) {
|
|
17140
|
+
if (this.store.mode === "hosted")
|
|
17141
|
+
throw new Error("hosted checks require check_jobs and probes");
|
|
15245
17142
|
const due = this.store.listMonitors().filter((monitor) => this.isDue(monitor, now));
|
|
15246
17143
|
const results = [];
|
|
15247
17144
|
for (const monitor of due) {
|
|
@@ -15268,6 +17165,147 @@ class UptimeService {
|
|
|
15268
17165
|
const last = new Date(monitor.lastCheckedAt).getTime();
|
|
15269
17166
|
return now.getTime() - last >= monitor.intervalSeconds * 1000;
|
|
15270
17167
|
}
|
|
17168
|
+
probeStore() {
|
|
17169
|
+
if (this.store.mode === "hosted") {
|
|
17170
|
+
throw new Error("hosted probe APIs require cloud check_jobs, workspace stores, and audit logging");
|
|
17171
|
+
}
|
|
17172
|
+
const store = this.store;
|
|
17173
|
+
const required2 = [
|
|
17174
|
+
"createProbeIdentity",
|
|
17175
|
+
"listProbeIdentities",
|
|
17176
|
+
"getProbeIdentity",
|
|
17177
|
+
"updateProbeIdentity",
|
|
17178
|
+
"touchProbeIdentity",
|
|
17179
|
+
"createProbeCheckJob",
|
|
17180
|
+
"getProbeCheckJob",
|
|
17181
|
+
"claimProbeCheckJob",
|
|
17182
|
+
"completeProbeCheckJob",
|
|
17183
|
+
"getProbeSubmission",
|
|
17184
|
+
"recordProbeSubmission"
|
|
17185
|
+
];
|
|
17186
|
+
for (const method of required2) {
|
|
17187
|
+
if (typeof store[method] !== "function") {
|
|
17188
|
+
throw new Error("probe support requires a probe-capable store");
|
|
17189
|
+
}
|
|
17190
|
+
}
|
|
17191
|
+
return store;
|
|
17192
|
+
}
|
|
17193
|
+
reportStore() {
|
|
17194
|
+
if (this.store.mode === "hosted") {
|
|
17195
|
+
throw new Error("hosted report schedules require cloud channel refs, workspace stores, and audit logging");
|
|
17196
|
+
}
|
|
17197
|
+
const store = this.store;
|
|
17198
|
+
const required2 = [
|
|
17199
|
+
"createReportSchedule",
|
|
17200
|
+
"listReportSchedules",
|
|
17201
|
+
"listDueReportSchedules",
|
|
17202
|
+
"getReportSchedule",
|
|
17203
|
+
"updateReportSchedule",
|
|
17204
|
+
"deleteReportSchedule",
|
|
17205
|
+
"recordReportRun",
|
|
17206
|
+
"listReportRuns",
|
|
17207
|
+
"recordAuditEvent",
|
|
17208
|
+
"listAuditEvents"
|
|
17209
|
+
];
|
|
17210
|
+
for (const method of required2) {
|
|
17211
|
+
if (typeof store[method] !== "function") {
|
|
17212
|
+
throw new Error("report scheduling requires a report-capable store");
|
|
17213
|
+
}
|
|
17214
|
+
}
|
|
17215
|
+
return store;
|
|
17216
|
+
}
|
|
17217
|
+
audit(action, resourceType, resourceId, message, metadata) {
|
|
17218
|
+
this.reportStore().recordAuditEvent({
|
|
17219
|
+
action,
|
|
17220
|
+
resourceType,
|
|
17221
|
+
resourceId,
|
|
17222
|
+
message,
|
|
17223
|
+
metadata,
|
|
17224
|
+
actor: "local"
|
|
17225
|
+
});
|
|
17226
|
+
}
|
|
17227
|
+
submitProbeResultInTransaction(input) {
|
|
17228
|
+
const store = this.probeStore();
|
|
17229
|
+
const probe = store.getProbeIdentity(input.probeId);
|
|
17230
|
+
if (!probe)
|
|
17231
|
+
throw new Error(`Probe not found: ${input.probeId}`);
|
|
17232
|
+
if (!probe.enabled)
|
|
17233
|
+
throw new Error(`Probe is disabled: ${probe.name}`);
|
|
17234
|
+
const monitor = this.store.getMonitor(input.monitorId);
|
|
17235
|
+
if (!monitor)
|
|
17236
|
+
throw new Error(`Monitor not found: ${input.monitorId}`);
|
|
17237
|
+
if (!monitor.enabled)
|
|
17238
|
+
throw new Error(`Monitor is disabled: ${monitor.name}`);
|
|
17239
|
+
if (probe.id !== input.probeId)
|
|
17240
|
+
throw new Error("Probe result must use canonical probe id");
|
|
17241
|
+
if (monitor.id !== input.monitorId)
|
|
17242
|
+
throw new Error("Probe result must use canonical monitor id");
|
|
17243
|
+
validateProbeSubmission(input);
|
|
17244
|
+
const job = store.getProbeCheckJob(input.jobId);
|
|
17245
|
+
if (!job)
|
|
17246
|
+
throw new Error(`Probe job not found: ${input.jobId}`);
|
|
17247
|
+
if (job.monitorId !== monitor.id)
|
|
17248
|
+
throw new Error("Probe job does not match monitor");
|
|
17249
|
+
if (job.scheduleSlot !== input.scheduleSlot)
|
|
17250
|
+
throw new Error("Probe job scheduleSlot does not match submission");
|
|
17251
|
+
if (!verifyProbeResultSignature({ ...input, probeId: probe.id, monitorId: monitor.id }, probe.publicKeyPem)) {
|
|
17252
|
+
throw new Error("Probe result signature is invalid");
|
|
17253
|
+
}
|
|
17254
|
+
const existingReceipt = store.getProbeSubmission(probe.id, input.nonce);
|
|
17255
|
+
if (existingReceipt) {
|
|
17256
|
+
if (existingReceipt.jobId !== input.jobId || existingReceipt.monitorId !== monitor.id || existingReceipt.checkedAt !== input.checkedAt) {
|
|
17257
|
+
throw new Error("Probe nonce already submitted");
|
|
17258
|
+
}
|
|
17259
|
+
const existingResult = this.store.getCheckResult?.(existingReceipt.checkResultId);
|
|
17260
|
+
if (!existingResult)
|
|
17261
|
+
throw new Error("Probe nonce already submitted");
|
|
17262
|
+
return { result: existingResult, receipt: existingReceipt };
|
|
17263
|
+
}
|
|
17264
|
+
if (job.monitorRevision !== input.monitorRevision)
|
|
17265
|
+
throw new Error("Probe job monitorRevision does not match submission");
|
|
17266
|
+
if (job.monitorRevision !== monitor.revision)
|
|
17267
|
+
throw new StaleCheckResultError(`Monitor changed since probe job was created: ${monitor.name}`);
|
|
17268
|
+
if (job.status === "submitted")
|
|
17269
|
+
throw new Error("Probe job already submitted");
|
|
17270
|
+
if (job.status === "cancelled")
|
|
17271
|
+
throw new Error("Probe job is cancelled");
|
|
17272
|
+
if (job.status !== "claimed")
|
|
17273
|
+
throw new Error(`Probe job is not claimable for submission: ${job.status}`);
|
|
17274
|
+
if (job.claimedByProbeId !== probe.id)
|
|
17275
|
+
throw new Error("Probe job was claimed by another probe");
|
|
17276
|
+
if (job.fencingToken !== input.fencingToken)
|
|
17277
|
+
throw new Error("Probe job fencing token is invalid");
|
|
17278
|
+
if (!job.leaseExpiresAt || job.leaseExpiresAt <= new Date().toISOString())
|
|
17279
|
+
throw new Error("Probe job lease expired");
|
|
17280
|
+
const result = this.store.recordCheckResult({
|
|
17281
|
+
monitorId: monitor.id,
|
|
17282
|
+
checkedAt: input.checkedAt,
|
|
17283
|
+
status: input.status,
|
|
17284
|
+
latencyMs: input.latencyMs,
|
|
17285
|
+
statusCode: input.statusCode ?? null,
|
|
17286
|
+
error: input.error ?? null,
|
|
17287
|
+
evidence: input.evidence ?? null,
|
|
17288
|
+
attemptCount: input.attemptCount ?? 1,
|
|
17289
|
+
expectedMonitorRevision: input.monitorRevision
|
|
17290
|
+
});
|
|
17291
|
+
const receipt = store.recordProbeSubmission({
|
|
17292
|
+
probeId: probe.id,
|
|
17293
|
+
jobId: job.id,
|
|
17294
|
+
monitorId: monitor.id,
|
|
17295
|
+
checkResultId: result.id,
|
|
17296
|
+
nonce: input.nonce,
|
|
17297
|
+
checkedAt: input.checkedAt
|
|
17298
|
+
});
|
|
17299
|
+
store.completeProbeCheckJob({
|
|
17300
|
+
jobId: job.id,
|
|
17301
|
+
probeId: probe.id,
|
|
17302
|
+
fencingToken: input.fencingToken,
|
|
17303
|
+
checkResultId: result.id,
|
|
17304
|
+
submittedAt: receipt.submittedAt
|
|
17305
|
+
});
|
|
17306
|
+
store.touchProbeIdentity(probe.id, receipt.submittedAt);
|
|
17307
|
+
return { result, receipt };
|
|
17308
|
+
}
|
|
15271
17309
|
}
|
|
15272
17310
|
class MonitorCheckBusyError extends Error {
|
|
15273
17311
|
constructor(message) {
|
|
@@ -15275,16 +17313,71 @@ class MonitorCheckBusyError extends Error {
|
|
|
15275
17313
|
this.name = "MonitorCheckBusyError";
|
|
15276
17314
|
}
|
|
15277
17315
|
}
|
|
17316
|
+
function enabledReportChannels(schedule) {
|
|
17317
|
+
return ["email", "sms", "logs"].filter((channel) => Boolean(schedule.channels[channel]));
|
|
17318
|
+
}
|
|
17319
|
+
function validateProbeSubmission(input) {
|
|
17320
|
+
if (!input.jobId.trim())
|
|
17321
|
+
throw new Error("Probe submission jobId is required");
|
|
17322
|
+
if (!input.scheduleSlot.trim())
|
|
17323
|
+
throw new Error("Probe submission scheduleSlot is required");
|
|
17324
|
+
if (!input.fencingToken.trim())
|
|
17325
|
+
throw new Error("Probe submission fencingToken is required");
|
|
17326
|
+
if (!input.nonce.trim())
|
|
17327
|
+
throw new Error("Probe submission nonce is required");
|
|
17328
|
+
if (input.nonce.length > 128)
|
|
17329
|
+
throw new Error("Probe submission nonce is too long");
|
|
17330
|
+
if (/[\x00-\x1f\x7f-\x9f]/.test(input.nonce))
|
|
17331
|
+
throw new Error("Probe submission nonce must not contain control characters");
|
|
17332
|
+
if (input.status !== "up" && input.status !== "down")
|
|
17333
|
+
throw new Error("Probe result status must be up or down");
|
|
17334
|
+
if (input.latencyMs !== null && (!Number.isFinite(input.latencyMs) || input.latencyMs < 0)) {
|
|
17335
|
+
throw new Error("Probe result latencyMs must be null or a non-negative number");
|
|
17336
|
+
}
|
|
17337
|
+
if (input.statusCode !== undefined && input.statusCode !== null && (!Number.isInteger(input.statusCode) || input.statusCode < 100 || input.statusCode > 599)) {
|
|
17338
|
+
throw new Error("Probe result statusCode must be an HTTP status from 100 to 599");
|
|
17339
|
+
}
|
|
17340
|
+
if (input.attemptCount !== undefined && (!Number.isInteger(input.attemptCount) || input.attemptCount < 1 || input.attemptCount > 20)) {
|
|
17341
|
+
throw new Error("Probe result attemptCount must be an integer from 1 to 20");
|
|
17342
|
+
}
|
|
17343
|
+
const monitorRevision = input.monitorRevision;
|
|
17344
|
+
if (!Number.isInteger(monitorRevision) || monitorRevision < 1) {
|
|
17345
|
+
throw new Error("Probe result monitorRevision is required");
|
|
17346
|
+
}
|
|
17347
|
+
const checkedAtMs = Date.parse(input.checkedAt);
|
|
17348
|
+
if (!Number.isFinite(checkedAtMs))
|
|
17349
|
+
throw new Error("Probe result checkedAt must be an ISO timestamp");
|
|
17350
|
+
const now = Date.now();
|
|
17351
|
+
if (checkedAtMs > now + MAX_PROBE_RESULT_FUTURE_MS)
|
|
17352
|
+
throw new Error("Probe result checkedAt is too far in the future");
|
|
17353
|
+
if (checkedAtMs < now - MAX_PROBE_RESULT_AGE_MS)
|
|
17354
|
+
throw new Error("Probe result checkedAt is too old");
|
|
17355
|
+
if (!input.signature.trim())
|
|
17356
|
+
throw new Error("Probe result signature is required");
|
|
17357
|
+
}
|
|
17358
|
+
function normalizeProbePublicKeyPem(publicKeyPem) {
|
|
17359
|
+
try {
|
|
17360
|
+
const key = createPublicKey(publicKeyPem);
|
|
17361
|
+
if (key.asymmetricKeyType !== "ed25519") {
|
|
17362
|
+
throw new Error("Probe public key must be an Ed25519 public key");
|
|
17363
|
+
}
|
|
17364
|
+
return key.export({ format: "pem", type: "spki" }).toString();
|
|
17365
|
+
} catch (error51) {
|
|
17366
|
+
if (error51 instanceof Error && error51.message.includes("Ed25519"))
|
|
17367
|
+
throw error51;
|
|
17368
|
+
throw new Error("Probe public key must be a valid PEM Ed25519 public key");
|
|
17369
|
+
}
|
|
17370
|
+
}
|
|
15278
17371
|
|
|
15279
17372
|
// src/version.ts
|
|
15280
17373
|
import { readFileSync } from "fs";
|
|
15281
|
-
import { dirname as dirname2, join as
|
|
17374
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
15282
17375
|
import { fileURLToPath } from "url";
|
|
15283
17376
|
function packageVersion() {
|
|
15284
17377
|
const here = dirname2(fileURLToPath(import.meta.url));
|
|
15285
17378
|
const candidates = [
|
|
15286
|
-
|
|
15287
|
-
|
|
17379
|
+
join3(here, "..", "package.json"),
|
|
17380
|
+
join3(here, "..", "..", "package.json")
|
|
15288
17381
|
];
|
|
15289
17382
|
for (const candidate of candidates) {
|
|
15290
17383
|
try {
|
|
@@ -15297,7 +17390,7 @@ function packageVersion() {
|
|
|
15297
17390
|
// src/mcp/index.ts
|
|
15298
17391
|
function createMcpServer(options = {}) {
|
|
15299
17392
|
const server = new McpServer({ name: "uptime", version: packageVersion() });
|
|
15300
|
-
const service = options.service ?? new UptimeService;
|
|
17393
|
+
const service = options.service ?? new UptimeService({ mode: "local" });
|
|
15301
17394
|
server.registerResource("uptime_summary", "uptime://summary", {
|
|
15302
17395
|
title: "Open Uptime Summary",
|
|
15303
17396
|
description: "Current monitor status, uptime percentages, latency, and incident totals.",
|
|
@@ -15313,6 +17406,21 @@ function createMcpServer(options = {}) {
|
|
|
15313
17406
|
description: "Recent downtime incidents.",
|
|
15314
17407
|
mimeType: "application/json"
|
|
15315
17408
|
}, async (uri) => jsonResource(uri, service.listIncidents({ limit: 100 })));
|
|
17409
|
+
server.registerResource("uptime_report_schedules", "uptime://report-schedules", {
|
|
17410
|
+
title: "Open Uptime Report Schedules",
|
|
17411
|
+
description: "Scheduled uptime reports and delivery channel configuration.",
|
|
17412
|
+
mimeType: "application/json"
|
|
17413
|
+
}, async (uri) => jsonResource(uri, service.listReportSchedules({ includeDisabled: true })));
|
|
17414
|
+
server.registerResource("uptime_report_runs", "uptime://report-runs", {
|
|
17415
|
+
title: "Open Uptime Report Runs",
|
|
17416
|
+
description: "Recent scheduled report delivery runs.",
|
|
17417
|
+
mimeType: "application/json"
|
|
17418
|
+
}, async (uri) => jsonResource(uri, service.listReportRuns({ limit: 100 })));
|
|
17419
|
+
server.registerResource("uptime_audit_events", "uptime://audit-events", {
|
|
17420
|
+
title: "Open Uptime Audit Events",
|
|
17421
|
+
description: "Recent local audit events.",
|
|
17422
|
+
mimeType: "application/json"
|
|
17423
|
+
}, async (uri) => jsonResource(uri, service.listAuditEvents({ limit: 100 })));
|
|
15316
17424
|
server.registerTool("uptime_create_monitor", {
|
|
15317
17425
|
title: "Create an uptime monitor",
|
|
15318
17426
|
description: "Create an HTTP or TCP uptime monitor in the local Open Uptime store.",
|
|
@@ -15419,6 +17527,134 @@ function createMcpServer(options = {}) {
|
|
|
15419
17527
|
logs: args.logs,
|
|
15420
17528
|
timeoutMs: args.timeoutMs
|
|
15421
17529
|
})));
|
|
17530
|
+
server.registerTool("uptime_create_report_schedule", {
|
|
17531
|
+
title: "Create a scheduled uptime report",
|
|
17532
|
+
description: "Create a local scheduled uptime report. Persistent schedules do not accept API keys; configure Mailery/Open Logs credentials through environment variables.",
|
|
17533
|
+
inputSchema: {
|
|
17534
|
+
name: exports_external.string(),
|
|
17535
|
+
intervalSeconds: exports_external.number().int().min(MIN_INTERVAL_SECONDS).max(MAX_INTERVAL_SECONDS),
|
|
17536
|
+
nextRunAt: exports_external.string().optional(),
|
|
17537
|
+
enabled: exports_external.boolean().optional(),
|
|
17538
|
+
subject: exports_external.string().nullable().optional(),
|
|
17539
|
+
channels: reportScheduleChannelsSchema()
|
|
17540
|
+
}
|
|
17541
|
+
}, async (args) => jsonResult(service.createReportSchedule(args)));
|
|
17542
|
+
server.registerTool("uptime_list_report_schedules", {
|
|
17543
|
+
title: "List scheduled uptime reports",
|
|
17544
|
+
description: "List local scheduled uptime reports.",
|
|
17545
|
+
inputSchema: {
|
|
17546
|
+
includeDisabled: exports_external.boolean().optional()
|
|
17547
|
+
}
|
|
17548
|
+
}, async (args) => jsonResult(service.listReportSchedules({ includeDisabled: args.includeDisabled })));
|
|
17549
|
+
server.registerTool("uptime_run_report_schedule", {
|
|
17550
|
+
title: "Run one scheduled uptime report",
|
|
17551
|
+
description: "Run a local scheduled uptime report now and record the run.",
|
|
17552
|
+
inputSchema: {
|
|
17553
|
+
idOrName: exports_external.string()
|
|
17554
|
+
}
|
|
17555
|
+
}, async (args) => jsonResult(await service.runReportSchedule(args.idOrName)));
|
|
17556
|
+
server.registerTool("uptime_run_due_report_schedules", {
|
|
17557
|
+
title: "Run due scheduled uptime reports",
|
|
17558
|
+
description: "Run all due local scheduled uptime reports and record runs.",
|
|
17559
|
+
inputSchema: {
|
|
17560
|
+
now: exports_external.string().optional()
|
|
17561
|
+
}
|
|
17562
|
+
}, async (args) => jsonResult(await service.runDueReportSchedules(args.now ? new Date(args.now) : new Date)));
|
|
17563
|
+
server.registerTool("uptime_report_runs", {
|
|
17564
|
+
title: "List scheduled report runs",
|
|
17565
|
+
description: "List local scheduled report run history.",
|
|
17566
|
+
inputSchema: {
|
|
17567
|
+
scheduleId: exports_external.string().optional(),
|
|
17568
|
+
limit: exports_external.number().int().min(1).max(MAX_RESULT_LIMIT).optional()
|
|
17569
|
+
}
|
|
17570
|
+
}, async (args) => jsonResult(service.listReportRuns({ scheduleId: args.scheduleId, limit: args.limit })));
|
|
17571
|
+
server.registerTool("uptime_audit_events", {
|
|
17572
|
+
title: "List audit events",
|
|
17573
|
+
description: "List recent local audit events.",
|
|
17574
|
+
inputSchema: {
|
|
17575
|
+
resourceType: exports_external.string().optional(),
|
|
17576
|
+
resourceId: exports_external.string().optional(),
|
|
17577
|
+
limit: exports_external.number().int().min(1).max(MAX_RESULT_LIMIT).optional()
|
|
17578
|
+
}
|
|
17579
|
+
}, async (args) => jsonResult(service.listAuditEvents({ resourceType: args.resourceType, resourceId: args.resourceId, limit: args.limit })));
|
|
17580
|
+
server.registerTool("uptime_import_preview", {
|
|
17581
|
+
title: "Preview an uptime inventory import",
|
|
17582
|
+
description: "Preview monitor candidates from manual, projects, servers, domains, or deployment records without writing.",
|
|
17583
|
+
inputSchema: {
|
|
17584
|
+
source: exports_external.enum(["manual", "projects", "servers", "domains", "deployment"]),
|
|
17585
|
+
records: exports_external.array(exports_external.unknown())
|
|
17586
|
+
}
|
|
17587
|
+
}, async (args) => jsonResult(service.previewImport({ source: args.source, records: args.records })));
|
|
17588
|
+
server.registerTool("uptime_create_probe", {
|
|
17589
|
+
title: "Create a private probe identity",
|
|
17590
|
+
description: "Create a local private probe identity and keypair, or register an externally managed public key.",
|
|
17591
|
+
inputSchema: {
|
|
17592
|
+
name: exports_external.string(),
|
|
17593
|
+
publicKeyPem: exports_external.string(),
|
|
17594
|
+
enabled: exports_external.boolean().optional()
|
|
17595
|
+
}
|
|
17596
|
+
}, async (args) => jsonResult(service.createProbe(args)));
|
|
17597
|
+
server.registerTool("uptime_list_probes", {
|
|
17598
|
+
title: "List private probe identities",
|
|
17599
|
+
description: "List local private probe identities.",
|
|
17600
|
+
inputSchema: {
|
|
17601
|
+
includeDisabled: exports_external.boolean().optional()
|
|
17602
|
+
}
|
|
17603
|
+
}, async (args) => jsonResult(service.listProbes({ includeDisabled: args.includeDisabled })));
|
|
17604
|
+
server.registerTool("uptime_create_probe_job", {
|
|
17605
|
+
title: "Create a private probe check job",
|
|
17606
|
+
description: "Create a local check job that a private probe can claim before submitting a signed result.",
|
|
17607
|
+
inputSchema: {
|
|
17608
|
+
monitorId: exports_external.string(),
|
|
17609
|
+
scheduleSlot: exports_external.string(),
|
|
17610
|
+
dueAt: exports_external.string().optional()
|
|
17611
|
+
}
|
|
17612
|
+
}, async (args) => jsonResult(service.createProbeCheckJob(args)));
|
|
17613
|
+
server.registerTool("uptime_claim_probe_job", {
|
|
17614
|
+
title: "Claim a private probe check job",
|
|
17615
|
+
description: "Claim a local private probe check job and receive a fencing token for signed result submission.",
|
|
17616
|
+
inputSchema: {
|
|
17617
|
+
jobId: exports_external.string(),
|
|
17618
|
+
probeId: exports_external.string(),
|
|
17619
|
+
leaseTtlMs: exports_external.number().int().min(1000).optional()
|
|
17620
|
+
}
|
|
17621
|
+
}, async (args) => jsonResult(service.claimProbeCheckJob(args)));
|
|
17622
|
+
server.registerTool("uptime_submit_probe_result", {
|
|
17623
|
+
title: "Submit a signed private probe result",
|
|
17624
|
+
description: "Submit a signed local private probe result for a claimed check job.",
|
|
17625
|
+
inputSchema: {
|
|
17626
|
+
probeId: exports_external.string(),
|
|
17627
|
+
jobId: exports_external.string(),
|
|
17628
|
+
scheduleSlot: exports_external.string(),
|
|
17629
|
+
fencingToken: exports_external.string(),
|
|
17630
|
+
monitorId: exports_external.string(),
|
|
17631
|
+
nonce: exports_external.string(),
|
|
17632
|
+
checkedAt: exports_external.string(),
|
|
17633
|
+
status: exports_external.enum(["up", "down"]),
|
|
17634
|
+
latencyMs: exports_external.number().nonnegative().nullable(),
|
|
17635
|
+
statusCode: exports_external.number().int().min(100).max(599).nullable().optional(),
|
|
17636
|
+
error: exports_external.string().nullable().optional(),
|
|
17637
|
+
attemptCount: exports_external.number().int().min(1).max(20).optional(),
|
|
17638
|
+
monitorRevision: exports_external.number().int().min(1),
|
|
17639
|
+
evidence: exports_external.unknown().nullable().optional(),
|
|
17640
|
+
signature: exports_external.string()
|
|
17641
|
+
}
|
|
17642
|
+
}, async (args) => jsonResult(service.submitProbeResult({ ...args, evidence: args.evidence })));
|
|
17643
|
+
server.registerTool("uptime_import_apply", {
|
|
17644
|
+
title: "Apply an uptime inventory import",
|
|
17645
|
+
description: "Apply monitor candidates from manual, projects, servers, domains, or deployment records idempotently.",
|
|
17646
|
+
inputSchema: {
|
|
17647
|
+
source: exports_external.enum(["manual", "projects", "servers", "domains", "deployment"]),
|
|
17648
|
+
records: exports_external.array(exports_external.unknown())
|
|
17649
|
+
}
|
|
17650
|
+
}, async (args) => jsonResult(service.applyImport({ source: args.source, records: args.records })));
|
|
17651
|
+
server.registerTool("uptime_import_rollback", {
|
|
17652
|
+
title: "Rollback an uptime import batch",
|
|
17653
|
+
description: "Rollback config changes from an import batch while preserving check history.",
|
|
17654
|
+
inputSchema: {
|
|
17655
|
+
batchId: exports_external.string()
|
|
17656
|
+
}
|
|
17657
|
+
}, async (args) => jsonResult(service.rollbackImport(args.batchId)));
|
|
15422
17658
|
server.registerTool("uptime_results", {
|
|
15423
17659
|
title: "List uptime check results",
|
|
15424
17660
|
description: "List recent check results.",
|
|
@@ -15458,6 +17694,28 @@ function jsonResource(uri, value) {
|
|
|
15458
17694
|
function errorResult(message) {
|
|
15459
17695
|
return { content: [{ type: "text", text: message }], isError: true };
|
|
15460
17696
|
}
|
|
17697
|
+
function reportScheduleChannelsSchema() {
|
|
17698
|
+
return exports_external.object({
|
|
17699
|
+
email: exports_external.union([exports_external.literal(true), exports_external.object({
|
|
17700
|
+
apiUrl: exports_external.string().url().optional(),
|
|
17701
|
+
from: exports_external.string().optional(),
|
|
17702
|
+
to: exports_external.union([exports_external.string(), exports_external.array(exports_external.string())]).optional(),
|
|
17703
|
+
subject: exports_external.string().optional(),
|
|
17704
|
+
providerId: exports_external.string().optional()
|
|
17705
|
+
}).strict()]).optional(),
|
|
17706
|
+
sms: exports_external.union([exports_external.literal(true), exports_external.object({
|
|
17707
|
+
apiUrl: exports_external.string().url().optional(),
|
|
17708
|
+
from: exports_external.string().optional(),
|
|
17709
|
+
to: exports_external.union([exports_external.string(), exports_external.array(exports_external.string())]).optional()
|
|
17710
|
+
}).strict()]).optional(),
|
|
17711
|
+
logs: exports_external.union([exports_external.literal(true), exports_external.object({
|
|
17712
|
+
apiUrl: exports_external.string().url().optional(),
|
|
17713
|
+
projectId: exports_external.string().optional(),
|
|
17714
|
+
environment: exports_external.string().optional(),
|
|
17715
|
+
service: exports_external.string().optional()
|
|
17716
|
+
}).strict()]).optional()
|
|
17717
|
+
});
|
|
17718
|
+
}
|
|
15461
17719
|
if (import.meta.main) {
|
|
15462
17720
|
main().catch((error51) => {
|
|
15463
17721
|
console.error(error51 instanceof Error ? error51.message : String(error51));
|